diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 370fae0..116c0fc 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -802,11 +802,13 @@ every change applies within one frame (no Pixi remount):
off, hides every non-LOCAL planet that sits beyond
`FlightDistance(localPlayerDrive)` of every LOCAL planet
(torus-aware metric).
-- **View** — visibility fog toggle (slightly lighter overlay
- outside the union of `VisibilityDistance(localPlayerDrive)`
- circles around LOCAL planets; LOCAL planets are always
- exempt) plus the torus / no-wrap radio that switches the
- renderer mode while preserving the camera centre.
+- **View** — "visible hyperspace" toggle (slightly lighter
+ overlay outside the union of
+ `VisibilityDistance(localPlayerDrive)` circles around LOCAL
+ planets; LOCAL planets are always exempt — the toggle is
+ 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
other toggle defaults to ON. Hiding a planet cascades onto every
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index 30b40cb..6b2d9ab 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -823,10 +823,11 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
не-LOCAL планету, отстоящую дальше
`FlightDistance(localPlayerDrive)` от любой LOCAL-планеты
(метрика учитывает торическую развёртку).
-- **Вид** — переключатель тумана видимости (чуть более светлая
- заливка вне объединения окружностей
+- **Вид** — переключатель «видимое гиперпространство» (чуть
+ более светлая заливка вне объединения окружностей
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
- LOCAL-планеты всегда вне фильтра) плюс радиогруппа
+ LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
+ области карты, а не по затемнённой) плюс радиогруппа
«торус / без переноса», переключающая режим рендерера с
сохранением центра камеры.
diff --git a/ui/PLAN.md b/ui/PLAN.md
index 81ca4f3..a42f28a 100644
--- a/ui/PLAN.md
+++ b/ui/PLAN.md
@@ -3189,7 +3189,10 @@ Artifacts:
toggles plus a `unreachablePlanets` switch that, when off,
hides planets beyond `FlightDistance(localPlayerDrive)` of
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)` —
declarative hide set; flips `Graphics.visible` per copy and
threads the set into `hitTest` so click-through to deeper
@@ -3245,11 +3248,13 @@ Decisions:
player's `FlightDistance`. Reach is already implicit in the
reach-aware destination picker (Phase 16+), so the cleaner UX
is filtering, not adding extra rings.
-2. **Visibility fog overlay**. A separate `visibilityFog` toggle
- draws a slightly lighter fog over the world outside the union
- of `VisibilityDistance` circles around LOCAL planets. The fog
- is a renderer-level concept (Pixi `Graphics.cut()`), not a
- primitive — it never participates in hit-test.
+2. **Visible-hyperspace overlay**. A separate `visibleHyperspace`
+ toggle draws a slightly lighter fog over the world outside the
+ union of `VisibilityDistance` circles around LOCAL planets. The
+ fog is a renderer-level concept (layered overpaint — fog rect
+ 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
spec's original "object visibility" list was extended:
foreign / uninhabited / unidentified planet kinds and
diff --git a/ui/docs/game-state.md b/ui/docs/game-state.md
index decb78c..5a1cf93 100644
--- a/ui/docs/game-state.md
+++ b/ui/docs/game-state.md
@@ -121,7 +121,7 @@ with the new mode.
Phase 29 adds a `mapToggles: MapToggles` rune that drives the
gear popover in the map view. Every flag defaults to `true` —
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
gear popover (`src/lib/active-view/map-toggles.svelte`) is a
thin view of the rune.
diff --git a/ui/docs/renderer.md b/ui/docs/renderer.md
index ef62950..fca2463 100644
--- a/ui/docs/renderer.md
+++ b/ui/docs/renderer.md
@@ -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
flips visibility within one frame without a Pixi remount.
-## Visibility fog
+## Visible-hyperspace overlay (the "fog")
`RendererHandle.setVisibilityFog(circles)` draws (or removes) the
-Phase 29 fog overlay. Each entry describes a circle around a
-LOCAL planet where the player has scanner / visibility coverage:
+Phase 29 fog overlay used to highlight the player's visible
+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.
- A non-empty list creates one fog `Graphics` per torus copy.
- Each fills the world rectangle with `FOG_COLOR` (two shades
- lighter than the dark theme background) and "cuts" every
- circle out of it via Pixi v8's `Graphics.cut()` path operator,
- so overlapping circles compose into a union hole (no
- even-odd-fill quirks). The fog is inserted at the bottom of
- each copy's z-order so primitives paint on top.
+ Each draws a world-sized rectangle filled with `FOG_COLOR` (two
+ shades lighter than the dark theme background), then paints an
+ opaque background-coloured circle on top for every visibility
+ circle. The overpaint order naturally unions overlapping circles
+ — earlier iterations used Pixi v8's `Graphics.cut()` to subtract
+ 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
top of fog, so clicks on visible planets work unchanged.
- 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.
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
@@ -334,9 +339,9 @@ state without scraping pixels:
- `getMapCamera()` returns the current camera + viewport +
canvas-origin snapshot, used by Phase 29 e2e specs to assert
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`).
- Empty when the fog toggle is off.
+ Empty when the `visibleHyperspace` toggle is off.
The active map view registers providers on mount via
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
diff --git a/ui/frontend/src/lib/active-view/map-toggles.svelte b/ui/frontend/src/lib/active-view/map-toggles.svelte
index 3898d0a..f56d098 100644
--- a/ui/frontend/src/lib/active-view/map-toggles.svelte
+++ b/ui/frontend/src/lib/active-view/map-toggles.svelte
@@ -185,11 +185,11 @@ bottom-tabs bar.
{i18n.t("game.map.toggles.wrap.label")}
diff --git a/ui/frontend/src/lib/active-view/map.svelte b/ui/frontend/src/lib/active-view/map.svelte
index 316864f..914415d 100644
--- a/ui/frontend/src/lib/active-view/map.svelte
+++ b/ui/frontend/src/lib/active-view/map.svelte
@@ -193,7 +193,7 @@ preference the store already manages.
void toggles.cargoRoutes;
void toggles.battleMarkers;
void toggles.bombingMarkers;
- void toggles.visibilityFog;
+ void toggles.visibleHyperspace;
// Phase 29 visibility derivation. Cargo routes and pending-
// Send overlay are extras (no Pixi remount on flip); the
diff --git a/ui/frontend/src/lib/game-state.svelte.ts b/ui/frontend/src/lib/game-state.svelte.ts
index 5c12eb1..3a7b4ae 100644
--- a/ui/frontend/src/lib/game-state.svelte.ts
+++ b/ui/frontend/src/lib/game-state.svelte.ts
@@ -57,7 +57,14 @@ export interface MapToggles {
cargoRoutes: boolean;
battleMarkers: 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 = {
@@ -71,7 +78,7 @@ export const DEFAULT_MAP_TOGGLES: MapToggles = {
cargoRoutes: true,
battleMarkers: true,
bombingMarkers: true,
- visibilityFog: true,
+ visibleHyperspace: true,
};
interface PersistedMapToggles {
diff --git a/ui/frontend/src/lib/i18n/locales/en.ts b/ui/frontend/src/lib/i18n/locales/en.ts
index 8e4e78a..0934719 100644
--- a/ui/frontend/src/lib/i18n/locales/en.ts
+++ b/ui/frontend/src/lib/i18n/locales/en.ts
@@ -128,7 +128,7 @@ const en = {
"game.map.toggles.uninhabited_planets": "uninhabited planets",
"game.map.toggles.unidentified_planets": "unidentified 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.torus": "torus",
"game.map.toggles.wrap.no_wrap": "no-wrap",
diff --git a/ui/frontend/src/lib/i18n/locales/ru.ts b/ui/frontend/src/lib/i18n/locales/ru.ts
index 4ea6d5c..20be513 100644
--- a/ui/frontend/src/lib/i18n/locales/ru.ts
+++ b/ui/frontend/src/lib/i18n/locales/ru.ts
@@ -129,7 +129,7 @@ const ru: Record = {
"game.map.toggles.uninhabited_planets": "необитаемые планеты",
"game.map.toggles.unidentified_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.torus": "тор",
"game.map.toggles.wrap.no_wrap": "без переноса",
diff --git a/ui/frontend/src/map/render.ts b/ui/frontend/src/map/render.ts
index 3a6b4d0..e65e601 100644
--- a/ui/frontend/src/map/render.ts
+++ b/ui/frontend/src/map/render.ts
@@ -704,31 +704,31 @@ export async function createRenderer(opts: RendererOptions): Promise hiddenIds.has(id),
setVisibilityFog: (circles) => {
- if (circles.length === 0) {
- for (const g of fogGraphics) {
- g.parent?.removeChild(g);
- 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.
+ // Drop the old fog Graphics 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();
}
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) {
const g = new Graphics();
g.rect(0, 0, opts.world.width, opts.world.height);
g.fill({ color: FOG_COLOR, alpha: 1 });
for (const c of circles) {
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
// planet glyphs paint on top. `addChildAt(g, 0)` keeps
diff --git a/ui/frontend/src/map/visibility.ts b/ui/frontend/src/map/visibility.ts
index b03cf7f..d1fb0aa 100644
--- a/ui/frontend/src/map/visibility.ts
+++ b/ui/frontend/src/map/visibility.ts
@@ -171,18 +171,19 @@ export function computeHiddenIds(
/**
* computeFogCircles produces the visibility-fog input — empty when
- * the toggle is off, otherwise one circle per LOCAL planet at
- * `VisibilityDistance(localPlayerDrive)`. When the drive tech is
- * zero the function returns an empty list as well: a zero-radius
- * fog cutout would leave the entire world fogged, which is more
- * confusing than helpful in tutorial / debug scenarios. The
- * renderer-side fog Graphics is destroyed on an empty list.
+ * the `visibleHyperspace` toggle is off, otherwise one circle per
+ * LOCAL planet at `VisibilityDistance(localPlayerDrive)`. When the
+ * drive tech is zero the function returns an empty list as well:
+ * a zero-radius fog cutout would leave the entire world fogged,
+ * which is more confusing than helpful in tutorial / debug
+ * scenarios. The renderer-side fog Graphics is destroyed on an
+ * empty list.
*/
export function computeFogCircles(
report: GameReport,
toggles: MapToggles,
): { x: number; y: number; radius: number }[] {
- if (!toggles.visibilityFog) return [];
+ if (!toggles.visibleHyperspace) return [];
const radius = report.localPlayerDrive * VISIBILITY_DISTANCE_PER_DRIVE;
if (radius <= 0) return [];
const circles: { x: number; y: number; radius: number }[] = [];
diff --git a/ui/frontend/tests/e2e/map-toggles.spec.ts b/ui/frontend/tests/e2e/map-toggles.spec.ts
index 090125b..c4ea0f3 100644
--- a/ui/frontend/tests/e2e/map-toggles.spec.ts
+++ b/ui/frontend/tests/e2e/map-toggles.spec.ts
@@ -306,7 +306,7 @@ test("visibility fog toggles between the LOCAL-planet circle list and an empty o
expect(initialFog[1].radius).toBe(300);
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
// 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.
- await page.getByTestId("map-toggles-visibility-fog").click();
+ await page.getByTestId("map-toggles-visible-hyperspace").click();
await page.waitForFunction(
() => window.__galaxyDebug!.getMapFog!().circles.length === 2,
);
diff --git a/ui/frontend/tests/map-toggles-component.test.ts b/ui/frontend/tests/map-toggles-component.test.ts
index 0cf06f9..600dd97 100644
--- a/ui/frontend/tests/map-toggles-component.test.ts
+++ b/ui/frontend/tests/map-toggles-component.test.ts
@@ -58,7 +58,7 @@ describe("MapTogglesControl", () => {
expect(ui.getByTestId("map-toggles-uninhabited-planets")).toBeChecked();
expect(ui.getByTestId("map-toggles-unidentified-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-no-wrap")).not.toBeChecked();
});
diff --git a/ui/frontend/tests/map-toggles-state.test.ts b/ui/frontend/tests/map-toggles-state.test.ts
index 541a656..b5b21d9 100644
--- a/ui/frontend/tests/map-toggles-state.test.ts
+++ b/ui/frontend/tests/map-toggles-state.test.ts
@@ -113,7 +113,7 @@ describe("GameStateStore.mapToggles persistence", () => {
await a.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
await a.setMapToggle("hyperspaceGroups", false);
await a.setMapToggle("battleMarkers", false);
- await a.setMapToggle("visibilityFog", false);
+ await a.setMapToggle("visibleHyperspace", false);
a.dispose();
listMyGamesSpy.mockResolvedValue([makeGameSummary(3)]);
@@ -121,7 +121,7 @@ describe("GameStateStore.mapToggles persistence", () => {
await b.init({ client: makeFakeClient(3), cache, gameId: GAME_ID });
expect(b.mapToggles.hyperspaceGroups).toBe(false);
expect(b.mapToggles.battleMarkers).toBe(false);
- expect(b.mapToggles.visibilityFog).toBe(false);
+ expect(b.mapToggles.visibleHyperspace).toBe(false);
// Untouched flags retain defaults.
expect(b.mapToggles.bombingMarkers).toBe(true);
b.dispose();
@@ -141,7 +141,7 @@ describe("GameStateStore.mapToggles persistence", () => {
expect(store.mapToggles.hyperspaceGroups).toBe(false);
expect(store.mapToggles.battleMarkers).toBe(true);
expect(store.mapToggles.bombingMarkers).toBe(true);
- expect(store.mapToggles.visibilityFog).toBe(true);
+ expect(store.mapToggles.visibleHyperspace).toBe(true);
store.dispose();
});
});
@@ -153,7 +153,7 @@ describe("GameStateStore.mapToggles new-turn reset", () => {
...DEFAULT_MAP_TOGGLES,
hyperspaceGroups: false,
battleMarkers: false,
- visibilityFog: false,
+ visibleHyperspace: false,
},
lastResetTurn: 4,
});
diff --git a/ui/frontend/tests/visibility-helpers.test.ts b/ui/frontend/tests/visibility-helpers.test.ts
index 1c80fc1..322327a 100644
--- a/ui/frontend/tests/visibility-helpers.test.ts
+++ b/ui/frontend/tests/visibility-helpers.test.ts
@@ -273,7 +273,7 @@ describe("computeFogCircles", () => {
planets: [makePlanet({ number: 1, kind: "local", x: 100, y: 100 })],
});
expect(
- computeFogCircles(report, toggles({ visibilityFog: false })),
+ computeFogCircles(report, toggles({ visibleHyperspace: false })),
).toEqual([]);
});