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:
@@ -0,0 +1,320 @@
|
||||
<!--
|
||||
Phase 29 gear popover. Sits in the top-right corner of the map
|
||||
canvas and exposes the per-game visibility / wrap toggles that the
|
||||
`GameStateStore` already owns. The component is a thin view of the
|
||||
store — every checkbox / radio fires `store.setMapToggle(...)` or
|
||||
`store.setWrapMode(...)` and reads back the current state through
|
||||
the rune.
|
||||
|
||||
Outside-click + Escape close the popover, matching the
|
||||
`header/view-menu.svelte` precedent. On mobile (<768 px) the
|
||||
surface re-styles into a bottom-sheet positioned above the
|
||||
bottom-tabs bar.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import type { MapToggles, GameStateStore } from "$lib/game-state.svelte";
|
||||
import type { WrapMode } from "../../map/world";
|
||||
|
||||
type Props = { store: GameStateStore };
|
||||
let { store }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let rootEl: HTMLDivElement | null = $state(null);
|
||||
|
||||
function toggleOpen(): void {
|
||||
open = !open;
|
||||
}
|
||||
|
||||
function setFlag<K extends keyof MapToggles>(
|
||||
key: K,
|
||||
event: Event & { currentTarget: HTMLInputElement },
|
||||
): void {
|
||||
void store.setMapToggle(key, event.currentTarget.checked as MapToggles[K]);
|
||||
}
|
||||
|
||||
function setWrap(mode: WrapMode): void {
|
||||
void store.setWrapMode(mode);
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === "Escape" && open) {
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const handleClick = (event: MouseEvent): void => {
|
||||
if (!open || rootEl === null) return;
|
||||
const target = event.target;
|
||||
if (target instanceof Node && rootEl.contains(target)) return;
|
||||
open = false;
|
||||
};
|
||||
document.addEventListener("click", handleClick, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClick, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="map-toggles" bind:this={rootEl}>
|
||||
<button
|
||||
type="button"
|
||||
class="trigger"
|
||||
data-testid="map-toggles-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={open
|
||||
? i18n.t("game.map.toggles.close")
|
||||
: i18n.t("game.map.toggles.open")}
|
||||
onclick={toggleOpen}
|
||||
>
|
||||
<span aria-hidden="true">⚙</span>
|
||||
</button>
|
||||
{#if open}
|
||||
<div class="surface" role="menu" data-testid="map-toggles-surface">
|
||||
<fieldset>
|
||||
<legend>{i18n.t("game.map.toggles.section.objects")}</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-hyperspace-groups"
|
||||
checked={store.mapToggles.hyperspaceGroups}
|
||||
onchange={(e) => setFlag("hyperspaceGroups", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.hyperspace_groups")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-incoming-groups"
|
||||
checked={store.mapToggles.incomingGroups}
|
||||
onchange={(e) => setFlag("incomingGroups", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.incoming_groups")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-unidentified-groups"
|
||||
checked={store.mapToggles.unidentifiedGroups}
|
||||
onchange={(e) => setFlag("unidentifiedGroups", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.unidentified_groups")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-cargo-routes"
|
||||
checked={store.mapToggles.cargoRoutes}
|
||||
onchange={(e) => setFlag("cargoRoutes", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.cargo_routes")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-battle-markers"
|
||||
checked={store.mapToggles.battleMarkers}
|
||||
onchange={(e) => setFlag("battleMarkers", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.battle_markers")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-bombing-markers"
|
||||
checked={store.mapToggles.bombingMarkers}
|
||||
onchange={(e) => setFlag("bombingMarkers", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.bombing_markers")}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{i18n.t("game.map.toggles.section.planets")}</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-foreign-planets"
|
||||
checked={store.mapToggles.foreignPlanets}
|
||||
onchange={(e) => setFlag("foreignPlanets", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.foreign_planets")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-uninhabited-planets"
|
||||
checked={store.mapToggles.uninhabitedPlanets}
|
||||
onchange={(e) => setFlag("uninhabitedPlanets", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.uninhabited_planets")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-unidentified-planets"
|
||||
checked={store.mapToggles.unidentifiedPlanets}
|
||||
onchange={(e) => setFlag("unidentifiedPlanets", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.unidentified_planets")}</span>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-unreachable-planets"
|
||||
checked={store.mapToggles.unreachablePlanets}
|
||||
onchange={(e) => setFlag("unreachablePlanets", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.unreachable_planets")}</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>{i18n.t("game.map.toggles.section.view")}</legend>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="map-toggles-visibility-fog"
|
||||
checked={store.mapToggles.visibilityFog}
|
||||
onchange={(e) => setFlag("visibilityFog", e)}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.visibility_fog")}</span>
|
||||
</label>
|
||||
<div class="wrap-row">
|
||||
<span class="wrap-label">{i18n.t("game.map.toggles.wrap.label")}</span>
|
||||
<label class="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="map-toggles-wrap"
|
||||
data-testid="map-toggles-wrap-torus"
|
||||
value="torus"
|
||||
checked={store.wrapMode === "torus"}
|
||||
onchange={() => setWrap("torus")}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.wrap.torus")}</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="map-toggles-wrap"
|
||||
data-testid="map-toggles-wrap-no-wrap"
|
||||
value="no-wrap"
|
||||
checked={store.wrapMode === "no-wrap"}
|
||||
onchange={() => setWrap("no-wrap")}
|
||||
/>
|
||||
<span>{i18n.t("game.map.toggles.wrap.no_wrap")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map-toggles {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 20;
|
||||
}
|
||||
.trigger {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font: inherit;
|
||||
font-size: 1.4rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(20, 24, 42, 0.85);
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.trigger:hover {
|
||||
background: #1c2238;
|
||||
}
|
||||
.surface {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
right: 0;
|
||||
min-width: 16rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
background: #14182a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
||||
padding: 0.5rem;
|
||||
z-index: 50;
|
||||
}
|
||||
fieldset {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
legend {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #aab;
|
||||
padding: 0 0 0.15rem 0;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.2rem 0.25rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
label:hover {
|
||||
background: #1c2238;
|
||||
}
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
accent-color: #6dd2ff;
|
||||
}
|
||||
.wrap-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.wrap-label {
|
||||
color: #aab;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
width: 100%;
|
||||
}
|
||||
.radio {
|
||||
padding: 0.15rem 0.4rem;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.surface {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 3.25rem;
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
border-radius: 0;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -29,6 +29,25 @@ export interface MapPrimitiveSnapshot {
|
||||
readonly strokeColor: number | null;
|
||||
readonly x: number | null;
|
||||
readonly y: number | null;
|
||||
/**
|
||||
* visible mirrors the renderer's per-id visibility flag — `false`
|
||||
* iff `RendererHandle.setHiddenPrimitiveIds` has put the id into
|
||||
* the hide set. Phase 29 e2e specs use this to assert toggle and
|
||||
* planet-cascade behaviour without poking at Pixi internals.
|
||||
*/
|
||||
readonly visible: boolean;
|
||||
}
|
||||
|
||||
/** Snapshot returned by `getMapFog()` — the current Phase 29
|
||||
* visibility-fog input as the renderer last received it. The array
|
||||
* is empty when the fog toggle is off (or when `localPlayerDrive`
|
||||
* is zero so the radius would be zero). */
|
||||
export interface MapFogSnapshot {
|
||||
readonly circles: ReadonlyArray<{
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly radius: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Snapshot returned by `getMapCamera()`. Mirrors the renderer's
|
||||
@@ -53,10 +72,12 @@ export interface MapPickStateSnapshot {
|
||||
type PrimitivesProvider = () => readonly MapPrimitiveSnapshot[];
|
||||
type PickStateProvider = () => MapPickStateSnapshot;
|
||||
type CameraProvider = () => MapCameraSnapshot | null;
|
||||
type FogProvider = () => MapFogSnapshot;
|
||||
|
||||
let primitivesProvider: PrimitivesProvider | null = null;
|
||||
let pickStateProvider: PickStateProvider | null = null;
|
||||
let cameraProvider: CameraProvider | null = null;
|
||||
let fogProvider: FogProvider | null = null;
|
||||
|
||||
/**
|
||||
* registerMapPrimitivesProvider attaches a provider that yields the
|
||||
@@ -101,6 +122,18 @@ export function registerMapCameraProvider(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* registerMapFogProvider attaches a provider that yields the current
|
||||
* Phase 29 fog input as last seen by the renderer. Same idempotent
|
||||
* semantics as the other providers.
|
||||
*/
|
||||
export function registerMapFogProvider(provider: FogProvider): () => void {
|
||||
fogProvider = provider;
|
||||
return () => {
|
||||
if (fogProvider === provider) fogProvider = null;
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_PICK_STATE: MapPickStateSnapshot = {
|
||||
active: false,
|
||||
sourcePlanetNumber: null,
|
||||
@@ -126,11 +159,19 @@ export function getMapCamera(): MapCameraSnapshot | null {
|
||||
return cameraProvider?.() ?? null;
|
||||
}
|
||||
|
||||
/** Pulls the current visibility-fog snapshot. Returns an empty
|
||||
* snapshot when no provider is registered (e.g. map view not
|
||||
* mounted). */
|
||||
export function getMapFog(): MapFogSnapshot {
|
||||
return fogProvider?.() ?? { circles: [] };
|
||||
}
|
||||
|
||||
interface RendererDebugWindow {
|
||||
__galaxyDebug?: {
|
||||
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
|
||||
getMapPickState?: () => MapPickStateSnapshot;
|
||||
getMapCamera?: () => MapCameraSnapshot | null;
|
||||
getMapFog?: () => MapFogSnapshot;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
@@ -153,6 +194,7 @@ export function installRendererDebugSurface(): () => void {
|
||||
getMapPrimitives,
|
||||
getMapPickState,
|
||||
getMapCamera,
|
||||
getMapFog,
|
||||
};
|
||||
win.__galaxyDebug = surface;
|
||||
return (): void => {
|
||||
@@ -170,5 +212,8 @@ export function installRendererDebugSurface(): () => void {
|
||||
if (current.getMapCamera === getMapCamera) {
|
||||
delete current.getMapCamera;
|
||||
}
|
||||
if (current.getMapFog === getMapFog) {
|
||||
delete current.getMapFog;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,54 @@ const HISTORY_NAMESPACE = "game-history";
|
||||
const HISTORY_KEY_TURN = (gameId: string, turn: number) =>
|
||||
`${gameId}/turn/${turn}`;
|
||||
|
||||
const MAP_TOGGLES_NAMESPACE = "game-map-toggles";
|
||||
|
||||
/**
|
||||
* MapToggles is the per-game visibility state exposed by the Phase 29
|
||||
* gear popover. Every flip persists into `Cache` under
|
||||
* `MAP_TOGGLES_NAMESPACE/<gameId>` so the next visit to the game keeps
|
||||
* the user's choices; a new server-side turn force-resets the blob to
|
||||
* `DEFAULT_MAP_TOGGLES` so a hidden category never makes the player
|
||||
* miss what changed (see `GameStateStore.setGame` and
|
||||
* `advanceToPending`).
|
||||
*
|
||||
* Categories with no per-toggle entry are always visible: `local`
|
||||
* planets, in-orbit / on-planet ship groups (rendered by the planet
|
||||
* inspector, never on the map), and the pending-Send overlay.
|
||||
*/
|
||||
export interface MapToggles {
|
||||
hyperspaceGroups: boolean;
|
||||
incomingGroups: boolean;
|
||||
unidentifiedGroups: boolean;
|
||||
foreignPlanets: boolean;
|
||||
uninhabitedPlanets: boolean;
|
||||
unidentifiedPlanets: boolean;
|
||||
unreachablePlanets: boolean;
|
||||
cargoRoutes: boolean;
|
||||
battleMarkers: boolean;
|
||||
bombingMarkers: boolean;
|
||||
visibilityFog: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_MAP_TOGGLES: MapToggles = {
|
||||
hyperspaceGroups: true,
|
||||
incomingGroups: true,
|
||||
unidentifiedGroups: true,
|
||||
foreignPlanets: true,
|
||||
uninhabitedPlanets: true,
|
||||
unidentifiedPlanets: true,
|
||||
unreachablePlanets: true,
|
||||
cargoRoutes: true,
|
||||
battleMarkers: true,
|
||||
bombingMarkers: true,
|
||||
visibilityFog: true,
|
||||
};
|
||||
|
||||
interface PersistedMapToggles {
|
||||
readonly toggles: MapToggles;
|
||||
readonly lastResetTurn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* GAME_STATE_CONTEXT_KEY is the Svelte context key the in-game shell
|
||||
* layout uses to expose its `GameStateStore` instance to descendants.
|
||||
@@ -53,6 +101,15 @@ export class GameStateStore {
|
||||
status: Status = $state("idle");
|
||||
report: GameReport | null = $state(null);
|
||||
wrapMode: WrapMode = $state("torus");
|
||||
/**
|
||||
* mapToggles is the per-game visibility state surfaced by the
|
||||
* Phase 29 gear popover. Every value defaults to `true` except for
|
||||
* the negative `unreachablePlanets` flag (which is also `true` so
|
||||
* the default view shows every reachable planet). The map view
|
||||
* resolves the flags into a hide-by-id set on every effect run via
|
||||
* `RendererHandle.setHiddenPrimitiveIds`.
|
||||
*/
|
||||
mapToggles: MapToggles = $state({ ...DEFAULT_MAP_TOGGLES });
|
||||
error: string | null = $state(null);
|
||||
/**
|
||||
* currentTurn mirrors the engine's turn number for the running
|
||||
@@ -109,6 +166,13 @@ export class GameStateStore {
|
||||
private cache: Cache | null = null;
|
||||
private destroyed = false;
|
||||
private visibilityListener: (() => void) | null = null;
|
||||
/**
|
||||
* lastResetTurn is the turn at which `mapToggles` was last reset to
|
||||
* defaults. Persisted alongside the toggle blob so the new-turn
|
||||
* reset path can compare against `currentTurn` after a cross-
|
||||
* session gap (browser closed at turn N, reopened at turn N + k).
|
||||
*/
|
||||
private lastResetTurn = 0;
|
||||
|
||||
/**
|
||||
* init kicks off the per-game lifecycle. The call is idempotent on
|
||||
@@ -151,6 +215,7 @@ export class GameStateStore {
|
||||
|
||||
this.wrapMode = await readWrapMode(this.cache, gameId);
|
||||
const lastViewed = await readLastViewedTurn(this.cache, gameId);
|
||||
const persistedToggles = await readMapToggles(this.cache, gameId);
|
||||
|
||||
try {
|
||||
const summary = await this.findGame(gameId);
|
||||
@@ -161,6 +226,26 @@ export class GameStateStore {
|
||||
}
|
||||
this.gameName = summary.gameName;
|
||||
this.currentTurn = summary.currentTurn;
|
||||
// New-turn reset: if the persisted blob is older than the
|
||||
// server-side `currentTurn`, drop user overrides and write
|
||||
// the fresh `{defaults, currentTurn}` back to cache so a
|
||||
// subsequent reload sees the same baseline. The cross-
|
||||
// session gap counts here too — a player who closed the
|
||||
// tab at turn N and returns at turn N + k still gets the
|
||||
// defaults on first map mount.
|
||||
if (persistedToggles.lastResetTurn < summary.currentTurn) {
|
||||
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
|
||||
this.lastResetTurn = summary.currentTurn;
|
||||
await writeMapToggles(
|
||||
this.cache,
|
||||
gameId,
|
||||
this.mapToggles,
|
||||
this.lastResetTurn,
|
||||
);
|
||||
} else {
|
||||
this.mapToggles = { ...persistedToggles.toggles };
|
||||
this.lastResetTurn = persistedToggles.lastResetTurn;
|
||||
}
|
||||
// If the persisted last-viewed turn is older than the
|
||||
// server-side current turn, open the user on their last-seen
|
||||
// snapshot and surface the gap through `pendingTurn` so the
|
||||
@@ -225,6 +310,13 @@ export class GameStateStore {
|
||||
this.currentTurn = summary.currentTurn;
|
||||
await this.loadTurn(summary.currentTurn, { isCurrent: true });
|
||||
this.pendingTurn = null;
|
||||
// Phase 29: a successful jump onto the new server turn
|
||||
// drops user-set map-visibility overrides so the next
|
||||
// frame surfaces every category. `viewTurn` is the
|
||||
// history-mode path and intentionally leaves toggles
|
||||
// alone — the single shared state stays put across
|
||||
// in-game time-travel.
|
||||
await this.resetMapTogglesForTurn(summary.currentTurn);
|
||||
} catch (err) {
|
||||
if (this.destroyed) return;
|
||||
this.status = "error";
|
||||
@@ -298,6 +390,40 @@ export class GameStateStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* setMapToggle flips one entry of the `mapToggles` rune and
|
||||
* persists the whole blob (alongside the unchanged
|
||||
* `lastResetTurn`). Mutating the rune in place keeps subscribers
|
||||
* reactive without requiring object identity changes.
|
||||
*/
|
||||
async setMapToggle<K extends keyof MapToggles>(
|
||||
key: K,
|
||||
value: MapToggles[K],
|
||||
): Promise<void> {
|
||||
this.mapToggles[key] = value;
|
||||
if (this.cache !== null) {
|
||||
await writeMapToggles(
|
||||
this.cache,
|
||||
this.gameId,
|
||||
this.mapToggles,
|
||||
this.lastResetTurn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async resetMapTogglesForTurn(turn: number): Promise<void> {
|
||||
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
|
||||
this.lastResetTurn = turn;
|
||||
if (this.cache !== null) {
|
||||
await writeMapToggles(
|
||||
this.cache,
|
||||
this.gameId,
|
||||
this.mapToggles,
|
||||
this.lastResetTurn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* failBootstrap is used by the layout to surface errors that
|
||||
* happen *before* `init` could be reached (missing keypair, missing
|
||||
@@ -329,6 +455,25 @@ export class GameStateStore {
|
||||
this.gameName = "Synthetic";
|
||||
this.error = null;
|
||||
this.wrapMode = await readWrapMode(opts.cache, opts.gameId);
|
||||
// Synthetic sessions skip the lobby query, so the new-turn
|
||||
// reset check uses the report's own turn as the reference. A
|
||||
// reload on the same synthetic id restores user overrides;
|
||||
// switching to a synthetic report with a higher turn resets
|
||||
// them.
|
||||
const persistedToggles = await readMapToggles(opts.cache, opts.gameId);
|
||||
if (persistedToggles.lastResetTurn < opts.report.turn) {
|
||||
this.mapToggles = { ...DEFAULT_MAP_TOGGLES };
|
||||
this.lastResetTurn = opts.report.turn;
|
||||
await writeMapToggles(
|
||||
opts.cache,
|
||||
opts.gameId,
|
||||
this.mapToggles,
|
||||
this.lastResetTurn,
|
||||
);
|
||||
} else {
|
||||
this.mapToggles = { ...persistedToggles.toggles };
|
||||
this.lastResetTurn = persistedToggles.lastResetTurn;
|
||||
}
|
||||
this.report = opts.report;
|
||||
this.currentTurn = opts.report.turn;
|
||||
this.viewedTurn = opts.report.turn;
|
||||
@@ -422,6 +567,59 @@ async function readWrapMode(cache: Cache, gameId: string): Promise<WrapMode> {
|
||||
return "torus";
|
||||
}
|
||||
|
||||
/**
|
||||
* readMapToggles loads the persisted `{toggles, lastResetTurn}` blob.
|
||||
* Missing entries (cleared site data, fresh game) return the defaults
|
||||
* with `lastResetTurn === -1`, guaranteeing the `setGame` reset path
|
||||
* runs on the very first visit. Per-field fallback to defaults keeps
|
||||
* forward-compat with future toggle additions: an older blob
|
||||
* persisted before a new flag landed loses nothing but the missing
|
||||
* flag, which gets the default value.
|
||||
*/
|
||||
async function readMapToggles(
|
||||
cache: Cache,
|
||||
gameId: string,
|
||||
): Promise<PersistedMapToggles> {
|
||||
const stored = await cache.get<Partial<PersistedMapToggles>>(
|
||||
MAP_TOGGLES_NAMESPACE,
|
||||
gameId,
|
||||
);
|
||||
if (stored === undefined || stored === null || typeof stored !== "object") {
|
||||
return { toggles: { ...DEFAULT_MAP_TOGGLES }, lastResetTurn: -1 };
|
||||
}
|
||||
const partial =
|
||||
stored.toggles !== undefined &&
|
||||
stored.toggles !== null &&
|
||||
typeof stored.toggles === "object"
|
||||
? stored.toggles
|
||||
: {};
|
||||
const toggles: MapToggles = { ...DEFAULT_MAP_TOGGLES };
|
||||
for (const k of Object.keys(DEFAULT_MAP_TOGGLES) as (keyof MapToggles)[]) {
|
||||
const candidate = (partial as Partial<MapToggles>)[k];
|
||||
if (typeof candidate === "boolean") {
|
||||
toggles[k] = candidate;
|
||||
}
|
||||
}
|
||||
const turn =
|
||||
typeof stored.lastResetTurn === "number" &&
|
||||
Number.isFinite(stored.lastResetTurn)
|
||||
? stored.lastResetTurn
|
||||
: -1;
|
||||
return { toggles, lastResetTurn: turn };
|
||||
}
|
||||
|
||||
async function writeMapToggles(
|
||||
cache: Cache,
|
||||
gameId: string,
|
||||
toggles: MapToggles,
|
||||
lastResetTurn: number,
|
||||
): Promise<void> {
|
||||
await cache.put<PersistedMapToggles>(MAP_TOGGLES_NAMESPACE, gameId, {
|
||||
toggles: { ...toggles },
|
||||
lastResetTurn,
|
||||
});
|
||||
}
|
||||
|
||||
async function readLastViewedTurn(
|
||||
cache: Cache,
|
||||
gameId: string,
|
||||
|
||||
@@ -113,6 +113,25 @@ const en = {
|
||||
"game.shell.history.return_to_current": "Return to current turn",
|
||||
"game.shell.history.current_badge": "current",
|
||||
"game.view.map": "map",
|
||||
"game.map.toggles.open": "open map visibility menu",
|
||||
"game.map.toggles.close": "close map visibility menu",
|
||||
"game.map.toggles.section.objects": "Objects",
|
||||
"game.map.toggles.section.planets": "Planets",
|
||||
"game.map.toggles.section.view": "View",
|
||||
"game.map.toggles.hyperspace_groups": "hyperspace groups",
|
||||
"game.map.toggles.incoming_groups": "incoming groups",
|
||||
"game.map.toggles.unidentified_groups": "unidentified groups",
|
||||
"game.map.toggles.cargo_routes": "cargo routes",
|
||||
"game.map.toggles.battle_markers": "battle markers",
|
||||
"game.map.toggles.bombing_markers": "bombing markers",
|
||||
"game.map.toggles.foreign_planets": "foreign planets",
|
||||
"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.wrap.label": "wrap scrolling",
|
||||
"game.map.toggles.wrap.torus": "torus",
|
||||
"game.map.toggles.wrap.no_wrap": "no-wrap",
|
||||
"game.view.table": "table",
|
||||
"game.view.table.planets": "planets",
|
||||
"game.view.table.ship_classes": "ship classes",
|
||||
|
||||
@@ -114,6 +114,25 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.shell.history.return_to_current": "Вернуться к текущему ходу",
|
||||
"game.shell.history.current_badge": "текущий",
|
||||
"game.view.map": "карта",
|
||||
"game.map.toggles.open": "открыть меню видимости карты",
|
||||
"game.map.toggles.close": "закрыть меню видимости карты",
|
||||
"game.map.toggles.section.objects": "Объекты",
|
||||
"game.map.toggles.section.planets": "Планеты",
|
||||
"game.map.toggles.section.view": "Вид",
|
||||
"game.map.toggles.hyperspace_groups": "группы в гиперпространстве",
|
||||
"game.map.toggles.incoming_groups": "входящие группы",
|
||||
"game.map.toggles.unidentified_groups": "неопознанные группы",
|
||||
"game.map.toggles.cargo_routes": "грузовые маршруты",
|
||||
"game.map.toggles.battle_markers": "метки сражений",
|
||||
"game.map.toggles.bombing_markers": "метки бомбардировок",
|
||||
"game.map.toggles.foreign_planets": "чужие планеты",
|
||||
"game.map.toggles.uninhabited_planets": "необитаемые планеты",
|
||||
"game.map.toggles.unidentified_planets": "неопознанные планеты",
|
||||
"game.map.toggles.unreachable_planets": "показывать недостижимые планеты",
|
||||
"game.map.toggles.visibility_fog": "туман видимости",
|
||||
"game.map.toggles.wrap.label": "перенос карты",
|
||||
"game.map.toggles.wrap.torus": "тор",
|
||||
"game.map.toggles.wrap.no_wrap": "без переноса",
|
||||
"game.view.table": "таблица",
|
||||
"game.view.table.planets": "планеты",
|
||||
"game.view.table.ship_classes": "классы кораблей",
|
||||
|
||||
@@ -60,9 +60,26 @@ export interface BombingMarkerTarget {
|
||||
|
||||
export type MarkerTarget = BattleMarkerTarget | BombingMarkerTarget;
|
||||
|
||||
/**
|
||||
* MarkerCategory tags every emitted primitive with the toggleable
|
||||
* surface it belongs to so the Phase 29 hide-set machinery can flip
|
||||
* each independently. Battles and bombings have their own toggles —
|
||||
* a player can hide the bombing rings while keeping the battle
|
||||
* crosses visible.
|
||||
*/
|
||||
export type MarkerCategory = "battleMarker" | "bombingMarker";
|
||||
|
||||
export interface BuildMarkersResult {
|
||||
primitives: Primitive[];
|
||||
lookup: Map<PrimitiveID, MarkerTarget>;
|
||||
categories: Map<PrimitiveID, MarkerCategory>;
|
||||
/**
|
||||
* planetDependents maps the anchor planet number to the ids of
|
||||
* markers drawn on it; the Phase 29 cascade hides the markers
|
||||
* together with the planet when the planet itself is filtered out
|
||||
* (kind toggle off or unreachable filter on).
|
||||
*/
|
||||
planetDependents: Map<number, Set<PrimitiveID>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +110,16 @@ export function buildBattleAndBombingMarkers(
|
||||
|
||||
const primitives: Primitive[] = [];
|
||||
const lookup = new Map<PrimitiveID, MarkerTarget>();
|
||||
const categories = new Map<PrimitiveID, MarkerCategory>();
|
||||
const planetDependents = new Map<number, Set<PrimitiveID>>();
|
||||
const addDependent = (planetNumber: number, id: PrimitiveID): void => {
|
||||
let set = planetDependents.get(planetNumber);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
planetDependents.set(planetNumber, set);
|
||||
}
|
||||
set.add(id);
|
||||
};
|
||||
|
||||
for (let i = 0; i < report.battles.length; i++) {
|
||||
const battle = report.battles[i];
|
||||
@@ -135,6 +162,10 @@ export function buildBattleAndBombingMarkers(
|
||||
primitives.push(lineA, lineB);
|
||||
lookup.set(lineA.id, target);
|
||||
lookup.set(lineB.id, target);
|
||||
categories.set(lineA.id, "battleMarker");
|
||||
categories.set(lineB.id, "battleMarker");
|
||||
addDependent(battle.planet, lineA.id);
|
||||
addDependent(battle.planet, lineB.id);
|
||||
}
|
||||
|
||||
for (let i = 0; i < report.bombings.length; i++) {
|
||||
@@ -162,7 +193,9 @@ export function buildBattleAndBombingMarkers(
|
||||
};
|
||||
primitives.push(ring);
|
||||
lookup.set(id, { kind: "bombing", planet: bombing.planetNumber });
|
||||
categories.set(id, "bombingMarker");
|
||||
addDependent(bombing.planetNumber, id);
|
||||
}
|
||||
|
||||
return { primitives, lookup };
|
||||
return { primitives, lookup, categories, planetDependents };
|
||||
}
|
||||
|
||||
@@ -86,18 +86,31 @@ const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
|
||||
* not present in the planet list (e.g. a destination newly
|
||||
* unidentified after a turn cutoff). Pure: relies only on the
|
||||
* report; no DOM access; no Pixi calls.
|
||||
*
|
||||
* `opts.skipPlanets` (Phase 29) is an optional set of planet numbers
|
||||
* whose routes — outgoing or incoming — should be filtered out so the
|
||||
* arrows do not point at hidden glyphs. Empty / undefined means no
|
||||
* extra filtering, preserving the pre-Phase-29 contract.
|
||||
*/
|
||||
export function buildCargoRouteLines(report: GameReport): LinePrim[] {
|
||||
export function buildCargoRouteLines(
|
||||
report: GameReport,
|
||||
opts?: { skipPlanets?: ReadonlySet<number> },
|
||||
): LinePrim[] {
|
||||
if (report.routes.length === 0) return [];
|
||||
const skip = opts?.skipPlanets;
|
||||
const planetById = new Map<number, ReportPlanet>();
|
||||
for (const planet of report.planets) {
|
||||
planetById.set(planet.number, planet);
|
||||
}
|
||||
const lines: LinePrim[] = [];
|
||||
for (const route of report.routes) {
|
||||
if (skip !== undefined && skip.has(route.sourcePlanetNumber)) continue;
|
||||
const source = planetById.get(route.sourcePlanetNumber);
|
||||
if (source === undefined) continue;
|
||||
for (const entry of route.entries) {
|
||||
if (skip !== undefined && skip.has(entry.destinationPlanetNumber)) {
|
||||
continue;
|
||||
}
|
||||
const dest = planetById.get(entry.destinationPlanetNumber);
|
||||
if (dest === undefined) continue;
|
||||
const dx = torusShortestDelta(source.x, dest.x, report.mapWidth);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type LinePrim,
|
||||
type PointPrim,
|
||||
type Primitive,
|
||||
type PrimitiveID,
|
||||
type Viewport,
|
||||
type World,
|
||||
type WrapMode,
|
||||
@@ -33,17 +34,25 @@ export interface Hit {
|
||||
|
||||
// hitTest returns the best-matching primitive under the cursor, or
|
||||
// null if no primitive matches within its hit slop.
|
||||
//
|
||||
// `hiddenIds` (optional) is consulted before every primitive — ids in
|
||||
// the set are skipped entirely, so a click on the area they used to
|
||||
// cover falls through to the next visible primitive. The renderer's
|
||||
// Phase 29 hide-by-id facility threads its current set in here so
|
||||
// the click / hover paths stay in lock-step with the visible scene.
|
||||
export function hitTest(
|
||||
world: World,
|
||||
camera: Camera,
|
||||
viewport: Viewport,
|
||||
cursorPx: { x: number; y: number },
|
||||
mode: WrapMode,
|
||||
hiddenIds?: ReadonlySet<PrimitiveID>,
|
||||
): Hit | null {
|
||||
const cursor = screenToWorld(cursorPx, camera, viewport);
|
||||
const candidates: Hit[] = [];
|
||||
|
||||
for (const p of world.primitives) {
|
||||
if (hiddenIds !== undefined && hiddenIds.has(p.id)) continue;
|
||||
const slopPx = p.hitSlopPx > 0 ? p.hitSlopPx : DEFAULT_HIT_SLOP_PX[p.kind];
|
||||
const slopWorld = slopPx / camera.scale;
|
||||
let result: number | null;
|
||||
|
||||
@@ -33,6 +33,27 @@ export function torusShortestDelta(a: number, b: number, size: number): number {
|
||||
return d + 0;
|
||||
}
|
||||
|
||||
// torusShortestDistance returns the wrap-aware Euclidean distance
|
||||
// between (ax, ay) and (bx, by) on a torus of size width × height.
|
||||
// Built on top of `torusShortestDelta` so the two axes share the
|
||||
// "shortest signed delta" semantics. Used by the Phase 29 reach
|
||||
// filter (hide planets beyond `FlightDistance` of every LOCAL
|
||||
// planet); both modes (torus / no-wrap) consume the same metric — in
|
||||
// no-wrap mode the wrapped distance is never shorter than the
|
||||
// straight-line one because the player cannot fly across the seam.
|
||||
export function torusShortestDistance(
|
||||
ax: number,
|
||||
ay: number,
|
||||
bx: number,
|
||||
by: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): number {
|
||||
const dx = torusShortestDelta(ax, bx, width);
|
||||
const dy = torusShortestDelta(ay, by, height);
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
// distSqPointToSegment returns the squared distance from point (px,py)
|
||||
// to the segment (ax,ay)–(bx,by). For zero-length segments it falls
|
||||
// back to point-to-point distance.
|
||||
|
||||
@@ -55,8 +55,10 @@ export function buildPendingSendLines(
|
||||
report: GameReport,
|
||||
commands: readonly OrderCommand[],
|
||||
statuses: Readonly<Record<string, string>>,
|
||||
opts?: { skipPlanets?: ReadonlySet<number> },
|
||||
): LinePrim[] {
|
||||
if (commands.length === 0) return [];
|
||||
const skip = opts?.skipPlanets;
|
||||
const planetById = new Map<number, ReportPlanet>();
|
||||
for (const planet of report.planets) {
|
||||
planetById.set(planet.number, planet);
|
||||
@@ -79,6 +81,8 @@ export function buildPendingSendLines(
|
||||
// origin / range to live coordinates and the in-space track
|
||||
// renders instead.
|
||||
if (group.origin !== null || group.range !== null) continue;
|
||||
if (skip !== undefined && skip.has(group.destination)) continue;
|
||||
if (skip !== undefined && skip.has(cmd.destinationPlanetNumber)) continue;
|
||||
const source = planetById.get(group.destination);
|
||||
const destination = planetById.get(cmd.destinationPlanetNumber);
|
||||
if (source === undefined || destination === undefined) continue;
|
||||
|
||||
@@ -155,6 +155,36 @@ export interface RendererHandle {
|
||||
* for unknown ids.
|
||||
*/
|
||||
getPrimitiveAlpha(id: PrimitiveID): number;
|
||||
/**
|
||||
* setHiddenPrimitiveIds replaces the set of primitives the
|
||||
* renderer should hide. Hidden primitives have their per-copy
|
||||
* `Graphics.visible` flipped to `false` and are skipped by
|
||||
* `hitAt`, so a click on the area they used to cover falls
|
||||
* through to the next primitive. Empty input clears the hide
|
||||
* set. Called every effect run by the Phase 29 map view to
|
||||
* materialise the `MapToggles` flags + planet-cascade rule
|
||||
* without a Pixi remount.
|
||||
*/
|
||||
setHiddenPrimitiveIds(ids: ReadonlySet<PrimitiveID>): void;
|
||||
/**
|
||||
* isPrimitiveHidden reports whether the supplied primitive id is
|
||||
* in the current hide set. Used by the debug surface so e2e
|
||||
* specs can assert toggle behaviour without poking at Pixi
|
||||
* internals.
|
||||
*/
|
||||
isPrimitiveHidden(id: PrimitiveID): boolean;
|
||||
/**
|
||||
* setVisibilityFog draws (or removes) the Phase 29 visibility
|
||||
* fog overlay. Each entry describes a circle around a LOCAL
|
||||
* planet that the player has scanner / visibility coverage on;
|
||||
* the overlay fills the world rectangle with a slightly lighter
|
||||
* fog colour and "punches" each circle out, leaving the
|
||||
* intelligence-covered area in the regular background. Empty
|
||||
* input destroys the existing fog Graphics.
|
||||
*/
|
||||
setVisibilityFog(
|
||||
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
||||
): void;
|
||||
resize(widthPx: number, heightPx: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -173,6 +203,18 @@ const TORUS_OFFSETS: ReadonlyArray<readonly [number, number]> = [
|
||||
|
||||
const ORIGIN_COPY_INDEX = 4; // (0, 0) entry of TORUS_OFFSETS
|
||||
|
||||
// EMPTY_HIDDEN_IDS is the default state of the Phase 29 hide set
|
||||
// (no primitive is hidden). Shared by every renderer instance so a
|
||||
// frequent `setHiddenPrimitiveIds(EMPTY_HIDDEN_IDS)` call from the
|
||||
// debug surface stays allocation-free.
|
||||
const EMPTY_HIDDEN_IDS: ReadonlySet<PrimitiveID> = new Set();
|
||||
|
||||
// FOG_COLOR is the Phase 29 visibility-fog colour. Two shades
|
||||
// lighter than the dark theme background (`0x0a0e1a`) so it reads
|
||||
// as a faint fog without contrasting against the rest of the map.
|
||||
// The colour is tunable in Phase 35 polish.
|
||||
const FOG_COLOR = 0x12162a;
|
||||
|
||||
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
|
||||
const theme = opts.theme ?? DARK_THEME;
|
||||
const preference = opts.preference ?? ["webgpu", "webgl"];
|
||||
@@ -225,6 +267,22 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const allPrimitiveIds: PrimitiveID[] = [];
|
||||
const extraPrimitiveIds = new Set<PrimitiveID>();
|
||||
let currentWorld: World = opts.world;
|
||||
// hiddenIds is the Phase 29 hide-by-id snapshot. Empty by default;
|
||||
// every map-view effect run replaces it with the current
|
||||
// MapToggles-derived set via `setHiddenPrimitiveIds`. Both
|
||||
// renderer-internal hit-test sites (pointer-move, clicked) and the
|
||||
// external `handle.hitAt` thread it through `hitTest`.
|
||||
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
|
||||
// Per-copy fog Graphics for the Phase 29 visibility fog overlay.
|
||||
// Created lazily when `setVisibilityFog` first receives a
|
||||
// non-empty list; cleared (and destroyed) when the list goes
|
||||
// empty again. Each fog Graphics is inserted at index 0 of its
|
||||
// torus copy so primitives paint on top.
|
||||
let fogGraphics: Graphics[] = [];
|
||||
const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
|
||||
const visible = !hiddenIds.has(id);
|
||||
for (const g of list) g.visible = visible;
|
||||
};
|
||||
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
||||
for (const c of copies) {
|
||||
const g = buildGraphics(prim, theme);
|
||||
@@ -239,6 +297,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
allPrimitiveIds.push(prim.id);
|
||||
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
|
||||
if (isExtra) extraPrimitiveIds.add(prim.id);
|
||||
// Fresh primitives honour the current hide set so cargo-route
|
||||
// or pending-Send extras pushed after `setHiddenPrimitiveIds`
|
||||
// inherit the right visibility.
|
||||
const list = primitiveGraphics.get(prim.id);
|
||||
if (list !== undefined) applyHiddenStateTo(prim.id, list);
|
||||
};
|
||||
for (const p of opts.world.primitives) {
|
||||
populatePrimitives(p, false);
|
||||
@@ -347,6 +410,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
handle.getViewport(),
|
||||
cursorPx,
|
||||
mode,
|
||||
hiddenIds,
|
||||
);
|
||||
const hoveredId = hit?.primitive.id ?? null;
|
||||
if (hoveredId === lastHoveredId) return;
|
||||
@@ -552,6 +616,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
handle.getViewport(),
|
||||
cursorPx,
|
||||
mode,
|
||||
hiddenIds,
|
||||
),
|
||||
setExtraPrimitives: (prims) => {
|
||||
// Drop the previous extras layer.
|
||||
@@ -629,6 +694,49 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
// torus tile), so the central-tile entry is representative.
|
||||
return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha;
|
||||
},
|
||||
setHiddenPrimitiveIds: (ids) => {
|
||||
// Snapshot the input so a later mutation by the caller does
|
||||
// not silently un-hide primitives on the next hit-test.
|
||||
hiddenIds = new Set(ids);
|
||||
for (const [id, list] of primitiveGraphics) {
|
||||
applyHiddenStateTo(id, list);
|
||||
}
|
||||
},
|
||||
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.
|
||||
for (const g of fogGraphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
fogGraphics = [];
|
||||
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();
|
||||
}
|
||||
// Fog sits below every primitive on the same copy so
|
||||
// planet glyphs paint on top. `addChildAt(g, 0)` keeps
|
||||
// the rest of the children's order intact.
|
||||
copy.addChildAt(g, 0);
|
||||
fogGraphics.push(g);
|
||||
}
|
||||
},
|
||||
resize: (w, h) => {
|
||||
app.renderer.resize(w, h);
|
||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||
@@ -651,6 +759,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
teardownPickMode();
|
||||
previous?.onPick(null);
|
||||
}
|
||||
// `app.destroy({...children: true})` below would also walk
|
||||
// fog graphics, but we drop them eagerly so the closure
|
||||
// reference clears even if a future caller queries the
|
||||
// renderer mid-dispose.
|
||||
for (const g of fogGraphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
fogGraphics = [];
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
viewport.off("moved", wrapTorusCamera);
|
||||
viewport.off("clicked", handleViewportClicked);
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
|
||||
import type {
|
||||
GameReport,
|
||||
ReportIncomingShipGroup,
|
||||
ReportLocalShipGroup,
|
||||
ReportOtherShipGroup,
|
||||
ReportPlanet,
|
||||
@@ -107,14 +106,51 @@ const PRIORITY_INCOMING_POINT = 6;
|
||||
const PRIORITY_INCOMING_LINE = 0;
|
||||
const PRIORITY_UNIDENTIFIED = 4;
|
||||
|
||||
/**
|
||||
* ShipGroupCategory tags every emitted primitive with the toggleable
|
||||
* surface it belongs to. The Phase 29 hide-set machinery in
|
||||
* `lib/active-view/map.svelte` looks these up via `categories` to
|
||||
* decide whether to hide the primitive when the matching `MapToggles`
|
||||
* flag is `false`.
|
||||
*/
|
||||
export type ShipGroupCategory =
|
||||
| "hyperspaceGroup"
|
||||
| "incomingGroup"
|
||||
| "unidentifiedGroup";
|
||||
|
||||
export interface ShipGroupPrimitives {
|
||||
primitives: (PointPrim | LinePrim)[];
|
||||
lookup: Map<PrimitiveID, ShipGroupRef>;
|
||||
categories: Map<PrimitiveID, ShipGroupCategory>;
|
||||
/**
|
||||
* planetDependents maps a planet number to the set of primitive
|
||||
* ids that should hide together with that planet. In Phase 29 the
|
||||
* hide-by-id machinery cascades planet visibility onto in-space
|
||||
* and incoming groups flying *to* the planet (their points + the
|
||||
* trajectory / track lines). Unidentified groups have no planet
|
||||
* anchor and therefore contribute nothing here.
|
||||
*/
|
||||
planetDependents: Map<number, Set<PrimitiveID>>;
|
||||
}
|
||||
|
||||
function addDependent(
|
||||
planetDependents: Map<number, Set<PrimitiveID>>,
|
||||
planetNumber: number,
|
||||
primitiveId: PrimitiveID,
|
||||
): void {
|
||||
let set = planetDependents.get(planetNumber);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
planetDependents.set(planetNumber, set);
|
||||
}
|
||||
set.add(primitiveId);
|
||||
}
|
||||
|
||||
export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives {
|
||||
const primitives: (PointPrim | LinePrim)[] = [];
|
||||
const lookup = new Map<PrimitiveID, ShipGroupRef>();
|
||||
const categories = new Map<PrimitiveID, ShipGroupCategory>();
|
||||
const planetDependents = new Map<number, Set<PrimitiveID>>();
|
||||
const planetIndex = new Map<number, ReportPlanet>();
|
||||
for (const planet of report.planets) {
|
||||
planetIndex.set(planet.number, planet);
|
||||
@@ -129,6 +165,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
||||
const id = SHIP_GROUP_ID_OFFSETS.local + i;
|
||||
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP));
|
||||
lookup.set(id, { variant: "local", id: group.id });
|
||||
categories.set(id, "hyperspaceGroup");
|
||||
addDependent(planetDependents, group.destination, id);
|
||||
// Yellow dashed track from the origin planet to the destination
|
||||
// planet. The colour matches the in-space group point so the
|
||||
// player can read both as one entity at a glance. Wrap-aware
|
||||
@@ -140,9 +178,10 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
||||
if (origin !== undefined && destination !== undefined) {
|
||||
const dx = torusShortestDelta(origin.x, destination.x, w);
|
||||
const dy = torusShortestDelta(origin.y, destination.y, h);
|
||||
const lineId = SHIP_GROUP_ID_OFFSETS.localLine + i;
|
||||
primitives.push({
|
||||
kind: "line",
|
||||
id: SHIP_GROUP_ID_OFFSETS.localLine + i,
|
||||
id: lineId,
|
||||
priority: PRIORITY_LOCAL_LINE,
|
||||
style: STYLE_LOCAL_INSPACE_LINE,
|
||||
hitSlopPx: 0,
|
||||
@@ -151,6 +190,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
||||
x2: origin.x + dx,
|
||||
y2: origin.y + dy,
|
||||
});
|
||||
categories.set(lineId, "hyperspaceGroup");
|
||||
addDependent(planetDependents, group.destination, lineId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +202,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
||||
const id = SHIP_GROUP_ID_OFFSETS.other + i;
|
||||
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, STYLE_OTHER_GROUP));
|
||||
lookup.set(id, { variant: "other", index: i });
|
||||
categories.set(id, "hyperspaceGroup");
|
||||
addDependent(planetDependents, group.destination, id);
|
||||
}
|
||||
|
||||
for (let i = 0; i < report.incomingShipGroups.length; i++) {
|
||||
@@ -189,6 +232,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
||||
x2: destX,
|
||||
y2: destY,
|
||||
});
|
||||
categories.set(lineId, "incomingGroup");
|
||||
addDependent(planetDependents, group.destination, lineId);
|
||||
const pos = interpolateAlongLine(destX, destY, origin.x, origin.y, group.distance);
|
||||
const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i;
|
||||
primitives.push(
|
||||
@@ -202,6 +247,8 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
||||
),
|
||||
);
|
||||
lookup.set(pointId, { variant: "incoming", index: i });
|
||||
categories.set(pointId, "incomingGroup");
|
||||
addDependent(planetDependents, group.destination, pointId);
|
||||
}
|
||||
|
||||
for (let i = 0; i < report.unidentifiedShipGroups.length; i++) {
|
||||
@@ -218,9 +265,10 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
|
||||
),
|
||||
);
|
||||
lookup.set(id, { variant: "unidentified", index: i });
|
||||
categories.set(id, "unidentifiedGroup");
|
||||
}
|
||||
|
||||
return { primitives, lookup };
|
||||
return { primitives, lookup, categories, planetDependents };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,8 +15,14 @@
|
||||
|
||||
import type { GameReport, ReportPlanet } from "../api/game-state";
|
||||
import type { ShipGroupRef } from "../lib/selection.svelte";
|
||||
import { buildBattleAndBombingMarkers } from "./battle-markers";
|
||||
import { shipGroupsToPrimitives } from "./ship-groups";
|
||||
import {
|
||||
buildBattleAndBombingMarkers,
|
||||
type MarkerCategory,
|
||||
} from "./battle-markers";
|
||||
import {
|
||||
shipGroupsToPrimitives,
|
||||
type ShipGroupCategory,
|
||||
} from "./ship-groups";
|
||||
import { World, type Primitive, type PrimitiveID, type Style } from "./world";
|
||||
|
||||
const STYLE_LOCAL: Style = {
|
||||
@@ -88,9 +94,45 @@ export type HitTarget =
|
||||
| { kind: "battle"; battleId: string; planet: number }
|
||||
| { kind: "bombing"; planet: number };
|
||||
|
||||
/**
|
||||
* PlanetCategory is the per-`ReportPlanet.kind` flavour exposed to the
|
||||
* Phase 29 visibility layer so the gear popover can toggle foreign /
|
||||
* uninhabited / unidentified planets independently of one another.
|
||||
* LOCAL planets stay always-on and therefore have no category-driven
|
||||
* hide path — they are simply excluded from the toggle table.
|
||||
*/
|
||||
export type PlanetCategory =
|
||||
| "planet-local"
|
||||
| "planet-foreign"
|
||||
| "planet-uninhabited"
|
||||
| "planet-unidentified";
|
||||
|
||||
/**
|
||||
* MapCategory unions every toggleable surface the gear popover can
|
||||
* hide. The map view in `lib/active-view/map.svelte` walks the
|
||||
* `categories` map produced by `reportToWorld`, looks the matching
|
||||
* `MapToggles` flag up, and feeds the union of hidden ids into
|
||||
* `RendererHandle.setHiddenPrimitiveIds`.
|
||||
*/
|
||||
export type MapCategory = PlanetCategory | ShipGroupCategory | MarkerCategory;
|
||||
|
||||
export interface ReportToWorldResult {
|
||||
world: World;
|
||||
hitLookup: Map<PrimitiveID, HitTarget>;
|
||||
/**
|
||||
* categories maps every emitted primitive id to the toggleable
|
||||
* surface it belongs to. Phase 29 uses this to resolve `MapToggles`
|
||||
* flags into a hide-by-id set.
|
||||
*/
|
||||
categories: Map<PrimitiveID, MapCategory>;
|
||||
/**
|
||||
* planetDependents maps a planet number to the set of primitive
|
||||
* ids whose visibility cascades on that planet. The set always
|
||||
* contains the planet's own primitive id (planet number itself);
|
||||
* it grows with battle / bombing markers anchored on the planet
|
||||
* and with in-space / incoming groups flying *to* it.
|
||||
*/
|
||||
planetDependents: Map<number, Set<PrimitiveID>>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +150,8 @@ export interface ReportToWorldResult {
|
||||
export function reportToWorld(report: GameReport): ReportToWorldResult {
|
||||
const primitives: Primitive[] = [];
|
||||
const hitLookup = new Map<PrimitiveID, HitTarget>();
|
||||
const categories = new Map<PrimitiveID, MapCategory>();
|
||||
const planetDependents = new Map<number, Set<PrimitiveID>>();
|
||||
|
||||
for (const planet of report.planets) {
|
||||
primitives.push({
|
||||
@@ -120,6 +164,14 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
|
||||
y: planet.y,
|
||||
});
|
||||
hitLookup.set(planet.number, { kind: "planet", number: planet.number });
|
||||
categories.set(planet.number, categoryForPlanet(planet.kind));
|
||||
// Seed the planet's own dependents set with the planet
|
||||
// primitive itself so the cascade iterator does not need a
|
||||
// special "planet-self" case — hiding planet N becomes
|
||||
// "hide everything in planetDependents[N]".
|
||||
const own = new Set<PrimitiveID>();
|
||||
own.add(planet.number);
|
||||
planetDependents.set(planet.number, own);
|
||||
}
|
||||
|
||||
const groups = shipGroupsToPrimitives(report);
|
||||
@@ -129,6 +181,10 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
|
||||
for (const [primId, ref] of groups.lookup) {
|
||||
hitLookup.set(primId, { kind: "shipGroup", ref });
|
||||
}
|
||||
for (const [primId, category] of groups.categories) {
|
||||
categories.set(primId, category);
|
||||
}
|
||||
mergeDependents(planetDependents, groups.planetDependents);
|
||||
|
||||
const markers = buildBattleAndBombingMarkers(report);
|
||||
for (const prim of markers.primitives) {
|
||||
@@ -137,8 +193,44 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
|
||||
for (const [primId, target] of markers.lookup) {
|
||||
hitLookup.set(primId, target);
|
||||
}
|
||||
for (const [primId, category] of markers.categories) {
|
||||
categories.set(primId, category);
|
||||
}
|
||||
mergeDependents(planetDependents, markers.planetDependents);
|
||||
|
||||
const width = report.mapWidth > 0 ? report.mapWidth : 1;
|
||||
const height = report.mapHeight > 0 ? report.mapHeight : 1;
|
||||
return { world: new World(width, height, primitives), hitLookup };
|
||||
return {
|
||||
world: new World(width, height, primitives),
|
||||
hitLookup,
|
||||
categories,
|
||||
planetDependents,
|
||||
};
|
||||
}
|
||||
|
||||
function categoryForPlanet(kind: ReportPlanet["kind"]): PlanetCategory {
|
||||
switch (kind) {
|
||||
case "local":
|
||||
return "planet-local";
|
||||
case "other":
|
||||
return "planet-foreign";
|
||||
case "uninhabited":
|
||||
return "planet-uninhabited";
|
||||
case "unidentified":
|
||||
return "planet-unidentified";
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDependents(
|
||||
into: Map<number, Set<PrimitiveID>>,
|
||||
from: Map<number, Set<PrimitiveID>>,
|
||||
): void {
|
||||
for (const [planetNumber, ids] of from) {
|
||||
let set = into.get(planetNumber);
|
||||
if (set === undefined) {
|
||||
set = new Set();
|
||||
into.set(planetNumber, set);
|
||||
}
|
||||
for (const id of ids) set.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Pure helpers for the Phase 29 visibility layer. The map view
|
||||
// (`lib/active-view/map.svelte`) reads `GameStateStore.mapToggles`
|
||||
// every effect run and feeds the result through these functions to
|
||||
// produce the renderer inputs:
|
||||
//
|
||||
// 1. `computeHiddenPlanetNumbers` resolves the per-kind toggles and
|
||||
// the optional `unreachablePlanets` filter into a set of planet
|
||||
// numbers to hide. LOCAL planets are always exempt.
|
||||
// 2. `computeHiddenIds` cascades that set onto every primitive id
|
||||
// tracked in `planetDependents` (planet, marker, in-space and
|
||||
// incoming group, trajectory line), then unions in the
|
||||
// category-toggled-off primitives walked from `categories`.
|
||||
// 3. `computeFogCircles` produces the visibility-fog input —
|
||||
// empty when the toggle is off, otherwise one circle per LOCAL
|
||||
// planet at `VisibilityDistance(localPlayerDrive)`.
|
||||
//
|
||||
// The constants `FLIGHT_DISTANCE_PER_DRIVE` and
|
||||
// `VISIBILITY_DISTANCE_PER_DRIVE` mirror `pkg/calc/race.go`:
|
||||
//
|
||||
// FlightDistance(driveTech) = driveTech * 40
|
||||
// VisibilityDistance(driveTech) = driveTech * 30
|
||||
//
|
||||
// A WASM bridge for the race-level calc helpers does not exist yet
|
||||
// (Phase 18 wired ship-level math only); the constants are
|
||||
// duplicated in TS following the same precedent as
|
||||
// `lib/inspectors/ship-group/actions.svelte` (`40 * localPlayerDrive`)
|
||||
// and `sync/order-types.ts:298`.
|
||||
|
||||
import type { GameReport } from "../api/game-state";
|
||||
import type { MapToggles } from "../lib/game-state.svelte";
|
||||
import { torusShortestDistance } from "./math";
|
||||
import type { MapCategory } from "./state-binding";
|
||||
import type { PrimitiveID } from "./world";
|
||||
|
||||
export const FLIGHT_DISTANCE_PER_DRIVE = 40;
|
||||
export const VISIBILITY_DISTANCE_PER_DRIVE = 30;
|
||||
|
||||
/**
|
||||
* isCategoryVisible reports whether the supplied `MapCategory` is
|
||||
* currently visible per the toggle state. LOCAL planets are not
|
||||
* controlled by a toggle; the function returns `true` for them
|
||||
* unconditionally. The map view combines this with the planet
|
||||
* cascade so a kind toggle (e.g. `foreignPlanets = false`) hides
|
||||
* the planet itself AND every dependent primitive (markers, in-
|
||||
* space groups flying to it).
|
||||
*/
|
||||
export function isCategoryVisible(
|
||||
category: MapCategory,
|
||||
toggles: MapToggles,
|
||||
): boolean {
|
||||
switch (category) {
|
||||
case "planet-local":
|
||||
return true;
|
||||
case "planet-foreign":
|
||||
return toggles.foreignPlanets;
|
||||
case "planet-uninhabited":
|
||||
return toggles.uninhabitedPlanets;
|
||||
case "planet-unidentified":
|
||||
return toggles.unidentifiedPlanets;
|
||||
case "hyperspaceGroup":
|
||||
return toggles.hyperspaceGroups;
|
||||
case "incomingGroup":
|
||||
return toggles.incomingGroups;
|
||||
case "unidentifiedGroup":
|
||||
return toggles.unidentifiedGroups;
|
||||
case "battleMarker":
|
||||
return toggles.battleMarkers;
|
||||
case "bombingMarker":
|
||||
return toggles.bombingMarkers;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* computeHiddenPlanetNumbers returns every non-LOCAL planet whose
|
||||
* kind toggle is off or — when `unreachablePlanets` is off — which
|
||||
* sits beyond `FlightDistance(localPlayerDrive)` of every LOCAL
|
||||
* planet. LOCAL planets themselves are never returned.
|
||||
*
|
||||
* `localPlayerDrive === 0` (zero drive tech) collapses the reach
|
||||
* threshold to zero, so when `unreachablePlanets` is off the
|
||||
* function returns every non-LOCAL planet — matching the engine's
|
||||
* "no fleet can move" baseline.
|
||||
*/
|
||||
export function computeHiddenPlanetNumbers(
|
||||
report: GameReport,
|
||||
toggles: MapToggles,
|
||||
): Set<number> {
|
||||
const hidden = new Set<number>();
|
||||
if (report.planets.length === 0) return hidden;
|
||||
const localPlanets: { x: number; y: number }[] = [];
|
||||
for (const p of report.planets) {
|
||||
if (p.kind === "local") localPlanets.push({ x: p.x, y: p.y });
|
||||
}
|
||||
const reachThreshold =
|
||||
toggles.unreachablePlanets || localPlanets.length === 0
|
||||
? Infinity
|
||||
: report.localPlayerDrive * FLIGHT_DISTANCE_PER_DRIVE;
|
||||
for (const p of report.planets) {
|
||||
if (p.kind === "local") continue;
|
||||
let kindVisible: boolean;
|
||||
switch (p.kind) {
|
||||
case "other":
|
||||
kindVisible = toggles.foreignPlanets;
|
||||
break;
|
||||
case "uninhabited":
|
||||
kindVisible = toggles.uninhabitedPlanets;
|
||||
break;
|
||||
case "unidentified":
|
||||
kindVisible = toggles.unidentifiedPlanets;
|
||||
break;
|
||||
}
|
||||
if (!kindVisible) {
|
||||
hidden.add(p.number);
|
||||
continue;
|
||||
}
|
||||
if (reachThreshold === Infinity) continue;
|
||||
let reachable = false;
|
||||
for (const lp of localPlanets) {
|
||||
const d = torusShortestDistance(
|
||||
p.x,
|
||||
p.y,
|
||||
lp.x,
|
||||
lp.y,
|
||||
report.mapWidth > 0 ? report.mapWidth : 1,
|
||||
report.mapHeight > 0 ? report.mapHeight : 1,
|
||||
);
|
||||
if (d <= reachThreshold) {
|
||||
reachable = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!reachable) hidden.add(p.number);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* computeHiddenIds resolves the toggle state into the final hide-by-
|
||||
* id set fed to `RendererHandle.setHiddenPrimitiveIds`. Inputs:
|
||||
*
|
||||
* - `categories`: every primitive's toggleable surface, as
|
||||
* produced by `reportToWorld`.
|
||||
* - `planetDependents`: for each planet number, the primitive ids
|
||||
* whose visibility cascades on that planet (planet itself, the
|
||||
* markers anchored on it, in-space / incoming groups flying to
|
||||
* it, their lines). Produced by `reportToWorld`.
|
||||
* - `hiddenPlanetNumbers`: the kind / reach-filtered set from
|
||||
* `computeHiddenPlanetNumbers`.
|
||||
* - `toggles`: the per-category toggle state.
|
||||
*
|
||||
* Returns the union of (a) every primitive id whose category toggle
|
||||
* is off and (b) every dependent of a hidden planet number.
|
||||
*/
|
||||
export function computeHiddenIds(
|
||||
categories: ReadonlyMap<PrimitiveID, MapCategory>,
|
||||
planetDependents: ReadonlyMap<number, ReadonlySet<PrimitiveID>>,
|
||||
hiddenPlanetNumbers: ReadonlySet<number>,
|
||||
toggles: MapToggles,
|
||||
): Set<PrimitiveID> {
|
||||
const hidden = new Set<PrimitiveID>();
|
||||
for (const [id, category] of categories) {
|
||||
if (!isCategoryVisible(category, toggles)) hidden.add(id);
|
||||
}
|
||||
for (const planetNumber of hiddenPlanetNumbers) {
|
||||
const deps = planetDependents.get(planetNumber);
|
||||
if (deps === undefined) continue;
|
||||
for (const id of deps) hidden.add(id);
|
||||
}
|
||||
return hidden;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function computeFogCircles(
|
||||
report: GameReport,
|
||||
toggles: MapToggles,
|
||||
): { x: number; y: number; radius: number }[] {
|
||||
if (!toggles.visibilityFog) return [];
|
||||
const radius = report.localPlayerDrive * VISIBILITY_DISTANCE_PER_DRIVE;
|
||||
if (radius <= 0) return [];
|
||||
const circles: { x: number; y: number; radius: number }[] = [];
|
||||
for (const p of report.planets) {
|
||||
if (p.kind !== "local") continue;
|
||||
circles.push({ x: p.x, y: p.y, radius });
|
||||
}
|
||||
return circles;
|
||||
}
|
||||
|
||||
/**
|
||||
* fingerprintHiddenPlanets returns a stable string identifying the
|
||||
* supplied hidden-planet set. The map view threads it into the
|
||||
* extras fingerprint so a toggle flip that changes the planet set
|
||||
* — and therefore changes which routes / pending-Send lines must be
|
||||
* filtered out — reliably triggers an `setExtraPrimitives` push.
|
||||
*/
|
||||
export function fingerprintHiddenPlanets(
|
||||
hiddenPlanetNumbers: ReadonlySet<number>,
|
||||
): string {
|
||||
if (hiddenPlanetNumbers.size === 0) return "";
|
||||
return Array.from(hiddenPlanetNumbers)
|
||||
.sort((a, b) => a - b)
|
||||
.join(",");
|
||||
}
|
||||
@@ -9,9 +9,11 @@
|
||||
import type { OrderCommand } from "../../../sync/order-types";
|
||||
import {
|
||||
getMapCamera,
|
||||
getMapFog,
|
||||
getMapPickState,
|
||||
getMapPrimitives,
|
||||
type MapCameraSnapshot,
|
||||
type MapFogSnapshot,
|
||||
type MapPickStateSnapshot,
|
||||
type MapPrimitiveSnapshot,
|
||||
} from "../../../lib/debug-surface.svelte";
|
||||
@@ -39,6 +41,7 @@
|
||||
getMapPrimitives(): readonly MapPrimitiveSnapshot[];
|
||||
getMapPickState(): MapPickStateSnapshot;
|
||||
getMapCamera(): MapCameraSnapshot | null;
|
||||
getMapFog(): MapFogSnapshot;
|
||||
}
|
||||
|
||||
type DebugWindow = typeof globalThis & { __galaxyDebug?: DebugSurface };
|
||||
@@ -136,6 +139,9 @@
|
||||
getMapCamera() {
|
||||
return getMapCamera();
|
||||
},
|
||||
getMapFog() {
|
||||
return getMapFog();
|
||||
},
|
||||
};
|
||||
(window as DebugWindow).__galaxyDebug = surface;
|
||||
ready = true;
|
||||
|
||||
Reference in New Issue
Block a user