Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// <para>
/// 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.
/// </para>
/// </summary>
[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");
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions Assets/_Project/Editor/BootstrapSceneGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,13 @@ private static GameObject CreateUiObject(MatchRunner runner, Camera camera)
PlacementGhostView ghost = uiObject.AddComponent<PlacementGhostView>();
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<BuildZoneOverlayView>();
WireReference(buildZone, "_runner", runner);
WireReference(buildZone, "_input", input);

ConstructionSiteMarkerView siteMarkers = uiObject.AddComponent<ConstructionSiteMarkerView>();
WireReference(siteMarkers, "_runner", runner);

Expand Down
209 changes: 209 additions & 0 deletions Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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
/// <see cref="ConstructionSystem.MinimumBuildingDistanceCells"/>.
/// <para>
/// THE RULE IS ASKED, NEVER REBUILT: each texel is the answer of
/// <see cref="ConstructionSystem.IsInsideBuildInfluence"/> and
/// <see cref="ConstructionSystem.HasMinimumBuildingSpacing"/> for a 3x3
/// footprint ORIGIN at that cell — the same two reads
/// <see cref="ConstructionSystem.ValidatePlacement"/> 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.
/// </para>
/// <para>
/// VISIBILITY: shown while <see cref="RtsDeviceInput.PlacementModeActive"/>
/// (the ghost is armed and the zone is the missing context) or while
/// pinned by the O toggle (<see cref="RtsDeviceInput.BuildZoneOverlayPinned"/>),
/// 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().
/// </para>
/// <para>
/// CADENCE AND MAPPING: same one-quad-one-texture approach as the fog
/// overlay (CreateFlatQuad, u -&gt; world x, v -&gt; 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.
/// </para>
/// </summary>
[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<MatchRunner>();
if (_input == null) _input = FindAnyObjectByType<RtsDeviceInput>();
}

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);
}

/// <summary>
/// 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).
/// </summary>
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();
}

/// <summary>
/// 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).
/// </summary>
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<MeshRenderer>().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).
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading