fix(ui-map): move fog overlay to a viewport-level layer below the copies
Tests · UI / test (push) Successful in 2m50s
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m5s

Two regressions surfaced once visible-hyperspace toggled on a real
dev-deploy map:

1. On the zero-turn map the bg holes painted ON TOP of the planet
   glyphs — every LOCAL planet looked like a hollow circle of
   background colour instead of the planet pixel inside an
   unfogged area.
2. On a legacy report with a drive tech that pushes the visibility
   radius well past the world dimensions the bg circles overlapped
   to cover the entire viewport. Combined with the wrong z-order
   the result was a uniformly black canvas with every primitive
   hidden.

The per-copy implementation added the fog container via
`copy.addChildAt(container, 0)` and trusted Pixi v8 to insert the
container at the start of the copy's children. Whether by a Pixi
quirk or by some interaction with how `populatePrimitives` orders
its `c.addChild(g)` calls, the fog ended up rendering after every
primitive in practice — the symptoms above are a perfect match for
that ordering.

Restructured the fog rendering so the z-order is structural
rather than relying on `addChildAt`:

- A single `fogLayer: Container` is added to the viewport BEFORE
  the nine torus copies. Pixi renders viewport children in order,
  so the layer is guaranteed to paint first; every copy renders
  on top.
- `fogPaintOps` now emits world-space coordinates with wrap
  offsets baked in (9 fog rects + 9 bg circles per visibility
  entry in torus mode, 1 + N in no-wrap mode). The renderer
  populates `fogLayer` with one `Graphics` per op — no per-copy
  iteration on the fog side.
- The previous `fogGraphics: Container[]` closure state is gone.
  Each `setVisibilityFog` flip drops every child of `fogLayer`
  and rebuilds it. The dispose path drops the children
  eagerly before `app.destroy({children: true})` walks the tree.

