feat(ui): Phase 29 map visibility toggles
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Failing after 8m7s

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:
Ilia Denisov
2026-05-19 21:33:53 +02:00
parent 65c0fbb87d
commit 2bd1b54936
32 changed files with 3046 additions and 63 deletions
+34 -1
View File
@@ -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 };
}
+14 -1
View File
@@ -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);
+9
View File
@@ -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;
+21
View File
@@ -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;
+117
View File
@@ -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);
+51 -3
View File
@@ -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 };
}
/**
+95 -3
View File
@@ -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);
}
}
+210
View File
@@ -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(",");
}