Skip to content
Open
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
52 changes: 52 additions & 0 deletions Assets/Tests/EditMode/Simulation/BuildZoneOverlayQueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,5 +104,57 @@ public void TheTwoReads_DistinguishOutsideFromInsideButSpacingBlocked()
Is.EqualTo(CommandResultCode.Applied),
"inside + unblocked validates");
}

/// <summary>
/// The overlay's repaint reads MASKS, not per-cell queries (main-thread
/// hitch fix): the masks must equal the per-cell reads on EVERY origin
/// of the grid — the picture may never drift from the rule. The fixture
/// deliberately mixes the four register cases: a completed own anchor,
/// a second completed own building, an ACTIVE own site (blocks spacing,
/// never anchors — D-108) and an enemy building (blocks spacing for
/// everyone, anchors for nobody here).
/// </summary>
[Test]
public void FillBuildZoneMasks_MatchesThePerCellReads_OnEveryCell()
{
var entities = new EntityManager(64);
var economy = new EconomySystem(entities, 5000);
var costField = new CostField(ConstructionSystem.GridSize, ConstructionSystem.GridSize);
var 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, "own HQ anchor");
Assert.That(construction.PlaceCompletedBuilding(Slot, DefPowerAlliance, 13, 13).IsValid, Is.True, "own completed Power anchor");
Assert.That(construction.TryPlaceBuilding(Slot, DefPowerAlliance, 20, 4), Is.True, "active own site");
Assert.That(construction.PlaceCompletedBuilding(1, 20, 40, 40).IsValid, Is.True, "enemy HQ (Legion def 20)");

int size = ConstructionSystem.GridSize;
var influence = new bool[size * size];
var spacingBlocked = new bool[size * size];
construction.FillBuildZoneMasks(Slot, influence, spacingBlocked);

int mismatches = 0;
for (int y = 0; y < size; y++)
{
for (int x = 0; x < size; x++)
{
int cell = y * size + x;
bool expectedInfluence = construction.IsInsideBuildInfluence(Slot, x, y);
bool expectedBlocked = !construction.HasMinimumBuildingSpacing(x, y);
if (influence[cell] != expectedInfluence || spacingBlocked[cell] != expectedBlocked)
{
if (mismatches++ < 5)
{
TestContext.Out.WriteLine(
$"({x},{y}): mask=({influence[cell]},{spacingBlocked[cell]}) direct=({expectedInfluence},{expectedBlocked})");
}
}
}
}
Assert.That(mismatches, Is.EqualTo(0), "the repaint masks must equal the per-cell reads on every origin of the grid");
}
}
}
30 changes: 25 additions & 5 deletions Assets/_Project/Scripts/Presentation/UI/BuildZoneOverlayView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ public sealed class BuildZoneOverlayView : MonoBehaviour
private Material _material;
private GameObject _quad;
private float _nextRepaintTime;
// Repaint scratch (21.4 follow-up): the two zone masks are filled by
// the construction system in ONE register pass per repaint — the
// per-texel query loop used to rescan that register per texel, which
// hitched the main thread four times a second while placing.
private bool[] _influenceMask;
private bool[] _spacingBlockedMask;

private void Awake()
{
Expand Down Expand Up @@ -121,24 +127,29 @@ private void LateUpdate()
/// 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).
/// The two masks come from the construction system pre-filled for the
/// whole grid (<see cref="ConstructionSystem.FillBuildZoneMasks"/>) —
/// texels only read them.
/// </summary>
private void Repaint(ConstructionSystem construction, byte viewerSlot)
{
int size = ConstructionSystem.GridSize;
int lastOrigin = size - SimDefinitions.BuildingFootprintCells;

EnsureMasks(size);
construction.FillBuildZoneMasks(viewerSlot, _influenceMask, _spacingBlockedMask);

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))
if (x <= lastOrigin && y <= lastOrigin && _influenceMask[row + x])
{
color = construction.HasMinimumBuildingSpacing(x, y)
? _buildableColor
: _spacingBlockedColor;
color = _spacingBlockedMask[row + x]
? _spacingBlockedColor
: _buildableColor;
}
_pixels[row + x] = color;
}
Expand All @@ -148,6 +159,15 @@ private void Repaint(ConstructionSystem construction, byte viewerSlot)
_texture.Apply();
}

private void EnsureMasks(int size)
{
if (_influenceMask == null || _influenceMask.Length != size * size)
{
_influenceMask = new bool[size * size];
_spacingBlockedMask = new bool[size * size];
}
}

/// <summary>
/// Builds texture, material and quad lazily on the first show.
/// Everything is runtime-generated and HideAndDontSave — the slice
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,96 @@ public bool HasMinimumBuildingSpacing(int originX, int originY)
return true;
}

