Phase 29 — Map Toggles #20

Merged
developer merged 8 commits from feature/ui-map-toggles into development 2026-05-19 22:37:30 +00:00
2 changed files with 130 additions and 117 deletions
Showing only changes of commit 53b892ae00 - Show all commits
+67 -76
View File
@@ -243,29 +243,26 @@ export type FogPaintOp =
/** /**
* fogPaintOps returns the ordered sequence of paint operations that * fogPaintOps returns the ordered sequence of paint operations that
* draw the Phase 29 visible-hyperspace overlay on a single torus * draw the Phase 29 visible-hyperspace overlay. The renderer
* copy. The first op is the fog-coloured rectangle covering the * dispatches each op onto its own Pixi `Graphics` inside a single
* full world; subsequent ops are background-coloured circles, one * `fogLayer` that sits below every primitive copy, so the natural
* per visibility circle, painted on top of the fog rectangle. The * rendering order paints fog underneath the world.
* natural rendering order unions overlapping circles for free — *
* earlier iterations relied on Pixi v8's `Graphics.cut()` to * Coordinates are in world space (the `fogLayer` has no transform),
* subtract holes, but `cut()` produced incorrect unions for * which means the wrap offsets are baked directly into the
* overlapping circles (the symptom was a handful of disconnected * positions — there is no per-tile dispatch on the renderer side.
* arc segments instead of a clean union).
* *
* `mode` controls the torus-wrap behaviour: * `mode` controls the torus-wrap behaviour:
* *
* - `"torus"`: every visibility circle is also drawn at the eight * - `"torus"`: every fog rect AND every visibility circle is
* wrapped positions (±width, ±height) so the circle remains * emitted at the nine offsets (`(dx, dy) ∈ {-1, 0, 1}²`), so
* visually continuous when its painted area extends past the * the fog covers all nine torus tiles and a planet near a seam
* world rectangle into a neighbouring tile — without the wraps * keeps a continuous visibility hole across it.
* the next tile's fog rectangle overpaints the bleed, producing * - `"no-wrap"`: only the central tile is emitted. The user can
* a "sector" artifact at the seam. * never pan past the boundary in no-wrap mode, so the
* - `"no-wrap"`: only the planet's own position is drawn. The * additional wraps would just be wasted paint — worse, a
* wrapped positions would create extra holes inside the world * wrapped circle from a planet near an edge would leak into
* rectangle when a planet sits near an edge (the user can never * the visible world rectangle as an unwanted hole.
* 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 * Empty `circles` returns an empty list — the caller skips fog
* rendering entirely. Width/height ≤ 0 also returns empty so a * rendering entirely. Width/height ≤ 0 also returns empty so a
@@ -282,17 +279,18 @@ export function fogPaintOps(
if (world.width <= 0 || world.height <= 0) return []; if (world.width <= 0 || world.height <= 0) return [];
const offsets: ReadonlyArray<readonly [number, number]> = const offsets: ReadonlyArray<readonly [number, number]> =
mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET; mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET;
const ops: FogPaintOp[] = [ const ops: FogPaintOp[] = [];
{ for (const [dx, dy] of offsets) {
ops.push({
kind: "fillRect", kind: "fillRect",
x: 0, x: dx * world.width,
y: 0, y: dy * world.height,
width: world.width, width: world.width,
height: world.height, height: world.height,
color: fogColor, color: fogColor,
alpha: 1, alpha: 1,
}, });
]; }
for (const c of circles) { for (const c of circles) {
for (const [dx, dy] of offsets) { for (const [dx, dy] of offsets) {
ops.push({ ops.push({
@@ -343,6 +341,17 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
app.stage.addChild(viewport); app.stage.addChild(viewport);
// Phase 29 fog layer: a single Container sharing the viewport's
// coordinate space, populated by `setVisibilityFog`. Added to
// the viewport BEFORE the nine torus copies so the layered
// repaint (fog rectangles + background-coloured circles) always
// renders underneath every primitive. An earlier per-copy
// approach with `copy.addChildAt(fog, 0)` ended up with fog on
// top in practice — moving the fog to a sibling of the copies
// avoids any reorder ambiguity.
const fogLayer = new Container();
viewport.addChild(fogLayer);
// Create nine torus copies, each holding its own primitive // Create nine torus copies, each holding its own primitive
// graphics. Origin copy is always visible; the other eight // graphics. Origin copy is always visible; the other eight
// follow the active wrap mode. // follow the active wrap mode.
@@ -368,17 +377,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
// renderer-internal hit-test sites (pointer-move, clicked) and the // renderer-internal hit-test sites (pointer-move, clicked) and the
// external `handle.hitAt` thread it through `hitTest`. // external `handle.hitAt` thread it through `hitTest`.
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS; let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
// Per-copy fog Containers for the Phase 29 visibility fog // `fogLayer` (declared above) is repopulated every time
// overlay. Each container holds one `Graphics` per // `setVisibilityFog` runs. We track the dispatched ops only
// `FogPaintOp` (fog rect + one bg-coloured circle per // implicitly via the layer's children; on every flip we drop
// visibility circle × wrap position), inserted at index 0 of // the previous children and rebuild from the new op list.
// 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 applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
const visible = !hiddenIds.has(id); const visible = !hiddenIds.has(id);
for (const g of list) g.visible = visible; for (const g of list) g.visible = visible;
@@ -804,16 +806,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}, },
isPrimitiveHidden: (id) => hiddenIds.has(id), isPrimitiveHidden: (id) => hiddenIds.has(id),
setVisibilityFog: (circles) => { setVisibilityFog: (circles) => {
// Drop the old fog Containers first — every flip rebuilds // Drop the previous fog children — every flip rebuilds
// from scratch instead of mutating in place, so the // from scratch instead of mutating in place. Pixi v8's
// implementation stays simple and Pixi-v8-residue-free. // `Container.removeChildren()` returns the detached
// `destroy({children: true})` propagates to every owned // children so we can destroy each one explicitly.
// Graphics inside the Container. for (const old of fogLayer.removeChildren()) {
for (const c of fogGraphics) { old.destroy({ children: true });
c.parent?.removeChild(c);
c.destroy({ children: true });
} }
fogGraphics = [];
const ops = fogPaintOps( const ops = fogPaintOps(
opts.world, opts.world,
circles, circles,
@@ -822,29 +821,21 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
mode, mode,
); );
if (ops.length === 0) return; if (ops.length === 0) return;
for (const copy of copies) { // Each op gets its own Graphics so any multi-shape Pixi
const container = new Container(); // quirks cannot drop a layer (an earlier all-in-one
// One Graphics per op — the fog rect first, then every // implementation surfaced exactly that symptom in DEV —
// background-coloured circle on top. Per-shape Graphics // only the last planet's glyph stayed visible inside the
// removes any risk of multi-shape Pixi quirks dropping a // bg holes). The ops carry world-space positions; the
// layer (the previous all-in-one Graphics implementation // `fogLayer` has no transform.
// surfaced exactly that symptom in DEV — only the last for (const op of ops) {
// planet's glyph stayed visible inside the bg holes). const g = new Graphics();
for (const op of ops) { if (op.kind === "fillRect") {
const g = new Graphics(); g.rect(op.x, op.y, op.width, op.height);
if (op.kind === "fillRect") { } else {
g.rect(op.x, op.y, op.width, op.height); g.circle(op.x, op.y, op.radius);
} else {
g.circle(op.x, op.y, op.radius);
}
g.fill({ color: op.color, alpha: op.alpha });
container.addChild(g);
} }
// Fog sits below every primitive on the same copy so g.fill({ color: op.color, alpha: op.alpha });
// planet glyphs paint on top. `addChildAt(g, 0)` keeps fogLayer.addChild(g);
// the rest of the children's order intact.
copy.addChildAt(container, 0);
fogGraphics.push(container);
} }
}, },
resize: (w, h) => { resize: (w, h) => {
@@ -869,15 +860,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
teardownPickMode(); teardownPickMode();
previous?.onPick(null); previous?.onPick(null);
} }
// `app.destroy({...children: true})` below would also walk // `app.destroy({...children: true})` below recursively
// fog containers, but we drop them eagerly so the closure // destroys every container in the scene graph, fogLayer
// reference clears even if a future caller queries the // included. The explicit removeChildren()/destroy here
// renderer mid-dispose. // drops the fog children eagerly so a future caller
for (const c of fogGraphics) { // querying the renderer mid-dispose does not see stale
c.parent?.removeChild(c); // fog instances still parented under the layer.
c.destroy({ children: true }); for (const old of fogLayer.removeChildren()) {
old.destroy({ children: true });
} }
fogGraphics = [];
viewport.off("moved", enforceCentreWhenLarger); viewport.off("moved", enforceCentreWhenLarger);
viewport.off("moved", wrapTorusCamera); viewport.off("moved", wrapTorusCamera);
viewport.off("clicked", handleViewportClicked); viewport.off("clicked", handleViewportClicked);
+63 -41
View File
@@ -1,14 +1,15 @@
// Phase 29 unit coverage for the Phase 29 fog overlay's layered // Phase 29 unit coverage for the visible-hyperspace overlay's
// overpaint logic. `fogPaintOps` lives in `src/map/render.ts` next // layered overpaint logic. `fogPaintOps` lives in `src/map/render.ts`
// to its sole consumer (`RendererHandle.setVisibilityFog`) — the // next to its sole consumer (`RendererHandle.setVisibilityFog`) —
// renderer dispatches each op straight onto its own Pixi `Graphics` // the renderer dispatches each op onto its own Pixi `Graphics`
// (one per shape) inside a per-copy `Container`, so the unit test // inside a `fogLayer` container that sits below every primitive
// exercises the public ordering contract: a single fog-coloured // copy. The natural rendering order paints fog underneath the
// rectangle followed by one background-coloured circle per // world, replacing the earlier `cut()` implementation that
// visibility entry (multiplied by the torus wrap offsets when the // produced disconnected arc segments.
// renderer is in torus mode). The natural rendering order unions //
// overlapping circles for free, replacing the earlier `cut()` // Coordinates returned by `fogPaintOps` are in world space because
// implementation that produced disconnected arc segments. // `fogLayer` has no transform — wraps for torus mode are baked
// into the ops directly.
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
@@ -22,7 +23,7 @@ describe("fogPaintOps — no-wrap mode", () => {
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]); expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "no-wrap")).toEqual([]);
}); });
test("single circle emits fog rect + one bg circle in that order", () => { test("single circle emits a single fog rect + one bg circle", () => {
const ops = fogPaintOps( const ops = fogPaintOps(
WORLD, WORLD,
[{ x: 100, y: 200, radius: 50 }], [{ x: 100, y: 200, radius: 50 }],
@@ -98,7 +99,7 @@ describe("fogPaintOps — no-wrap mode", () => {
}); });
describe("fogPaintOps — torus mode", () => { describe("fogPaintOps — torus mode", () => {
test("each circle is emitted at nine wrapped positions", () => { test("single circle expands to 9 fog rects + 9 bg circles in world space", () => {
const ops = fogPaintOps( const ops = fogPaintOps(
WORLD, WORLD,
[{ x: 100, y: 200, radius: 50 }], [{ x: 100, y: 200, radius: 50 }],
@@ -106,25 +107,43 @@ describe("fogPaintOps — torus mode", () => {
BG_COLOR, BG_COLOR,
"torus", "torus",
); );
// 1 fog rect + 9 wrapped circles. // 9 fog rects + 9 wrapped circles.
expect(ops.length).toBe(10); expect(ops.length).toBe(18);
expect(ops[0].kind).toBe("fillRect"); // The first 9 ops are fog rects, one per neighbour tile.
const positions = ops const rectPositions = ops
.slice(1) .slice(0, 9)
.map((op) => (op.kind === "fillCircle" ? `${op.x},${op.y}` : "")) .map((op) =>
op.kind === "fillRect" ? `${op.x},${op.y}` : "non-rect",
)
.sort(); .sort();
// Every neighbour offset is emitted with width=1000 / height=800. const expectedRectPositions: string[] = [];
const expected: string[] = [];
for (const dx of [-1, 0, 1]) { for (const dx of [-1, 0, 1]) {
for (const dy of [-1, 0, 1]) { for (const dy of [-1, 0, 1]) {
expected.push(`${100 + dx * 1000},${200 + dy * 800}`); expectedRectPositions.push(`${dx * 1000},${dy * 800}`);
} }
} }
expected.sort(); expectedRectPositions.sort();
expect(positions).toEqual(expected); 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 × N wrapped circles after the fog rect", () => { test("multiple circles produce 9 fog rects + 9N bg circles", () => {
const ops = fogPaintOps( const ops = fogPaintOps(
WORLD, WORLD,
[ [
@@ -135,14 +154,17 @@ describe("fogPaintOps — torus mode", () => {
BG_COLOR, BG_COLOR,
"torus", "torus",
); );
// 1 fog rect + (9 wraps × 2 circles) = 19 ops. // 9 fog rects + (9 wraps × 2 circles) = 27 ops.
expect(ops.length).toBe(19); expect(ops.length).toBe(27);
expect(ops[0].kind).toBe("fillRect"); expect(
// Each circle keeps its own radius across every wrap. 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 const radii = ops
.slice(1) .slice(9)
.map((op) => (op.kind === "fillCircle" ? op.radius : 0)) .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 === 50).length).toBe(9);
expect(radii.filter((r) => r === 30).length).toBe(9); expect(radii.filter((r) => r === 30).length).toBe(9);
}); });
@@ -150,10 +172,10 @@ describe("fogPaintOps — torus mode", () => {
test("a circle near the right edge produces a wrapped copy past the seam", () => { test("a circle near the right edge produces a wrapped copy past the seam", () => {
// Planet at (950, 400) with radius 300 — the painted area // Planet at (950, 400) with radius 300 — the painted area
// extends to x = 1250 in the central tile. In torus mode the // extends to x = 1250 in the central tile. In torus mode the
// renderer also draws a wrapped circle at (950 - 1000, 400) = // renderer also draws wrapped circles at (-50, 400) and
// (-50, 400) so the next tile (with its own fog rect) keeps a // (1950, 400) so the circle stays continuous across the seam
// matching unfogged hole at the seam — this is the fix for // instead of appearing as a sector clipped by the neighbour
// the "sector" artifact at the wrap boundary. // tile's fog rectangle.
const ops = fogPaintOps( const ops = fogPaintOps(
WORLD, WORLD,
[{ x: 950, y: 400, radius: 300 }], [{ x: 950, y: 400, radius: 300 }],
@@ -161,12 +183,12 @@ describe("fogPaintOps — torus mode", () => {
BG_COLOR, BG_COLOR,
"torus", "torus",
); );
const xs = ops const circleXs = ops
.slice(1) .filter((op) => op.kind === "fillCircle")
.map((op) => (op.kind === "fillCircle" ? op.x : 0)); .map((op) => (op.kind === "fillCircle" ? op.x : 0));
expect(xs).toContain(-50); expect(circleXs).toContain(-50);
expect(xs).toContain(950); expect(circleXs).toContain(950);
expect(xs).toContain(1950); expect(circleXs).toContain(1950);
}); });
test("empty input still returns no ops in torus mode", () => { test("empty input still returns no ops in torus mode", () => {