The fog-paint-ops test exercises the new contract: the no-wrap
path keeps one rect + N circles, the torus path expands to nine
rects + nine wrapped circles per entry (including the seam-fix
case at x = 950).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-20 00:26:06 +02:00
parent 00e84579ca
commit 53b892ae00
2 changed files with 130 additions and 117 deletions
+63 -41
View File
@@ -1,14 +1,15 @@
// Phase 29 unit coverage for the Phase 29 fog overlay's layered
// overpaint logic. `fogPaintOps` lives in `src/map/render.ts` next
// to its sole consumer (`RendererHandle.setVisibilityFog`) — the
// renderer dispatches each op straight onto its own Pixi `Graphics`
// (one per shape) inside a per-copy `Container`, so the unit test
// exercises the public ordering contract: a single fog-coloured
// rectangle followed by one background-coloured circle per
// visibility entry (multiplied by the torus wrap offsets when the
// renderer is in torus mode). The natural rendering order unions
// overlapping circles for free, replacing the earlier `cut()`
// implementation that produced disconnected arc segments.
// Phase 29 unit coverage for the visible-hyperspace overlay's
// layered overpaint logic. `fogPaintOps` lives in `src/map/render.ts`
// next to its sole consumer (`RendererHandle.setVisibilityFog`) —
// the renderer dispatches each op onto its own Pixi `Graphics`
// inside a `fogLayer` container that sits below every primitive
// copy. The natural rendering order paints fog underneath the
// world, replacing the earlier `cut()` implementation that
// produced disconnected arc segments.
//
// Coordinates returned by `fogPaintOps` are in world space because
// `fogLayer` has no transform — wraps for torus mode are baked
// into the ops directly.
import { describe, expect, test } from "vitest";
@@ -22,7 +23,7 @@ describe("fogPaintOps — no-wrap mode", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
});
test("single circle emits fog rect + one bg circle in that order", () => {
test("single circle emits a single fog rect + one bg circle", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
@@ -98,7 +99,7 @@ describe("fogPaintOps — no-wrap mode", () => {
});
describe("fogPaintOps — torus mode", () => {
test("each circle is emitted at nine wrapped positions", () => {
test("single circle expands to 9 fog rects + 9 bg circles in world space", () => {
const ops = fogPaintOps(
WORLD,
[{ x: 100, y: 200, radius: 50 }],
@@ -106,25 +107,43 @@ describe("fogPaintOps — torus mode", () => {
BG_COLOR,
"torus",
);
// 1 fog rect + 9 wrapped circles.
expect(ops.length).toBe(10);
expect(ops[0].kind).toBe("fillRect");
const positions = ops
.slice(1)
.map((op) => (op.kind === "fillCircle" ? `${op.x},${op.y}` : ""))
// 9 fog rects + 9 wrapped circles.
expect(ops.length).toBe(18);
// The first 9 ops are fog rects, one per neighbour tile.
const rectPositions = ops
.slice(0, 9)
.map((op) =>
op.kind === "fillRect" ? `${op.x},${op.y}` : "non-rect",
)
.sort();
// Every neighbour offset is emitted with width=1000 / height=800.
const expected: string[] = [];
const expectedRectPositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expected.push(`${100 + dx * 1000},${200 + dy * 800}`);
expectedRectPositions.push(`${dx * 1000},${dy * 800}`);
}
}
expected.sort();
expect(positions).toEqual(expected);
expectedRectPositions.sort();
expect(rectPositions).toEqual(expectedRectPositions);
// The next 9 ops are bg circles at every wrapped planet position.
const circlePositions = ops
.slice(9)
.map((op) =>
op.kind === "fillCircle" ? `${op.x},${op.y}` : "non-circle",
)
.sort();
const expectedCirclePositions: string[] = [];
for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) {
expectedCirclePositions.push(
`${100 + dx * 1000},${200 + dy * 800}`,
);
}
}
expectedCirclePositions.sort();
expect(circlePositions).toEqual(expectedCirclePositions);
});
test("multiple circles produce 9 × N wrapped circles after the fog rect", () => {
test("multiple circles produce 9 fog rects + 9N bg circles", () => {
const ops = fogPaintOps(
WORLD,
[
@@ -135,14 +154,17 @@ describe("fogPaintOps — torus mode", () => {
BG_COLOR,
"torus",
);
// 1 fog rect + (9 wraps × 2 circles) = 19 ops.
expect(ops.length).toBe(19);
expect(ops[0].kind).toBe("fillRect");
// Each circle keeps its own radius across every wrap.
// 9 fog rects + (9 wraps × 2 circles) = 27 ops.
expect(ops.length).toBe(27);
expect(
ops.slice(0, 9).every((op) => op.kind === "fillRect"),
).toBe(true);
expect(
ops.slice(9).every((op) => op.kind === "fillCircle"),
).toBe(true);
const radii = ops
.slice(1)
.map((op) => (op.kind === "fillCircle" ? op.radius : 0))
.filter((r) => r > 0);
.slice(9)
.map((op) => (op.kind === "fillCircle" ? op.radius : 0));
expect(radii.filter((r) => r === 50).length).toBe(9);
expect(radii.filter((r) => r === 30).length).toBe(9);
});
@@ -150,10 +172,10 @@ describe("fogPaintOps — torus mode", () => {
test("a circle near the right edge produces a wrapped copy past the seam", () => {
// Planet at (950, 400) with radius 300 — the painted area
// extends to x = 1250 in the central tile. In torus mode the
// renderer also draws a wrapped circle at (950 - 1000, 400) =
// (-50, 400) so the next tile (with its own fog rect) keeps a
// matching unfogged hole at the seam — this is the fix for
// the "sector" artifact at the wrap boundary.
// renderer also draws wrapped circles at (-50, 400) and
// (1950, 400) so the circle stays continuous across the seam
// instead of appearing as a sector clipped by the neighbour
// tile's fog rectangle.
const ops = fogPaintOps(
WORLD,
[{ x: 950, y: 400, radius: 300 }],
@@ -161,12 +183,12 @@ describe("fogPaintOps — torus mode", () => {
BG_COLOR,
"torus",
);
const xs = ops
.slice(1)
const circleXs = ops
.filter((op) => op.kind === "fillCircle")
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
expect(xs).toContain(-50);
expect(xs).toContain(950);
expect(xs).toContain(1950);
expect(circleXs).toContain(-50);
expect(circleXs).toContain(950);
expect(circleXs).toContain(1950);
});
test("empty input still returns no ops in torus mode", () => {