f6e4a4f6bd
The map view now selects a DARK_THEME or LIGHT_THEME palette from the resolved app theme and threads it through every primitive builder, so the canvas, planets, ship groups, cargo routes, battle/bombing markers, fog, reach + selection rings, pending-Send tracks, and the pick overlay all switch with the rest of the chrome. A theme flip remounts the renderer preserving the camera — Pixi bakes the background at init and every primitive bakes its colour at build, so a live re-tint is not possible on the same instance. This also fixes the reported bug: the gear-popover trigger and the loading overlay hardcoded a dark navy background, so in light theme the gear was invisible (dark icon on dark chip) until hover flipped it to a white chip. Both now use the --color-surface-overlay token and read correctly in both themes. The light palette mirrors the dark one role-for-role, darkened / saturated for contrast on a light background while keeping the incoming, battle, and bombing accents vivid. The values are a first pass meant to be refined during the F8 manual-QA loop. Removes the now-dead "Phase 35" references from the code and lifts the map-recoloring prohibition from the design-system / renderer docs; the battle scene stays a fixed-palette data-viz surface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
205 lines
5.7 KiB
TypeScript
205 lines
5.7 KiB
TypeScript
// Phase 29 unit coverage for the visible-hyperspace overlay's paint
|
||
// ops. `fogPaintOps` lives in `src/map/render.ts` next to its sole
|
||
// consumer (`RendererHandle.setVisibilityFog`): the renderer draws
|
||
// the rectangle ops into a `fogLayer` container (below every
|
||
// primitive copy) and feeds the circle ops into an inverse stencil
|
||
// mask that cuts the visibility holes out of the fog. `fogPaintOps`
|
||
// only produces the ordered op list — rect(s) first, then one circle
|
||
// per visibility circle — which is what these tests pin; earlier
|
||
// renderer implementations used Pixi `cut()` (disconnected arcs) and
|
||
// then opaque overpaint (a fill-rate cliff under Safari's WebGPU).
|
||
//
|
||
// Coordinates returned by `fogPaintOps` are in world space because
|
||
// `fogLayer` and the mask have no transform — wraps for torus mode
|
||
// are baked into the ops directly.
|
||
|
||
import { describe, expect, test } from "vitest";
|
||
|
||
import { fogPaintOps } from "../src/map/render";
|
||
import { DARK_THEME } from "../src/map/world";
|
||
|
||
// The fog colour now lives on the theme; the renderer passes
|
||
// `theme.fog` to `fogPaintOps`. These ops tests pin the pure
|
||
// projection, so they reference the dark palette's value directly.
|
||
const FOG_COLOR = DARK_THEME.fog;
|
||
const BG_COLOR = DARK_THEME.background;
|
||
const WORLD = { width: 1000, height: 800 };
|
||
|
||
describe("fogPaintOps — no-wrap mode", () => {
|
||
test("empty input returns no ops", () => {
|
||
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
|
||
});
|
||
|
||
test("single circle emits a single fog rect + one bg circle", () => {
|
||
const ops = fogPaintOps(
|
||
WORLD,
|
||
[{ x: 100, y: 200, radius: 50 }],
|
||
FOG_COLOR,
|
||
BG_COLOR,
|
||
"no-wrap",
|
||
);
|
||
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,
|
||
"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");
|
||
const op = ops[i];
|
||
if (op.kind === "fillCircle") {
|
||
expect(op.color).toBe(BG_COLOR);
|
||
expect(op.alpha).toBe(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,
|
||
"no-wrap",
|
||
),
|
||
).toEqual([]);
|
||
expect(
|
||
fogPaintOps(
|
||
{ width: 1000, height: -1 },
|
||
[{ x: 0, y: 0, radius: 10 }],
|
||
FOG_COLOR,
|
||
BG_COLOR,
|
||
"no-wrap",
|
||
),
|
||
).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe("fogPaintOps — torus mode", () => {
|
||
test("single circle expands to 9 fog rects + 9 bg circles in world space", () => {
|
||
const ops = fogPaintOps(
|
||
WORLD,
|
||
[{ x: 100, y: 200, radius: 50 }],
|
||
FOG_COLOR,
|
||
BG_COLOR,
|
||
"torus",
|
||
);
|
||
// 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();
|
||
const expectedRectPositions: string[] = [];
|
||
for (const dx of [-1, 0, 1]) {
|
||
for (const dy of [-1, 0, 1]) {
|
||
expectedRectPositions.push(`${dx * 1000},${dy * 800}`);
|
||
}
|
||
}
|
||
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 fog rects + 9N bg circles", () => {
|
||
const ops = fogPaintOps(
|
||
WORLD,
|
||
[
|
||
{ x: 100, y: 100, radius: 50 },
|
||
{ x: 700, y: 600, radius: 30 },
|
||
],
|
||
FOG_COLOR,
|
||
BG_COLOR,
|
||
"torus",
|
||
);
|
||
// 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(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);
|
||
});
|
||
|
||
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 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 }],
|
||
FOG_COLOR,
|
||
BG_COLOR,
|
||
"torus",
|
||
);
|
||
const circleXs = ops
|
||
.filter((op) => op.kind === "fillCircle")
|
||
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
|
||
expect(circleXs).toContain(-50);
|
||
expect(circleXs).toContain(950);
|
||
expect(circleXs).toContain(1950);
|
||
});
|
||
|
||
test("empty input still returns no ops in torus mode", () => {
|
||
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]);
|
||
});
|
||
});
|