diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts index e65e601..3c6461b 100644 --- a/ui/frontend/src/map/render.ts +++ b/ui/frontend/src/map/render.ts @@ -213,7 +213,81 @@ const EMPTY_HIDDEN_IDS: ReadonlySet = new Set(); // lighter than the dark theme background (`0x0a0e1a`) so it reads // as a faint fog without contrasting against the rest of the map. // The colour is tunable in Phase 35 polish. -const FOG_COLOR = 0x12162a; +export const FOG_COLOR = 0x12162a; + +/** + * FogPaintOp is one item in the ordered draw sequence produced by + * `fogPaintOps`. The renderer dispatches each op directly onto a + * Pixi `Graphics`; the indirection exists so the Phase 29 layered + * overpaint (fog rect then background-coloured circles on top) can + * be unit-tested without a Pixi context. + */ +export type FogPaintOp = + | { + readonly kind: "fillRect"; + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; + readonly color: number; + readonly alpha: number; + } + | { + readonly kind: "fillCircle"; + readonly x: number; + readonly y: number; + readonly radius: number; + readonly color: number; + readonly alpha: number; + }; + +/** + * 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). + * + * 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. + */ +export function fogPaintOps( + world: { width: number; height: number }, + circles: ReadonlyArray<{ x: number; y: number; radius: number }>, + fogColor: number, + bgColor: number, +): FogPaintOp[] { + if (circles.length === 0) return []; + if (world.width <= 0 || world.height <= 0) return []; + const ops: FogPaintOp[] = [ + { + kind: "fillRect", + x: 0, + y: 0, + width: world.width, + height: world.height, + color: fogColor, + alpha: 1, + }, + ]; + for (const c of circles) { + ops.push({ + kind: "fillCircle", + x: c.x, + y: c.y, + radius: c.radius, + color: bgColor, + alpha: 1, + }); + } + return ops; +} export async function createRenderer(opts: RendererOptions): Promise { const theme = opts.theme ?? DARK_THEME; @@ -712,23 +786,22 @@ export async function createRenderer(opts: RendererOptions): Promise { + test("empty input returns no ops", () => { + expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR)).toEqual([]); + }); + + test("single circle emits fog rect + one bg circle in that order", () => { + const ops = fogPaintOps( + WORLD, + [{ x: 100, y: 200, radius: 50 }], + FOG_COLOR, + BG_COLOR, + ); + expect(ops).toEqual([ + { + kind: "fillRect", + x: 0, + y: 0, + width: 1000, + height: 800, + color: FOG_COLOR, + alpha: 1, + }, + { + kind: "fillCircle", + x: 100, + y: 200, + radius: 50, + color: BG_COLOR, + alpha: 1, + }, + ]); + }); + + test("multiple circles produce one fog rect followed by N bg circles", () => { + const ops = fogPaintOps( + WORLD, + [ + { x: 100, y: 100, radius: 50 }, + { x: 300, y: 200, radius: 80 }, + { x: 500, y: 600, radius: 30 }, + ], + FOG_COLOR, + BG_COLOR, + ); + 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); + expect(op.alpha).toBe(1); + } + } + }); + + 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( + { width: 0, height: 800 }, + [{ x: 0, y: 0, radius: 10 }], + FOG_COLOR, + BG_COLOR, + ), + ).toEqual([]); + expect( + fogPaintOps( + { width: 1000, height: -1 }, + [{ x: 0, y: 0, radius: 10 }], + FOG_COLOR, + BG_COLOR, + ), + ).toEqual([]); + }); +});