diff --git a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs index 8d82d9f..4320fa4 100644 --- a/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs +++ b/Assets/Tests/EditMode/Gameplay/CommandCardPresenterTests.cs @@ -378,5 +378,34 @@ public void DisplayNames_MatchTheMs1NameTables() // Fallback for roles outside the named roster stays the enum name. Assert.AreEqual(UnitRole.Unit.ToString(), CommandCardPresenter.UnitDisplayName(FactionId.Alliance, UnitRole.Unit)); } + + // ---------------------------------------------------------------- + // Field reserve line (21.2, #86) + // ---------------------------------------------------------------- + + [Test] + public void FormatFieldReserveAE_GroupsThousandsGermanStyle() + { + // The report's own example from sprint package 21.2. + Assert.AreEqual("6.420 / 9.000 AE", CommandCardPresenter.FormatFieldReserveAE(6420, 9000)); + Assert.AreEqual("0 / 15.000 AE", CommandCardPresenter.FormatFieldReserveAE(0, 15000)); + Assert.AreEqual("12.345.678 / 12.345.678 AE", CommandCardPresenter.FormatFieldReserveAE(12345678, 12345678)); + } + + [Test] + public void FormatFieldReserveAE_SmallValuesStayUngrouped() + { + Assert.AreEqual("642 / 900 AE", CommandCardPresenter.FormatFieldReserveAE(642, 900)); + Assert.AreEqual("1 / 1 AE", CommandCardPresenter.FormatFieldReserveAE(1, 1)); + } + + [Test] + public void FormatFieldReserveAE_NonPositiveValuesRenderAsZero() + { + // Reserve values are never negative in the sim; a hostile input + // must still render sanely instead of producing "-6.420". + Assert.AreEqual("0 / 0 AE", CommandCardPresenter.FormatFieldReserveAE(0, 0)); + Assert.AreEqual("0 / 9.000 AE", CommandCardPresenter.FormatFieldReserveAE(-5, 9000)); + } } } diff --git a/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs b/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs new file mode 100644 index 0000000..01f4f5d --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs @@ -0,0 +1,71 @@ +using NUnit.Framework; +using Nova.Gameplay; + +namespace Nova.Gameplay.Tests +{ + /// + /// Contract tests for the field-marker staging rule of + /// (21.2, #86): ceiling of the reserve + /// fraction times the shard count — full reserve lights every shard, any + /// reserve above zero keeps at least one, exactly 0 AE lights none, and + /// the result never leaves [0, shardCount]. + /// + [TestFixture] + public class FieldCrystalStagesTests + { + [Test] + public void VisibleShards_FullReserve_ShowsEveryShard() + { + Assert.AreEqual(7, FieldCrystalStages.VisibleShards(9000, 9000, 7)); + Assert.AreEqual(7, FieldCrystalStages.VisibleShards(15000, 15000, 7)); + } + + [Test] + public void VisibleShards_ZeroRemaining_ShowsNone() + { + Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 9000, 7)); + } + + [Test] + public void VisibleShards_StageBoundaries_RoundUp() + { + // shardCount 4 over 8.000 AE: stage k holds while the reserve is + // in ((k-1)/4, k/4] of the initial reserve — the boundary value + // itself still shows the HIGHER stage's lower edge exactly. + Assert.AreEqual(4, FieldCrystalStages.VisibleShards(8000, 8000, 4)); + Assert.AreEqual(4, FieldCrystalStages.VisibleShards(6001, 8000, 4)); + Assert.AreEqual(3, FieldCrystalStages.VisibleShards(6000, 8000, 4)); + Assert.AreEqual(3, FieldCrystalStages.VisibleShards(4001, 8000, 4)); + Assert.AreEqual(2, FieldCrystalStages.VisibleShards(4000, 8000, 4)); + Assert.AreEqual(1, FieldCrystalStages.VisibleShards(2000, 8000, 4)); + Assert.AreEqual(1, FieldCrystalStages.VisibleShards(1, 8000, 4), "any reserve above zero keeps one shard"); + Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 8000, 4), "0 AE means none — the stump is the view's business"); + } + + [Test] + public void VisibleShards_IsMonotonicallyNonIncreasing() + { + int previous = FieldCrystalStages.VisibleShards(9000, 9000, 7); + for (long remaining = 8999; remaining >= 0; remaining -= 97) + { + int stage = FieldCrystalStages.VisibleShards(remaining, 9000, 7); + Assert.LessOrEqual(stage, previous, $"stage must not rise as the reserve falls (remaining {remaining})"); + Assert.GreaterOrEqual(stage, 0); + Assert.LessOrEqual(stage, 7); + previous = stage; + } + Assert.AreEqual(0, FieldCrystalStages.VisibleShards(0, 9000, 7), "the walk ends at the exhausted stage"); + } + + [Test] + public void VisibleShards_Guards_ClampIntoRange() + { + // Over-reserve (or a layout/console slip) can never light more + // shards than the cluster has; degenerate inputs show nothing. + Assert.AreEqual(7, FieldCrystalStages.VisibleShards(20000, 9000, 7)); + Assert.AreEqual(0, FieldCrystalStages.VisibleShards(100, 0, 7), "unknown initial reserve shows nothing rather than dividing by zero"); + Assert.AreEqual(0, FieldCrystalStages.VisibleShards(-5, 9000, 7)); + Assert.AreEqual(0, FieldCrystalStages.VisibleShards(100, 100, 0), "an empty cluster has no shards to light"); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs.meta b/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs.meta new file mode 100644 index 0000000..07525ec --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/FieldCrystalStagesTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 69d1addf551c455787f5b89c21a2f40f diff --git a/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs b/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs index e794fe4..c04a74d 100644 --- a/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs +++ b/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs @@ -152,5 +152,66 @@ public void SelectionManager_RecallEmptyGroup_IsANoOp() Assert.IsFalse(selection.HasControlGroup(5)); Assert.AreEqual(0, selection.RecallControlGroup(5, entities, playerId: 0)); } + + // ------------------------------------------------------------------ + // Sprint 21.2 (#86): field selection — UI-only, coupled both ways + // ------------------------------------------------------------------ + + [Test] + public void SelectionManager_SelectField_ClearsEntitySelection() + { + var entities = new EntityManager(10); + var selection = new SelectionManager(); + EntityId u1 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(5)); + EntityId u2 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(5)); + selection.SelectSingle(u1); + selection.AddSingle(u2); + + selection.SelectField(3); + + Assert.AreEqual(0, selection.SelectedCount, "a field takes no entity orders — the entity selection goes"); + Assert.AreEqual((ushort)3, selection.SelectedFieldId); + } + + [Test] + public void SelectionManager_EntitySelection_ClearsSelectedField() + { + var entities = new EntityManager(10); + var selection = new SelectionManager(); + EntityId u1 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(5)); + EntityId u2 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(5)); + + selection.SelectField(2); + selection.SelectSingle(u1); + Assert.AreEqual((ushort)0, selection.SelectedFieldId, "SelectSingle replaces the field"); + Assert.AreEqual(1, selection.SelectedCount); + + selection.SelectField(2); + selection.AddSingle(u2); + Assert.AreEqual((ushort)0, selection.SelectedFieldId, "an additive entity pick ends the field selection too"); + Assert.AreEqual(1, selection.SelectedCount); + + selection.SelectField(2); + selection.SelectBox(entities, playerId: 0, minX: 0f, minY: 0f, maxX: 20f, maxY: 20f); + Assert.AreEqual((ushort)0, selection.SelectedFieldId, "a box selection replaces the field"); + Assert.AreEqual(2, selection.SelectedCount); + } + + [Test] + public void SelectionManager_ClearSelection_ClearsFieldAndEntities() + { + var entities = new EntityManager(10); + var selection = new SelectionManager(); + EntityId u1 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(5)); + selection.SelectSingle(u1); + + selection.ClearSelection(); + Assert.AreEqual(0, selection.SelectedCount); + Assert.AreEqual((ushort)0, selection.SelectedFieldId); + + selection.SelectField(5); + selection.ClearSelection(); + Assert.AreEqual((ushort)0, selection.SelectedFieldId, "the ingress rebind relies on ClearSelection dropping the field too"); + } } } diff --git a/Assets/Tests/PlayMode/FieldReservePickTests.cs b/Assets/Tests/PlayMode/FieldReservePickTests.cs new file mode 100644 index 0000000..1841222 --- /dev/null +++ b/Assets/Tests/PlayMode/FieldReservePickTests.cs @@ -0,0 +1,124 @@ +using System.Collections; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Nova.Gameplay; +using Nova.Gameplay.Match; + +namespace Nova.PlayMode.Tests +{ + /// + /// Regression/diagnosis for the 21.2 play-observation finding "fields are + /// not clickable" (#86): drives the real click path of RtsDeviceInput + /// (private SelectSingle/TryPickUnit/TryPickField via reflection — test + /// assemblies may not reference Nova.Presentation.UI, the MainMenuTests + /// pattern) with the field centre projected through the real main camera, + /// and logs every stage's verdict so a failure names its stage. + /// Run headless-with-graphics and NEVER with -quit (see + /// GrayboxDemoProofTests' header for the invocation). + /// + public sealed class FieldReservePickTests + { + private const string ScenePath = "Assets/_Project/Scenes/Bootstrap.unity"; + + [UnityTest] + public IEnumerator ClickOnStartField_SelectsTheField() + { + yield return SceneManager.LoadSceneAsync(ScenePath, LoadSceneMode.Single); + + var bootstrap = Object.FindAnyObjectByType(); + Assert.NotNull(bootstrap, "Bootstrap scene contains no MatchBootstrap"); + bootstrap.StartGrayboxMatch(); + Assert.IsTrue(bootstrap.IsMatchReady, "match did not start"); + + // Two frames: let every Awake/Start and the first model rebuilds settle. + yield return null; + yield return null; + + MonoBehaviour input = FindByTypeName("RtsDeviceInput"); + Assert.NotNull(input, "scene contains no RtsDeviceInput"); + + // The serialized scene predates the 21.2 field: a missing YAML + // entry must materialise the C# default 2f — pin that assumption, + // it is exactly the kind of silent zero a scene upgrade swallows. + FieldInfo radiusField = input.GetType().GetField( + "_fieldPickRadiusWorld", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(radiusField, "RtsDeviceInput._fieldPickRadiusWorld missing"); + float radius = (float)radiusField.GetValue(input); + Debug.Log($"[FieldPick] _fieldPickRadiusWorld = {radius}"); + Assert.Greater(radius, 0.5f, "field pick radius deserialised as ~0 — old scene asset ate the default"); + + Camera camera = Camera.main; + Assert.NotNull(camera, "no main camera"); + + // Stage 1: the raw field probe at the field centre of the + // canonical start field (7,7) -> centre (7.5, 0, 7.5). + var world = new Vector3(7.5f, 0f, 7.5f); + object[] pickArgs = { world, (ushort)0 }; + bool fieldHit = (bool)InvokePrivate(input, "TryPickField", pickArgs); + Debug.Log($"[FieldPick] TryPickField({world}) = {fieldHit}, id = {pickArgs[1]}"); + + // Stage 2: does a UNIT claim the same point first (the intended + // priority — but then the click reads as unit selection)? + object[] unitArgs = { world, true, Nova.Core.EntityId.Invalid }; + bool unitHit = (bool)InvokePrivate(input, "TryPickUnit", unitArgs); + Debug.Log($"[FieldPick] TryPickUnit({world}, own) = {unitHit}, id = {unitArgs[2]}"); + + // Stage 3: the real click path with the camera-projected point. + Vector3 screen = camera.WorldToScreenPoint(world); + Debug.Log($"[FieldPick] field centre on screen = {screen} (screen {Screen.width}x{Screen.height})"); + InvokePrivate(input, "SelectSingle", new object[] { new Vector2(screen.x, screen.y), false }); + + var selection = (SelectionManager)input.GetType().GetProperty("Selection").GetValue(input); + Debug.Log($"[FieldPick] after SelectSingle: SelectedFieldId = {selection.SelectedFieldId}, " + + $"SelectedCount = {selection.SelectedCount}"); + + Assert.IsTrue(fieldHit, "TryPickField rejected the field centre itself"); + Assert.AreEqual((ushort)1, selection.SelectedFieldId, + "a click on the start field must select field #1 (21.2, #86)"); + Assert.AreEqual(0, selection.SelectedCount, "a field selection owns no entities"); + + // The real play-observation failure (T-02): on a trackpad a + // "click" is a MICRO-DRAG past the 8 px threshold, which becomes + // a box — and the box held no entities, so the gesture read as + // "clear selection". A unit forgives the same gesture (the box + // catches it), a field did not. Reproduce that exact gesture: + // a small, unit-empty drag across the field must select it too. + selection.ClearSelection(); + // A tight quadrant of +10 px around the field-centre pixel: + // provably unit-empty here (stage 2 found no own unit within + // the wider 1.5-cell pick radius), so the box exercises the + // empty-gesture path and nothing else. + InvokePrivate(input, "SelectBox", new object[] + { + new Vector2(screen.x, screen.y), + new Vector2(screen.x + 10f, screen.y + 10f), + false, + }); + Debug.Log($"[FieldPick] after micro-drag SelectBox: SelectedFieldId = {selection.SelectedFieldId}, " + + $"SelectedCount = {selection.SelectedCount}"); + Assert.AreEqual((ushort)1, selection.SelectedFieldId, + "a micro-drag over the start field (no units inside the box) must select field #1, not clear into nothing (21.2 play finding)"); + } + + private static object InvokePrivate(MonoBehaviour target, string method, object[] args) + { + MethodInfo info = target.GetType().GetMethod(method, BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(info, $"{target.GetType().Name}.{method} not found"); + object result = info.Invoke(target, args); + return result; + } + + private static MonoBehaviour FindByTypeName(string typeName) + { + MonoBehaviour[] all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; i < all.Length; i++) + { + if (all[i] != null && all[i].GetType().Name == typeName) return all[i]; + } + return null; + } + } +} diff --git a/Assets/Tests/PlayMode/FieldReservePickTests.cs.meta b/Assets/Tests/PlayMode/FieldReservePickTests.cs.meta new file mode 100644 index 0000000..b847689 --- /dev/null +++ b/Assets/Tests/PlayMode/FieldReservePickTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 058f58a4d84a24eb6aac7241f3cf2a16 \ No newline at end of file diff --git a/Assets/_Project/Editor/BootstrapSceneGenerator.cs b/Assets/_Project/Editor/BootstrapSceneGenerator.cs index 693e88c..fd0c639 100644 --- a/Assets/_Project/Editor/BootstrapSceneGenerator.cs +++ b/Assets/_Project/Editor/BootstrapSceneGenerator.cs @@ -345,6 +345,7 @@ private static GameObject CreateUiObject(MatchRunner runner, Camera camera) WireReference(card, "_runner", runner); WireReference(card, "_input", input); WireReference(card, "_buildMenu", menu); + WireReference(card, "_bootstrap", runner.GetComponent()); WireReference(input, "_commandCard", card); diff --git a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs index 363b599..cf29520 100644 --- a/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs +++ b/Assets/_Project/Scripts/Gameplay/Match/MatchBootstrap.cs @@ -247,6 +247,27 @@ private struct FieldLayout public ushort LocalFieldId => LocalPlayerLayout.FieldId; public ushort EnemyFieldId => EnemyPlayerLayout.FieldId; + /// + /// The initial reserve of a canonical field (21.2, #86). It lives in + /// the canonical map layout, NOT in simulation state — + /// AetheriumField deliberately carries no InitialReserve field + /// because the Simulation/State/ layout is frozen. Returns false for + /// an unknown id. + /// + public bool TryGetFieldInitialReserve(ushort fieldId, out long reserveAE) + { + for (int i = 0; i < FieldLayouts.Length; i++) + { + if (FieldLayouts[i].Id == fieldId) + { + reserveAE = FieldLayouts[i].ReserveAE; + return true; + } + } + reserveAE = 0L; + return false; + } + /// Aetherium field cell of the human player (7, 7). public Vector2Int LocalFieldCell => new Vector2Int(LocalPlayerLayout.FieldX, LocalPlayerLayout.FieldY); diff --git a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs index 335f5f5..54fbe4f 100644 --- a/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs +++ b/Assets/_Project/Scripts/Gameplay/UI/CommandCardPresenter.cs @@ -1,4 +1,5 @@ using System; +using System.Text; using Nova.Core; using Nova.Simulation.Construction; using Nova.Simulation.Definitions; @@ -309,6 +310,42 @@ public static bool TryFindRepairBuilder(EntityManager entities, byte playerSlot, return false; } + /// + /// The field card's reserve line (21.2, #86): "6.420 / 9.000 AE" — + /// German thousands grouping, assembled digit by digit so the output + /// is identical under ANY ambient culture (a build on an en-US host + /// must not render "6,420"). + /// + public static string FormatFieldReserveAE(long remainingAE, long initialReserveAE) + { + var builder = new StringBuilder(24); + AppendGroupedDe(builder, remainingAE); + builder.Append(" / "); + AppendGroupedDe(builder, initialReserveAE); + builder.Append(" AE"); + return builder.ToString(); + } + + /// Decimal digits with the German '.' group separator; reserve values are never negative, so a non-positive input renders as "0". + private static void AppendGroupedDe(StringBuilder builder, long value) + { + if (value <= 0) + { + builder.Append('0'); + return; + } + + int digitCount = 1; + for (long rest = value; rest >= 10; rest /= 10) digitCount++; + for (int i = 0; i < digitCount; i++) + { + if (i > 0 && (digitCount - i) % 3 == 0) builder.Append('.'); + long divisor = 1; + for (int d = 1; d < digitCount - i; d++) divisor *= 10; + builder.Append((char)('0' + (int)(value / divisor % 10))); + } + } + /// /// German display names of the eight MS-1 unit roles per faction, /// from the canonical MS-1 name tables of docs/gamedesign/Vehicles.md diff --git a/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs b/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs new file mode 100644 index 0000000..0c3fd67 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs @@ -0,0 +1,42 @@ +namespace Nova.Gameplay +{ + /// + /// The crystal-stage rule of an Aetherium field marker (21.2, #86): how + /// many of a cluster's shards stay visible at a given remaining reserve. + /// Pure presentation math over read-only economy values — no simulation + /// contact, no UnityEngine dependency, so EditMode tests cover the whole + /// staging. + /// + /// STAGING RULE: the visible shard count is the reserve fraction mapped + /// onto the cluster, rounded UP (ceiling). A full field shows every + /// shard; any reserve above zero keeps at least one shard, so a field + /// being worked never reads as empty; exactly 0 AE + /// (AetheriumField.IsExhausted) shows none. The exhausted LOOK — + /// one flattened, darkened stump instead of bare ground — is the view's + /// own dressing on top of stage 0 and not this function's concern. The + /// result is clamped into [0, shardCount], so an over-reserve reading + /// (or an unknown initial reserve) can never light more shards than the + /// cluster has. + /// + /// + public static class FieldCrystalStages + { + /// + /// Shards lit at out of + /// for a cluster of + /// : ceiling of the reserve fraction + /// times the shard count (see the class remarks for the exact + /// staging rule). + /// + public static int VisibleShards(long remainingAE, long initialReserveAE, int shardCount) + { + if (shardCount <= 0) return 0; + if (remainingAE <= 0 || initialReserveAE <= 0) return 0; + if (remainingAE >= initialReserveAE) return shardCount; + + long visible = (remainingAE * shardCount + initialReserveAE - 1) / initialReserveAE; + if (visible < 1) return 1; + return visible > shardCount ? shardCount : (int)visible; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs.meta b/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs.meta new file mode 100644 index 0000000..8c9176d --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/UI/FieldCrystalStages.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 509aa27c3f904909ad2f96767b1e3d8f diff --git a/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs b/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs index b6e9ddc..10363ae 100644 --- a/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs +++ b/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs @@ -28,6 +28,13 @@ public sealed class SelectionManager public int SelectedCount => _selectedCount; public ReadOnlySpan SelectedEntities => _selectedIds.AsSpan(0, _selectedCount); + /// + /// The selected Aetherium field (21.2, #86), 0 = none. Fields are not + /// entities, so their id lives BESIDE the entity list, never inside + /// it — same UI-only character, no simulation contact. + /// + public ushort SelectedFieldId { get; private set; } + public SelectionManager() { _selectedIds = new EntityId[MaxSelectedEntities]; @@ -40,6 +47,14 @@ public SelectionManager() public void ClearSelection() { _selectedCount = 0; + SelectedFieldId = 0; + } + + /// Selects a field for its reserve readout; clears the entity selection (a field takes no entity orders). + public void SelectField(ushort fieldId) + { + ClearSelection(); + SelectedFieldId = fieldId; } public bool SelectSingle(EntityId id) @@ -60,6 +75,8 @@ public bool AddSingle(EntityId id) { if (_selectedIds[i] == id) return false; } + // An entity joining the selection ends any field selection. + SelectedFieldId = 0; _selectedIds[_selectedCount++] = id; return true; } diff --git a/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs b/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs index 16b5996..af6fc6b 100644 --- a/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs +++ b/Assets/_Project/Scripts/Presentation/Maps/GlutrinneBlockoutView.cs @@ -1,5 +1,8 @@ +using System.Collections.Generic; using UnityEngine; +using Nova.Gameplay; using Nova.Gameplay.Match; +using Nova.Simulation.Economy; namespace Nova.Presentation.Maps { @@ -25,6 +28,16 @@ namespace Nova.Presentation.Maps /// clone; the procedural desert is the permanent baseline a CC0 drop-in /// may later decorate, never a prerequisite. /// + /// + /// RESERVE STAGES (21.2, #86): the crystal cluster of each field follows + /// its remaining reserve — the shards go out one by one as the field is + /// worked ( owns the staging rule), and + /// an exhausted field keeps a single flattened, darkened stump, so it + /// reads as burnt-out rather than simply empty. The readout runs on a + /// half-second cadence over the same read-only economy API the HUD + /// uses — never a per-frame simulation query — and a match restart + /// simply re-reads the refilled reserves and restores every shard. + /// /// [DisallowMultipleComponent] [DefaultExecutionOrder(50)] // builds after MatchBootstrap.Start (default order 0) @@ -79,6 +92,22 @@ public sealed class GlutrinneBlockoutView : MonoBehaviour 1.10f, 0.65f, 0.80f, 0.55f, 0.70f, 0.50f, 0.60f, }; + /// Reserve readout cadence (21.2): the shard stages follow RemainingAE on the HUD's read rail — the harvester-escort interval of RtsDeviceInput, never a per-frame query. + private const float ReserveReadIntervalSeconds = 0.5f; + + /// Height of the one flattened stump an exhausted field keeps (the burnt-out read instead of bare ground). + private const float ExhaustedStumpHeight = 0.12f; + + // Shard cache per field, built once in Start: index f holds the + // shards of field id f + 1, because AllFieldCells iterates the + // canonical layout in ascending id order. A match restart does NOT + // rebuild the scene, so the markers survive — the cadence re-reads + // the fresh economy and restores the full clusters itself. + private readonly List _fieldShards = new List(); + private readonly List _fieldStages = new List(); + private float _reserveNextReadTime; + private MaterialPropertyBlock _propertyBlock; + private void Start() { if (_bootstrap == null) _bootstrap = FindAnyObjectByType(); @@ -95,7 +124,39 @@ private void Start() BuildWeatheredEdge(_bootstrap.MapSize); for (int i = 0; i < fieldCells.Length; i++) { - BuildFieldMarker(fieldCells[i], $"Field_{i + 1}"); + _fieldShards.Add(BuildFieldMarker(fieldCells[i], $"Field_{i + 1}")); + // Full reserve until the first readout: the clusters are + // built lit, so nothing flickers while no economy exists yet. + _fieldStages.Add(ClusterOffsets.Length); + } + } + + /// + /// The reserve readout (21.2, #86): each field's shard stage follows + /// its RemainingAE, applied ONLY on a stage change (the health-tint + /// bucket precedent of UnitViewManager — a full match costs no more + /// than before). No runner or no economy yet (menu, network + /// handshake) means no read: the markers keep their full look. + /// + private void Update() + { + if (Time.time < _reserveNextReadTime) return; + _reserveNextReadTime = Time.time + ReserveReadIntervalSeconds; + + MatchRunner runner = _bootstrap != null ? _bootstrap.Runner : null; + EconomySystem economy = runner != null ? runner.Economy : null; + if (economy == null) return; + + for (int f = 0; f < _fieldShards.Count; f++) + { + ushort fieldId = (ushort)(f + 1); + if (!economy.TryGetField(fieldId, out AetheriumField field)) continue; + if (!_bootstrap.TryGetFieldInitialReserve(fieldId, out long initialReserveAE)) continue; + + int stage = FieldCrystalStages.VisibleShards(field.RemainingAE, initialReserveAE, _fieldShards[f].Length); + if (stage == _fieldStages[f]) continue; + _fieldStages[f] = stage; + ApplyFieldStage(_fieldShards[f], stage, field.IsExhausted); } } @@ -255,13 +316,14 @@ private static float Next01(ref uint state) } } - private void BuildFieldMarker(Vector2Int cell, string label) + private GameObject[] BuildFieldMarker(Vector2Int cell, string label) { var marker = new GameObject($"AetheriumField_{label}_{cell.x}_{cell.y}"); marker.transform.SetParent(transform, false); marker.transform.position = new Vector3(cell.x + 0.5f, 0f, cell.y + 0.5f); Material crystalMaterial = CreateRuntimeMaterial(_crystalColor); + var shards = new GameObject[ClusterOffsets.Length]; for (int i = 0; i < ClusterOffsets.Length; i++) { float height = ClusterHeights[i]; @@ -275,7 +337,64 @@ private void BuildFieldMarker(Vector2Int cell, string label) // Pure marker: no collider, so nothing can ever pick or block a crystal. Destroy(shard.GetComponent()); shard.GetComponent().sharedMaterial = crystalMaterial; + shards[i] = shard; } + return shards; + } + + /// + /// Applies one reserve stage: the first + /// shards stand lit, the rest go dark — except on an exhausted field, + /// where shard 0 survives as the flattened, darkened stump that reads + /// "burnt-out" instead of "there was never anything here". + /// + private void ApplyFieldStage(GameObject[] shards, int stage, bool exhausted) + { + for (int i = 0; i < shards.Length; i++) + { + if (i < stage) + { + RestoreShard(shards[i], i); + } + else if (exhausted && i == 0) + { + ApplyExhaustedStump(shards[i]); + } + else + { + shards[i].SetActive(false); + } + } + } + + /// The lit look of one shard: its literal cluster shape — and no property block, dropping any exhausted darkening (a match restart refills every reserve). + private void RestoreShard(GameObject shard, int clusterIndex) + { + float height = ClusterHeights[clusterIndex]; + shard.transform.localPosition = new Vector3(ClusterOffsets[clusterIndex].x, height * 0.5f, ClusterOffsets[clusterIndex].y); + shard.transform.localScale = new Vector3(0.35f, height, 0.35f); + shard.GetComponent().SetPropertyBlock(null); + shard.SetActive(true); + } + + /// + /// The exhausted stump: shard 0 flattened and darkened to a third of + /// the crystal tone via MaterialPropertyBlock (both colour + /// properties, the FactionTint idiom) — the shared cluster material + /// itself stays untouched, or every field would darken together. + /// + private void ApplyExhaustedStump(GameObject shard) + { + shard.transform.localPosition = new Vector3(ClusterOffsets[0].x, ExhaustedStumpHeight * 0.5f, ClusterOffsets[0].y); + shard.transform.localScale = new Vector3(0.45f, ExhaustedStumpHeight, 0.45f); + + if (_propertyBlock == null) _propertyBlock = new MaterialPropertyBlock(); + _propertyBlock.Clear(); + Color darkened = _crystalColor * 0.30f; + darkened.a = 1f; + FactionTint.ApplyToPropertyBlock(_propertyBlock, darkened); + shard.GetComponent().SetPropertyBlock(_propertyBlock); + shard.SetActive(true); } } } diff --git a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs index 1b92529..7c04ecb 100644 --- a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs @@ -7,6 +7,7 @@ using Nova.Gameplay.Match; using Nova.Simulation.Construction; using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; using Nova.Simulation.Production; using Nova.Simulation.State; using EntityId = Nova.Core.EntityId; @@ -36,6 +37,10 @@ namespace Nova.Presentation.UI /// shown with the head entry's progress (Q16.16 ticks against /// BuildTicks<<16) and a per-entry cancel. A construction site gets /// its progress and CancelConstruction (75% refund). + /// A selected Aetherium FIELD (21.2, #86) gets no buttons at all — just + /// its title plus one row, the remaining reserve against the canonical + /// initial reserve from the map layout ("6.420 / 9.000 AE", exhausted + /// fields named in the title). /// /// /// REPAIR FLOW (decided, GB-006): the sim issues repair as a BUILDER-side @@ -117,6 +122,8 @@ private sealed class CardModel public EntityId LeadId; /// Generation or draw of a completed building; null on unit and site cards. public string BuildingPowerText; + /// The field card's reserve line ("6.420 / 9.000 AE"); null on every entity card. + public string FieldReserveText; public readonly List Buttons = new List(16); public string QueueHeader; public readonly List QueueRows = new List(ProductionSystem.MaxQueueEntries); @@ -134,6 +141,7 @@ public void Clear() Title = string.Empty; LeadId = EntityId.Invalid; BuildingPowerText = null; + FieldReserveText = null; Buttons.Clear(); QueueHeader = null; QueueRows.Clear(); @@ -149,6 +157,8 @@ public void Clear() [SerializeField] private RtsDeviceInput _input; [Tooltip("Build bar; the card docks directly above it.")] [SerializeField] private BuildMenuHud _buildMenu; + [Tooltip("Match bootstrap; the canonical map layout carries each field's initial reserve (21.2, #86).")] + [SerializeField] private MatchBootstrap _bootstrap; [Header("Presentation")] [Tooltip("Whole panel is scaled by this factor, matching the BuildMenuHud/DebugHud convention for Retina displays.")] @@ -176,6 +186,7 @@ private void Awake() if (_runner == null) _runner = FindAnyObjectByType(); if (_input == null) _input = FindAnyObjectByType(); if (_buildMenu == null) _buildMenu = FindAnyObjectByType(); + if (_bootstrap == null) _bootstrap = FindAnyObjectByType(); } /// @@ -216,6 +227,16 @@ private void BuildModel(CardModel model) EntityManager entities = _runner.Entities; if (entities == null || _input == null) return; + // A selected Aetherium field (21.2, #86) owns the card BEFORE any + // entity readout: fields are not entities, so the whole entity + // path below does not apply to them. + ushort selectedFieldId = _input.Selection.SelectedFieldId; + if (selectedFieldId != 0) + { + BuildFieldModel(model, selectedFieldId); + return; + } + ReadOnlySpan selected = _input.Selection.SelectedEntities; if (selected.Length == 0) return; if (!entities.TryGetUnit(selected[0], out UnitState lead)) return; // stale handle @@ -251,6 +272,30 @@ private void BuildModel(CardModel model) } } + /// + /// The field card (21.2, #86): the remaining reserve against the + /// canonical initial reserve from the map layout. No buttons — a + /// field takes no orders; harvesting stays the Harvester's H + /// gesture. An unknown field id or an unwired bootstrap leaves the + /// model empty instead of crashing the card on a stale selection. + /// + private void BuildFieldModel(CardModel model, ushort fieldId) + { + if (_runner.Economy == null + || !_runner.Economy.TryGetField(fieldId, out AetheriumField field) + || _bootstrap == null + || !_bootstrap.TryGetFieldInitialReserve(fieldId, out long initialReserveAE)) + { + return; + } + + model.Title = field.IsExhausted + ? "Aetherium-Vorkommen — erschöpft" + : "Aetherium-Vorkommen"; + model.FieldReserveText = CommandCardPresenter.FormatFieldReserveAE(field.RemainingAE, initialReserveAE); + model.Visible = true; + } + /// The unit card: the lead unit's role decides the buttons (armed? harvester? builder?). private void BuildUnitModel(CardModel model, FactionId faction, UnitRole leadRole, int selectedCount) { @@ -493,6 +538,10 @@ private void OnGUI() { GUILayout.Label(model.BuildingPowerText, _rowStyle, GUILayout.Height(RowHeight)); } + if (model.FieldReserveText != null) + { + GUILayout.Label(model.FieldReserveText, _rowStyle, GUILayout.Height(RowHeight)); + } if (model.ProgressBar01 >= 0f) DrawProgressBar(model.ProgressBar01); if (model.SiteStatusText != null) { @@ -594,6 +643,7 @@ private float EstimateHeight(CardModel model) float height = HudChrome.PanelStyle.padding.vertical; height += TitleHeight + _titleStyle.margin.vertical; if (model.BuildingPowerText != null) height += RowHeight + _rowStyle.margin.vertical; + if (model.FieldReserveText != null) height += RowHeight + _rowStyle.margin.vertical; if (model.ProgressBar01 >= 0f) height += ProgressHeight; // GUIStyle.none: no margin if (model.SiteStatusText != null) height += SiteStatusHeight + _siteStatusStyle.margin.vertical; for (int i = 0; i < model.Buttons.Count; i++) diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs index 7366252..5287888 100644 --- a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs +++ b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs @@ -98,6 +98,8 @@ public sealed class RtsDeviceInput : MonoBehaviour [SerializeField] private float _dragThresholdPixels = 8f; [Tooltip("Click-select radius in world units (= cells).")] [SerializeField] private float _pickRadiusWorld = 1.5f; + [Tooltip("Click-select radius for Aetherium fields in world units (= cells). Wider than the unit pick radius because the marker is a seven-shard cluster; exhausted fields stay clickable for their readout (21.2, #86).")] + [SerializeField] private float _fieldPickRadiusWorld = 2f; [Header("Canonical Alliance definition ids (resolved to the local slot faction at runtime)")] [Tooltip("B: Power — Alliance defId 5, 450 AE, prerequisite-free.")] @@ -412,7 +414,8 @@ private void Awake() $"E {_scoutDefId} | Shift+E {_lightTankDefId} | D {_battleTankDefId} | Shift+D {_artilleryDefId}\n" + "Command card (bottom right): LMB an order button, then LMB its target in the world (RMB/ESC cancels the pick)\n" + "Groups: Ctrl+1..9 save selection, 1..9 recall | Shift+LMB/drag adds to the selection\n" + - "Camera: arrow keys / screen edge pan | wheel zoom | Z,X rotate | MMB drag rotate | Space reset rotation"; + "Camera: arrow keys / screen edge pan | wheel zoom | Z,X rotate | MMB drag rotate | Space reset rotation\n" + + "Linksklick auf ein Vorkommen: Restbestand anzeigen"; } private void Update() @@ -460,6 +463,8 @@ private bool EnsureDispatcher() _runner.Construction, _runner.Production); _dispatcher = new RtsIntentDispatcher(ingress, stateView); _boundIngress = ingress; + // ClearSelection also drops the selected field (21.2): a rebinding + // means a fresh match, and its ids belong to the old one. _selection.ClearSelection(); return true; } @@ -1298,6 +1303,14 @@ private bool TryPickBuilding(Vector3 world, out EntityId picked, out UnitState b /// Box select over the ground-projected AABB of all four drag corners. /// Four, not two: under a tilted camera the screen rectangle projects /// to a trapezoid, and two corners would clip the selection. + /// + /// A non-additive box that catches NO units falls through to the field + /// pick at the box centre (21.2, #86): on a trackpad a plain "click" is + /// a micro-drag past the threshold, and without this fallback that + /// gesture over a field read as "clear selection" — the field readout + /// was unreachable in the T-02 play observation, while the same gesture + /// over a unit still selected it. + /// /// private void SelectBox(Vector2 a, Vector2 b, bool additive) { @@ -1315,30 +1328,61 @@ private void SelectBox(Vector2 a, Vector2 b, bool additive) int count = additive ? _selection.SelectBoxAdditive(_runner.Entities, _dispatcher.LocalSlot, minX, minY, maxX, maxY) : _selection.SelectBox(_runner.Entities, _dispatcher.LocalSlot, minX, minY, maxX, maxY); - _lastCommandStatus = additive ? $"Box select (added): {count} unit(s) selected" : $"Box select: {count} unit(s)"; - if (count > 0) AudioServiceLocator.Play2D(SoundEventId.UI_Select); + if (count > 0) + { + _lastCommandStatus = additive ? $"Box select (added): {count} unit(s) selected" : $"Box select: {count} unit(s)"; + AudioServiceLocator.Play2D(SoundEventId.UI_Select); + return; + } + + if (!additive) + { + var centre = new Vector3((minX + maxX) * 0.5f, 0f, (minY + maxY) * 0.5f); + if (TryPickField(centre, out ushort fieldId)) + { + _selection.SelectField(fieldId); + _lastCommandStatus = $"Selected Aetherium field #{fieldId}"; + AudioServiceLocator.Play2D(SoundEventId.UI_Select); + return; + } + } + + _lastCommandStatus = additive ? "Box select (added): 0 unit(s) selected" : "Box select: 0 unit(s)"; } - /// Click select: nearest own active unit within ; additive with Shift, else replace (a plain click on empty ground clears). + /// Click select: nearest own active unit within , else — non-additive only — the field under the cursor within for its reserve readout (21.2, #86); additive with Shift, else replace (a plain click on empty ground clears). private void SelectSingle(Vector2 screenPoint, bool additive) { if (_runner.Entities == null) return; - if (TryScreenPointToGround(screenPoint, out Vector3 world) - && TryPickUnit(world, ownedByLocalSlot: true, out EntityId picked)) + if (TryScreenPointToGround(screenPoint, out Vector3 world)) { - if (additive) + if (TryPickUnit(world, ownedByLocalSlot: true, out EntityId picked)) { - bool added = _selection.AddSingle(picked); - _lastCommandStatus = $"Added entity {picked.Index} ({_selection.SelectedCount} selected)"; - if (added) AudioServiceLocator.Play2D(SoundEventId.UI_Select); + if (additive) + { + bool added = _selection.AddSingle(picked); + _lastCommandStatus = $"Added entity {picked.Index} ({_selection.SelectedCount} selected)"; + if (added) AudioServiceLocator.Play2D(SoundEventId.UI_Select); + } + else + { + _selection.SelectSingle(picked); + _lastCommandStatus = $"Selected entity {picked.Index}"; + AudioServiceLocator.Play2D(SoundEventId.UI_Select); + } + return; } - else + + // The field pick sits BEHIND the unit pick: a harvester + // standing on its field stays selectable. Additive clicks + // never pick a field — a field cannot join a unit selection. + if (!additive && TryPickField(world, out ushort fieldId)) { - _selection.SelectSingle(picked); - _lastCommandStatus = $"Selected entity {picked.Index}"; + _selection.SelectField(fieldId); + _lastCommandStatus = $"Selected Aetherium field #{fieldId}"; AudioServiceLocator.Play2D(SoundEventId.UI_Select); + return; } - return; } if (!additive) @@ -1413,6 +1457,39 @@ private bool TryResolveNearestField(Vector3 world, out ushort fieldId) return fieldId != 0; } + /// + /// The field under a click (21.2, #86): nearest registered field + /// whose centre lies within of + /// the ground point. Same id-probe pattern as + /// , but bounded by the pick + /// radius and WITHOUT the exhausted filter — a depleted field must + /// stay clickable so its readout (0 AE, erschöpft) remains + /// reachable. + /// + private bool TryPickField(Vector3 world, out ushort fieldId) + { + fieldId = 0; + EconomySystem economy = _runner.Economy; + if (economy == null) return false; + + float best = _fieldPickRadiusWorld * _fieldPickRadiusWorld; + int found = 0; + for (ushort id = 1; id <= EconomySystem.MaxFields && found < economy.FieldCount; id++) + { + if (!economy.TryGetField(id, out AetheriumField field)) continue; + found++; + + float dx = field.GridPos.X + 0.5f - world.x; + float dy = field.GridPos.Y + 0.5f - world.z; + float distanceSq = dx * dx + dy * dy; + if (distanceSq >= best) continue; + + best = distanceSq; + fieldId = field.FieldId; + } + return fieldId != 0; + } + /// /// Producer for a unit definition: a selected own building of the /// definition's producer role wins, otherwise the first own building of diff --git a/CHANGELOG.md b/CHANGELOG.md index 347ead8..7c5f02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,17 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Restbestand der Vorkommen anklickbar und sichtbar (Paket 21.2, #86).** + Ein Linksklick auf ein Aetherium-Vorkommen (auch ein erschöpftes) zeigt in der + Befehlskarte Restbestand und Anfangsreserve („Aetherium-Vorkommen — 6.420 / + 9.000 AE", erschöpfte Felder ausdrücklich im Titel markiert), und der + Kristallstand jedes Feldes folgt dem Bestand stufenweise bis zum abgedunkelten + Stumpf. Reine Präsentation: die Anfangsreserve wird aus der kanonischen + Kartenlage (`MatchBootstrap.FieldLayouts`) gelesen, der Simulationszustand + bleibt unverändert. Nachklang aus der ersten Spielabnahme: eine leere, + nicht-additive Ziehbox fällt jetzt auf den Feld-Pick am Boxzentrum zurück — + auf Trackpads ist ein „Klick" ein Mikro-Drag über die 8-px-Schwelle, und ohne + den Fallback las die Geste über einem Vorkommen nur „Auswahl leeren" - **Die Startzone ist gemessen statt geschätzt (Paket 21.1).** `tools/Nova.SimRunner.Tests/BuildZoneCapacityTests.cs` beziffert in zwei Spuren, wie viele Gebäude in die Bauzone des kanonischen HQ-Ankers passen: eine echte