test(ui-map): unit-cover the fog overlay's layered-overpaint contract
Tests · UI / test (push) Successful in 2m49s

Lifted the Phase 29 fog draw sequence out of `setVisibilityFog`
into a pure `fogPaintOps` helper that returns an ordered list of
fill operations (one fog rect, then one background-coloured
circle per visibility entry). The renderer now dispatches each op
straight onto a Pixi `Graphics`; the indirection lets the layered-
overpaint contract be tested without booting Pixi.

`tests/fog-paint-ops.test.ts` covers: empty input → no ops; single
circle → fog rect + bg circle in that order; multiple circles → N
bg circles after the fog rect; overlapping circles emitted
independently (the rendering order unions them); zero / negative
world dimensions → no ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-19 23:42:39 +02:00
parent 37580b7699
commit 7ade838df8
2 changed files with 219 additions and 16 deletions
+89 -16
View File
@@ -213,7 +213,81 @@ const EMPTY_HIDDEN_IDS: ReadonlySet<PrimitiveID> = 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<RendererHandle> {
const theme = opts.theme ?? DARK_THEME;
@@ -712,23 +786,22 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
g.destroy();
}
fogGraphics = [];
if (circles.length === 0) return;
// Layered overpaint: a fog-tinted rectangle covers the
// world, then opaque background-coloured circles drawn on
// top reveal the visible-hyperspace area. The natural
// rendering order handles overlapping circles correctly —
// Pixi v8's `Graphics.cut()` produces inconsistent
// results for unions of holes (the previous Phase 29
// implementation hit this), and the overpaint approach
// avoids the geometry calculation entirely.
const bg = theme.background;
const ops = fogPaintOps(
opts.world,
circles,
FOG_COLOR,
theme.background,
);
if (ops.length === 0) return;
for (const copy of copies) {
const g = new Graphics();
g.rect(0, 0, opts.world.width, opts.world.height);
g.fill({ color: FOG_COLOR, alpha: 1 });
for (const c of circles) {
g.circle(c.x, c.y, c.radius);
g.fill({ color: bg, alpha: 1 });
for (const op of ops) {
if (op.kind === "fillRect") {
g.rect(op.x, op.y, op.width, op.height);
} else {
g.circle(op.x, op.y, op.radius);
}
g.fill({ color: op.color, alpha: op.alpha });
}
// Fog sits below every primitive on the same copy so
// planet glyphs paint on top. `addChildAt(g, 0)` keeps