From 37580b7699f4906c93eb9c9d82c42b53b39640f1 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 19 May 2026 23:39:39 +0200 Subject: [PATCH] fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/FUNCTIONAL.md | 12 ++++---- docs/FUNCTIONAL_ru.md | 7 +++-- ui/PLAN.md | 17 +++++++---- ui/docs/game-state.md | 2 +- ui/docs/renderer.md | 29 +++++++++++-------- .../src/lib/active-view/map-toggles.svelte | 8 ++--- ui/frontend/src/lib/active-view/map.svelte | 2 +- ui/frontend/src/lib/game-state.svelte.ts | 11 +++++-- ui/frontend/src/lib/i18n/locales/en.ts | 2 +- ui/frontend/src/lib/i18n/locales/ru.ts | 2 +- ui/frontend/src/map/render.ts | 28 +++++++++--------- ui/frontend/src/map/visibility.ts | 15 +++++----- ui/frontend/tests/e2e/map-toggles.spec.ts | 4 +-- .../tests/map-toggles-component.test.ts | 2 +- ui/frontend/tests/map-toggles-state.test.ts | 8 ++--- ui/frontend/tests/visibility-helpers.test.ts | 2 +- 16 files changed, 86 insertions(+), 65 deletions(-) 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([]); });