9ae7b88b89
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet. pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm. Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store. Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
757 lines
25 KiB
Svelte
757 lines
25 KiB
Svelte
<!--
|
|
Phase 11 map active view: integrates the Phase 9 renderer with the
|
|
per-game `GameStateStore` provided through context by
|
|
`routes/games/[id]/+layout.svelte`. The view mounts the renderer
|
|
once the store has produced a report and re-mounts when the
|
|
report's turn changes (a refresh that returns the same turn keeps
|
|
the existing renderer instance alive). Empty-planet reports render
|
|
the empty world without errors — the regression test in
|
|
`tests/e2e/game-shell-map.spec.ts` covers this.
|
|
|
|
Phase 9 owns the renderer's hit-test and pan/zoom semantics. Phase 13
|
|
plugs map clicks into the inspector by translating the renderer's
|
|
`clicked` event into a hit-test, looking the planet up by id in the
|
|
report, and calling `SelectionStore.selectPlanet`. The selection
|
|
store, set in the layout, drives both the desktop sidebar inspector
|
|
tab and the mobile bottom-sheet — the map view itself does not need
|
|
to know which surface is showing the result.
|
|
|
|
Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
|
|
preference the store already manages.
|
|
-->
|
|
<script lang="ts">
|
|
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/state";
|
|
import { i18n } from "$lib/i18n/index.svelte";
|
|
import {
|
|
createRenderer,
|
|
minScaleNoWrap,
|
|
type RendererHandle,
|
|
} from "../../map/index";
|
|
import { buildCargoRouteLines } from "../../map/cargo-routes";
|
|
import { buildPendingSendLines } from "../../map/pending-send-routes";
|
|
import { computeReachCircles } from "../../map/reach-circles";
|
|
import { reachStore } from "$lib/calculator/reach.svelte";
|
|
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,
|
|
OrderDraftStore,
|
|
} from "../../sync/order-draft.svelte";
|
|
import type { OrderCommand } from "../../sync/order-types";
|
|
import {
|
|
GAME_STATE_CONTEXT_KEY,
|
|
type GameStateStore,
|
|
type MapToggles,
|
|
} from "$lib/game-state.svelte";
|
|
import {
|
|
SELECTION_CONTEXT_KEY,
|
|
type SelectionStore,
|
|
} from "$lib/selection.svelte";
|
|
import {
|
|
RENDERED_REPORT_CONTEXT_KEY,
|
|
type RenderedReportSource,
|
|
} from "$lib/rendered-report.svelte";
|
|
import {
|
|
MAP_PICK_CONTEXT_KEY,
|
|
MapPickService,
|
|
} from "$lib/map-pick.svelte";
|
|
import {
|
|
installRendererDebugSurface,
|
|
registerMapCameraProvider,
|
|
registerMapFogProvider,
|
|
registerMapModeProvider,
|
|
registerMapPickStateProvider,
|
|
registerMapPrimitivesProvider,
|
|
registerMapRenderCountProvider,
|
|
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>(
|
|
RENDERED_REPORT_CONTEXT_KEY,
|
|
);
|
|
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
|
// `MapPickService` is owned by the in-game shell layout (set on
|
|
// the context tree the inspector subsections also descend from).
|
|
// Renderer changes attach / detach via `bindResolver` so a
|
|
// remount mid-pick does not orphan a pending promise. The map
|
|
// view is mounted only beneath the layout, so the service is
|
|
// always present in production; tests render the map in isolation
|
|
// and may omit it.
|
|
const pickService = getContext<MapPickService | undefined>(
|
|
MAP_PICK_CONTEXT_KEY,
|
|
);
|
|
// Pending Send commands turn into green dashed tracks on the
|
|
// map overlay; the draft is read alongside the report so the
|
|
// overlay stays in lock-step with the order tab.
|
|
const orderDraft = getContext<OrderDraftStore | undefined>(
|
|
ORDER_DRAFT_CONTEXT_KEY,
|
|
);
|
|
|
|
let canvasEl: HTMLCanvasElement | null = $state(null);
|
|
let containerEl: HTMLDivElement | null = $state(null);
|
|
let mountError: string | null = $state(null);
|
|
|
|
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;
|
|
let detachClick: (() => void) | null = null;
|
|
let detachDebugProviders: (() => void) | null = null;
|
|
let detachDebugSurface: (() => void) | null = null;
|
|
// `mounted` must be `$state` so the renderer-mount effect re-runs
|
|
// once `onMount` flips it true. On the first map navigation the
|
|
// effect's initial pass returns early (gameState is still hydrating
|
|
// → `report` is null), and the subsequent server-driven `report`
|
|
// transition re-fires the effect after `onMount` has already
|
|
// completed. On a second navigation back to /map the report is
|
|
// already loaded — without reactivity here the effect's first
|
|
// pass would gate on `mounted === false`, and there would be no
|
|
// later state change to wake it up. The visible symptom is a
|
|
// black canvas (renderer never re-mounted on the new DOM).
|
|
let mounted = $state(false);
|
|
// Mount serialization. The `$effect` may re-fire while the
|
|
// async `mountRenderer` is mid-flight (e.g. report transitions
|
|
// from null → populated → overlay-mutated during boot). Without
|
|
// the in-progress gate, parallel `createRenderer` awaits would
|
|
// leave both old and new viewport listeners on the canvas,
|
|
// double-firing every click. The gate is intentionally a plain
|
|
// `let` (not `$state`) so reads from the effect do not register
|
|
// as a reactive dependency.
|
|
let mountInProgress = false;
|
|
let pendingMountSignal = $state(0);
|
|
// Track the latest cargo-route fingerprint we pushed to the
|
|
// renderer so a no-op push (e.g. report refresh that yields the
|
|
// same overlay) doesn't churn Pixi graphics needlessly.
|
|
let lastExtrasFingerprint: string | null = null;
|
|
|
|
$effect(() => {
|
|
// Read the overlay-applied report so the map labels reflect
|
|
// pending renames immediately. Falls back to raw report when
|
|
// the rendered source is missing (e.g. component used outside
|
|
// the in-game shell layout).
|
|
const report = renderedReport?.report ?? store?.report;
|
|
const status = store?.status ?? "idle";
|
|
// 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 || toggles === undefined) return;
|
|
|
|
// Explicit reads of every toggle key — Svelte 5's deep proxy
|
|
// tracks per-property access, and the actual consumers
|
|
// (computeHiddenIds, computeFogCircles, buildExtras) run
|
|
// inside `untrack` blocks or async continuations where the
|
|
// tracking would otherwise be lost. Touching every key here
|
|
// synchronously guarantees a flip triggers the effect.
|
|
void toggles.hyperspaceGroups;
|
|
void toggles.incomingGroups;
|
|
void toggles.unidentifiedGroups;
|
|
void toggles.foreignPlanets;
|
|
void toggles.uninhabitedPlanets;
|
|
void toggles.unidentifiedPlanets;
|
|
void toggles.unreachablePlanets;
|
|
void toggles.cargoRoutes;
|
|
void toggles.battleMarkers;
|
|
void toggles.bombingMarkers;
|
|
void toggles.visibleHyperspace;
|
|
|
|
// Subscribe to the calculator's published reach so the rings
|
|
// redraw as the design or the selected planet changes.
|
|
void reachStore.origin;
|
|
void reachStore.speedPerTurn;
|
|
|
|
// 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
|
|
// can change inside a single turn without disposing the Pixi
|
|
// `Application` — Pixi 8 does not reliably re-init on the
|
|
// same canvas. The fingerprint guard avoids redundant Pixi
|
|
// 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). 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 reachOrigin = reachStore.origin;
|
|
const reachFingerprint =
|
|
reachOrigin === null
|
|
? ""
|
|
: `${reachOrigin.x},${reachOrigin.y},${reachStore.speedPerTurn}`;
|
|
const extrasFingerprint =
|
|
`cr=${toggles.cargoRoutes ? "1" : "0"}|hp=${hiddenPlanetFingerprint}|` +
|
|
`reach=${reachFingerprint}|` +
|
|
computeRoutesFingerprint(report.routes) +
|
|
"|" +
|
|
computePendingSendFingerprint(draftCommands, draftStatuses);
|
|
|
|
const sameSnapshot =
|
|
mountedTurn === report.turn &&
|
|
mountedGameId === gameId &&
|
|
handle !== null;
|
|
if (sameSnapshot) {
|
|
// Apply wrap-mode flips in-place via the renderer's own
|
|
// `setMode` — a full re-mount is unnecessary (the world,
|
|
// primitives, and camera are unchanged) and Pixi 8 does
|
|
// not reliably re-init on the same canvas (the symptom is
|
|
// a crashed tab when the wrap-mode radio fires).
|
|
if (handle !== null && handle.getMode() !== mode) {
|
|
untrack(() => {
|
|
handle?.setMode(mode);
|
|
});
|
|
}
|
|
// 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(
|
|
buildExtras(
|
|
report,
|
|
draftCommands,
|
|
draftStatuses,
|
|
toggles,
|
|
hiddenPlanetNumbers,
|
|
mode,
|
|
),
|
|
);
|
|
});
|
|
lastExtrasFingerprint = extrasFingerprint;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Read the pending-mount signal so the effect re-runs after
|
|
// the in-flight mount completes (it bumps the signal in its
|
|
// finally block). Without this, a dep change observed while
|
|
// `mountInProgress` is true would be silently dropped.
|
|
void pendingMountSignal;
|
|
if (mountInProgress) return;
|
|
untrack(() => {
|
|
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>,
|
|
mode: "torus" | "no-wrap",
|
|
): 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,
|
|
);
|
|
// Reach circles published by the ship-class calculator. Empty
|
|
// when no own planet is selected or the design is invalid, so
|
|
// this is a no-op for the rest of the map.
|
|
const reachOrigin = reachStore.origin;
|
|
const reach =
|
|
reachOrigin !== null && reachStore.speedPerTurn > 0
|
|
? computeReachCircles(
|
|
reachOrigin,
|
|
reachStore.speedPerTurn,
|
|
report.mapWidth,
|
|
report.mapHeight,
|
|
mode,
|
|
)
|
|
: [];
|
|
return [...cargo, ...pending, ...reach];
|
|
}
|
|
|
|
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",
|
|
toggles: MapToggles,
|
|
hiddenPlanetNumbers: ReadonlySet<number>,
|
|
extrasFingerprint: string,
|
|
draftCommands: readonly OrderCommand[],
|
|
draftStatuses: Readonly<Record<string, string>>,
|
|
): Promise<void> {
|
|
mountInProgress = true;
|
|
try {
|
|
await mountRenderer(report, mode);
|
|
if (handle === null) return;
|
|
applyVisibilityState(report, toggles, hiddenPlanetNumbers);
|
|
handle.setExtraPrimitives(
|
|
buildExtras(
|
|
report,
|
|
draftCommands,
|
|
draftStatuses,
|
|
toggles,
|
|
hiddenPlanetNumbers,
|
|
mode,
|
|
),
|
|
);
|
|
lastExtrasFingerprint = extrasFingerprint;
|
|
} finally {
|
|
mountInProgress = false;
|
|
// Bump the reactive signal so any dep change observed
|
|
// while the gate was up gets a fresh effect run with the
|
|
// current state.
|
|
pendingMountSignal += 1;
|
|
}
|
|
}
|
|
|
|
function computeRoutesFingerprint(
|
|
routes: NonNullable<GameStateStore["report"]>["routes"],
|
|
): string {
|
|
if (routes.length === 0) return "";
|
|
const parts = routes.map((route) => {
|
|
const entries = route.entries
|
|
.map((entry) => `${entry.loadType}->${entry.destinationPlanetNumber}`)
|
|
.join(",");
|
|
return `${route.sourcePlanetNumber}:${entries}`;
|
|
});
|
|
return parts.join(";");
|
|
}
|
|
|
|
function computePendingSendFingerprint(
|
|
commands: readonly OrderCommand[],
|
|
statuses: Readonly<Record<string, string>>,
|
|
): string {
|
|
const parts: string[] = [];
|
|
for (const cmd of commands) {
|
|
if (cmd.kind !== "sendShipGroup") continue;
|
|
const status = statuses[cmd.id];
|
|
if (status === "rejected" || status === "invalid") continue;
|
|
parts.push(`${cmd.groupId}->${cmd.destinationPlanetNumber}`);
|
|
}
|
|
return parts.join(";");
|
|
}
|
|
|
|
async function mountRenderer(
|
|
report: NonNullable<GameStateStore["report"]>,
|
|
mode: "torus" | "no-wrap",
|
|
): Promise<void> {
|
|
if (canvasEl === null || containerEl === null) return;
|
|
// Capture camera state before disposing so a remount inside
|
|
// the same game (e.g. cargo-route overlay change) keeps the
|
|
// user's pan/zoom. A new game / first mount has no prior
|
|
// camera, so `previousCamera` stays null and the default
|
|
// centring path runs.
|
|
const previousGameId = mountedGameId;
|
|
const targetGameId = store?.gameId ?? "";
|
|
const previousCamera =
|
|
handle !== null && previousGameId === targetGameId
|
|
? handle.getCamera()
|
|
: null;
|
|
if (detachClick !== null) {
|
|
detachClick();
|
|
detachClick = null;
|
|
}
|
|
// Detach the previous resolver before disposing — the
|
|
// renderer's `dispose` already calls `onPick(null)` on any
|
|
// open session, which `bindResolver(null)` would also do, so
|
|
// we route the cancel through one path only.
|
|
pickService?.bindResolver(null);
|
|
if (detachDebugProviders !== null) {
|
|
detachDebugProviders();
|
|
detachDebugProviders = null;
|
|
}
|
|
if (handle !== null) {
|
|
handle.dispose();
|
|
handle = null;
|
|
}
|
|
try {
|
|
const {
|
|
world,
|
|
hitLookup: nextHitLookup,
|
|
categories,
|
|
planetDependents,
|
|
} = reportToWorld(report);
|
|
hitLookup = nextHitLookup;
|
|
currentCategories = categories;
|
|
currentPlanetDependents = planetDependents;
|
|
handle = await createRenderer({
|
|
canvas: canvasEl,
|
|
world,
|
|
mode,
|
|
preference: ["webgpu", "webgl"],
|
|
});
|
|
const minScale = minScaleNoWrap(
|
|
{
|
|
widthPx: containerEl.clientWidth,
|
|
heightPx: containerEl.clientHeight,
|
|
},
|
|
world,
|
|
);
|
|
if (previousCamera !== null) {
|
|
// Same-game remount — preserve pan/zoom. Clamp zoom
|
|
// to `minScale` so a remount that re-derives the
|
|
// minimum (e.g. a viewport resize between renderers)
|
|
// does not strand the user below the current floor.
|
|
handle.viewport.moveCenter(
|
|
previousCamera.centerX,
|
|
previousCamera.centerY,
|
|
);
|
|
handle.viewport.setZoom(
|
|
Math.max(previousCamera.scale, minScale),
|
|
true,
|
|
);
|
|
} else {
|
|
handle.viewport.moveCenter(world.width / 2, world.height / 2);
|
|
handle.viewport.setZoom(minScale * 1.05, true);
|
|
}
|
|
if (mode === "no-wrap") handle.setMode("no-wrap");
|
|
detachClick = handle.onClick(handleMapClick);
|
|
pickService?.bindResolver(({ sourcePlanetNumber, reachableIds, onResolve }) => {
|
|
if (handle === null) {
|
|
onResolve(null);
|
|
return null;
|
|
}
|
|
const planet = report.planets.find(
|
|
(p) => p.number === sourcePlanetNumber,
|
|
);
|
|
if (planet === undefined) {
|
|
onResolve(null);
|
|
return null;
|
|
}
|
|
return handle.setPickMode({
|
|
sourcePrimitiveId: sourcePlanetNumber,
|
|
sourceX: planet.x,
|
|
sourceY: planet.y,
|
|
reachableIds,
|
|
onPick: onResolve,
|
|
});
|
|
});
|
|
const detachPrim = registerMapPrimitivesProvider(() => {
|
|
const h = handle;
|
|
if (h === null) return [];
|
|
return h.getPrimitives().map<MapPrimitiveSnapshot>((p) => ({
|
|
id: p.id,
|
|
kind: p.kind,
|
|
priority: p.priority,
|
|
alpha: h.getPrimitiveAlpha(p.id),
|
|
fillColor: p.style.fillColor ?? null,
|
|
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(() => {
|
|
const h = handle;
|
|
if (h === null) {
|
|
return {
|
|
active: false,
|
|
sourcePlanetNumber: null,
|
|
reachableIds: [],
|
|
hoveredId: null,
|
|
} satisfies MapPickStateSnapshot;
|
|
}
|
|
const state = h.getPickState();
|
|
return {
|
|
active: state.active,
|
|
sourcePlanetNumber:
|
|
state.sourcePrimitiveId === null
|
|
? null
|
|
: Number(state.sourcePrimitiveId),
|
|
reachableIds:
|
|
state.reachableIds === null
|
|
? []
|
|
: Array.from(state.reachableIds).map((id) => Number(id)),
|
|
hoveredId:
|
|
state.hoveredId === null ? null : Number(state.hoveredId),
|
|
} satisfies MapPickStateSnapshot;
|
|
});
|
|
const detachCamera = registerMapCameraProvider(() => {
|
|
const h = handle;
|
|
if (h === null) return null;
|
|
const camera = h.getCamera();
|
|
const viewport = h.getViewport();
|
|
const rect = canvasEl?.getBoundingClientRect();
|
|
return {
|
|
camera,
|
|
viewport,
|
|
canvasOrigin: {
|
|
x: rect?.left ?? 0,
|
|
y: rect?.top ?? 0,
|
|
},
|
|
} satisfies MapCameraSnapshot;
|
|
});
|
|
const detachFog = registerMapFogProvider(() => ({
|
|
circles: currentFogCircles.map((c) => ({ ...c })),
|
|
}) satisfies MapFogSnapshot);
|
|
const detachMode = registerMapModeProvider(() =>
|
|
handle === null ? null : handle.getMode(),
|
|
);
|
|
const detachRenderCount = registerMapRenderCountProvider(() =>
|
|
handle === null ? null : handle.getRenderCount(),
|
|
);
|
|
detachDebugProviders = (): void => {
|
|
detachPrim();
|
|
detachPick();
|
|
detachCamera();
|
|
detachFog();
|
|
detachMode();
|
|
detachRenderCount();
|
|
};
|
|
mountedTurn = report.turn;
|
|
mountedGameId = targetGameId;
|
|
// 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;
|
|
} catch (err) {
|
|
mountError = err instanceof Error ? err.message : String(err);
|
|
}
|
|
}
|
|
|
|
// handleMapClick translates a renderer click into a selection
|
|
// update. A click that misses every primitive (empty space) is a
|
|
// deliberate no-op: the selection rule from Phase 13 is that only
|
|
// the explicit close button on the mobile sheet clears the
|
|
// current selection. The Phase 19 ship-group surface dispatches
|
|
// through the same `hit-test` plumbing — the hitLookup map keyed
|
|
// by primitive id resolves a hit back to either a planet or a
|
|
// ship-group selection variant.
|
|
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
|
if (handle === null || store?.report === undefined || store.report === null) {
|
|
return;
|
|
}
|
|
if (selection === undefined) return;
|
|
const hit = handle.hitAt(cursorPx);
|
|
if (hit === null) return;
|
|
const target = hitLookup.get(hit.primitive.id);
|
|
if (target === undefined) return;
|
|
switch (target.kind) {
|
|
case "planet":
|
|
if (hit.primitive.kind !== "point") return;
|
|
selection.selectPlanet(target.number);
|
|
break;
|
|
case "shipGroup":
|
|
if (hit.primitive.kind !== "point") return;
|
|
selection.selectShipGroup(target.ref);
|
|
break;
|
|
case "battle": {
|
|
const gameId = page.params.id ?? "";
|
|
const turn = store?.report?.turn ?? 0;
|
|
void goto(
|
|
`/games/${gameId}/battle/${target.battleId}?turn=${turn}`,
|
|
);
|
|
break;
|
|
}
|
|
case "bombing": {
|
|
const gameId = page.params.id ?? "";
|
|
void goto(
|
|
`/games/${gameId}/report#report-bombings`,
|
|
).then(() => {
|
|
if (typeof document === "undefined") return;
|
|
const row = document.querySelector(
|
|
`[data-testid="report-bombing-row"][data-planet="${target.planet}"]`,
|
|
);
|
|
if (row && row.scrollIntoView) {
|
|
row.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
mounted = true;
|
|
onResize = (): void => {
|
|
if (handle === null || containerEl === null) return;
|
|
handle.resize(containerEl.clientWidth, containerEl.clientHeight);
|
|
};
|
|
window.addEventListener("resize", onResize);
|
|
// In DEV the in-game shell mounts on a fresh document load
|
|
// (`page.goto`), which discards anything the
|
|
// `/__debug/store` route may have installed earlier in the
|
|
// session. The renderer-side accessors are still useful for
|
|
// e2e specs driving the map, so we install them here too.
|
|
if (import.meta.env.DEV) {
|
|
detachDebugSurface = installRendererDebugSurface();
|
|
}
|
|
});
|
|
|
|
onDestroy(() => {
|
|
mounted = false;
|
|
if (onResize !== null) {
|
|
window.removeEventListener("resize", onResize);
|
|
onResize = null;
|
|
}
|
|
if (detachClick !== null) {
|
|
detachClick();
|
|
detachClick = null;
|
|
}
|
|
pickService?.bindResolver(null);
|
|
if (detachDebugProviders !== null) {
|
|
detachDebugProviders();
|
|
detachDebugProviders = null;
|
|
}
|
|
if (detachDebugSurface !== null) {
|
|
detachDebugSurface();
|
|
detachDebugSurface = null;
|
|
}
|
|
if (handle !== null) {
|
|
handle.dispose();
|
|
handle = null;
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<section class="active-view" data-testid="active-view-map" data-status={store?.status ?? "idle"}>
|
|
{#if store?.status === "error"}
|
|
<p class="overlay error" role="alert" data-testid="map-error">
|
|
{store.error ?? "request failed"}
|
|
</p>
|
|
{:else if mountError !== null}
|
|
<p class="overlay error" role="alert" data-testid="map-mount-error">
|
|
{mountError}
|
|
</p>
|
|
{:else if store?.status !== "ready"}
|
|
<p class="overlay" data-testid="map-loading">{i18n.t("common.loading")}</p>
|
|
{/if}
|
|
<div
|
|
class="canvas-wrap"
|
|
data-testid="map-canvas-wrap"
|
|
data-planet-count={store?.report?.planets.length ?? 0}
|
|
bind:this={containerEl}
|
|
>
|
|
<canvas bind:this={canvasEl}></canvas>
|
|
{#if store !== undefined && store.status === "ready"}
|
|
<MapTogglesControl {store} />
|
|
{/if}
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.active-view {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
min-height: 0;
|
|
}
|
|
.canvas-wrap {
|
|
flex: 1;
|
|
min-height: 0;
|
|
position: relative;
|
|
overflow: hidden;
|
|
background: #0a0e1a;
|
|
}
|
|
canvas {
|
|
display: block;
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
.overlay {
|
|
position: absolute;
|
|
top: 0.75rem;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
padding: 0.4rem 0.9rem;
|
|
background: rgba(20, 24, 42, 0.85);
|
|
color: #e8eaf6;
|
|
border: 1px solid #2a3150;
|
|
border-radius: 6px;
|
|
z-index: 10;
|
|
font-family: system-ui, sans-serif;
|
|
font-size: 0.9rem;
|
|
margin: 0;
|
|
}
|
|
.overlay.error {
|
|
background: #4a1820;
|
|
border-color: #6d2530;
|
|
color: #ffb4b4;
|
|
}
|
|
</style>
|