fix(ui-map): repaint fog as layered overpaint; rename to visibleHyperspace
Tests · UI / test (push) Waiting to run
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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "без переноса",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }[] = [];
|
||||
|
||||
Reference in New Issue
Block a user