diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index 3c6461b..9664faa 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -253,18 +253,35 @@ export type FogPaintOp = * overlapping circles (the symptom was a handful of disconnected * arc segments instead of a clean union). * + * `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). + * * Empty `circles` returns an empty list — the caller skips fog * rendering entirely. Width/height ≤ 0 also returns empty so a - * degenerate world cannot produce a non-emit op set. + * degenerate world cannot produce a non-empty op set. */ export function fogPaintOps( world: { width: number; height: number }, circles: ReadonlyArray<{ x: number; y: number; radius: number }>, fogColor: number, bgColor: number, + mode: WrapMode, ): FogPaintOp[] { if (circles.length === 0) return []; if (world.width <= 0 || world.height <= 0) return []; + const offsets: ReadonlyArray = + mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET; const ops: FogPaintOp[] = [ { kind: "fillRect", @@ -277,18 +294,22 @@ export function fogPaintOps( }, ]; for (const c of circles) { - ops.push({ - kind: "fillCircle", - x: c.x, - y: c.y, - radius: c.radius, - color: bgColor, - alpha: 1, - }); + for (const [dx, dy] of offsets) { + ops.push({ + kind: "fillCircle", + x: c.x + dx * world.width, + y: c.y + dy * world.height, + radius: c.radius, + color: bgColor, + alpha: 1, + }); + } } return ops; } +const ORIGIN_ONLY_OFFSET: ReadonlyArray = [[0, 0]]; + export async function createRenderer(opts: RendererOptions): Promise { const theme = opts.theme ?? DARK_THEME; const preference = opts.preference ?? ["webgpu", "webgl"]; @@ -347,12 +368,17 @@ export async function createRenderer(opts: RendererOptions): Promise = EMPTY_HIDDEN_IDS; - // Per-copy fog Graphics for the Phase 29 visibility fog overlay. - // Created lazily when `setVisibilityFog` first receives a - // non-empty list; cleared (and destroyed) when the list goes - // empty again. Each fog Graphics is inserted at index 0 of its - // torus copy so primitives paint on top. - let fogGraphics: Graphics[] = []; + // 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[] = []; const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => { const visible = !hiddenIds.has(id); for (const g of list) g.visible = visible; @@ -778,12 +804,14 @@ export async function createRenderer(opts: RendererOptions): Promise hiddenIds.has(id), setVisibilityFog: (circles) => { - // Drop the old fog Graphics first — every flip rebuilds + // 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. - for (const g of fogGraphics) { - g.parent?.removeChild(g); - g.destroy(); + // `destroy({children: true})` propagates to every owned + // Graphics inside the Container. + for (const c of fogGraphics) { + c.parent?.removeChild(c); + c.destroy({ children: true }); } fogGraphics = []; const ops = fogPaintOps( @@ -791,23 +819,32 @@ export async function createRenderer(opts: RendererOptions): Promise { @@ -833,12 +870,12 @@ export async function createRenderer(opts: RendererOptions): Promise { +describe("fogPaintOps — no-wrap mode", () => { test("empty input returns no ops", () => { - expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR)).toEqual([]); + expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]); }); test("single circle emits fog rect + one bg circle in that order", () => { @@ -26,6 +28,7 @@ describe("fogPaintOps", () => { [{ x: 100, y: 200, radius: 50 }], FOG_COLOR, BG_COLOR, + "no-wrap", ); expect(ops).toEqual([ { @@ -58,12 +61,12 @@ describe("fogPaintOps", () => { ], FOG_COLOR, BG_COLOR, + "no-wrap", ); expect(ops.length).toBe(4); expect(ops[0].kind).toBe("fillRect"); for (let i = 1; i < ops.length; i++) { expect(ops[i].kind).toBe("fillCircle"); - // Background-coloured circles paint on top of the fog rect. const op = ops[i]; if (op.kind === "fillCircle") { expect(op.color).toBe(BG_COLOR); @@ -72,43 +75,6 @@ describe("fogPaintOps", () => { } }); - test("overlapping circles are emitted independently — the rendering order unions them", () => { - // Two overlapping circles around adjacent LOCAL planets — the - // op list keeps both circles. The renderer relies on the - // overpaint to merge them visually; `cut()` (the previous - // implementation) miscomputed the union. - const ops = fogPaintOps( - WORLD, - [ - { x: 200, y: 200, radius: 100 }, - { x: 250, y: 200, radius: 100 }, - ], - FOG_COLOR, - BG_COLOR, - ); - expect(ops.length).toBe(3); - expect(ops[1]).toMatchObject({ x: 200, y: 200, radius: 100 }); - expect(ops[2]).toMatchObject({ x: 250, y: 200, radius: 100 }); - }); - - test("the fog rect always covers the full world rectangle", () => { - const ops = fogPaintOps( - { width: 3200, height: 1600 }, - [{ x: 0, y: 0, radius: 10 }], - FOG_COLOR, - BG_COLOR, - ); - expect(ops[0]).toEqual({ - kind: "fillRect", - x: 0, - y: 0, - width: 3200, - height: 1600, - color: FOG_COLOR, - alpha: 1, - }); - }); - test("zero or negative world dimensions return no ops", () => { expect( fogPaintOps( @@ -116,6 +82,7 @@ describe("fogPaintOps", () => { [{ x: 0, y: 0, radius: 10 }], FOG_COLOR, BG_COLOR, + "no-wrap", ), ).toEqual([]); expect( @@ -124,7 +91,85 @@ describe("fogPaintOps", () => { [{ x: 0, y: 0, radius: 10 }], FOG_COLOR, BG_COLOR, + "no-wrap", ), ).toEqual([]); }); }); + +describe("fogPaintOps — torus mode", () => { + test("each circle is emitted at nine wrapped positions", () => { + const ops = fogPaintOps( + WORLD, + [{ x: 100, y: 200, radius: 50 }], + FOG_COLOR, + 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}` : "")) + .sort(); + // Every neighbour offset is emitted with width=1000 / height=800. + const expected: string[] = []; + for (const dx of [-1, 0, 1]) { + for (const dy of [-1, 0, 1]) { + expected.push(`${100 + dx * 1000},${200 + dy * 800}`); + } + } + expected.sort(); + expect(positions).toEqual(expected); + }); + + test("multiple circles produce 9 × N wrapped circles after the fog rect", () => { + const ops = fogPaintOps( + WORLD, + [ + { x: 100, y: 100, radius: 50 }, + { x: 700, y: 600, radius: 30 }, + ], + FOG_COLOR, + 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. + const radii = ops + .slice(1) + .map((op) => (op.kind === "fillCircle" ? op.radius : 0)) + .filter((r) => r > 0); + expect(radii.filter((r) => r === 50).length).toBe(9); + expect(radii.filter((r) => r === 30).length).toBe(9); + }); + + 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. + const ops = fogPaintOps( + WORLD, + [{ x: 950, y: 400, radius: 300 }], + FOG_COLOR, + BG_COLOR, + "torus", + ); + const xs = ops + .slice(1) + .map((op) => (op.kind === "fillCircle" ? op.x : 0)); + expect(xs).toContain(-50); + expect(xs).toContain(950); + expect(xs).toContain(1950); + }); + + test("empty input still returns no ops in torus mode", () => { + expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]); + }); +});