/// <summary>
/// The build-zone overlay's two reads, hoisted from PER TEXEL to ONE
/// PASS (21.4 follow-up — the per-cell calls of
/// <see cref="IsInsideBuildInfluence"/> and
/// <see cref="HasMinimumBuildingSpacing"/> cost the overlay several
/// hundred million operations per repaint: every texel rescanned the
/// placement register, and each entry paid a linear site-register
/// probe for the finished-only rule. At the 4 Hz repaint cadence
/// while a placement ghost is armed, that was a main-thread hitch
/// four times a second in exactly the phase the player was placing).
/// <para>
/// The fill is EXACT, not an approximation: both predicates are
/// rectangle-Chebyshev distances between uniform f×f footprints, so
/// the set of candidate origins they answer for is a square around
/// each register entry. With RectDist(o, p) = max(0, |o−p| − (f−1))
/// per axis: inside-influence ⟺ |o−a| ≤ BuildInfluenceRadiusCells +
/// f − 1 per axis; spacing-blocked ⟺ |o−b| ≤
/// MinimumBuildingDistanceCells + f − 2 per axis. The per-cell reads
/// stay the validators' own path — this mask exists for the overlay's
/// repaint loop only, and the edit-mode suite pins their equivalence
/// over the full grid (BuildZoneOverlayQueryTests).
/// </para>
/// <para>
/// Filters mirror the per-cell reads precisely: influence anchors are
/// own, living, COMPLETED placements (a site never anchors — D-108);
/// spacing blockers are every active site and every placement with a
/// living entity, of ANY owner. Both masks are cleared first and must
/// cover the full grid (GridSize × GridSize).
/// </para>
/// </summary>
public void FillBuildZoneMasks(byte playerSlot, bool[] influenceMask, bool[] spacingBlockedMask)
{
int size = GridSize;
if (influenceMask == null || influenceMask.Length < size * size)
{
throw new ArgumentException("influenceMask must cover the full grid", nameof(influenceMask));
}
if (spacingBlockedMask == null || spacingBlockedMask.Length < size * size)
{
throw new ArgumentException("spacingBlockedMask must cover the full grid", nameof(spacingBlockedMask));
}
Array.Clear(influenceMask, 0, size * size);
Array.Clear(spacingBlockedMask, 0, size * size);

int f = SimDefinitions.BuildingFootprintCells;
int influenceHalf = BuildInfluenceRadiusCells + f - 1;
int spacingHalf = MinimumBuildingDistanceCells + f - 2;

for (int i = 0; i < MaxSites; i++)
{
ref readonly SiteState site = ref _sites[i];
if (site.IsActive)
{
FillOriginBox(spacingBlockedMask, size, site.OriginX, site.OriginY, spacingHalf);
}
}

for (int i = 0; i < MaxBuildings; i++)
{
ref readonly PlacementState placement = ref _buildings[i];
if (!placement.IsActive) continue;
EntityId id = UnitCommandStateView.ToEntityId(placement.RawEntityId);
if (_entityManager.IsValid(id))
{
FillOriginBox(spacingBlockedMask, size, placement.OriginX, placement.OriginY, spacingHalf);
}

if (IsActiveSite(id)) continue; // finished only: a site never extends the zone
if (!_entityManager.TryGetUnit(id, out UnitState unit) || unit.PlayerId != playerSlot) continue;
FillOriginBox(influenceMask, size, placement.OriginX, placement.OriginY, influenceHalf);
}
}

/// <summary>Marks the clamped square of candidate origins (cx±half, cy±half) in a row-major grid mask.</summary>
private static void FillOriginBox(bool[] mask, int size, int centreX, int centreY, int half)
{
int minX = Math.Max(0, centreX - half);
int maxX = Math.Min(size - 1, centreX + half);
int minY = Math.Max(0, centreY - half);
int maxY = Math.Min(size - 1, centreY + half);
for (int y = minY; y <= maxY; y++)
{
int row = y * size;
for (int x = minX; x <= maxX; x++)
{
mask[row + x] = true;
}
}
}

private bool HasValidFieldSpacing(UnitRole role, int originX, int originY)
{
bool refineryHasFieldInRange = false;
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,17 @@ die Versionierung folgt (in der aktuellen Doku-Phase) dem Dokumentationsstand de
bei 2 AE/Tick, bis eine gespielte Balance-Kalibrierung belastbare Werte gibt

### Behoben
- **Ruckler in der Bauphase: das Baubereich-Overlay hat den Hauptthread gebremst.**
Das 21.4-Overlay fragte die beiden Zonen-Reads (`IsInsideBuildInfluence`,
`HasMinimumBuildingSpacing`) **pro Texel** ab — jeder Texel scannte das
Platzierungsregister erneut, jeder Eintrag zahlte eine lineare
Baustellen-Probe. Das sind mehrere hundert Millionen Operationen pro
Repaint, viermal pro Sekunde, ausgerechnet während ein Bau-Ghost armiert
ist — Spielbericht: „komisch verzögert". Jetzt füllt
`ConstructionSystem.FillBuildZoneMasks` beide Masken in **einem** Pass
(exakte Rechtecks-Chebyshev-Boxen statt Texel-Scan, Äquivalenz über das
ganze Raster gepinnt); der Repaint liest nur noch. Selbes Bild, kein
Hitch. Simulationsverhalten unverändert, keine Baseline bewegt
- **#85: Die KI erntet nicht länger endlos auf dem leeren Feld.** Aus dem
Betatest vom 10.08.2026: die KI kam nach Erschöpfung ihres Startvorkommens
wirtschaftlich zum Stillstand. Das war kein Strategiemangel, sondern ein
Expand Down
Loading