test(ui-map): unit-cover the fog overlay's layered-overpaint contract
Tests · UI / test (push) Successful in 2m49s
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:
@@ -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
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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 a Pixi `Graphics`, so
|
||||
// the unit test exercises the public ordering contract: a single
|
||||
// fog-coloured rectangle followed by one background-coloured
|
||||
// circle per visibility entry. The natural rendering order unions
|
||||
// overlapping circles for free, replacing the earlier `cut()`
|
||||
// implementation that produced disconnected arc segments.
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { FOG_COLOR, fogPaintOps } from "../src/map/render";
|
||||
|
||||
const BG_COLOR = 0x0a0e1a;
|
||||
const WORLD = { width: 1000, height: 800 };
|
||||
|
||||
describe("fogPaintOps", () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user