feat(ui): Phase 29 map visibility toggles
Adds the gear-icon popover on the map view with per-game persistence of every category toggle plus the wrap-mode radio. Hide-by-id and visibility-fog facilities land on the renderer so every flip applies within one frame without a Pixi remount; the wrap-mode toggle keeps its existing remount + camera-preserve path. A new server-side turn force-resets every flag to defaults so a hidden category never makes the player miss the next turn's news. Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go (plus the single Go caller); the TS side keeps duplicating the formula until a race-level WASM bridge lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,17 @@ preference the store already manages.
|
||||
} from "../../map/index";
|
||||
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
||||
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
||||
import { reportToWorld, type HitTarget } from "../../map/state-binding";
|
||||
import {
|
||||
reportToWorld,
|
||||
type HitTarget,
|
||||
type MapCategory,
|
||||
} from "../../map/state-binding";
|
||||
import {
|
||||
computeFogCircles,
|
||||
computeHiddenIds,
|
||||
computeHiddenPlanetNumbers,
|
||||
fingerprintHiddenPlanets,
|
||||
} from "../../map/visibility";
|
||||
import type { PrimitiveID } from "../../map/world";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
@@ -41,6 +51,7 @@ preference the store already manages.
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
type GameStateStore,
|
||||
type MapToggles,
|
||||
} from "$lib/game-state.svelte";
|
||||
import {
|
||||
SELECTION_CONTEXT_KEY,
|
||||
@@ -57,12 +68,15 @@ preference the store already manages.
|
||||
import {
|
||||
installRendererDebugSurface,
|
||||
registerMapCameraProvider,
|
||||
registerMapFogProvider,
|
||||
registerMapPickStateProvider,
|
||||
registerMapPrimitivesProvider,
|
||||
type MapCameraSnapshot,
|
||||
type MapFogSnapshot,
|
||||
type MapPickStateSnapshot,
|
||||
type MapPrimitiveSnapshot,
|
||||
} from "$lib/debug-surface.svelte";
|
||||
import MapTogglesControl from "./map-toggles.svelte";
|
||||
|
||||
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
|
||||
const renderedReport = getContext<RenderedReportSource | undefined>(
|
||||
@@ -92,6 +106,26 @@ preference the store already manages.
|
||||
|
||||
let handle: RendererHandle | null = null;
|
||||
let hitLookup = new Map<PrimitiveID, HitTarget>();
|
||||
// currentCategories / currentPlanetDependents are populated by
|
||||
// `reportToWorld` inside `mountRenderer` and consumed by the
|
||||
// Phase 29 hide-set computation on every effect re-run (mount or
|
||||
// toggle change). Both maps cover the base world; extras
|
||||
// (cargo-routes, pending-Send) are gated upstream via
|
||||
// `skipPlanets`, so they never need a categories entry.
|
||||
let currentCategories: ReadonlyMap<PrimitiveID, MapCategory> = new Map();
|
||||
let currentPlanetDependents: ReadonlyMap<
|
||||
number,
|
||||
ReadonlySet<PrimitiveID>
|
||||
> = new Map();
|
||||
// currentFogCircles mirrors the latest `setVisibilityFog` input so
|
||||
// the debug surface can report it to Playwright. The renderer
|
||||
// keeps the Graphics, not the data; recomputing on every read
|
||||
// would duplicate work.
|
||||
let currentFogCircles: ReadonlyArray<{
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
}> = [];
|
||||
let mountedTurn: number | null = null;
|
||||
let mountedGameId: string | null = null;
|
||||
let onResize: (() => void) | null = null;
|
||||
@@ -134,9 +168,23 @@ preference the store already manages.
|
||||
// Track the wrap mode so the renderer remounts when Phase 29's
|
||||
// toggle UI flips it; the read here also subscribes the effect.
|
||||
const mode = store?.wrapMode ?? "torus";
|
||||
// Track the Phase 29 visibility toggles so the effect re-runs
|
||||
// when the gear popover flips any flag. The hide set + fog +
|
||||
// extras filter all derive from this rune.
|
||||
const toggles = store?.mapToggles;
|
||||
const gameId = store?.gameId ?? "";
|
||||
if (!mounted || canvasEl === null || containerEl === null) return;
|
||||
if (status !== "ready" || !report) return;
|
||||
if (status !== "ready" || !report || toggles === undefined) return;
|
||||
|
||||
// Phase 29 visibility derivation. Cargo routes and pending-
|
||||
// Send overlay are extras (no Pixi remount on flip); the
|
||||
// cascade-filtering happens here so the extras list shrinks
|
||||
// when a destination planet hides. The hide set + fog are
|
||||
// applied after mount / on every toggle change without a
|
||||
// remount.
|
||||
const hiddenPlanetNumbers = computeHiddenPlanetNumbers(report, toggles);
|
||||
const hiddenPlanetFingerprint =
|
||||
fingerprintHiddenPlanets(hiddenPlanetNumbers);
|
||||
|
||||
// Cargo-route arrows and pending-Send tracks are pushed onto
|
||||
// the live renderer via `setExtraPrimitives` so the overlay
|
||||
@@ -146,10 +194,13 @@ preference the store already manages.
|
||||
// rebuilds when the overlay computation re-runs but the
|
||||
// routes / pending-Send content is unchanged (e.g. status
|
||||
// transitions valid → submitting → applied for the same
|
||||
// command).
|
||||
// command). The Phase 29 cascade + cargoRoutes toggle are
|
||||
// folded into the fingerprint so a toggle flip that changes
|
||||
// the visible set reliably triggers a push.
|
||||
const draftCommands = orderDraft?.commands ?? [];
|
||||
const draftStatuses = orderDraft?.statuses ?? {};
|
||||
const extrasFingerprint =
|
||||
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
||||
computeRoutesFingerprint(report.routes) +
|
||||
"|" +
|
||||
computePendingSendFingerprint(draftCommands, draftStatuses);
|
||||
@@ -160,12 +211,24 @@ preference the store already manages.
|
||||
handle !== null &&
|
||||
handle.getMode() === mode;
|
||||
if (sameSnapshot) {
|
||||
// Always re-apply hide set + fog on a same-snapshot pass:
|
||||
// toggle flips bypass the extras fingerprint when they
|
||||
// only change which baked-world primitives are hidden,
|
||||
// and a no-op `setHiddenPrimitiveIds` is cheap.
|
||||
untrack(() => {
|
||||
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
|
||||
});
|
||||
if (lastExtrasFingerprint !== extrasFingerprint) {
|
||||
untrack(() => {
|
||||
handle?.setExtraPrimitives([
|
||||
...buildCargoRouteLines(report),
|
||||
...buildPendingSendLines(report, draftCommands, draftStatuses),
|
||||
]);
|
||||
handle?.setExtraPrimitives(
|
||||
buildExtras(
|
||||
report,
|
||||
draftCommands,
|
||||
draftStatuses,
|
||||
toggles,
|
||||
hiddenPlanetNumbers,
|
||||
),
|
||||
);
|
||||
});
|
||||
lastExtrasFingerprint = extrasFingerprint;
|
||||
}
|
||||
@@ -179,18 +242,80 @@ preference the store already manages.
|
||||
void pendingMountSignal;
|
||||
if (mountInProgress) return;
|
||||
untrack(() => {
|
||||
void runSerializedMount(report, mode, extrasFingerprint);
|
||||
void runSerializedMount(
|
||||
report,
|
||||
mode,
|
||||
toggles,
|
||||
hiddenPlanetNumbers,
|
||||
extrasFingerprint,
|
||||
draftCommands,
|
||||
draftStatuses,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function buildExtras(
|
||||
report: NonNullable<GameStateStore["report"]>,
|
||||
draftCommands: readonly OrderCommand[],
|
||||
draftStatuses: Readonly<Record<string, string>>,
|
||||
toggles: MapToggles,
|
||||
hiddenPlanetNumbers: ReadonlySet<number>,
|
||||
): import("../../map/world").Primitive[] {
|
||||
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
|
||||
const cargo = toggles.cargoRoutes
|
||||
? buildCargoRouteLines(report, skip ? { skipPlanets: skip } : undefined)
|
||||
: [];
|
||||
const pending = buildPendingSendLines(
|
||||
report,
|
||||
draftCommands,
|
||||
draftStatuses,
|
||||
skip ? { skipPlanets: skip } : undefined,
|
||||
);
|
||||
return [...cargo, ...pending];
|
||||
}
|
||||
|
||||
function applyVisibilityState(
|
||||
report: NonNullable<GameStateStore["report"]>,
|
||||
toggles: MapToggles,
|
||||
hiddenPlanetNumbers: ReadonlySet<number>,
|
||||
): void {
|
||||
if (handle === null) return;
|
||||
const hiddenIds = computeHiddenIds(
|
||||
currentCategories,
|
||||
currentPlanetDependents,
|
||||
hiddenPlanetNumbers,
|
||||
toggles,
|
||||
);
|
||||
handle.setHiddenPrimitiveIds(hiddenIds);
|
||||
const fogCircles = computeFogCircles(report, toggles);
|
||||
currentFogCircles = fogCircles;
|
||||
handle.setVisibilityFog(fogCircles);
|
||||
}
|
||||
|
||||
async function runSerializedMount(
|
||||
report: NonNullable<GameStateStore["report"]>,
|
||||
mode: "torus" | "no-wrap",
|
||||
routesFingerprint: string,
|
||||
toggles: MapToggles,
|
||||
hiddenPlanetNumbers: ReadonlySet<number>,
|
||||
extrasFingerprint: string,
|
||||
draftCommands: readonly OrderCommand[],
|
||||
draftStatuses: Readonly<Record<string, string>>,
|
||||
): Promise<void> {
|
||||
mountInProgress = true;
|
||||
try {
|
||||
await mountRenderer(report, mode, routesFingerprint);
|
||||
await mountRenderer(report, mode);
|
||||
if (handle === null) return;
|
||||
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
|
||||
handle.setExtraPrimitives(
|
||||
buildExtras(
|
||||
report,
|
||||
draftCommands,
|
||||
draftStatuses,
|
||||
toggles,
|
||||
hiddenPlanetNumbers,
|
||||
),
|
||||
);
|
||||
lastExtrasFingerprint = extrasFingerprint;
|
||||
} finally {
|
||||
mountInProgress = false;
|
||||
// Bump the reactive signal so any dep change observed
|
||||
@@ -230,7 +355,6 @@ preference the store already manages.
|
||||
async function mountRenderer(
|
||||
report: NonNullable<GameStateStore["report"]>,
|
||||
mode: "torus" | "no-wrap",
|
||||
routesFingerprint: string,
|
||||
): Promise<void> {
|
||||
if (canvasEl === null || containerEl === null) return;
|
||||
// Capture camera state before disposing so a remount inside
|
||||
@@ -262,8 +386,15 @@ preference the store already manages.
|
||||
handle = null;
|
||||
}
|
||||
try {
|
||||
const { world, hitLookup: nextHitLookup } = reportToWorld(report);
|
||||
const {
|
||||
world,
|
||||
hitLookup: nextHitLookup,
|
||||
categories,
|
||||
planetDependents,
|
||||
} = reportToWorld(report);
|
||||
hitLookup = nextHitLookup;
|
||||
currentCategories = categories;
|
||||
currentPlanetDependents = planetDependents;
|
||||
handle = await createRenderer({
|
||||
canvas: canvasEl,
|
||||
world,
|
||||
@@ -328,6 +459,7 @@ preference the store already manages.
|
||||
strokeColor: p.style.strokeColor ?? null,
|
||||
x: p.kind === "point" ? p.x : null,
|
||||
y: p.kind === "point" ? p.y : null,
|
||||
visible: !h.isPrimitiveHidden(p.id),
|
||||
}));
|
||||
});
|
||||
const detachPick = registerMapPickStateProvider(() => {
|
||||
@@ -370,20 +502,25 @@ preference the store already manages.
|
||||
},
|
||||
} satisfies MapCameraSnapshot;
|
||||
});
|
||||
const detachFog = registerMapFogProvider(() => ({
|
||||
circles: currentFogCircles.map((c) => ({ ...c })),
|
||||
}) satisfies MapFogSnapshot);
|
||||
detachDebugProviders = (): void => {
|
||||
detachPrim();
|
||||
detachPick();
|
||||
detachCamera();
|
||||
detachFog();
|
||||
};
|
||||
mountedTurn = report.turn;
|
||||
mountedGameId = targetGameId;
|
||||
// Initial mount carries no extras yet; the post-mount
|
||||
// effect run pushes the current cargo-route lines via
|
||||
// `setExtraPrimitives` once `lastExtrasFingerprint`
|
||||
// disagrees with the freshly computed fingerprint.
|
||||
// runSerializedMount immediately pushes the visibility
|
||||
// state + extras after this resolves; clearing the
|
||||
// fingerprint here is defensive in case the post-mount
|
||||
// path is ever bypassed (e.g. mount-then-throw before the
|
||||
// extras push). The hide set / fog are applied by the
|
||||
// caller too, so we do not call them here.
|
||||
lastExtrasFingerprint = null;
|
||||
mountError = null;
|
||||
void routesFingerprint;
|
||||
} catch (err) {
|
||||
mountError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -503,6 +640,9 @@ preference the store already manages.
|
||||
bind:this={containerEl}
|
||||
>
|
||||
<canvas bind:this={canvasEl}></canvas>
|
||||
{#if store !== undefined && store.status === "ready"}
|
||||
<MapTogglesControl {store} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user