fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace
Tests · UI / test (push) Waiting to run

The Phase 29 fog overlay rendered as a handful of random arc
segments instead of a clean union of holes around LOCAL planets
— Pixi v8's `Graphics.cut()` does not reliably subtract multiple
overlapping circles from a base path.

Replaced the cut-based approach with a layered overpaint: a
fog-tinted rectangle fills the world, then opaque background-
coloured circles are painted on top for every visibility circle.
The natural rendering order unions overlapping circles for free —
no geometry, no `cut()` quirks, one extra fill per circle.

Renamed the toggle from `visibilityFog` to `visibleHyperspace`
across the store, i18n strings, popover, tests, and docs. The
overlay still implements the visual "fog" effect at the renderer
level (FOG_COLOR, setVisibilityFog, getMapFog); the toggle is
named after the player-facing concept it controls — the portion
of the map that is visible (intelligence/scan coverage) — rather
than the obscured part.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-19 23:39:39 +02:00
parent 2f4dc01d54
commit 37580b7699
16 changed files with 86 additions and 65 deletions
+7 -5
View File
@@ -802,11 +802,13 @@ every change applies within one frame (no Pixi remount):
off, hides every non-LOCAL planet that sits beyond off, hides every non-LOCAL planet that sits beyond
`FlightDistance(localPlayerDrive)` of every LOCAL planet `FlightDistance(localPlayerDrive)` of every LOCAL planet
(torus-aware metric). (torus-aware metric).
- **View** — visibility fog toggle (slightly lighter overlay - **View** — "visible hyperspace" toggle (slightly lighter
outside the union of `VisibilityDistance(localPlayerDrive)` overlay outside the union of
circles around LOCAL planets; LOCAL planets are always `VisibilityDistance(localPlayerDrive)` circles around LOCAL
exempt) plus the torus / no-wrap radio that switches the planets; LOCAL planets are always exempt — the toggle is
renderer mode while preserving the camera centre. named after the visible part of the map rather than the
obscured one) plus the torus / no-wrap radio that switches
the renderer mode while preserving the camera centre.
LOCAL planets are always rendered — they have no toggle. Every LOCAL planets are always rendered — they have no toggle. Every
other toggle defaults to ON. Hiding a planet cascades onto every other toggle defaults to ON. Hiding a planet cascades onto every
+4 -3
View File
@@ -823,10 +823,11 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
не-LOCAL планету, отстоящую дальше не-LOCAL планету, отстоящую дальше
`FlightDistance(localPlayerDrive)` от любой LOCAL-планеты `FlightDistance(localPlayerDrive)` от любой LOCAL-планеты
(метрика учитывает торическую развёртку). (метрика учитывает торическую развёртку).
- **Вид** — переключатель тумана видимости (чуть более светлая - **Вид** — переключатель «видимое гиперпространство» (чуть
заливка вне объединения окружностей более светлая заливка вне объединения окружностей
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет; `VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
LOCAL-планеты всегда вне фильтра) плюс радиогруппа LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
области карты, а не по затемнённой) плюс радиогруппа
«торус / без переноса», переключающая режим рендерера с «торус / без переноса», переключающая режим рендерера с
сохранением центра камеры. сохранением центра камеры.
+11 -6
View File
@@ -3189,7 +3189,10 @@ Artifacts:
toggles plus a `unreachablePlanets` switch that, when off, toggles plus a `unreachablePlanets` switch that, when off,
hides planets beyond `FlightDistance(localPlayerDrive)` of hides planets beyond `FlightDistance(localPlayerDrive)` of
every LOCAL planet (torus-aware). every LOCAL planet (torus-aware).
- **View** — visibility-fog checkbox + torus / no-wrap radios. - **View** — "visible hyperspace" checkbox + torus / no-wrap
radios. The fog overlay is named for the visible part of the
map (intelligence/scan area), since that is what the toggle
controls from the player's perspective.
- `RendererHandle.setHiddenPrimitiveIds(ids)` — - `RendererHandle.setHiddenPrimitiveIds(ids)` —
declarative hide set; flips `Graphics.visible` per copy and declarative hide set; flips `Graphics.visible` per copy and
threads the set into `hitTest` so click-through to deeper threads the set into `hitTest` so click-through to deeper
@@ -3245,11 +3248,13 @@ Decisions:
player's `FlightDistance`. Reach is already implicit in the player's `FlightDistance`. Reach is already implicit in the
reach-aware destination picker (Phase 16+), so the cleaner UX reach-aware destination picker (Phase 16+), so the cleaner UX
is filtering, not adding extra rings. is filtering, not adding extra rings.
2. **Visibility fog overlay**. A separate `visibilityFog` toggle 2. **Visible-hyperspace overlay**. A separate `visibleHyperspace`
draws a slightly lighter fog over the world outside the union toggle draws a slightly lighter fog over the world outside the
of `VisibilityDistance` circles around LOCAL planets. The fog union of `VisibilityDistance` circles around LOCAL planets. The
is a renderer-level concept (Pixi `Graphics.cut()`), not a fog is a renderer-level concept (layered overpaint — fog rect
primitive — it never participates in hit-test. then background-coloured circles on top — instead of Pixi's
`Graphics.cut()`, which produced incorrect unions of holes), not
a primitive: it never participates in hit-test.
3. **Per-kind planet toggles + unidentified-group toggle**. The 3. **Per-kind planet toggles + unidentified-group toggle**. The
spec's original "object visibility" list was extended: spec's original "object visibility" list was extended:
foreign / uninhabited / unidentified planet kinds and foreign / uninhabited / unidentified planet kinds and
+1 -1
View File
@@ -121,7 +121,7 @@ with the new mode.
Phase 29 adds a `mapToggles: MapToggles` rune that drives the Phase 29 adds a `mapToggles: MapToggles` rune that drives the
gear popover in the map view. Every flag defaults to `true` gear popover in the map view. Every flag defaults to `true`
including `unreachablePlanets` (showing every planet by default) including `unreachablePlanets` (showing every planet by default)
and `visibilityFog` (the fog overlay on by default). The and `visibleHyperspace` (the fog overlay on by default). The
exhaustive shape lives in `src/lib/game-state.svelte.ts`; the exhaustive shape lives in `src/lib/game-state.svelte.ts`; the
gear popover (`src/lib/active-view/map-toggles.svelte`) is a gear popover (`src/lib/active-view/map-toggles.svelte`) is a
thin view of the rune. thin view of the rune.
+17 -12
View File
@@ -292,20 +292,25 @@ the set from the per-game `MapToggles` rune + the planet-cascade
rule and pushes it on every effect run; toggling a checkbox rule and pushes it on every effect run; toggling a checkbox
flips visibility within one frame without a Pixi remount. flips visibility within one frame without a Pixi remount.
## Visibility fog ## Visible-hyperspace overlay (the "fog")
`RendererHandle.setVisibilityFog(circles)` draws (or removes) the `RendererHandle.setVisibilityFog(circles)` draws (or removes) the
Phase 29 fog overlay. Each entry describes a circle around a Phase 29 fog overlay used to highlight the player's visible
LOCAL planet where the player has scanner / visibility coverage: hyperspace. Each entry describes a circle around a LOCAL planet
where the player has scanner / visibility coverage:
- An empty list destroys the existing fog Graphics. - An empty list destroys the existing fog Graphics.
- A non-empty list creates one fog `Graphics` per torus copy. - A non-empty list creates one fog `Graphics` per torus copy.
Each fills the world rectangle with `FOG_COLOR` (two shades Each draws a world-sized rectangle filled with `FOG_COLOR` (two
lighter than the dark theme background) and "cuts" every shades lighter than the dark theme background), then paints an
circle out of it via Pixi v8's `Graphics.cut()` path operator, opaque background-coloured circle on top for every visibility
so overlapping circles compose into a union hole (no circle. The overpaint order naturally unions overlapping circles
even-odd-fill quirks). The fog is inserted at the bottom of — earlier iterations used Pixi v8's `Graphics.cut()` to subtract
each copy's z-order so primitives paint on top. holes, but `cut()` produces incorrect unions for multiple
overlapping holes; layered repainting trades one extra fill per
circle for a predictable, geometry-free union.
- The fog is inserted at the bottom of each copy's z-order so
primitives paint on top.
- The fog never participates in hit-test. Planet glyphs sit on - The fog never participates in hit-test. Planet glyphs sit on
top of fog, so clicks on visible planets work unchanged. top of fog, so clicks on visible planets work unchanged.
- Wrap mode is honoured for free — `applyMode` hides every - Wrap mode is honoured for free — `applyMode` hides every
@@ -313,7 +318,7 @@ LOCAL planet where the player has scanner / visibility coverage:
behaviour because the fog Graphics is a child of each copy. behaviour because the fog Graphics is a child of each copy.
The map view recomputes the fog input only when the report or the The map view recomputes the fog input only when the report or the
fog toggle changes — per-frame cost stays at zero. `visibleHyperspace` toggle changes — per-frame cost stays at zero.
## Debug surface ## Debug surface
@@ -334,9 +339,9 @@ state without scraping pixels:
- `getMapCamera()` returns the current camera + viewport + - `getMapCamera()` returns the current camera + viewport +
canvas-origin snapshot, used by Phase 29 e2e specs to assert canvas-origin snapshot, used by Phase 29 e2e specs to assert
camera preservation across wrap-mode flips. camera preservation across wrap-mode flips.
- `getMapFog()` returns the most recent visibility-fog input - `getMapFog()` returns the most recent fog input
(the list of circles last passed to `setVisibilityFog`). (the list of circles last passed to `setVisibilityFog`).
Empty when the fog toggle is off. Empty when the `visibleHyperspace` toggle is off.
The active map view registers providers on mount via The active map view registers providers on mount via
`registerMapPrimitivesProvider` / `registerMapPickStateProvider` `registerMapPrimitivesProvider` / `registerMapPickStateProvider`
@@ -185,11 +185,11 @@ bottom-tabs bar.
<label> <label>
<input <input
type="checkbox" type="checkbox"
data-testid="map-toggles-visibility-fog" data-testid="map-toggles-visible-hyperspace"
checked={store.mapToggles.visibilityFog} checked={store.mapToggles.visibleHyperspace}
onchange={(e) => setFlag("visibilityFog", e)} onchange={(e) => setFlag("visibleHyperspace", e)}
/> />
<span>{i18n.t("game.map.toggles.visibility_fog")}</span> <span>{i18n.t("game.map.toggles.visible_hyperspace")}</span>
</label> </label>
<div class="wrap-row"> <div class="wrap-row">
<span class="wrap-label">{i18n.t("game.map.toggles.wrap.label")}</span> <span class="wrap-label">{i18n.t("game.map.toggles.wrap.label")}</span>
+1 -1
View File
@@ -193,7 +193,7 @@ preference the store already manages.
void toggles.cargoRoutes; void toggles.cargoRoutes;
void toggles.battleMarkers; void toggles.battleMarkers;
void toggles.bombingMarkers; void toggles.bombingMarkers;
void toggles.visibilityFog; void toggles.visibleHyperspace;
// Phase 29 visibility derivation. Cargo routes and pending- // Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the // Send overlay are extras (no Pixi remount on flip); the
+9 -2
View File
@@ -57,7 +57,14 @@ export interface MapToggles {
cargoRoutes: boolean; cargoRoutes: boolean;
battleMarkers: boolean; battleMarkers: boolean;
bombingMarkers: boolean; bombingMarkers: boolean;
visibilityFog: boolean; /**
* visibleHyperspace toggles the foggy overlay that darkens the
* world OUTSIDE the union of `VisibilityDistance` circles around
* LOCAL planets. The visible part of the map — the player's
* intelligence/scan coverage — stays in the regular background
* colour; everything else looks "foggy". Default ON.
*/
visibleHyperspace: boolean;
} }
export const DEFAULT_MAP_TOGGLES: MapToggles = { export const DEFAULT_MAP_TOGGLES: MapToggles = {
@@ -71,7 +78,7 @@ export const DEFAULT_MAP_TOGGLES: MapToggles = {
cargoRoutes: true, cargoRoutes: true,
battleMarkers: true, battleMarkers: true,
bombingMarkers: true, bombingMarkers: true,
visibilityFog: true, visibleHyperspace: true,
}; };
interface PersistedMapToggles { interface PersistedMapToggles {
+1 -1
View File
@@ -128,7 +128,7 @@ const en = {
"game.map.toggles.uninhabited_planets": "uninhabited planets", "game.map.toggles.uninhabited_planets": "uninhabited planets",
"game.map.toggles.unidentified_planets": "unidentified planets", "game.map.toggles.unidentified_planets": "unidentified planets",
"game.map.toggles.unreachable_planets": "show unreachable planets", "game.map.toggles.unreachable_planets": "show unreachable planets",
"game.map.toggles.visibility_fog": "visibility fog", "game.map.toggles.visible_hyperspace": "visible hyperspace",
"game.map.toggles.wrap.label": "wrap scrolling", "game.map.toggles.wrap.label": "wrap scrolling",
"game.map.toggles.wrap.torus": "torus", "game.map.toggles.wrap.torus": "torus",
"game.map.toggles.wrap.no_wrap": "no-wrap", "game.map.toggles.wrap.no_wrap": "no-wrap",
+1 -1
View File
@@ -129,7 +129,7 @@ const ru: Record<keyof typeof en, string> = {
"game.map.toggles.uninhabited_planets": "необитаемые планеты", "game.map.toggles.uninhabited_planets": "необитаемые планеты",
"game.map.toggles.unidentified_planets": "неопознанные планеты", "game.map.toggles.unidentified_planets": "неопознанные планеты",
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты", "game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
"game.map.toggles.visibility_fog": "туман видимости", "game.map.toggles.visible_hyperspace": "видимое гиперпространство",
"game.map.toggles.wrap.label": "перенос карты", "game.map.toggles.wrap.label": "перенос карты",
"game.map.toggles.wrap.torus": "тор", "game.map.toggles.wrap.torus": "тор",
"game.map.toggles.wrap.no_wrap": "без переноса", "game.map.toggles.wrap.no_wrap": "без переноса",
+14 -14
View File
@@ -704,31 +704,31 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
}, },
isPrimitiveHidden: (id) => hiddenIds.has(id), isPrimitiveHidden: (id) => hiddenIds.has(id),
setVisibilityFog: (circles) => { setVisibilityFog: (circles) => {
if (circles.length === 0) { // Drop the old fog Graphics first — every flip rebuilds
for (const g of fogGraphics) { // from scratch instead of mutating in place, so the
g.parent?.removeChild(g); // implementation stays simple and Pixi-v8-residue-free.
g.destroy();
}
fogGraphics = [];
return;
}
// Recreate the fog Graphics on every call. Pixi v8's
// `Graphics.clear()` exists but reusing the same instance
// with multiple `.cut()` operations across calls can
// accumulate stale path state in our experience; a fresh
// Graphics keeps the contract simple.
for (const g of fogGraphics) { for (const g of fogGraphics) {
g.parent?.removeChild(g); g.parent?.removeChild(g);
g.destroy(); g.destroy();
} }
fogGraphics = []; fogGraphics = [];
if (circles.length === 0) return;
// Layered overpaint: a fog-tinted rectangle covers the
// world, then opaque background-coloured circles drawn on
// top reveal the visible-hyperspace area. The natural
// rendering order handles overlapping circles correctly —
// Pixi v8's `Graphics.cut()` produces inconsistent
// results for unions of holes (the previous Phase 29
// implementation hit this), and the overpaint approach
// avoids the geometry calculation entirely.
const bg = theme.background;
for (const copy of copies) { for (const copy of copies) {
const g = new Graphics(); const g = new Graphics();
g.rect(0, 0, opts.world.width, opts.world.height); g.rect(0, 0, opts.world.width, opts.world.height);
g.fill({ color: FOG_COLOR, alpha: 1 }); g.fill({ color: FOG_COLOR, alpha: 1 });
for (const c of circles) { for (const c of circles) {
g.circle(c.x, c.y, c.radius); g.circle(c.x, c.y, c.radius);
g.cut(); g.fill({ color: bg, alpha: 1 });
} }
// 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
+8 -7
View File
@@ -171,18 +171,19 @@ export function computeHiddenIds(
/** /**
* computeFogCircles produces the visibility-fog input — empty when * computeFogCircles produces the visibility-fog input — empty when
* the toggle is off, otherwise one circle per LOCAL planet at * the `visibleHyperspace` toggle is off, otherwise one circle per
* `VisibilityDistance(localPlayerDrive)`. When the drive tech is * LOCAL planet at `VisibilityDistance(localPlayerDrive)`. When the
* zero the function returns an empty list as well: a zero-radius * drive tech is zero the function returns an empty list as well:
* fog cutout would leave the entire world fogged, which is more * a zero-radius fog cutout would leave the entire world fogged,
* confusing than helpful in tutorial / debug scenarios. The * which is more confusing than helpful in tutorial / debug
* renderer-side fog Graphics is destroyed on an empty list. * scenarios. The renderer-side fog Graphics is destroyed on an
* empty list.
*/ */
export function computeFogCircles( export function computeFogCircles(
report: GameReport, report: GameReport,
toggles: MapToggles, toggles: MapToggles,
): { x: number; y: number; radius: number }[] { ): { x: number; y: number; radius: number }[] {
if (!toggles.visibilityFog) return []; if (!toggles.visibleHyperspace) return [];
const radius = report.localPlayerDrive * VISIBILITY_DISTANCE_PER_DRIVE; const radius = report.localPlayerDrive * VISIBILITY_DISTANCE_PER_DRIVE;
if (radius <= 0) return []; if (radius <= 0) return [];
const circles: { x: number; y: number; radius: number }[] = []; const circles: { x: number; y: number; radius: number }[] = [];
+2 -2
View File
@@ -306,7 +306,7 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o
expect(initialFog[1].radius).toBe(300); expect(initialFog[1].radius).toBe(300);
await page.getByTestId("map-toggles-trigger").click(); await page.getByTestId("map-toggles-trigger").click();
await page.getByTestId("map-toggles-visibility-fog").click(); await page.getByTestId("map-toggles-visible-hyperspace").click();
// The effect re-run is async; wait for the fog payload to clear // The effect re-run is async; wait for the fog payload to clear
// instead of reading it on the next tick. // instead of reading it on the next tick.
@@ -315,7 +315,7 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o
); );
// Toggling back on rebuilds the fog circles for the same planets. // Toggling back on rebuilds the fog circles for the same planets.
await page.getByTestId("map-toggles-visibility-fog").click(); await page.getByTestId("map-toggles-visible-hyperspace").click();
await page.waitForFunction( await page.waitForFunction(
() => window.__galaxyDebug!.getMapFog!().circles.length === 2, () => window.__galaxyDebug!.getMapFog!().circles.length === 2,
); );
@@ -58,7 +58,7 @@ describe("MapTogglesControl", () => {
expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked(); expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked(); expect(ui.getByTestId("map-toggles-unidentified-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked(); expect(ui.getByTestId("map-toggles-unreachable-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-visibility-fog")).toBeChecked(); expect(ui.getByTestId("map-toggles-visible-hyperspace")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked(); expect(ui.getByTestId("map-toggles-wrap-torus")).toBeChecked();
expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked(); expect(ui.getByTestId("map-toggles-wrap-no-wrap")).not.toBeChecked();
}); });
+4 -4
View File
@@ -113,7 +113,7 @@ describe("GameStateStore.mapToggles persistence", () => {
await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID }); await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
await a.setMapToggle("hyperspaceGroups", false); await a.setMapToggle("hyperspaceGroups", false);
await a.setMapToggle("battleMarkers", false); await a.setMapToggle("battleMarkers", false);
await a.setMapToggle("visibilityFog", false); await a.setMapToggle("visibleHyperspace", false);
a.dispose(); a.dispose();
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]); listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
@@ -121,7 +121,7 @@ describe("GameStateStore.mapToggles persistence", () => {
await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID }); await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
expect(b.mapToggles.hyperspaceGroups).toBe(false); expect(b.mapToggles.hyperspaceGroups).toBe(false);
expect(b.mapToggles.battleMarkers).toBe(false); expect(b.mapToggles.battleMarkers).toBe(false);
expect(b.mapToggles.visibilityFog).toBe(false); expect(b.mapToggles.visibleHyperspace).toBe(false);
// Untouched flags retain defaults. // Untouched flags retain defaults.
expect(b.mapToggles.bombingMarkers).toBe(true); expect(b.mapToggles.bombingMarkers).toBe(true);
b.dispose(); b.dispose();
@@ -141,7 +141,7 @@ describe("GameStateStore.mapToggles persistence", () => {
expect(store.mapToggles.hyperspaceGroups).toBe(false); expect(store.mapToggles.hyperspaceGroups).toBe(false);
expect(store.mapToggles.battleMarkers).toBe(true); expect(store.mapToggles.battleMarkers).toBe(true);
expect(store.mapToggles.bombingMarkers).toBe(true); expect(store.mapToggles.bombingMarkers).toBe(true);
expect(store.mapToggles.visibilityFog).toBe(true); expect(store.mapToggles.visibleHyperspace).toBe(true);
store.dispose(); store.dispose();
}); });
}); });
@@ -153,7 +153,7 @@ describe("GameStateStore.mapToggles new-turn reset", () => {
...DEFAULT_MAP_TOGGLES, ...DEFAULT_MAP_TOGGLES,
hyperspaceGroups: false, hyperspaceGroups: false,
battleMarkers: false, battleMarkers: false,
visibilityFog: false, visibleHyperspace: false,
}, },
lastResetTurn: 4, lastResetTurn: 4,
}); });
+1 -1
View File
@@ -273,7 +273,7 @@ describe("computeFogCircles", () => {
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })], planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
}); });
expect( expect(
computeFogCircles(report, toggles({ visibilityFog: false })), computeFogCircles(report, toggles({ visibleHyperspace: false })),
).toEqual([]); ).toEqual([]);
}); });