From 5a11f931a7963f3bfd2d9a3e28455399c49260c9 Mon Sep 17 00:00:00 2001 From: Dennis Westermann Date: Tue, 18 Aug 2026 14:18:10 +0200 Subject: [PATCH] feat(presentation): Baubereich als Overlay sichtbar machen (#91, Paket 21.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bisher erfuhr der Spieler die Bauzone nur durch Ablehnung. Das Overlay malt sie vor dem Klick, in drei Zustaenden: ausserhalb der Zone gar nichts, innerhalb und baubar gruen, innerhalb aber Mindestabstand verletzt orange. Die zweite Unterscheidung ist der eigentliche Befund aus dem Testbericht — MinimumBuildingDistanceCells sperrt Zellen INNERHALB des Radius. Die Regel wird abgefragt, nicht nachgebaut: jeder Texel ist die Antwort von IsInsideBuildInfluence und HasMinimumBuildingSpacing, also exakt der beiden Reads, die ValidatePlacement selbst konsumiert. Damit erbt das Overlay die D-108-Ankeraenderung, ohne angefasst zu werden. In ConstructionSystem sind nur diese beiden Methoden oeffentlich geworden; die Rumpfe sind unveraendert. Sichtbar im Platzierungsmodus und per O umschaltbar. 4 Hz Repaint nur solange sichtbar, FilterMode.Point damit Gruen und Orange nicht zu einem sinnlosen Drittton verschwimmen. Umsetzung durch den Kimi-K3-Worker. Co-Authored-By: Claude Opus 5 --- .../Simulation/BuildZoneOverlayQueryTests.cs | 108 +++++++++ .../BuildZoneOverlayQueryTests.cs.meta | 11 + .../Editor/BootstrapSceneGenerator.cs | 7 + .../Presentation/UI/BuildZoneOverlayView.cs | 209 ++++++++++++++++++ .../UI/BuildZoneOverlayView.cs.meta | 11 + .../Scripts/Presentation/UI/RtsDeviceInput.cs | 33 ++- .../Construction/ConstructionSystem.cs | 22 +- 7 files changed, 398 insertions(+), 3 deletions(-) create mode 100644 Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs create mode 100644 Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs.meta create mode 100644 Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs create mode 100644 Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs.meta diff --git a/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs new file mode 100644 index 0000000..1a843ce --- /dev/null +++ b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs @@ -0,0 +1,108 @@ +using NUnit.Framework; +using Nova.Core; +using Nova.Simulation; +using Nova.Simulation.CommandsV1; +using Nova.Simulation.Construction; +using Nova.Simulation.Definitions; +using Nova.Simulation.Economy; +using Nova.Simulation.Pathfinding; +using Nova.Simulation.State; + +namespace Nova.Simulation.Tests +{ + /// + /// Sprint 21 package 21.4 (issue #91): pins the contract the build-zone + /// overlay relies on. The overlay must never re-derive the zone rule, so + /// what is pinned here is the RELATION between the two public reads and + /// the validator, never the values behind them — no radius, no anchor + /// roles: when D-108 re-opens the anchor list, this suite must keep + /// passing untouched, with the picture simply following the simulation. + /// + /// Two statements: (1) IsInsideBuildInfluence and + /// HasMinimumBuildingSpacing are NECESSARY for an accepted placement — + /// a cell either query rejects must never validate, or the overlay would + /// paint "outside/blocked" on cells a click could still place at. + /// (2) The pair distinguishes "outside the zone" from "inside the zone + /// but spacing-blocked" — the two states the overlay paints differently, + /// and the confusion the test report complained about. + /// + /// + [TestFixture] + public class BuildZoneOverlayQueryTests + { + private const byte Slot = 0; + private const ushort DefHQAlliance = 3; + private const ushort DefPowerAlliance = 5; + + private sealed class Fixture + { + public ConstructionSystem Construction { get; } + + public Fixture() + { + var entities = new EntityManager(64); + var economy = new EconomySystem(entities, 1000); + var costField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize); + Construction = new ConstructionSystem(entities, economy, costField); + var kernel = new SimulationKernel(new SimRandom(42UL)); + kernel.RegisterSystem(economy); + kernel.RegisterSystem(Construction); + kernel.Start(); + Assert.That( + Construction.PlaceCompletedBuilding(Slot, DefHQAlliance, 4, 4).IsValid, + Is.True, "canonical-style start HQ anchor at footprint origin (4,4)"); + } + } + + [Test] + public void InfluenceAndSpacing_AreNecessaryForAcceptedPlacement() + { + var f = new Fixture(); + int size = ConstructionSystem.GridSize; + int footprint = SimDefinitions.BuildingFootprintCells; + for (int y = 0; y + footprint <= size; y += 7) + { + for (int x = 0; x + footprint <= size; x += 7) + { + if (f.Construction.IsInsideBuildInfluence(Slot, x, y) + && f.Construction.HasMinimumBuildingSpacing(x, y)) + { + continue; + } + Assert.That( + f.Construction.ValidatePlacement(Slot, DefPowerAlliance, x, y), + Is.Not.EqualTo(CommandResultCode.Applied), + $"({x},{y}): a cell either overlay read rejects must never validate"); + } + } + } + + [Test] + public void TheTwoReads_DistinguishOutsideFromInsideButSpacingBlocked() + { + var f = new Fixture(); + + // (20,20): far from the only anchor — outside the zone, spacing fine. + Assert.That(f.Construction.IsInsideBuildInfluence(Slot, 20, 20), Is.False, "outside the zone"); + Assert.That(f.Construction.HasMinimumBuildingSpacing(20, 20), Is.True, "far from every footprint"); + + // (7,4): one cell from the HQ footprint — inside the zone, spacing-blocked. + Assert.That(f.Construction.IsInsideBuildInfluence(Slot, 7, 4), Is.True, "inside the zone"); + Assert.That(f.Construction.HasMinimumBuildingSpacing(7, 4), Is.False, "too close to the HQ footprint"); + Assert.That( + f.Construction.ValidatePlacement(Slot, DefPowerAlliance, 7, 4), + Is.Not.EqualTo(CommandResultCode.Applied), + "spacing-blocked stays rejected — the state the second tint exists for"); + + // (13,13): inside the zone, spacing kept — and the full validator + // agrees (no fields registered, open cost field, prerequisite-free + // Power definition, so nothing else stands in the way). + Assert.That(f.Construction.IsInsideBuildInfluence(Slot, 13, 13), Is.True, "inside the zone"); + Assert.That(f.Construction.HasMinimumBuildingSpacing(13, 13), Is.True, "clear of every footprint"); + Assert.That( + f.Construction.ValidatePlacement(Slot, DefPowerAlliance, 13, 13), + Is.EqualTo(CommandResultCode.Applied), + "inside + unblocked validates"); + } + } +} diff --git a/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs.meta b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs.meta new file mode 100644 index 0000000..d2a89e3 --- /dev/null +++ b/Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7485d2da23c6489fabcb3e76baf78f8f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Editor/BootstrapSceneGenerator.cs b/Assets/_Project/Editor/BootstrapSceneGenerator.cs index cbcf7c4..d153788 100644 --- a/Assets/_Project/Editor/BootstrapSceneGenerator.cs +++ b/Assets/_Project/Editor/BootstrapSceneGenerator.cs @@ -333,6 +333,13 @@ private static GameObject CreateUiObject(MatchRunner runner, Camera camera) PlacementGhostView ghost = uiObject.AddComponent(); WireReference(ghost, "_input", input); + // #91: the build-zone overlay — asks the construction system's + // own placement reads, so only the data source (runner) and the + // visibility state (input) are wired here. + BuildZoneOverlayView buildZone = uiObject.AddComponent(); + WireReference(buildZone, "_runner", runner); + WireReference(buildZone, "_input", input); + ConstructionSiteMarkerView siteMarkers = uiObject.AddComponent(); WireReference(siteMarkers, "_runner", runner); diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs new file mode 100644 index 0000000..cb9d998 --- /dev/null +++ b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs @@ -0,0 +1,209 @@ +// Graybox throwaway. Legacy Input + OnGUI. Does NOT satisfy G2/G4 UI criteria +// (see docs/production/MVPRecoveryPlan.md). Replaced when the new Input System and the real UI land. +using UnityEngine; +using Nova.Gameplay.Match; +using Nova.Simulation.CommandsV1; +using Nova.Simulation.Construction; +using Nova.Simulation.Definitions; + +namespace Nova.Presentation.UI +{ + /// + /// The build-zone overlay (issue #91, test report T-01): one flat quad + /// over the whole map, textured per cell from the construction system's + /// OWN placement reads, so the player can finally SEE the rule that + /// otherwise only speaks through rejections. Two states, painted + /// differently on purpose: a footprint origin inside the build influence + /// and clear of every other footprint reads as a green wash, an origin + /// inside the influence but rejected by the minimum building distance + /// reads orange — "in the zone but blocked" is a different statement + /// than "outside the zone" (no tint), and confusing the two was exactly + /// the test-report complaint about + /// . + /// + /// THE RULE IS ASKED, NEVER REBUILT: each texel is the answer of + /// and + /// for a 3x3 + /// footprint ORIGIN at that cell — the same two reads + /// consumes, made + /// public for exactly this caller. The overlay holds no radius constant + /// and no anchor list of its own, so when D-108 re-opens the anchor + /// list the picture follows the simulation on its own instead of + /// silently going stale. Terrain walkability and field spacing are + /// deliberately NOT painted: field spacing is role-dependent (the + /// Refinery inverts it) and the placement ghost already gives the full + /// per-position verdict at the cursor. Texel (x, y) answers for a + /// footprint origin at cell (x, y); the ghost's origin sits one cell + /// south-west of the cursor cell, which is exactly the value the LMB + /// click validates. + /// + /// + /// VISIBILITY: shown while + /// (the ghost is armed and the zone is the missing context) or while + /// pinned by the O toggle (), + /// so the base reach is readable without a build intent. Hidden when no + /// match is live. Pure presentation in the sense of the + /// FogOfWarOverlayView boundary: no collider, no input handling, no + /// simulation write — the mask is read, pixels are written, Apply(). + /// + /// + /// CADENCE AND MAPPING: same one-quad-one-texture approach as the fog + /// overlay (CreateFlatQuad, u -> world x, v -> world z, texel = + /// cell one-to-one, no flip). A full 128x128 repaint runs both cell + /// reads, each a scan over the placement tables — cheap but not free — + /// so the texture refreshes at a fixed cadence while visible + /// (construction state only changes on tick events) and immediately on + /// show, instead of repainting 16 KiB of pixels per frame. The quad + /// sits above the fog sheet (0.04) and below the lowest HUD marker + /// (0.06). FilterMode.Point, not bilinear: the two states must stay + /// crisp per cell — blending green into orange at the boundary would + /// invent a third colour that means nothing. + /// + /// + [DisallowMultipleComponent] + public sealed class BuildZoneOverlayView : MonoBehaviour + { + [Header("Wiring (scene generator)")] + [SerializeField] private MatchRunner _runner; + [SerializeField] private RtsDeviceInput _input; + + [Header("Presentation")] + [Tooltip("Tint of a footprint origin inside the build influence and clear of the minimum building distance.")] + [SerializeField] private Color32 _buildableColor = new Color32(70, 230, 90, 26); + [Tooltip("Tint of a footprint origin inside the build influence but blocked by the minimum building distance.")] + [SerializeField] private Color32 _spacingBlockedColor = new Color32(255, 150, 40, 44); + [Tooltip("World height of the overlay quad: above the fog sheet (0.04) and below the lowest HUD marker (0.06).")] + [SerializeField] private float _overlayHeight = 0.05f; + [Tooltip("Seconds between full repaints while the overlay is visible.")] + [SerializeField, Range(0.05f, 2f)] private float _repaintIntervalSeconds = 0.25f; + + private static readonly Color32 Clear = new Color32(0, 0, 0, 0); + + private Texture2D _texture; + private Color32[] _pixels; + private Material _material; + private GameObject _quad; + private float _nextRepaintTime; + + private void Awake() + { + if (_runner == null) _runner = FindAnyObjectByType(); + if (_input == null) _input = FindAnyObjectByType(); + } + + private void LateUpdate() + { + // The systems are re-read every frame: a new match replaces them, + // and a stale reference would paint a dead match's zone. + ConstructionSystem construction = _runner != null ? _runner.Construction : null; + MatchSession session = _runner != null ? _runner.Session : null; + bool wanted = construction != null && session != null && _input != null + && (_input.PlacementModeActive || _input.BuildZoneOverlayPinned); + if (!wanted) + { + SetQuadActive(false); + // The next show repaints immediately, not one interval late. + _nextRepaintTime = 0f; + return; + } + + EnsureResources(); + SetQuadActive(true); + if (Time.time < _nextRepaintTime) return; + _nextRepaintTime = Time.time + _repaintIntervalSeconds; + Repaint(construction, session.LocalSlot); + } + + /// + /// One texel per cell: outside the build influence stays clear, + /// inside + minimum distance kept gets the buildable tint, inside + + /// too close to an existing footprint gets the blocked tint. Origins + /// whose 3x3 footprint would leave the map stay clear as well — + /// painting them buildable would promise a placement the validator + /// rejects on bounds alone (map geometry, not the zone rule). + /// + private void Repaint(ConstructionSystem construction, byte viewerSlot) + { + int size = ConstructionSystem.GridSize; + int lastOrigin = size - SimDefinitions.BuildingFootprintCells; + + for (int y = 0; y < size; y++) + { + int row = y * size; + for (int x = 0; x < size; x++) + { + Color32 color = Clear; + if (x <= lastOrigin && y <= lastOrigin + && construction.IsInsideBuildInfluence(viewerSlot, x, y)) + { + color = construction.HasMinimumBuildingSpacing(x, y) + ? _buildableColor + : _spacingBlockedColor; + } + _pixels[row + x] = color; + } + } + + _texture.SetPixels32(_pixels); + _texture.Apply(); + } + + /// + /// Builds texture, material and quad lazily on the first show. + /// Everything is runtime-generated and HideAndDontSave — the slice + /// forbids hand-made material assets (GroundMarkerVisuals / + /// FogOfWarOverlayView convention). + /// + private void EnsureResources() + { + if (_texture == null) + { + int size = ConstructionSystem.GridSize; + _texture = new Texture2D(size, size, TextureFormat.RGBA32, mipChain: false) + { + hideFlags = HideFlags.HideAndDontSave, + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp + }; + _pixels = new Color32[size * size]; + } + + if (_material == null) + { + Shader shader = Shader.Find("Sprites/Default"); + if (shader == null) shader = Shader.Find("Universal Render Pipeline/Unlit"); + _material = new Material(shader) + { + hideFlags = HideFlags.HideAndDontSave, + mainTexture = _texture + }; + } + + if (_quad == null) + { + _quad = GroundMarkerVisuals.CreateFlatQuad("BuildZoneOverlay", transform); + _quad.GetComponent().sharedMaterial = _material; + float size = ConstructionSystem.GridSize; + _quad.transform.position = new Vector3(size * 0.5f, _overlayHeight, size * 0.5f); + _quad.transform.localScale = new Vector3(size, size, 1f); + } + } + + private void SetQuadActive(bool active) + { + if (_quad != null && _quad.activeSelf != active) + { + _quad.SetActive(active); + } + } + + private void OnDestroy() + { + if (_texture != null) Destroy(_texture); + if (_material != null) Destroy(_material); + // The quad is a child GameObject and dies with the UI object; the + // shared GroundMarkerVisuals assets are untouched (the overlay + // runs on its own material). + } + } +} diff --git a/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs.meta b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs.meta new file mode 100644 index 0000000..120b24a --- /dev/null +++ b/Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 60b14ad0059d4ecbb3bb0f16e8f28aaf +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs index 5287888..51b9443 100644 --- a/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs +++ b/Assets/_Project/Scripts/Presentation/UI/RtsDeviceInput.cs @@ -168,6 +168,11 @@ private enum PendingOrder private int _placementOriginY; private bool _placementValid; + // Build-zone overlay pin (O key, #91): pure view state read by + // BuildZoneOverlayView — placement mode shows the overlay regardless, + // the pin keeps it visible without a build intent. + private bool _buildZoneOverlayPinned; + // Order target-pick mode state (command card): the armed order whose // target the next LMB world click resolves. Mutually exclusive with // placement mode (one gesture owns the next click). @@ -215,6 +220,14 @@ private enum PendingOrder /// True while a building placement ghost is armed (build bar click or building hotkey). public bool PlacementModeActive => _placementMode; + /// + /// True while the build-zone overlay is pinned visible by the O key + /// (placement mode shows it regardless). Read by + /// ; pure view state, no command + /// and no simulation read is involved in the toggle itself. + /// + public bool BuildZoneOverlayPinned => _buildZoneOverlayPinned; + /// Definition the armed placement ghost currently carries. public ushort PlacementDefId => _placementDefId; @@ -406,7 +419,8 @@ private void Awake() _legend = "LMB click/drag select | RMB move — with an own producer building selected: set its rally point | S stop | " + "A attack enemy under cursor (else plain move; armed units auto-acquire visible in-range enemies, D-087) | " + - "H harvest nearest field | R return cargo | P pause/resume (local match only)\n" + + "H harvest nearest field | R return cargo | P pause/resume (local match only) | " + + "O build zone overlay on/off (shows on its own while a placement ghost is armed)\n" + "Build (build bar below or hotkey — a ghost follows the cursor; LMB place | RMB/ESC cancel): " + $"B {_buildingDefId} | Shift+B {_altBuildingDefId} | C {_storageDefId} | V {_vehicleFactoryDefId} | " + $"T {_researchLabDefId} | G {_radarDefId} | F {_defensePlatformDefId} | Y {_refineryDefId}\n" + @@ -425,6 +439,7 @@ private void Update() Vector2 mouse = Input.mousePosition; UpdatePlacementHover(mouse); + HandleBuildZoneOverlayToggle(); UpdateHarvesterEscort(); HudPointerLink.Publish(IsPointerOverHud(mouse)); HandleSelection(mouse); @@ -502,6 +517,22 @@ private void UpdatePlacementHover(Vector2 mouse) slot, _placementDefId, _placementOriginX, _placementOriginY) == CommandResultCode.Applied; } + /// + /// The O key pins the build-zone overlay on/off (#91). Handled here, + /// not in HandleOrders, so the toggle stays live while a placement + /// ghost or an order pick is armed — the overlay shows during + /// placement anyway, and the pin simply survives the placement. Pure + /// view state: no command, no simulation read. + /// + private void HandleBuildZoneOverlayToggle() + { + if (!Input.GetKeyDown(KeyCode.O)) return; + _buildZoneOverlayPinned = !_buildZoneOverlayPinned; + _lastCommandStatus = _buildZoneOverlayPinned + ? "Build zone overlay pinned on (O hides it again)" + : "Build zone overlay off"; + } + /// /// Input flow while the ghost is armed: LMB places (through the /// dispatcher, at the hovered footprint origin), RMB or ESC cancels, diff --git a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs index 044eaf5..53e0510 100644 --- a/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs +++ b/Assets/_Project/Scripts/Simulation/Construction/ConstructionSystem.cs @@ -1114,8 +1114,15 @@ private bool FootprintIsWalkable(int originX, int originY) /// accepted (D-108): a chain of cheap buildings can push the build /// zone across the map — that coupling of expansion to the economy /// is the decided behavior, not a defect. + /// + /// THIS METHOD IS THE RULE, and it is public so that the build-zone + /// overlay can ASK it per cell instead of re-deriving the radius or + /// the anchor list on its own. That is what let the anchor list open + /// (D-108) without the overlay being touched at all. Pure read, no + /// mutation — consumed by . + /// /// - private bool IsInsideBuildInfluence(byte playerSlot, int originX, int originY) + public bool IsInsideBuildInfluence(byte playerSlot, int originX, int originY) { for (int i = 0; i < MaxBuildings; i++) { @@ -1133,7 +1140,18 @@ private bool IsInsideBuildInfluence(byte playerSlot, int originX, int originY) return false; } - private bool HasMinimumBuildingSpacing(int originX, int originY) + /// + /// The D-104 clearance read: true when a 3x3 footprint with its + /// origin at (originX, originY) keeps + /// from every site and + /// every completed placement (any owner). This is the check that + /// rejects cells INSIDE the build zone — the build-zone overlay + /// paints exactly the pair (IsInsideBuildInfluence, + /// HasMinimumBuildingSpacing) as its two distinguishable states. + /// Consumed by . Pure read, no + /// mutation. + /// + public bool HasMinimumBuildingSpacing(int originX, int originY) { for (int i = 0; i < MaxSites; i++) {