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) {
|
||||
ops.push({
|
||||
kind: "fillCircle",
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
radius: c.radius,
|
||||
color: bgColor,
|
||||
alpha: 1,
|
||||
});
|
||||
for (const [dx, dy] of offsets) {
|
||||
ops.push({
|
||||
kind: "fillCircle",
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user