From 53b892ae005cdb45de47d5e401a54cd5e8f3700a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 20 May 2026 00:26:06 +0200 Subject: [PATCH] fix(ui-map): move fog overlay to a viewport-level layer below the copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/frontend/src/map/render.ts | 143 +++++++++++------------- ui/frontend/tests/fog-paint-ops.test.ts | 104 ++++++++++------- 2 files changed, 130 insertions(+), 117 deletions(-) diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 9664faa..5320ad8 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -243,29 +243,26 @@ export type FogPaintOp = /** * fogPaintOps returns the ordered sequence of paint operations that - * draw the Phase 29 visible-hyperspace overlay on a single torus - * copy. The first op is the fog-coloured rectangle covering the - * full world; subsequent ops are background-coloured circles, one - * per visibility circle, painted on top of the fog rectangle. The - * natural rendering order unions overlapping circles for free — - * earlier iterations relied on Pixi v8's `Graphics.cut()` to - * subtract holes, but `cut()` produced incorrect unions for - * overlapping circles (the symptom was a handful of disconnected - * arc segments instead of a clean union). + * draw the Phase 29 visible-hyperspace overlay. The renderer + * dispatches each op onto its own Pixi `Graphics` inside a single + * `fogLayer` that sits below every primitive copy, so the natural + * rendering order paints fog underneath the world. + * + * Coordinates are in world space (the `fogLayer` has no transform), + * which means the wrap offsets are baked directly into the + * positions — there is no per-tile dispatch on the renderer side. * * `mode` controls the torus-wrap behaviour: * - * - `"torus"`: every visibility circle is also drawn at the eight - * wrapped positions (±width, ±height) so the circle remains - * visually continuous when its painted area extends past the - * world rectangle into a neighbouring tile — without the wraps - * the next tile's fog rectangle overpaints the bleed, producing - * a "sector" artifact at the seam. - * - `"no-wrap"`: only the planet's own position is drawn. The - * wrapped positions would create extra holes inside the world - * rectangle when a planet sits near an edge (the user can never - * pan past the boundary in no-wrap mode, but the wrapped circle - * could still leak into the visible area). + * - `"torus"`: every fog rect AND every visibility circle is + * emitted at the nine offsets (`(dx, dy) ∈ {-1, 0, 1}²`), so + * the fog covers all nine torus tiles and a planet near a seam + * keeps a continuous visibility hole across it. + * - `"no-wrap"`: only the central tile is emitted. The user can + * never pan past the boundary in no-wrap mode, so the + * additional wraps would just be wasted paint — worse, a + * wrapped circle from a planet near an edge would leak into + * the visible world rectangle as an unwanted hole. * * Empty `circles` returns an empty list — the caller skips fog * rendering entirely. Width/height ≤ 0 also returns empty so a @@ -282,17 +279,18 @@ export function fogPaintOps( if (world.width <= 0 || world.height <= 0) return []; const offsets: ReadonlyArray = mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET; - const ops: FogPaintOp[] = [ - { + const ops: FogPaintOp[] = []; + for (const [dx, dy] of offsets) { + ops.push({ kind: "fillRect", - x: 0, - y: 0, + x: dx * world.width, + y: dy * world.height, width: world.width, height: world.height, color: fogColor, alpha: 1, - }, - ]; + }); + } for (const c of circles) { for (const [dx, dy] of offsets) { ops.push({ @@ -343,6 +341,17 @@ export async function createRenderer(opts: RendererOptions): Promise = EMPTY_HIDDEN_IDS; - // Per-copy fog Containers for the Phase 29 visibility fog - // overlay. Each container holds one `Graphics` per - // `FogPaintOp` (fog rect + one bg-coloured circle per - // visibility circle × wrap position), inserted at index 0 of - // the torus copy so primitives paint on top. Created lazily - // when `setVisibilityFog` first receives a non-empty list and - // destroyed wholesale on every subsequent call — Pixi v8's - // multi-shape Graphics is supported in theory, but stacking - // each fill on its own Graphics removes any risk of an - // internal-state regression dropping a layer. - let fogGraphics: Container[] = []; + // `fogLayer` (declared above) is repopulated every time + // `setVisibilityFog` runs. We track the dispatched ops only + // implicitly via the layer's children; on every flip we drop + // the previous children and rebuild from the new op list. const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => { const visible = !hiddenIds.has(id); for (const g of list) g.visible = visible; @@ -804,16 +806,13 @@ export async function createRenderer(opts: RendererOptions): Promise hiddenIds.has(id), setVisibilityFog: (circles) => { - // Drop the old fog Containers first — every flip rebuilds - // from scratch instead of mutating in place, so the - // implementation stays simple and Pixi-v8-residue-free. - // `destroy({children: true})` propagates to every owned - // Graphics inside the Container. - for (const c of fogGraphics) { - c.parent?.removeChild(c); - c.destroy({ children: true }); + // Drop the previous fog children — every flip rebuilds + // from scratch instead of mutating in place. Pixi v8's + // `Container.removeChildren()` returns the detached + // children so we can destroy each one explicitly. + for (const old of fogLayer.removeChildren()) { + old.destroy({ children: true }); } - fogGraphics = []; const ops = fogPaintOps( opts.world, circles, @@ -822,29 +821,21 @@ export async function createRenderer(opts: RendererOptions): Promise { @@ -869,15 +860,15 @@ export async function createRenderer(opts: RendererOptions): Promise { 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", () => {