fix(ui-map): split fog overlay into per-shape Graphics + torus-wrap circles
Tests · UI / test (push) Successful in 3m23s
Tests · UI / test (push) Successful in 3m23s
Two visible regressions in the in-game map's fog overlay surfaced
on dev-deploy:
1. With three LOCAL planets close together, only the last planet
glyph stayed visible inside the bg holes — the other two were
obscured. The previous implementation stacked the fog rectangle
plus every bg circle onto a single `Graphics` via repeated
`g.rect(...).fill(...).circle(...).fill(...)...`. Pixi v8's
multi-shape Graphics is supported in theory, but in practice
only the last shape's fill seems to land, dropping the earlier
bg holes (and the planet glyphs on top look like they vanished
along with their hole). Splitting each op onto its own
`Graphics` inside a per-copy `Container` removes the ambiguity
— one shape, one fill, one render pass.
2. A planet near the right world edge produced a "sector" — the
bg circle painted into the area past the seam, but the
neighbouring tile's fog rectangle then overpainted that bleed,
leaving a quarter-circle hole. In torus mode each visibility
circle is now drawn at the nine wrapped positions
(`(dx, dy) ∈ {-1, 0, 1}²`); the wrapped copies in the
neighbour-tile-aligned positions keep the hole continuous
across the seam. No-wrap mode keeps a single emission per
circle, because wrapped circles would leak into the visible
world rectangle as unwanted holes.
The `fogPaintOps` helper now takes the wrap mode as a parameter;
`tests/fog-paint-ops.test.ts` covers the torus expansion
(nine-wrap product per circle, the seam-fix case at x = 950) and
re-asserts the no-wrap path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -253,18 +253,35 @@ export type FogPaintOp =
|
||||
* overlapping circles (the symptom was a handful of disconnected
|
||||
* 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
|
||||
* 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(
|
||||
world: { width: number; height: number },
|
||||
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
||||
fogColor: number,
|
||||
bgColor: number,
|
||||
mode: WrapMode,
|
||||
): FogPaintOp[] {
|
||||
if (circles.length === 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[] = [
|
||||
{
|
||||
kind: "fillRect",
|
||||
@@ -277,18 +294,22 @@ export function fogPaintOps(
|
||||
},
|
||||
];
|
||||
for (const c of circles) {
|
||||
for (const [dx, dy] of offsets) {
|
||||
ops.push({
|
||||
kind: "fillCircle",
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
x: c.x + dx * world.width,
|
||||
y: c.y + dy * world.height,
|
||||
radius: c.radius,
|
||||
color: bgColor,
|
||||
alpha: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
const ORIGIN_ONLY_OFFSET: ReadonlyArray<readonly [number, number]> = [[0, 0]];
|
||||
|
||||
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
|
||||
const theme = opts.theme ?? DARK_THEME;
|
||||
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
|
||||
// external `handle.hitAt` thread it through `hitTest`.
|
||||
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
|
||||
// Per-copy fog Graphics for the Phase 29 visibility fog overlay.
|
||||
// Created lazily when `setVisibilityFog` first receives a
|
||||
// non-empty list; cleared (and destroyed) when the list goes
|
||||
// empty again. Each fog Graphics is inserted at index 0 of its
|
||||
// torus copy so primitives paint on top.
|
||||
let fogGraphics: Graphics[] = [];
|
||||
// Per-copy fog Containers for the Phase 29 visibility fog
|
||||
// overlay. Each container holds one `Graphics` per
|
||||
// `FogPaintOp` (fog rect + one bg-coloured circle per
|
||||
// visibility circle × wrap position), inserted at index 0 of
|
||||
// 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 visible = !hiddenIds.has(id);
|
||||
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),
|
||||
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
|
||||
// implementation stays simple and Pixi-v8-residue-free.
|
||||
for (const g of fogGraphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
// `destroy({children: true})` propagates to every owned
|
||||
// Graphics inside the Container.
|
||||
for (const c of fogGraphics) {
|
||||
c.parent?.removeChild(c);
|
||||
c.destroy({ children: true });
|
||||
}
|
||||
fogGraphics = [];
|
||||
const ops = fogPaintOps(
|
||||
@@ -791,23 +819,32 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
circles,
|
||||
FOG_COLOR,
|
||||
theme.background,
|
||||
mode,
|
||||
);
|
||||
if (ops.length === 0) return;
|
||||
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) {
|
||||
const g = new Graphics();
|
||||
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 });
|
||||
container.addChild(g);
|
||||
}
|
||||
// Fog sits below every primitive on the same copy so
|
||||
// planet glyphs paint on top. `addChildAt(g, 0)` keeps
|
||||
// the rest of the children's order intact.
|
||||
copy.addChildAt(g, 0);
|
||||
fogGraphics.push(g);
|
||||
copy.addChildAt(container, 0);
|
||||
fogGraphics.push(container);
|
||||
}
|
||||
},
|
||||
resize: (w, h) => {
|
||||
@@ -833,12 +870,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
previous?.onPick(null);
|
||||
}
|
||||
// `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
|
||||
// renderer mid-dispose.
|
||||
for (const g of fogGraphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
for (const c of fogGraphics) {
|
||||
c.parent?.removeChild(c);
|
||||
c.destroy({ children: true });
|
||||
}
|
||||
fogGraphics = [];
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// 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
|
||||
// renderer dispatches each op straight onto its own Pixi `Graphics`
|
||||
// (one per shape) inside a per-copy `Container`, so the unit test
|
||||
// exercises the public ordering contract: a single fog-coloured
|
||||
// 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()`
|
||||
// implementation that produced disconnected arc segments.
|
||||
|
||||
@@ -15,9 +17,9 @@ import { FOG_COLOR, fogPaintOps } from "../src/map/render";
|
||||
const BG_COLOR = 0x0a0e1a;
|
||||
const WORLD = { width: 1000, height: 800 };
|
||||
|
||||
describe("fogPaintOps", () => {
|
||||
describe("fogPaintOps — no-wrap mode", () => {
|
||||
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", () => {
|
||||
@@ -26,6 +28,7 @@ describe("fogPaintOps", () => {
|
||||
[{ x: 100, y: 200, radius: 50 }],
|
||||
FOG_COLOR,
|
||||
BG_COLOR,
|
||||
"no-wrap",
|
||||
);
|
||||
expect(ops).toEqual([
|
||||
{
|
||||
@@ -58,12 +61,12 @@ describe("fogPaintOps", () => {
|
||||
],
|
||||
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");
|
||||
// Background-coloured circles paint on top of the fog rect.
|
||||
const op = ops[i];
|
||||
if (op.kind === "fillCircle") {
|
||||
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", () => {
|
||||
expect(
|
||||
fogPaintOps(
|
||||
@@ -116,6 +82,7 @@ describe("fogPaintOps", () => {
|
||||
[{ x: 0, y: 0, radius: 10 }],
|
||||
FOG_COLOR,
|
||||
BG_COLOR,
|
||||
"no-wrap",
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
@@ -124,7 +91,85 @@ describe("fogPaintOps", () => {
|
||||
[{ x: 0, y: 0, radius: 10 }],
|
||||
FOG_COLOR,
|
||||
BG_COLOR,
|
||||
"no-wrap",
|
||||
),
|
||||
).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