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
@@ -185,11 +185,11 @@ bottom-tabs bar.
<label>
<input
type="checkbox"
data-testid="map-toggles-visibility-fog"
checked={store.mapToggles.visibilityFog}
onchange={(e) => setFlag("visibilityFog", e)}
data-testid="map-toggles-visible-hyperspace"
checked={store.mapToggles.visibleHyperspace}
onchange={(e) => setFlag("visibleHyperspace", e)}
/>
<span>{i18n.t("game.map.toggles.visibility_fog")}</span>
<span>{i18n.t("game.map.toggles.visible_hyperspace")}</span>
</label>
<div class="wrap-row">
<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.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
+9 -2
View File
@@ -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 {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -129,7 +129,7 @@ const ru: Record<keyof typeof en, string> = {
"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": "без переноса",
+14 -14
View File
@@ -704,31 +704,31 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
},
isPrimitiveHidden: (id) => 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
+8 -7
View File
@@ -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 }[] = [];
+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);
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,
);
@@ -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();
});
+4 -4
View File
@@ -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,
});
+1 -1
View File
@@ -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([]);
});