Phase 29 — Map Toggles #20
@@ -253,18 +253,35 @@ export type FogPaintOp =
|
|||||||
* overlapping circles (the symptom was a handful of disconnected
|
* overlapping circles (the symptom was a handful of disconnected
|
||||||
* arc segments instead of a clean union).
|
* arc segments instead of a clean union).
|
||||||
*
|
*
|
||||||
|
* `mode` controls the torus-wrap behaviour:
|
||||||
|
*
|
||||||
|
* - `"torus"`: every visibility circle is also drawn at the eight
|
||||||
|
* wrapped positions (±width, ±height) so the circle remains
|
||||||
|
* visually continuous when its painted area extends past the
|
||||||
|
* world rectangle into a neighbouring tile — without the wraps
|
||||||
|
* the next tile's fog rectangle overpaints the bleed, producing
|
||||||
|
* a "sector" artifact at the seam.
|
||||||
|
* - `"no-wrap"`: only the planet's own position is drawn. The
|
||||||
|
* wrapped positions would create extra holes inside the world
|
||||||
|
* rectangle when a planet sits near an edge (the user can never
|
||||||
|
* 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
|
||||||
* degenerate world cannot produce a non-emit op set.
|
* degenerate world cannot produce a non-empty op set.
|
||||||
*/
|
*/
|
||||||
export function fogPaintOps(
|
export function fogPaintOps(
|
||||||
world: { width: number; height: number },
|
world: { width: number; height: number },
|
||||||
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
||||||
fogColor: number,
|
fogColor: number,
|
||||||
bgColor: number,
|
bgColor: number,
|
||||||
|
mode: WrapMode,
|
||||||
): FogPaintOp[] {
|
): FogPaintOp[] {
|
||||||
if (circles.length === 0) return [];
|
if (circles.length === 0) return [];
|
||||||
if (world.width <= 0 || world.height <= 0) return [];
|
if (world.width <= 0 || world.height <= 0) return [];
|
||||||
|
const offsets: ReadonlyArray<readonly [number, number]> =
|
||||||
|
mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET;
|
||||||
const ops: FogPaintOp[] = [
|
const ops: FogPaintOp[] = [
|
||||||
{
|
{
|
||||||
kind: "fillRect",
|
kind: "fillRect",
|
||||||
@@ -277,18 +294,22 @@ export function fogPaintOps(
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
for (const c of circles) {
|
for (const c of circles) {
|
||||||
ops.push({
|
for (const [dx, dy] of offsets) {
|
||||||
kind: "fillCircle",
|
ops.push({
|
||||||
x: c.x,
|
kind: "fillCircle",
|
||||||
y: c.y,
|
x: c.x + dx * world.width,
|
||||||
radius: c.radius,
|
y: c.y + dy * world.height,
|
||||||
color: bgColor,
|
radius: c.radius,
|
||||||
alpha: 1,
|
color: bgColor,
|
||||||
});
|
alpha: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ops;
|
return ops;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ORIGIN_ONLY_OFFSET: ReadonlyArray<readonly [number, number]> = [[0, 0]];
|
||||||
|
|
||||||
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
|
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
|
||||||
const theme = opts.theme ?? DARK_THEME;
|
const theme = opts.theme ?? DARK_THEME;
|
||||||
const preference = opts.preference ?? ["webgpu", "webgl"];
|
const preference = opts.preference ?? ["webgpu", "webgl"];
|
||||||
@@ -347,12 +368,17 @@ 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 Graphics for the Phase 29 visibility fog overlay.
|
// Per-copy fog Containers for the Phase 29 visibility fog
|
||||||
// Created lazily when `setVisibilityFog` first receives a
|
// overlay. Each container holds one `Graphics` per
|
||||||
// non-empty list; cleared (and destroyed) when the list goes
|
// `FogPaintOp` (fog rect + one bg-coloured circle per
|
||||||
// empty again. Each fog Graphics is inserted at index 0 of its
|
// visibility circle × wrap position), inserted at index 0 of
|
||||||
// torus copy so primitives paint on top.
|
// the torus copy so primitives paint on top. Created lazily
|
||||||
let fogGraphics: Graphics[] = [];
|
// 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;
|
||||||
@@ -778,12 +804,14 @@ 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 Graphics first — every flip rebuilds
|
// Drop the old fog Containers first — every flip rebuilds
|
||||||
// from scratch instead of mutating in place, so the
|
// from scratch instead of mutating in place, so the
|
||||||
// implementation stays simple and Pixi-v8-residue-free.
|
// implementation stays simple and Pixi-v8-residue-free.
|
||||||
for (const g of fogGraphics) {
|
// `destroy({children: true})` propagates to every owned
|
||||||
g.parent?.removeChild(g);
|
// Graphics inside the Container.
|
||||||
g.destroy();
|
for (const c of fogGraphics) {
|
||||||
|
c.parent?.removeChild(c);
|
||||||
|
c.destroy({ children: true });
|
||||||
}
|
}
|
||||||
fogGraphics = [];
|
fogGraphics = [];
|
||||||
const ops = fogPaintOps(
|
const ops = fogPaintOps(
|
||||||
@@ -791,23 +819,32 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
circles,
|
circles,
|
||||||
FOG_COLOR,
|
FOG_COLOR,
|
||||||
theme.background,
|
theme.background,
|
||||||
|
mode,
|
||||||
);
|
);
|
||||||
if (ops.length === 0) return;
|
if (ops.length === 0) return;
|
||||||
for (const copy of copies) {
|
for (const copy of copies) {
|
||||||
const g = new Graphics();
|
const container = new Container();
|
||||||
|
// One Graphics per op — the fog rect first, then every
|
||||||
|
// background-coloured circle on top. Per-shape Graphics
|
||||||
|
// removes any risk of multi-shape Pixi quirks dropping a
|
||||||
|
// layer (the previous all-in-one Graphics implementation
|
||||||
|
// surfaced exactly that symptom in DEV — only the last
|
||||||
|
// planet's glyph stayed visible inside the bg holes).
|
||||||
for (const op of ops) {
|
for (const op of ops) {
|
||||||
|
const g = new Graphics();
|
||||||
if (op.kind === "fillRect") {
|
if (op.kind === "fillRect") {
|
||||||
g.rect(op.x, op.y, op.width, op.height);
|
g.rect(op.x, op.y, op.width, op.height);
|
||||||
} else {
|
} else {
|
||||||
g.circle(op.x, op.y, op.radius);
|
g.circle(op.x, op.y, op.radius);
|
||||||
}
|
}
|
||||||
g.fill({ color: op.color, alpha: op.alpha });
|
g.fill({ color: op.color, alpha: op.alpha });
|
||||||
|
container.addChild(g);
|
||||||
}
|
}
|
||||||
// Fog sits below every primitive on the same copy so
|
// Fog sits below every primitive on the same copy so
|
||||||
// planet glyphs paint on top. `addChildAt(g, 0)` keeps
|
// planet glyphs paint on top. `addChildAt(g, 0)` keeps
|
||||||
// the rest of the children's order intact.
|
// the rest of the children's order intact.
|
||||||
copy.addChildAt(g, 0);
|
copy.addChildAt(container, 0);
|
||||||
fogGraphics.push(g);
|
fogGraphics.push(container);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resize: (w, h) => {
|
resize: (w, h) => {
|
||||||
@@ -833,12 +870,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
|||||||
previous?.onPick(null);
|
previous?.onPick(null);
|
||||||
}
|
}
|
||||||
// `app.destroy({...children: true})` below would also walk
|
// `app.destroy({...children: true})` below would also walk
|
||||||
// fog graphics, but we drop them eagerly so the closure
|
// fog containers, but we drop them eagerly so the closure
|
||||||
// reference clears even if a future caller queries the
|
// reference clears even if a future caller queries the
|
||||||
// renderer mid-dispose.
|
// renderer mid-dispose.
|
||||||
for (const g of fogGraphics) {
|
for (const c of fogGraphics) {
|
||||||
g.parent?.removeChild(g);
|
c.parent?.removeChild(c);
|
||||||
g.destroy();
|
c.destroy({ children: true });
|
||||||
}
|
}
|
||||||
fogGraphics = [];
|
fogGraphics = [];
|
||||||
viewport.off("moved", enforceCentreWhenLarger);
|
viewport.off("moved", enforceCentreWhenLarger);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// Phase 29 unit coverage for the Phase 29 fog overlay's layered
|
// Phase 29 unit coverage for the Phase 29 fog overlay's layered
|
||||||
// overpaint logic. `fogPaintOps` lives in `src/map/render.ts` next
|
// overpaint logic. `fogPaintOps` lives in `src/map/render.ts` next
|
||||||
// to its sole consumer (`RendererHandle.setVisibilityFog`) — the
|
// to its sole consumer (`RendererHandle.setVisibilityFog`) — the
|
||||||
// renderer dispatches each op straight onto a Pixi `Graphics`, so
|
// renderer dispatches each op straight onto its own Pixi `Graphics`
|
||||||
// the unit test exercises the public ordering contract: a single
|
// (one per shape) inside a per-copy `Container`, so the unit test
|
||||||
// fog-coloured rectangle followed by one background-coloured
|
// exercises the public ordering contract: a single fog-coloured
|
||||||
// circle per visibility entry. The natural rendering order unions
|
// rectangle followed by one background-coloured circle per
|
||||||
|
// visibility entry (multiplied by the torus wrap offsets when the
|
||||||
|
// renderer is in torus mode). The natural rendering order unions
|
||||||
// overlapping circles for free, replacing the earlier `cut()`
|
// overlapping circles for free, replacing the earlier `cut()`
|
||||||
// implementation that produced disconnected arc segments.
|
// implementation that produced disconnected arc segments.
|
||||||
|
|
||||||
@@ -15,9 +17,9 @@ import { FOG_COLOR, fogPaintOps } from "../src/map/render";
|
|||||||
const BG_COLOR = 0x0a0e1a;
|
const BG_COLOR = 0x0a0e1a;
|
||||||
const WORLD = { width: 1000, height: 800 };
|
const WORLD = { width: 1000, height: 800 };
|
||||||
|
|
||||||
describe("fogPaintOps", () => {
|
describe("fogPaintOps — no-wrap mode", () => {
|
||||||
test("empty input returns no ops", () => {
|
test("empty input returns no ops", () => {
|
||||||
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR)).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 fog rect + one bg circle in that order", () => {
|
||||||
@@ -26,6 +28,7 @@ describe("fogPaintOps", () => {
|
|||||||
[{ x: 100, y: 200, radius: 50 }],
|
[{ x: 100, y: 200, radius: 50 }],
|
||||||
FOG_COLOR,
|
FOG_COLOR,
|
||||||
BG_COLOR,
|
BG_COLOR,
|
||||||
|
"no-wrap",
|
||||||
);
|
);
|
||||||
expect(ops).toEqual([
|
expect(ops).toEqual([
|
||||||
{
|
{
|
||||||
@@ -58,12 +61,12 @@ describe("fogPaintOps", () => {
|
|||||||
],
|
],
|
||||||
FOG_COLOR,
|
FOG_COLOR,
|
||||||
BG_COLOR,
|
BG_COLOR,
|
||||||
|
"no-wrap",
|
||||||
);
|
);
|
||||||
expect(ops.length).toBe(4);
|
expect(ops.length).toBe(4);
|
||||||
expect(ops[0].kind).toBe("fillRect");
|
expect(ops[0].kind).toBe("fillRect");
|
||||||
for (let i = 1; i < ops.length; i++) {
|
for (let i = 1; i < ops.length; i++) {
|
||||||
expect(ops[i].kind).toBe("fillCircle");
|
expect(ops[i].kind).toBe("fillCircle");
|
||||||
// Background-coloured circles paint on top of the fog rect.
|
|
||||||
const op = ops[i];
|
const op = ops[i];
|
||||||
if (op.kind === "fillCircle") {
|
if (op.kind === "fillCircle") {
|
||||||
expect(op.color).toBe(BG_COLOR);
|
expect(op.color).toBe(BG_COLOR);
|
||||||
@@ -72,43 +75,6 @@ describe("fogPaintOps", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
test("zero or negative world dimensions return no ops", () => {
|
||||||
expect(
|
expect(
|
||||||
fogPaintOps(
|
fogPaintOps(
|
||||||
@@ -116,6 +82,7 @@ describe("fogPaintOps", () => {
|
|||||||
[{ x: 0, y: 0, radius: 10 }],
|
[{ x: 0, y: 0, radius: 10 }],
|
||||||
FOG_COLOR,
|
FOG_COLOR,
|
||||||
BG_COLOR,
|
BG_COLOR,
|
||||||
|
"no-wrap",
|
||||||
),
|
),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
expect(
|
expect(
|
||||||
@@ -124,7 +91,85 @@ describe("fogPaintOps", () => {
|
|||||||
[{ x: 0, y: 0, radius: 10 }],
|
[{ x: 0, y: 0, radius: 10 }],
|
||||||
FOG_COLOR,
|
FOG_COLOR,
|
||||||
BG_COLOR,
|
BG_COLOR,
|
||||||
|
"no-wrap",
|
||||||
),
|
),
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("fogPaintOps — torus mode", () => {
|
||||||
|
test("each circle is emitted at nine wrapped positions", () => {
|
||||||
|
const ops = fogPaintOps(
|
||||||
|
WORLD,
|
||||||
|
[{ x: 100, y: 200, radius: 50 }],
|
||||||
|
FOG_COLOR,
|
||||||
|
BG_COLOR,
|
||||||
|
"torus",
|
||||||
|
);
|
||||||
|
// 1 fog rect + 9 wrapped circles.
|
||||||
|
expect(ops.length).toBe(10);
|
||||||
|
expect(ops[0].kind).toBe("fillRect");
|
||||||
|
const positions = ops
|
||||||
|
.slice(1)
|
||||||
|
.map((op) => (op.kind === "fillCircle" ? `${op.x},${op.y}` : ""))
|
||||||
|
.sort();
|
||||||
|
// Every neighbour offset is emitted with width=1000 / height=800.
|
||||||
|
const expected: string[] = [];
|
||||||
|
for (const dx of [-1, 0, 1]) {
|
||||||
|
for (const dy of [-1, 0, 1]) {
|
||||||
|
expected.push(`${100 + dx * 1000},${200 + dy * 800}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expected.sort();
|
||||||
|
expect(positions).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("multiple circles produce 9 × N wrapped circles after the fog rect", () => {
|
||||||
|
const ops = fogPaintOps(
|
||||||
|
WORLD,
|
||||||
|
[
|
||||||
|
{ x: 100, y: 100, radius: 50 },
|
||||||
|
{ x: 700, y: 600, radius: 30 },
|
||||||
|
],
|
||||||
|
FOG_COLOR,
|
||||||
|
BG_COLOR,
|
||||||
|
"torus",
|
||||||
|
);
|
||||||
|
// 1 fog rect + (9 wraps × 2 circles) = 19 ops.
|
||||||
|
expect(ops.length).toBe(19);
|
||||||
|
expect(ops[0].kind).toBe("fillRect");
|
||||||
|
// Each circle keeps its own radius across every wrap.
|
||||||
|
const radii = ops
|
||||||
|
.slice(1)
|
||||||
|
.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 === 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 a wrapped circle at (950 - 1000, 400) =
|
||||||
|
// (-50, 400) so the next tile (with its own fog rect) keeps a
|
||||||
|
// matching unfogged hole at the seam — this is the fix for
|
||||||
|
// the "sector" artifact at the wrap boundary.
|
||||||
|
const ops = fogPaintOps(
|
||||||
|
WORLD,
|
||||||
|
[{ x: 950, y: 400, radius: 300 }],
|
||||||
|
FOG_COLOR,
|
||||||
|
BG_COLOR,
|
||||||
|
"torus",
|
||||||
|
);
|
||||||
|
const xs = ops
|
||||||
|
.slice(1)
|
||||||
|
.map((op) => (op.kind === "fillCircle" ? op.x : 0));
|
||||||
|
expect(xs).toContain(-50);
|
||||||
|
expect(xs).toContain(950);
|
||||||
|
expect(xs).toContain(1950);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty input still returns no ops in torus mode", () => {
|
||||||
|
expect(fogPaintOps(WORLD, [], FOG_COLOR, BG_COLOR, "torus")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user