diff --git a/.gitignore b/.gitignore index 2461b9473..ac9c49142 100644 --- a/.gitignore +++ b/.gitignore @@ -95,7 +95,6 @@ Packages/packages-lock.json GameData/run.cc GameData/player.cc GameData/RemovedMods/ -Vault/Aetheria/.obsidian/plugins/copilot/data.json # Agent scratch, sandboxed toolchain caches, and throwaway checkouts. These had # grown to 47 untracked entries; the worktree must stay legible, so they never diff --git a/.lfsconfig b/.lfsconfig new file mode 100644 index 000000000..d9ce439fc --- /dev/null +++ b/.lfsconfig @@ -0,0 +1,2 @@ +[lfs] + url = https://eureka.gamecult.org/forge/GameCult/Aetheria.git/info/lfs diff --git a/Assets/Editor/AetheriaBuild.cs b/Assets/Editor/AetheriaBuild.cs index 7d3c891d2..3cf434ee4 100644 --- a/Assets/Editor/AetheriaBuild.cs +++ b/Assets/Editor/AetheriaBuild.cs @@ -8,12 +8,12 @@ using UnityEditor.AddressableAssets.Settings; using UnityEditor.Build.Reporting; using Debug = UnityEngine.Debug; -// The one way to make a Win64 player directory. Owns what the directory contains: Aetheria.exe and its data, +// The one way to make a player directory, for Windows or Linux. Owns what the directory contains: the player and its data, // GameData/Aetheria.cc, GameData/Narrative/**, GameData/Mods/** and ModTools/AetherDb. Manual builds, smokes and // release cuts all call this and stage nothing themselves. // // Unity -batchmode -quit -projectPath -executeMethod AetheriaBuild.Build -// -aetheriaVersion -aetheriaOut -logFile +// -aetheriaVersion -aetheriaOut [-aetheriaTarget win64|linux64] -logFile // // AetherDb is published from tools/AetherDb, so the headless build's CULTLIB_ROOT / CULTMATH_ROOT (Directory.Build.props) // must be set for dotnet when the defaults do not hold the pinned revisions. @@ -22,6 +22,14 @@ public static class AetheriaBuild { private static readonly Regex SemVer = new Regex(@"^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]+)?$"); + // A target: the player Unity builds, its executable, and the runtime AetherDb is published for. + private static (BuildTarget target, string player, string runtime, string aetherDb) Target(string name) => name switch + { + "win64" => (BuildTarget.StandaloneWindows64, "Aetheria.exe", "win-x64", "AetherDb.exe"), + "linux64" => (BuildTarget.StandaloneLinux64, "Aetheria.x86_64", "linux-x64", "AetherDb"), + _ => throw new InvalidOperationException($"-aetheriaTarget {name}: expected win64 or linux64"), + }; + public static void Build() { var step = "arguments"; @@ -30,6 +38,7 @@ public static class AetheriaBuild var version = Argument("-aetheriaVersion"); if (!SemVer.IsMatch(version)) throw new InvalidOperationException("-aetheriaVersion is not a semver"); var output = Path.GetFullPath(Argument("-aetheriaOut")); + var target = Target(Argument("-aetheriaTarget", "win64")); var repoRoot = Directory.GetParent(UnityEngine.Application.dataPath).FullName; step = "version"; @@ -46,8 +55,8 @@ public static class AetheriaBuild var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions { scenes = scenes, - locationPathName = Path.Combine(output, "Aetheria.exe"), - target = BuildTarget.StandaloneWindows64, + locationPathName = Path.Combine(output, target.player), + target = target.target, options = BuildOptions.None, }); if (report.summary.result != BuildResult.Succeeded || report.summary.totalErrors != 0) @@ -57,7 +66,7 @@ public static class AetheriaBuild PlayerStaging.Stage(repoRoot, output); step = "aetherdb"; - Publish(repoRoot, Path.Combine(output, "ModTools")); + Publish(repoRoot, Path.Combine(output, "ModTools"), target.runtime, target.aetherDb); step = "burst"; var burst = Path.Combine(output, "Aetheria_BurstDebugInformation_DoNotShip"); @@ -74,11 +83,11 @@ public static class AetheriaBuild EditorApplication.Exit(0); } - private static void Publish(string repoRoot, string modTools) + private static void Publish(string repoRoot, string modTools, string runtime, string aetherDb) { var info = new ProcessStartInfo("dotnet") { - Arguments = $"publish tools/AetherDb -c Release -r win-x64 --self-contained -p:PublishSingleFile=true -o \"{modTools}\"", + Arguments = $"publish tools/AetherDb -c Release -r {runtime} --self-contained -p:PublishSingleFile=true -o \"{modTools}\"", WorkingDirectory = repoRoot, UseShellExecute = false, RedirectStandardOutput = true, @@ -92,15 +101,16 @@ public static class AetheriaBuild if (process.ExitCode != 0) throw new InvalidOperationException($"dotnet publish exited {process.ExitCode}:\n{text}\n{errors.Result}"); } - if (!File.Exists(Path.Combine(modTools, "AetherDb.exe"))) - throw new InvalidOperationException("dotnet publish produced no ModTools/AetherDb.exe"); + if (!File.Exists(Path.Combine(modTools, aetherDb))) + throw new InvalidOperationException($"dotnet publish produced no ModTools/{aetherDb}"); } - private static string Argument(string name) + private static string Argument(string name, string fallback = null) { var args = Environment.GetCommandLineArgs(); var at = Array.IndexOf(args, name); - if (at < 0 || at + 1 >= args.Length) throw new InvalidOperationException(name + " is required"); + if (at < 0 || at + 1 >= args.Length) + return fallback ?? throw new InvalidOperationException(name + " is required"); return args[at + 1]; } } diff --git a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs index 85223b510..69109a622 100644 --- a/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs +++ b/Assets/Scripts/Editor/DrawnPoseWiringVerify.cs @@ -159,7 +159,7 @@ namespace Aetheria.EditorTools MaxRange = 1e6f, FireRange = 1e6f, CommitTime = 1e5f, ArrivalTime = 2e5f }; var projectile = new GameObject("Wiring Round").AddComponent(); - projectile.Launch(zone, shot, new Vector3(0, 0, 0)); + projectile.Launch(null, zone, shot, new Vector3(0, 0, 0)); yield return null; zone.Update(.5f); diff --git a/Assets/Scripts/Editor/ScenarioPickerCapture.cs b/Assets/Scripts/Editor/ScenarioPickerCapture.cs new file mode 100644 index 000000000..b39dd7a1b --- /dev/null +++ b/Assets/Scripts/Editor/ScenarioPickerCapture.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.UI; + +// A render of the New Game screen as the game builds it: the real Main Menu scene's panel prototype, docked and filled +// by ScenarioPicker.Dock and ScenarioPicker.Show with every scenario group, on a plain dark background (the cloud +// backdrop is a play-mode renderer). Writes /Temp/scenario-picker.png at 1920x1080, or the path after -pngOut (Unity empties Temp on exit). +// +// Unity -batchmode -projectPath -executeMethod ScenarioPickerCapture.Run [-pngOut ] -logFile +// +// Needs a GPU device; do not pass -nographics or -quit: Run exits the Editor itself, 0 on success and 1 on any failure. +public static class ScenarioPickerCapture +{ + private const int Width = 1920, Height = 1080; + + public static void Run() + { + try + { + Capture(); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + + // The menu's two panes built the way MainMenu.Start builds them, against the Main Menu scene. + internal static (MainMenu menu, PropertiesPanel list, PropertiesPanel detail) Build() + { + EditorSceneManager.OpenScene("Assets/Scenes/Main Menu.unity", OpenSceneMode.Single); + var menu = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)[0]; + menu.PanelPrototype.gameObject.SetActive(false); + var list = menu.PanelPrototype.Instantiate(); + var detail = menu.PanelPrototype.Instantiate(); + // Edit mode runs no Awake, which is what activates each row a panel adds. + list.Awake(); + detail.Awake(); + ScenarioPicker.Dock(detail, list); + return (menu, list, detail); + } + + internal static List<(string label, Scenario[] scenarios)> AllGroups() => new List<(string label, Scenario[] scenarios)> + { + ("modes", Scenarios.Modes), ("development", Scenarios.Development), ("test arenas", Scenarios.Tests) + }; + + private static void Capture() + { + var (menu, list, detail) = Build(); + menu.Dialog.gameObject.SetActive(false); + list.Title.text = MainMenu.TitleSubtitle("new game", "scenarios"); + ScenarioPicker.Show(list, detail, AllGroups(), _ => { }, () => { }, menu.Dialog.Confirm); + list.RefreshValues(); + detail.RefreshValues(); + + var canvas = menu.GetComponentInParent(true).rootCanvas; + var target = new RenderTexture(Width, Height, 24, RenderTextureFormat.ARGB32); + var cameraObject = new GameObject("ScenarioPickerCapture camera"); + var camera = cameraObject.AddComponent(); + camera.orthographic = true; + camera.orthographicSize = 5; + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = new Color(.04f, .05f, .08f); + camera.cullingMask = 1 << LayerMask.NameToLayer("UI"); + camera.targetTexture = target; + canvas.renderMode = RenderMode.ScreenSpaceCamera; + canvas.worldCamera = camera; + canvas.planeDistance = 10; + canvas.gameObject.SetActive(true); + Canvas.ForceUpdateCanvases(); + LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) list.transform); + LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) detail.transform); + foreach (var text in list.transform.GetComponentsInChildren()) + text.ForceMeshUpdate(); + Canvas.ForceUpdateCanvases(); + list.Content.GetComponentInParent().onValueChanged.Invoke(Vector2.zero); + camera.Render(); + + var previous = RenderTexture.active; + RenderTexture.active = target; + var read = new Texture2D(Width, Height, TextureFormat.RGBA32, false); + read.ReadPixels(new Rect(0, 0, Width, Height), 0, 0); + RenderTexture.active = previous; + + var background = read.GetPixel(0, 0); + var differing = 0; + foreach (var pixel in read.GetPixels32()) + if (Mathf.Abs(pixel.r / 255f - background.r) + Mathf.Abs(pixel.g / 255f - background.g) + Mathf.Abs(pixel.b / 255f - background.b) > .05f) + differing++; + if (differing < Width * Height / 100) + throw new InvalidOperationException($"The capture is uniform: only {differing} pixels differ from the background."); + + var path = Path.GetFullPath(Path.Combine(Application.dataPath, "..", "Temp", "scenario-picker.png")); + var args = Environment.GetCommandLineArgs(); + var outIndex = Array.IndexOf(args, "-pngOut"); + if (outIndex >= 0) path = Path.GetFullPath(args[outIndex + 1]); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllBytes(path, read.EncodeToPNG()); + Debug.Log($"[ScenarioPickerCapture] {path} ({differing} of {Width * Height} pixels differ from the background)"); + } +} diff --git a/Assets/Scripts/Editor/ScenarioPickerCapture.cs.meta b/Assets/Scripts/Editor/ScenarioPickerCapture.cs.meta new file mode 100644 index 000000000..ebeb742e8 --- /dev/null +++ b/Assets/Scripts/Editor/ScenarioPickerCapture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2e79f280179150e4080250c45e07cd69 \ No newline at end of file diff --git a/Assets/Scripts/Editor/ScenarioPickerVerify.cs b/Assets/Scripts/Editor/ScenarioPickerVerify.cs new file mode 100644 index 000000000..6816cafc9 --- /dev/null +++ b/Assets/Scripts/Editor/ScenarioPickerVerify.cs @@ -0,0 +1,265 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TMPro; +using UnityEditor; +using UnityEngine; +using UnityEngine.EventSystems; +using UnityEngine.UI; + +// The picker's selection and keyboard rules, driven through the events the game delivers: Move and Cancel reach the +// focused Launch button through ExecuteEvents, as the Input System UI module sends them, and a click is Button.onClick. +// +// Unity -batchmode -projectPath -executeMethod ScenarioPickerVerify.Run -logFile +// +// Exits the Editor itself, 0 when every rule holds and 1 on the first that does not. +public static class ScenarioPickerVerify +{ + private sealed class Fake : Scenario + { + private readonly uint _seed; + private readonly bool _ambient; + public Fake(string name, uint seed = 0, bool ambient = true) { Name = name; _seed = seed; _ambient = ambient; } + public override string Name { get; } + public override string Id => Name.ToLowerInvariant(); + public override string Brief => $"brief of {Name}"; + public override uint Seed => _seed; + public override bool Ambient => _ambient; + public override Galaxy Generate(GalaxyStage stage) => throw new NotSupportedException(); + public override void Stage(ScenarioStage stage) => throw new NotSupportedException(); + } + + private sealed class Screen + { + public ScenarioPicker Picker; + public PropertiesPanel List, Detail; + public Button[] Rows; + public Button Back, Launch; + public readonly List Launched = new List(); + public int Backs; + public Scenario[] A, B, C; + } + + public static void Run() + { + try + { + SelectionFollowsClicksAndArrows(); + ArrowsStopAtTheEnds(); + LeftAndRightAreIgnored(); + LaunchIsOnePathAndFocusClears(); + CancelAndBackAreOnePath(); + DetailPaneFollowsSelection(); + OneGroupHasNoSectionLabel(); + LaunchesOnceHoweverItIsReached(); + ArrowingThroughTheRealListKeepsTheSelectionVisible(); + Debug.Log("[ScenarioPickerVerify] ok"); + EditorApplication.Exit(0); + } + catch (Exception exception) + { + Debug.LogException(exception); + EditorApplication.Exit(1); + } + } + + private static void Require(bool condition, string what) + { + if (!condition) throw new InvalidOperationException(what); + } + + private static Screen Build(bool oneGroup = false, bool real = false) + { + var (menu, list, detail) = ScenarioPickerCapture.Build(); + // Build opens the scene afresh, which destroys the previous EventSystem. + var events = new GameObject("verify EventSystem").AddComponent(); + // Edit mode never calls OnEnable, which is what registers an EventSystem as current. + typeof(EventSystem).GetMethod("OnEnable", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).Invoke(events, null); + var screen = new Screen + { + List = list, Detail = detail, + A = new Scenario[] { new Fake("Alpha", 7), new Fake("Beta") }, + B = new Scenario[] { new Fake("Gamma", ambient: false) }, + C = new Scenario[] { new Fake("Delta"), new Fake("Echo") } + }; + var groups = real ? ScenarioPickerCapture.AllGroups() : oneGroup + ? new List<(string label, Scenario[] scenarios)> { ("modes", screen.A) } + : new List<(string label, Scenario[] scenarios)> { ("modes", screen.A), ("development", screen.B), ("test arenas", screen.C) }; + // The menu builds a panel while it is inactive and shows it afterwards; the picker must find its scroll all the same. + if (real) list.gameObject.SetActive(false); + screen.Picker = ScenarioPicker.Show(list, detail, groups, screen.Launched.Add, () => screen.Backs++, menu.Dialog.Confirm); + var buttons = list.GetComponentsInChildren(false).Where(button => !button.transform.IsChildOf(detail.transform)).Select(button => button.Button).ToArray(); + screen.Rows = buttons.Take(buttons.Length - 1).ToArray(); + screen.Back = buttons.Last(); + screen.Launch = detail.GetComponentsInChildren