From 5abcc193e57439f33a3c3ca1ce90a9dfe01d2f88 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Sat, 29 Aug 2026 12:40:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20die=20Auswahl=20wird=20benutzbar=20?= =?UTF-8?q?=E2=80=94=20Typzeile,=20Doppelklick,=20Pioniersprung=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Gameplay/IdleBuilderQueryTests.cs | 231 ++++++++++++++++++ .../Gameplay/IdleBuilderQueryTests.cs.meta | 2 + .../Gameplay/SelectionManagerTests.cs | 74 ++++++ .../Scripts/Gameplay/UI/IdleBuilderQuery.cs | 181 ++++++++++++++ .../Gameplay/UI/IdleBuilderQuery.cs.meta | 2 + .../Scripts/Gameplay/UI/SelectionManager.cs | 54 ++++ .../Scripts/Presentation/UI/CommandCardHud.cs | 77 +++++- .../Scripts/Presentation/UI/RtsDeviceInput.cs | 169 ++++++++++++- CHANGELOG.md | 16 ++ 9 files changed, 799 insertions(+), 7 deletions(-) create mode 100644 Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs create mode 100644 Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs.meta create mode 100644 Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs create mode 100644 Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs.meta diff --git a/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs b/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs new file mode 100644 index 0000000..3f252d6 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs @@ -0,0 +1,231 @@ +using System; +using NUnit.Framework; +using Nova.Core; +using Nova.Gameplay; +using Nova.Simulation; +using Nova.Simulation.Construction; +using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; +using Nova.Simulation.State; + +namespace Nova.Gameplay.Tests +{ + /// + /// Contract tests for (sprint 22, #50): + /// the entity-side idle predicate against the four standing-order + /// markers the Stop command clears, the construction-site assignment + /// collection against a REAL site (placed and ticked through the + /// kernel, so the sim's own auto-assignment names the busy Builder), + /// and the deterministic ascending-index cycle with its single wrap. + /// The documented blind spot (a standing repair order is not observable + /// outside ConstructionSystem) is deliberately NOT approximated here — + /// no test pins a repairing Builder as busy, because the query cannot + /// see him; the class docstring and the sprint report carry that. + /// + [TestFixture] + public class IdleBuilderQueryTests + { + /// + /// A live construction domain, mirroring ConstructionSystemTests' + /// fixture: entity store, economy, cost field and kernel, so site + /// creation and Builder auto-assignment run the sim's own code path. + /// + private sealed class Fixture + { + public EntityManager Entities { get; } + public ConstructionSystem Construction { get; } + public SimulationKernel Kernel { get; } + + public Fixture() + { + Entities = new EntityManager(64); + var economy = new EconomySystem(Entities, 1000); + var costField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize); + Construction = new ConstructionSystem(Entities, economy, costField); + Kernel = new SimulationKernel(new SimRandom(42UL)); + Kernel.RegisterSystem(economy); + Kernel.RegisterSystem(Construction); + if (economy.FieldCount == 0) + { + economy.TryAddField(63, new GridPos2D(20, 24), 9000); + } + Kernel.Start(); + } + + public EntityId SpawnBuilder(byte slot, int x, int y) + { + return Entities.SpawnUnit( + slot, + new Transform2D(SimFixed.FromInt(x), SimFixed.FromInt(y)), + SimFixed.FromInt(3), + role: UnitRole.Builder); + } + } + + // ---------------------------------------------------------------- + // The entity-side predicate (the four markers Stop clears) + // ---------------------------------------------------------------- + + [Test] + public void HasNoEntitySideOrder_FreshBuilder_IsIdle() + { + var entities = new EntityManager(8); + entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), role: UnitRole.Builder); + ref readonly UnitState builder = ref entities.RawUnits[0]; + + Assert.IsTrue(IdleBuilderQuery.HasNoEntitySideOrder(in builder)); + } + + [Test] + public void HasNoEntitySideOrder_AnyStandingOrder_IsBusy() + { + var entities = new EntityManager(8); + EntityId mover = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(1), SimFixed.FromInt(1)), SimFixed.FromInt(3), role: UnitRole.Builder); + EntityId attacker = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(3), role: UnitRole.Builder); + EntityId harvester = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(3), SimFixed.FromInt(3)), SimFixed.FromInt(3), role: UnitRole.Builder); + EntityId returner = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(4), SimFixed.FromInt(4)), SimFixed.FromInt(3), role: UnitRole.Builder); + EntityId target = entities.SpawnUnit(1, new Transform2D(SimFixed.FromInt(5), SimFixed.FromInt(5)), SimFixed.FromInt(3), role: UnitRole.LightTank); + + entities.GetUnitRef(mover).SetTarget(new GridPos2D(7, 7)); + entities.GetUnitRef(attacker).AttackTarget = target; + // A Builder never legitimately holds the economy orders — the + // predicate still reads them as busy (the view's Apply does not + // role-filter, so a state that should not exist must never pass + // as free labour). + entities.GetUnitRef(harvester).HarvestFieldId = 1; + entities.GetUnitRef(returner).IsReturningCargo = true; + + Assert.IsFalse(IdleBuilderQuery.HasNoEntitySideOrder(in entities.RawUnits[mover.Index]), "movement order"); + Assert.IsFalse(IdleBuilderQuery.HasNoEntitySideOrder(in entities.RawUnits[attacker.Index]), "attack order"); + Assert.IsFalse(IdleBuilderQuery.HasNoEntitySideOrder(in entities.RawUnits[harvester.Index]), "harvest order"); + Assert.IsFalse(IdleBuilderQuery.HasNoEntitySideOrder(in entities.RawUnits[returner.Index]), "return-cargo order"); + } + + // ---------------------------------------------------------------- + // Site assignment collection (construction-side marker) + // ---------------------------------------------------------------- + + [Test] + public void CollectAssignedBuilderRaws_NoSites_ReturnsEmpty() + { + var f = new Fixture(); + f.SpawnBuilder(0, 30, 30); + var scratch = new uint[ConstructionSystem.MaxSites]; + + Assert.AreEqual(0, IdleBuilderQuery.CollectAssignedBuilderRaws(f.Entities, f.Construction, scratch)); + Assert.AreEqual(0, IdleBuilderQuery.CollectAssignedBuilderRaws(null, f.Construction, scratch), "no store, no raws"); + Assert.AreEqual(0, IdleBuilderQuery.CollectAssignedBuilderRaws(f.Entities, null, scratch), "no construction, no raws"); + } + + [Test] + public void CollectAssignedBuilderRaws_ActiveSite_HoldsTheAutoAssignedBuilder() + { + var f = new Fixture(); + // The existing suite's placement pair: a completed HQ anchors + // the build influence, a Power site lands at (10,10). + Assert.IsTrue(f.Construction.PlaceCompletedBuilding(0, 3, 0, 10).IsValid, "HQ influence anchor"); + EntityId builder = f.SpawnBuilder(0, 30, 30); + Assert.IsTrue(f.Construction.TryPlaceBuilding(0, 5, 10, 10), "Power site placement"); + + f.Kernel.StepTick(); // ProgressSites auto-assigns the lowest-index own Builder + + var scratch = new uint[ConstructionSystem.MaxSites]; + int count = IdleBuilderQuery.CollectAssignedBuilderRaws(f.Entities, f.Construction, scratch); + + Assert.AreEqual(1, count, "one active site, one assigned Builder"); + Assert.AreEqual(UnitCommandStateView.ToRawEntityId(builder), scratch[0]); + } + + [Test] + public void IsIdleBuilder_SiteAssignment_IsTheConstructionSideBusyMarker() + { + var f = new Fixture(); + Assert.IsTrue(f.Construction.PlaceCompletedBuilding(0, 3, 0, 10).IsValid, "HQ influence anchor"); + EntityId assigned = f.SpawnBuilder(0, 30, 30); + EntityId free = f.SpawnBuilder(0, 40, 40); + Assert.IsTrue(f.Construction.TryPlaceBuilding(0, 5, 10, 10), "Power site placement"); + f.Kernel.StepTick(); // the lowest-index Builder (assigned) gets the site + + var scratch = new uint[ConstructionSystem.MaxSites]; + int count = IdleBuilderQuery.CollectAssignedBuilderRaws(f.Entities, f.Construction, scratch); + + Assert.IsFalse( + IdleBuilderQuery.IsIdleBuilder(in f.Entities.RawUnits[assigned.Index], 0, scratch.AsSpan(0, count)), + "a Builder standing still but held by a site is BUSY — his feet do not decide"); + Assert.IsTrue( + IdleBuilderQuery.IsIdleBuilder(in f.Entities.RawUnits[free.Index], 0, scratch.AsSpan(0, count)), + "the unassigned Builder with no orders is idle"); + } + + // ---------------------------------------------------------------- + // The deterministic cycle + // ---------------------------------------------------------------- + + [Test] + public void TryFindNextIdleBuilder_SkipsBusyForeignAndNonBuilders() + { + var f = new Fixture(); + EntityId mover = f.SpawnBuilder(0, 1, 1); // index 0: busy (move order) + f.Entities.GetUnitRef(mover).SetTarget(new GridPos2D(7, 7)); + f.Entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(2), SimFixed.FromInt(2)), SimFixed.FromInt(2), role: UnitRole.Harvester); // index 1: wrong role + f.SpawnBuilder(1, 3, 3); // index 2: foreign + EntityId expected = f.SpawnBuilder(0, 4, 4); // index 3: the only idle own Builder + var scratch = new uint[ConstructionSystem.MaxSites]; + + bool found = IdleBuilderQuery.TryFindNextIdleBuilder( + f.Entities, f.Construction, 0, -1, scratch, out EntityId builder); + + Assert.IsTrue(found); + Assert.AreEqual(expected, builder); + } + + [Test] + public void TryFindNextIdleBuilder_ToursAscendingAndWrapsOnce() + { + var f = new Fixture(); + EntityId first = f.SpawnBuilder(0, 1, 1); + EntityId second = f.SpawnBuilder(0, 2, 2); + EntityId third = f.SpawnBuilder(0, 3, 3); + var scratch = new uint[ConstructionSystem.MaxSites]; + + Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, -1, scratch, out EntityId tour)); + Assert.AreEqual(first, tour, "a fresh tour starts at the lowest index"); + Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, tour.Index, scratch, out tour)); + Assert.AreEqual(second, tour, "strictly after the previous index"); + Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, tour.Index, scratch, out tour)); + Assert.AreEqual(third, tour); + Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, tour.Index, scratch, out tour)); + Assert.AreEqual(first, tour, "the round wraps to the bottom exactly once"); + } + + [Test] + public void TryFindNextIdleBuilder_SoleIdleBuilder_IsReturnedEveryPress() + { + var f = new Fixture(); + EntityId only = f.SpawnBuilder(0, 1, 1); + EntityId mover = f.SpawnBuilder(0, 2, 2); + f.Entities.GetUnitRef(mover).SetTarget(new GridPos2D(7, 7)); + var scratch = new uint[ConstructionSystem.MaxSites]; + + Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, -1, scratch, out EntityId tour)); + Assert.AreEqual(only, tour); + Assert.IsTrue(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, f.Construction, 0, tour.Index, scratch, out tour)); + Assert.AreEqual(only, tour, "one idle Builder is the whole round"); + } + + [Test] + public void TryFindNextIdleBuilder_NoneIdle_ReturnsFalse() + { + var f = new Fixture(); + EntityId mover = f.SpawnBuilder(0, 1, 1); + f.Entities.GetUnitRef(mover).SetTarget(new GridPos2D(7, 7)); + var scratch = new uint[ConstructionSystem.MaxSites]; + + Assert.IsFalse(IdleBuilderQuery.TryFindNextIdleBuilder( + f.Entities, f.Construction, 0, -1, scratch, out EntityId builder)); + Assert.IsFalse(builder.IsValid); + Assert.IsFalse(IdleBuilderQuery.TryFindNextIdleBuilder(null, f.Construction, 0, -1, scratch, out _), "no store"); + Assert.IsFalse(IdleBuilderQuery.TryFindNextIdleBuilder(f.Entities, null, 0, -1, scratch, out _), "no construction"); + } + } +} diff --git a/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs.meta b/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs.meta new file mode 100644 index 0000000..c84c742 --- /dev/null +++ b/Assets/Tests/EditMode/Gameplay/IdleBuilderQueryTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c2b1a0f9e8d7c6b5a4938274655abc1 diff --git a/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs b/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs index c04a74d..dc24841 100644 --- a/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs +++ b/Assets/Tests/EditMode/Gameplay/SelectionManagerTests.cs @@ -213,5 +213,79 @@ public void SelectionManager_ClearSelection_ClearsFieldAndEntities() selection.ClearSelection(); Assert.AreEqual((ushort)0, selection.SelectedFieldId, "the ingress rebind relies on ClearSelection dropping the field too"); } + + // ------------------------------------------------------------------ + // Sprint 22 (#50): type-row filter + double-click role select + // ------------------------------------------------------------------ + + [Test] + public void SelectionManager_RetainRole_KeepsOnlyThatRoleInStableOrder() + { + var entities = new EntityManager(10); + var selection = new SelectionManager(); + EntityId firstTank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(3), role: UnitRole.LightTank); + EntityId harvester = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(2), role: UnitRole.Harvester); + EntityId secondTank = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(14), SimFixed.FromInt(14)), SimFixed.FromInt(3), role: UnitRole.LightTank); + selection.SelectBox(entities, playerId: 0, minX: 0f, minY: 0f, maxX: 20f, maxY: 20f); + Assert.AreEqual(3, selection.SelectedCount); + + int kept = selection.RetainRole(entities, UnitRole.LightTank); + + Assert.AreEqual(2, kept, "the row click reduces the selection to the row's type"); + Assert.AreEqual(firstTank, selection.SelectedEntities[0], "the selection order is stable, so the first tank leads"); + Assert.AreEqual(secondTank, selection.SelectedEntities[1]); + } + + [Test] + public void SelectionManager_RetainRole_DropsStaleHandlesAndAbsentRoles() + { + var entities = new EntityManager(10); + var selection = new SelectionManager(); + EntityId dying = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(10), SimFixed.FromInt(10)), SimFixed.FromInt(3), role: UnitRole.LightTank); + EntityId living = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(12), SimFixed.FromInt(12)), SimFixed.FromInt(3), role: UnitRole.LightTank); + selection.SelectBox(entities, playerId: 0, minX: 0f, minY: 0f, maxX: 20f, maxY: 20f); + entities.DespawnUnit(dying); // died between the card's model build and the row click + + Assert.AreEqual(1, selection.RetainRole(entities, UnitRole.LightTank), "the stale handle is dropped against the live store"); + Assert.AreEqual(living, selection.SelectedEntities[0]); + + Assert.AreEqual(0, selection.RetainRole(entities, UnitRole.Harvester), "a role nothing selected has leaves an empty selection"); + Assert.AreEqual(0, selection.SelectedCount); + } + + [Test] + public void SelectionManager_ReplaceSelection_ReplacesDedupesAndClearsField() + { + 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)); + EntityId u3 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(14), SimFixed.FromInt(14)), SimFixed.FromInt(5)); + selection.SelectField(3); // a field readout owns the card before the gesture + + int count = selection.ReplaceSelection(new[] { u2, u3, u2 }); + + Assert.AreEqual(2, count, "duplicates collapse through AddSingle"); + Assert.AreEqual(u2, selection.SelectedEntities[0], "the new list leads, u1 never joined it"); + Assert.AreEqual((ushort)0, selection.SelectedFieldId, "an entity selection ends the field selection"); + } + + [Test] + public void SelectionManager_AddRange_UnionsLikeShiftClick() + { + 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)); + EntityId u3 = entities.SpawnUnit(0, new Transform2D(SimFixed.FromInt(14), SimFixed.FromInt(14)), SimFixed.FromInt(5)); + selection.SelectSingle(u1); + + int count = selection.AddRange(new[] { u2, u1, u3 }); + + Assert.AreEqual(3, count, "the new ids join, the already-selected one is not duplicated"); + Assert.AreEqual(u1, selection.SelectedEntities[0], "the existing selection keeps its lead"); + Assert.AreEqual(u2, selection.SelectedEntities[1]); + Assert.AreEqual(u3, selection.SelectedEntities[2]); + } } } diff --git a/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs b/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs new file mode 100644 index 0000000..0b455d1 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs @@ -0,0 +1,181 @@ +using System; +using Nova.Core; +using Nova.Simulation.Construction; +using Nova.Simulation.State; + +namespace Nova.Gameplay +{ + /// + /// The "next idle Builder" query behind the I key (sprint 22, #50 — the + /// beta report's build-flow break: the player lost the Builder in a clump + /// and could not build). Pure, Unity-free and allocation-free on purpose, + /// the same precedent as : the whole + /// predicate and the cycling order are EditMode-tested here, the device + /// input only wires the key and the camera. + /// + /// "IDLE" AS READ FROM THE CODE, not from intuition — a Builder is idle + /// when he carries NONE of the standing-order markers the sim knows. The + /// entity store carries four, and the Stop command's clearing list + /// (, Stop case) is the sim's own + /// enumeration of them: (a movement + /// order; set by SetTarget, cleared by arrival and by Stop), + /// (an attack order), + /// and + /// (the two economy orders — a + /// Builder never legitimately holds these, but the predicate checks them + /// anyway: the view's Apply does not role-filter, so a state that + /// should not exist must still read as busy, never as free labour). The + /// fifth marker lives construction-side: a site's + /// AssignedBuilderRaw — the builder a site holds is building (or + /// is expected to walk there), readable per site via + /// and collected here by + /// . + /// + /// + /// KNOWN BLIND SPOT, reported instead of approximated: the sixth marker, + /// a standing REPAIR order, is not observable from this layer. It lives + /// in ConstructionSystem's private repair table; the only public surface + /// is write-side (AssignRepairOrder/ClearRepairOrder) plus a global + /// capacity count, and the repair tick mutates the TARGET's health, never + /// the Builder's UnitState. Closing this needs a one-line public reader + /// inside Simulation/** — out of scope for #50, which is selection and + /// presentation only. So a Builder mid-repair with his feet still reads + /// as idle here. That is a deliberate, documented wrong answer in exactly + /// one occasional state, not a guess: the predicate is shaped so the + /// future repair read slots in as one more clause, and the report names + /// the seam. + /// + /// + /// CYCLING: walks entity indices + /// ascending — the entity store's own order, stable and reproducible — + /// starting strictly after the previously returned index and wrapping + /// once, so repeated presses tour every idle Builder in a deterministic + /// round. A Builder who is the only idle one is returned every press. + /// + /// + public static class IdleBuilderQuery + { + /// + /// The entity-store half of idle: no movement, attack or economy + /// order on the unit itself. Construction-side markers (site + /// assignment) are NOT visible here — they arrive as + /// in + /// ; the repair blind spot is documented + /// on the class. + /// + public static bool HasNoEntitySideOrder(in UnitState unit) + { + return !unit.IsMoving + && !unit.AttackTarget.IsValid + && unit.HarvestFieldId == 0 + && !unit.IsReturningCargo; + } + + /// + /// Collects the AssignedBuilderRaw of every active + /// construction site into and returns + /// the count written (capped at the destination length; sized to + /// nothing is ever dropped). + /// Sites of ANY owner are collected: the membership test in + /// only ever runs against the local + /// player's Builders, and a raw id (index + version) cannot alias a + /// different living entity, so a foreign site's row can never mark an + /// own Builder busy. One read pass per key press — the entity scan + /// asks of every active + /// entity, and a non-site misses the site register immediately. + /// + public static int CollectAssignedBuilderRaws( + EntityManager entities, ConstructionSystem construction, uint[] destination) + { + if (destination == null) throw new ArgumentNullException(nameof(destination)); + if (entities == null || construction == null) return 0; + + int written = 0; + UnitState[] units = entities.RawUnits; + int capacity = entities.Capacity; + for (int i = 0; i < capacity && written < destination.Length; i++) + { + ref readonly UnitState unit = ref units[i]; + if (!unit.IsActive) continue; + if (construction.TryGetSite( + UnitCommandStateView.ToRawEntityId(unit.Id), + out _, out _, out uint assignedBuilderRaw) + && assignedBuilderRaw != 0) + { + destination[written++] = assignedBuilderRaw; + } + } + return written; + } + + /// + /// The full idle predicate for one unit: an ACTIVE Builder of + /// with no entity-side order + /// () and no site assignment in + /// (as collected by + /// ). The unobservable + /// standing repair order is the documented exception (class remarks). + /// + public static bool IsIdleBuilder( + in UnitState unit, byte playerSlot, ReadOnlySpan assignedBuilderRaws) + { + if (!unit.IsActive || unit.Role != UnitRole.Builder || unit.PlayerId != playerSlot) + { + return false; + } + if (!HasNoEntitySideOrder(in unit)) return false; + + uint raw = UnitCommandStateView.ToRawEntityId(unit.Id); + for (int i = 0; i < assignedBuilderRaws.Length; i++) + { + if (assignedBuilderRaws[i] == raw) return false; // a site holds his Bauauftrag + } + return true; + } + + /// + /// The next idle Builder strictly after + /// in ascending entity-index order, wrapping once to index 0 when the + /// tail holds none — the deterministic round the I key tours. Pass -1 + /// to start at the lowest-index idle Builder. + /// is the caller's reusable buffer + /// for (sized + /// ), so the per-press path + /// stays allocation-free. + /// + public static bool TryFindNextIdleBuilder( + EntityManager entities, ConstructionSystem construction, byte playerSlot, + int afterIndex, uint[] assignedScratch, out EntityId builder) + { + builder = EntityId.Invalid; + if (entities == null || construction == null || assignedScratch == null) return false; + + int assignedCount = CollectAssignedBuilderRaws(entities, construction, assignedScratch); + ReadOnlySpan assigned = assignedScratch.AsSpan(0, assignedCount); + + UnitState[] units = entities.RawUnits; + int capacity = entities.Capacity; + int start = afterIndex < -1 ? -1 : afterIndex; + + // Two ascending passes — after start, then from 0 up to start — + // so the tour is a strict index cycle with a single wrap. + for (int i = start + 1; i < capacity; i++) + { + if (IsIdleBuilder(in units[i], playerSlot, assigned)) + { + builder = units[i].Id; + return true; + } + } + for (int i = 0; i <= start && i < capacity; i++) + { + if (IsIdleBuilder(in units[i], playerSlot, assigned)) + { + builder = units[i].Id; + return true; + } + } + return false; + } + } +} diff --git a/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs.meta b/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs.meta new file mode 100644 index 0000000..58702a8 --- /dev/null +++ b/Assets/_Project/Scripts/Gameplay/UI/IdleBuilderQuery.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8f4b2c9a1d3e4f5a6b7c8d9e0f1a2b3c diff --git a/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs b/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs index 10363ae..62e2dd9 100644 --- a/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs +++ b/Assets/_Project/Scripts/Gameplay/UI/SelectionManager.cs @@ -113,6 +113,60 @@ public int SelectBoxAdditive(EntityManager entityManager, byte playerId, float m return _selectedCount; } + /// + /// The command card's breakdown-row click (21.5 rows, made clickable + /// in sprint 22 for #50): reduces the selection to its entities of + /// , preserving the selection's own order (it + /// is stable, so the lead of the reduced selection is the first + /// selected entity of that role — the same first-occurrence rule the + /// rows are drawn in). Stale handles are dropped against the live + /// store, the same courtesy applies + /// at recall. Returns the surviving count; 0 means the selection is + /// empty (the row's units died between model build and click). + /// + public int RetainRole(EntityManager entityManager, UnitRole role) + { + if (entityManager == null || _selectedCount == 0) return _selectedCount; + + int kept = 0; + for (int i = 0; i < _selectedCount; i++) + { + EntityId id = _selectedIds[i]; + if (!entityManager.TryGetUnit(id, out UnitState unit) || unit.Role != role) continue; + _selectedIds[kept++] = id; + } + _selectedCount = kept; + return _selectedCount; + } + + /// + /// Replaces the selection with (deduped and + /// capped through , field selection cleared). + /// The double-click role select's replace half (sprint 22, #50); + /// store-less like — the caller vouches + /// for the ids, staleness is filtered on use. Returns the new count. + /// + public int ReplaceSelection(ReadOnlySpan ids) + { + ClearSelection(); + return AddRange(ids); + } + + /// + /// Adds every id of to the selection (deduped, + /// capped, field selection cleared) — the double-click role select's + /// Shift half, the same additive discipline Shift-click and + /// Shift-drag already follow (sprint 09 §7). Returns the new count. + /// + public int AddRange(ReadOnlySpan ids) + { + for (int i = 0; i < ids.Length; i++) + { + AddSingle(ids[i]); + } + return _selectedCount; + } + // ------------------------------------------------------------------ // Control groups (sprint 09 §7) // ------------------------------------------------------------------ diff --git a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs index ed56e57..d546b14 100644 --- a/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs +++ b/Assets/_Project/Scripts/Presentation/UI/CommandCardHud.cs @@ -50,6 +50,20 @@ namespace Nova.Presentation.UI /// fields named in the title). /// /// + /// CLICKABLE BREAKDOWN ROWS (sprint 22, #50): the 21.5 per-type rows are + /// no longer read-only — clicking a row reduces the selection to exactly + /// that row's role (the card then redraws with that type's commands, + /// which is the cheapest path from "I see what is selected" to "I can + /// work with it"). The rows are buttons, so the panel hit test + /// () already covers them: they live + /// inside the same BeginArea, and keeps its + /// row-for-row contract because the row-button style carries the LABEL + /// style's vertical margin (see EnsureStyles — that coupling is the + /// "~40 px short" bug's fence). While a gesture is armed (placement + /// ghost or order pick) the filter is refused: the input discipline + /// forbids selection changes mid-gesture. + /// + /// /// REPAIR FLOW (decided, GB-006): the sim issues repair as a BUILDER-side /// standing order on an own damaged completed building — there is no /// building-side assignment. The unit card therefore arms a target pick @@ -117,6 +131,17 @@ private struct QueueRow public float Progress01; } + /// + /// One clickable breakdown row (sprint 22, #50): the 21.5 label plus + /// the role it stands for, so a row click can reduce the selection + /// to exactly that type without re-deriving the grouping. + /// + private struct SelectionRow + { + public string Label; + public UnitRole Role; + } + /// /// One frame's panel content. Rebuilt at most once per frame and /// shared between the hit test and the draw, so the two cannot @@ -131,8 +156,8 @@ private sealed class CardModel public string BuildingPowerText; /// The field card's reserve line ("6.420 / 9.000 AE"); null on every entity card. public string FieldReserveText; - /// Per-type breakdown rows of a multi-entity selection (21.5, #88), first-occurrence order; empty otherwise. - public readonly List SelectionRows = new List(8); + /// Per-type breakdown rows of a multi-entity selection (21.5, #88 — clickable since sprint 22, #50), first-occurrence order; empty otherwise. + public readonly List SelectionRows = new List(8); public readonly List Buttons = new List(16); public string QueueHeader; public readonly List QueueRows = new List(ProductionSystem.MaxQueueEntries); @@ -191,6 +216,7 @@ public void Clear() private GUIStyle _buttonStyle; private GUIStyle _sectionStyle; private GUIStyle _rowStyle; + private GUIStyle _selectionRowStyle; private GUIStyle _siteStatusStyle; private GUIStyle _cancelStyle; private GUIStyle _hintStyle; @@ -370,8 +396,16 @@ private void BuildUnitModel(CardModel model, FactionId faction, in UnitState lea model.Title = $"{CommandCardPresenter.UnitDisplayName(faction, firstMobileRole)} — {mobileCount} Einheiten"; for (int i = 0; i < groupCount; i++) { - model.SelectionRows.Add(CommandCardPresenter.FormatSelectionGroup(faction, in _selectionGroupScratch[i])); + model.SelectionRows.Add(new SelectionRow + { + Label = CommandCardPresenter.FormatSelectionGroup(faction, in _selectionGroupScratch[i]), + Role = _selectionGroupScratch[i].Role, + }); } + // Rows are clickable (sprint 22, #50) — say so once, where + // the gesture lives; an armed order pick overwrites this + // with its own hint in BuildModel. + model.FooterHint = "Klick auf eine Zeile: nur diesen Typ auswählen"; } // The intersection votes once per ROLE — repeating a role per @@ -624,7 +658,17 @@ private void OnGUI() } for (int i = 0; i < model.SelectionRows.Count; i++) { - GUILayout.Label(model.SelectionRows[i], _rowStyle, GUILayout.Height(RowHeight)); + // Buttons, not labels (sprint 22, #50): a row click filters + // the selection down to that row's role. The row-button + // style carries _rowStyle's vertical margin, so + // EstimateHeight's per-row cost stays exact (its comment + // names the bug that fence guards). + SelectionRow row = model.SelectionRows[i]; + if (GUILayout.Button(row.Label, _selectionRowStyle, GUILayout.Height(RowHeight))) + { + AudioServiceLocator.Play2D(SoundEventId.UI_Click); + _input.FilterSelectionToRole(row.Role); + } } if (model.ProgressBar01 >= 0f) DrawProgressBar(model.ProgressBar01); if (model.SiteStatusText != null) @@ -731,7 +775,12 @@ private float EstimateHeight(CardModel model) for (int i = 0; i < model.SelectionRows.Count; i++) { // Row for row with OnGUI: each breakdown row costs its - // content height PLUS the row style's vertical margin. + // content height PLUS the row style's vertical margin. The + // rows are buttons since sprint 22 (#50), but + // _selectionRowStyle copies _rowStyle's margin verbatim + // (EnsureStyles), so this price stays exact — changing one + // style's margin without the other reopens the "~40 px + // short: visible, but not clickable" bug above. height += RowHeight + _rowStyle.margin.vertical; } if (model.ProgressBar01 >= 0f) height += ProgressHeight; // GUIStyle.none: no margin @@ -836,6 +885,24 @@ private void EnsureStyles() { _rowStyle = new GUIStyle(GUI.skin.label) { fontSize = 11, wordWrap = false }; } + if (_selectionRowStyle == null) + { + // The clickable breakdown row (sprint 22, #50): a button that + // READS like the label rows around it (left-aligned, same + // font size) — and carries _rowStyle's exact margin, because + // EstimateHeight prices every row as RowHeight plus + // _rowStyle.margin.vertical. Any margin drift reopens the + // "visible, but not clickable" height bug documented there. + _selectionRowStyle = new GUIStyle(GUI.skin.button) + { + fontSize = 11, + wordWrap = false, + alignment = TextAnchor.MiddleLeft, + margin = new RectOffset( + _rowStyle.margin.left, _rowStyle.margin.right, + _rowStyle.margin.top, _rowStyle.margin.bottom), + }; + } if (_siteStatusStyle == null) { // Wraps: the no-Builder warning is longer than the panel is diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs index dbb3642..ec5d3ea 100644 --- a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs +++ b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs @@ -115,6 +115,8 @@ public sealed class RtsDeviceInput : MonoBehaviour [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; + [Tooltip("Seconds between two clicks on the same unit that read as a double-click — that selects all own units of its role visible in the current camera image (sprint 22, #50).")] + [SerializeField] private float _doubleClickSeconds = 0.35f; [Header("Canonical Alliance definition ids (resolved to the local slot faction at runtime)")] [Tooltip("B: Power — Alliance defId 5, 450 AE, prerequisite-free.")] @@ -202,6 +204,23 @@ private enum PendingOrder // a non-Builder actor). private readonly EntityId[] _builderScratch = new EntityId[SelectionManager.MaxSelectedEntities]; + // Double-click role select (sprint 22, #50): the last own-unit click + // pick and its Time.time — a second pick of the SAME entity within + // _doubleClickSeconds reads as the double-click gesture. The scratch + // gathers the visible same-role ids before they replace (or, with + // Shift, join) the selection, so the gesture stays allocation-free. + private float _lastPickTime = -1f; + private EntityId _lastPickedEntity = EntityId.Invalid; + private readonly EntityId[] _roleSelectScratch = new EntityId[SelectionManager.MaxSelectedEntities]; + + // Idle-Builder tour (sprint 22, #50, I key): the entity index the + // last press returned, so the next press continues the ascending + // round. The scratch collects the sites' assigned-Builder raws for + // IdleBuilderQuery (sized to the site format capacity, so the + // collection can never truncate). + private int _lastIdleBuilderIndex = -1; + private readonly uint[] _assignedBuilderScratch = new uint[ConstructionSystem.MaxSites]; + // Harvester escort cadence (D-085-pattern client dispatch): how often // the standing harvest/return orders are re-checked against the // economy's reach rules. Moves go through the plain command path; no @@ -346,6 +365,30 @@ public void RequestRepairOrder() ArmOrderPick(PendingOrder.Repair, "Repair"); } + /// + /// The command card's breakdown-row click (sprint 22, #50): reduces + /// the selection to that row's role — the card then redraws with + /// exactly this type's commands, which is the cheapest path from + /// "I see what is selected" to "I can work with it". Refused while a + /// gesture is armed (placement ghost or order pick): the input + /// discipline forbids selection changes mid-gesture. + /// + public void FilterSelectionToRole(UnitRole role) + { + if (!EnsureDispatcher()) return; + if (_placementMode || _pendingOrder != PendingOrder.None) + { + _lastCommandStatus = "Type filter: resolve or cancel the armed gesture first"; + return; + } + + int kept = _selection.RetainRole(_runner.Entities, role); + _lastCommandStatus = kept > 0 + ? $"Type filter: {kept} unit(s) of role {role} selected" + : $"Type filter: nothing of role {role} left — selection cleared"; + if (kept > 0) AudioServiceLocator.Play2D(SoundEventId.UI_Select); + } + /// Stop for the mobile selection — the S key and the card button share this path. public void OrderStop() { @@ -452,7 +495,8 @@ private void Awake() $"Units: Q {_unitDefId} | Shift+Q {_altUnitDefId} | U {_builderDefId} | N {_antiArmorDefId} | " + $"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" + + "Groups: Ctrl+1..9 save selection, 1..9 recall | Shift+LMB/drag adds to the selection | " + + "double-click a unit: select all visible of its type | I: select next idle Builder, camera follows (press again to cycle)\n" + "Camera: arrow keys / screen edge pan | wheel zoom | Z,X rotate | MMB drag rotate | Space reset rotation\n" + "Linksklick auf ein Vorkommen: Restbestand anzeigen"; } @@ -533,6 +577,12 @@ private bool EnsureDispatcher() _placementMode = false; _pendingOrder = PendingOrder.None; _dragActive = false; + // The selection gestures' memory belongs to the OLD match too: + // an idle-Builder tour would keep cycling stale indices, and a + // click pair started before the rebind must not complete as a + // double-click in the new match. + _lastIdleBuilderIndex = -1; + _lastPickedEntity = EntityId.Invalid; return true; } @@ -991,6 +1041,11 @@ private void HandleOrders(Vector2 mouse) if (Input.GetKeyDown(KeyCode.R)) OrderReturnCargo(); + // Sprint 22 (#50): jump to the next idle Builder — the beta + // report's build-flow break in its most direct form (select + + // camera centre, repeated presses cycle). + if (Input.GetKeyDown(KeyCode.I)) SelectNextIdleBuilder(); + // Pause/resume no longer lives here: ESC/P and the pause menu // belong to PauseMenuHud, which pauses the kernel clock directly // (no command involved). While its modal is up, the gate in @@ -1425,7 +1480,7 @@ private void SelectBox(Vector2 a, Vector2 b, bool additive) _lastCommandStatus = additive ? "Box select (added): 0 unit(s) selected" : "Box select: 0 unit(s)"; } - /// 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). + /// 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). A second click on the SAME unit within is the double-click gesture (sprint 22, #50): all own units of its role visible in the camera image. private void SelectSingle(Vector2 screenPoint, bool additive) { if (_runner.Entities == null) return; @@ -1433,6 +1488,20 @@ private void SelectSingle(Vector2 screenPoint, bool additive) { if (TryPickUnit(world, ownedByLocalSlot: true, out EntityId picked)) { + // The double-click check runs BEFORE the record updates: + // a pair is the same entity twice within the window. + bool doubleClick = picked == _lastPickedEntity + && Time.time - _lastPickTime <= _doubleClickSeconds; + _lastPickedEntity = picked; + _lastPickTime = Time.time; + + if (doubleClick + && _runner.Entities.TryGetUnit(picked, out UnitState pickedUnit)) + { + SelectVisibleSameRole(in pickedUnit, additive); + return; + } + if (additive) { bool added = _selection.AddSingle(picked); @@ -1448,6 +1517,12 @@ private void SelectSingle(Vector2 screenPoint, bool additive) return; } + // No unit under the cursor: any in-flight click pair breaks + // here, so the next unit pick starts a fresh pair instead of + // completing a stale double-click across an empty-ground + // click. + _lastPickedEntity = EntityId.Invalid; + // 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. @@ -1467,6 +1542,96 @@ private void SelectSingle(Vector2 screenPoint, bool additive) } } + /// + /// The double-click gesture (sprint 22, #50): selects every own unit + /// of the double-clicked unit's role whose centre projects into the + /// CURRENT camera image — the RTS convention, bounded to the + /// viewport on purpose: a map-wide select would be a different + /// command and would surprise the player. Shift keeps the gesture + /// additive, the same discipline Shift-click and Shift-drag already + /// follow (sprint 09 §7). The clicked unit anchors the result even + /// if its own centre sits a hair outside the viewport (the pick + /// radius reaches past the screen edge), so the gesture can never + /// read as "clear selection". + /// + private void SelectVisibleSameRole(in UnitState clicked, bool additive) + { + EntityManager entities = _runner.Entities; + byte slot = _dispatcher.LocalSlot; + UnitState[] units = entities.RawUnits; + int capacity = entities.Capacity; + + int gathered = 0; + _roleSelectScratch[gathered++] = clicked.Id; // the anchor, always + for (int i = 0; i < capacity && gathered < _roleSelectScratch.Length; i++) + { + ref readonly UnitState unit = ref units[i]; + if (!unit.IsActive || unit.PlayerId != slot || unit.Role != clicked.Role) continue; + if (unit.Id == clicked.Id) continue; // already anchored + if (!IsCentreOnScreen(in unit)) continue; + _roleSelectScratch[gathered++] = unit.Id; + } + + int total = additive + ? _selection.AddRange(_roleSelectScratch.AsSpan(0, gathered)) + : _selection.ReplaceSelection(_roleSelectScratch.AsSpan(0, gathered)); + _lastCommandStatus = additive + ? $"Double-click (added): {total} unit(s) of role {clicked.Role} selected" + : $"Double-click: {total} visible unit(s) of role {clicked.Role}"; + AudioServiceLocator.Play2D(SoundEventId.UI_Select); + } + + /// True when the unit's centre projects into the current camera image — presentation-side visibility for the double-click gesture (floats are this layer's currency). + private bool IsCentreOnScreen(in UnitState unit) + { + if (_camera == null) _camera = Camera.main; + if (_camera == null) return false; + + Vector3 screen = _camera.WorldToScreenPoint(new Vector3( + unit.Transform.PositionX.ToFloat(), _groundPlaneY, unit.Transform.PositionY.ToFloat())); + return screen.z > 0f + && screen.x >= 0f && screen.x <= Screen.width + && screen.y >= 0f && screen.y <= Screen.height; + } + + /// + /// The I key (sprint 22, #50 — the beta report's build-flow break: + /// the player lost the Builder in a clump and could not build): + /// selects the next IDLE own Builder and centres the camera on him; + /// repeated presses tour all idle Builders in ascending entity + /// order. owns the idle definition + /// (with its documented repair blind spot) and the deterministic + /// cycle. The camera jump travels the minimap's focus channel — this + /// assembly may not reference the camera rig (same rank) — and is + /// pure presentation: no command, no simulation read, nothing a + /// snapshot sees. + /// + private void SelectNextIdleBuilder() + { + if (_runner.Entities == null || _runner.Construction == null) return; + + if (!IdleBuilderQuery.TryFindNextIdleBuilder( + _runner.Entities, _runner.Construction, _dispatcher.LocalSlot, + _lastIdleBuilderIndex, _assignedBuilderScratch, out EntityId builder)) + { + // No idle Builder: restart the tour at the bottom on the next + // press, so a Builder finishing his job is found immediately. + _lastIdleBuilderIndex = -1; + _lastCommandStatus = "Idle Builder: none — every Builder is working (or none exists)"; + return; + } + + _selection.SelectSingle(builder); + _lastIdleBuilderIndex = builder.Index; + if (_runner.Entities.TryGetUnit(builder, out UnitState state)) + { + MinimapCameraLink.RequestFocus( + state.Transform.PositionX.ToFloat(), state.Transform.PositionY.ToFloat()); + } + _lastCommandStatus = $"Idle Builder: entity {builder.Index} selected — I cycles"; + AudioServiceLocator.Play2D(SoundEventId.UI_Select); + } + /// Nearest active unit to a ground point, filtered by ownership. private bool TryPickUnit(Vector3 world, bool ownedByLocalSlot, out EntityId picked) { diff --git a/CHANGELOG.md b/CHANGELOG.md index b6183aa..6a698d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,22 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de spielerisch abgenommen und kein Meilenstein-Nachweis ### Hinzugefügt +- **Die Auswahl ist benutzbar geworden (#50).** Aus dem Betatest: „Weil ich den + Pionier in der Gruppe nicht wiederfand, konnte ich nicht bauen." Paket 21.5 + hatte die Aufstellung geliefert, aber man konnte sie nicht anfassen. Jetzt + **filtert ein Klick auf eine Typzeile** die Auswahl auf genau diese Einheiten + (unter Erhalt der Auswahlreihenfolge, tote Handles fallen raus), ein + **Doppelklick** wählt alle Einheiten derselben Rolle *im Kamerabild* (Shift + bleibt additiv), und **`I`** springt zum nächsten unbeschäftigten Pionier und + zentriert die Kamera auf ihn — ohne das Zentrieren fände man ihn genauso wenig + wie vorher. Wiederholtes Drücken tourt in aufsteigender Entitäts-Reihenfolge + und läuft einmal um. Was „unbeschäftigt" heißt, ist am Code abgelesen und + nicht geraten: die fünf Marker sind die Räumliste des `Stop`-Befehls plus die + Baustellenzuweisung. Ein sechster — ein laufender Reparaturauftrag — ist aus + der Auswahlschicht nicht sichtbar und ist als bewusster, dokumentierter + blinder Fleck im Code vermerkt, statt genähert zu werden; ihn zu schließen + bräuchte einen Leser in `Simulation/**` und gehört nicht zu #50. Reine + Auswahl und Darstellung, kein Simulationseingriff - **Tragzeit des Startfelds ist gemessen, nicht geraten (Paket 21.3, #87).** `tools/Nova.SimRunner.Tests/StartFieldLongevityTests.cs` fährt den echten Ernte-Auto-Zyklus auf der kanonischen Eröffnungsgeometrie (Feld (7,7) mit