Files
galaxy-game/ui/frontend/src/map/ship-groups.ts
T
Ilia Denisov 80ed11e3b6
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
feat(ui): F8-10 — tables planets / ship-groups / fleets, ship-classes delete guard (#53)
Lights up three previously-stubbed table active views and tightens the
existing one:

  - table-planets: 4 kind checkboxes (own / foreign / uninhabited /
    unknown) + race dropdown that filters the foreign slice; row click
    selects + centres the planet on the map.
  - table-ship-groups: local + foreign groups in one grid, owner
    checkboxes, planet dropdown (destination OR origin), class
    dropdown; on-planet click focuses the destination planet, in-space
    click focuses the ship group itself (camera follows interpolated
    position).
  - table-fleets: own fleets only with the shared planet dropdown;
    on-planet click focuses the planet, in-space click centres the
    camera on the interpolated fleet position without altering the
    selection (no fleet variant in Selected).
  - table-ship-classes: per-row Delete is disabled with a count tooltip
    while at least one local ship group references the class. The
    engine refuses the removal anyway; the UI pre-empts the surface.

Wires the click → map flow through a transient `SelectionStore.focus`
/ `focusPoint` channel that `map.svelte` consumes once on mount —
in-memory only, so an F5 does not re-centre.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:35:38 +02:00

356 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Phase 19 ship-group → World primitive translation. Sits next to
// `state-binding.ts` so the latter can stay focused on planets while
// the more involved group geometry (in-hyperspace interpolation,
// incoming-trajectory lines) lives here.
//
// Position rules:
// - On-planet local / other groups (origin === null, range === null)
// are NOT rendered on the map. Stationed groups would otherwise
// pile up next to every populated planet and turn the canvas
// into noise; the planet inspector lists them instead
// (see `lib/inspectors/planet/ship-groups.svelte`).
// - In-hyperspace local / other groups (origin / range set) —
// interpolated along the origin → destination line at `range`
// world units from the destination. The line is the wrap-aware
// shortest path on a torus.
// - Incoming groups — origin and destination are always present;
// emit a dashed red trajectory line from origin to a wrap-aware
// destination plus a clickable point at the interpolated
// position (range = the `distance` field).
// - Unidentified groups — drawn at the absolute (x, y) the radar
// reports.
//
// Torus-shortest deltas come from `map/math.torusShortestDelta`. The
// canonical Go-side equivalent is `pkg/calc.ShortestDelta`; the TS
// helper duplicates the formula because the renderer's hot path
// avoids the WASM boundary cost. Both implementations agree on the
// half-circumference tie-break.
//
// PrimitiveIDs are partitioned via large per-variant offsets so they
// never collide with planet ids (which run in `[0, planetCount)`).
import type {
GameReport,
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportUnidentifiedShipGroup,
} from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte";
import { torusShortestDelta } from "./math";
import {
DARK_THEME,
type LinePrim,
type PointPrim,
type PrimitiveID,
type Style,
type Theme,
} from "./world";
/**
* SHIP_GROUP_ID_OFFSETS partitions the primitive-id namespace so a
* hit on a ship-group primitive is unambiguous: the offset alone
* disambiguates the variant and `id - offset` recovers the index
* (or, for `local`, lookup happens via the parallel hitLookup map
* since UUID strings cannot fit in a numeric primitive id).
*/
export const SHIP_GROUP_ID_OFFSETS = {
local: 100_000_000,
localLine: 150_000_000,
other: 200_000_000,
incoming: 300_000_000,
incomingLine: 350_000_000,
unidentified: 400_000_000,
} as const;
// shipGroupStyles builds the per-variant `Style` objects for the
// active theme. Only the colours are theme-driven; the alpha, radius,
// and dash spacing are fixed emphasis values. The in-space track
// reuses the own-group colour and the incoming trajectory line reuses
// the incoming colour so each pair reads as one entity.
function shipGroupStyles(theme: Theme): {
local: Style;
localLine: Style;
other: Style;
incoming: Style;
incomingLine: Style;
unidentified: Style;
} {
return {
local: { fillColor: theme.shipLocal, fillAlpha: 0.95, pointRadiusPx: 3 },
localLine: {
strokeColor: theme.shipLocal,
strokeAlpha: 0.7,
strokeWidthPx: 1,
strokeDashPx: 4,
},
other: { fillColor: theme.shipOther, fillAlpha: 0.9, pointRadiusPx: 3 },
incoming: {
fillColor: theme.shipIncoming,
fillAlpha: 1,
pointRadiusPx: 4,
},
incomingLine: {
strokeColor: theme.shipIncoming,
strokeAlpha: 0.85,
strokeWidthPx: 1,
strokeDashPx: 4,
},
unidentified: {
fillColor: theme.shipUnidentified,
fillAlpha: 0.65,
pointRadiusPx: 3,
},
};
}
// Priority order inside `hit-test`: ship groups outrank planets so a
// hyperspace group landing on top of an unidentified planet is
// selectable. The trajectory line itself is given the lowest priority
// so a click on the dashed segment never "wins" over the clickable
// point at the interpolated position.
const PRIORITY_LOCAL = 5;
const PRIORITY_LOCAL_LINE = 0;
const PRIORITY_OTHER = 5;
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,
theme: Theme = DARK_THEME,
): ShipGroupPrimitives {
const styles = shipGroupStyles(theme);
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);
}
const w = report.mapWidth;
const h = report.mapHeight;
for (let i = 0; i < report.localShipGroups.length; i++) {
const group = report.localShipGroups[i]!;
const pos = computeInSpacePosition(group, planetIndex, w, h);
if (pos === null) continue;
const id = SHIP_GROUP_ID_OFFSETS.local + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, styles.local));
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
// like the incoming-line: we unwrap `destination` relative to
// `origin`, drawing the segment in a single tile, and PixiJS
// repeats the world in torus mode.
const origin = planetIndex.get(group.origin!);
const destination = planetIndex.get(group.destination);
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: lineId,
priority: PRIORITY_LOCAL_LINE,
style: styles.localLine,
hitSlopPx: 0,
x1: origin.x,
y1: origin.y,
x2: origin.x + dx,
y2: origin.y + dy,
});
categories.set(lineId, "hyperspaceGroup");
addDependent(planetDependents, group.destination, lineId);
}
}
for (let i = 0; i < report.otherShipGroups.length; i++) {
const group = report.otherShipGroups[i]!;
const pos = computeInSpacePosition(group, planetIndex, w, h);
if (pos === null) continue;
const id = SHIP_GROUP_ID_OFFSETS.other + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, styles.other));
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++) {
const group = report.incomingShipGroups[i]!;
const origin = planetIndex.get(group.origin);
const destination = planetIndex.get(group.destination);
if (origin === undefined || destination === undefined) continue;
// Unwrap the destination relative to origin so the line crosses
// the torus seam when that is the shorter path. Renderer-side
// we draw the segment in a single tile; in torus mode PixiJS
// repeats the world so the line still appears continuous on
// the visible side of the seam.
const dx = torusShortestDelta(origin.x, destination.x, w);
const dy = torusShortestDelta(origin.y, destination.y, h);
const destX = origin.x + dx;
const destY = origin.y + dy;
const lineId = SHIP_GROUP_ID_OFFSETS.incomingLine + i;
primitives.push({
kind: "line",
id: lineId,
priority: PRIORITY_INCOMING_LINE,
style: styles.incomingLine,
hitSlopPx: 0,
x1: origin.x,
y1: origin.y,
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(
makePoint(
pointId,
pos.x,
pos.y,
PRIORITY_INCOMING_POINT,
styles.incoming,
/*hitSlopPx*/ 4,
),
);
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++) {
const group: ReportUnidentifiedShipGroup =
report.unidentifiedShipGroups[i]!;
const id = SHIP_GROUP_ID_OFFSETS.unidentified + i;
primitives.push(
makePoint(
id,
group.x,
group.y,
PRIORITY_UNIDENTIFIED,
styles.unidentified,
),
);
lookup.set(id, { variant: "unidentified", index: i });
categories.set(id, "unidentifiedGroup");
}
return { primitives, lookup, categories, planetDependents };
}
/**
* computeInSpacePosition returns the renderer-side (x, y) of a local
* or foreign group that is currently in hyperspace. On-planet groups
* (origin === null || range === null) are intentionally skipped so the
* map does not pile dozens of primitives onto every populated planet
* — the planet inspector lists them instead. Returns null when either
* the group is on-planet, or the origin / destination planets are
* not visible to the local player.
*
* Exported so the active-view map can centre the camera on an
* in-space group when the F8-10 tables raise a `selection.focus`
* request for one.
*/
export function computeInSpacePosition(
group: ReportLocalShipGroup | ReportOtherShipGroup,
planetIndex: Map<number, ReportPlanet>,
mapWidth: number,
mapHeight: number,
): { x: number; y: number } | null {
if (group.origin === null || group.range === null) return null;
const destination = planetIndex.get(group.destination);
if (destination === undefined) return null;
const origin = planetIndex.get(group.origin);
if (origin === undefined) return null;
const dx = torusShortestDelta(destination.x, origin.x, mapWidth);
const dy = torusShortestDelta(destination.y, origin.y, mapHeight);
const total = Math.hypot(dx, dy);
if (total === 0 || group.range <= 0) {
return { x: destination.x, y: destination.y };
}
const t = Math.min(1, group.range / total);
return { x: destination.x + t * dx, y: destination.y + t * dy };
}
/**
* interpolateAlongLine returns the point that sits `range` world
* units away from `(dx, dy)` toward `(ox, oy)`. The total path length
* is the Euclidean distance between the two anchors; the position is
* `dest + (range / total) × (origin - dest)`. When the anchors are
* coincident or `range` is zero the result is the destination, which
* is fine for the ship-group rendering — a degenerate group still
* gets a click target on the destination planet.
*/
function interpolateAlongLine(
dx: number,
dy: number,
ox: number,
oy: number,
range: number,
): { x: number; y: number } {
const ddx = ox - dx;
const ddy = oy - dy;
const total = Math.hypot(ddx, ddy);
if (total === 0 || range <= 0) return { x: dx, y: dy };
const t = Math.min(1, range / total);
return { x: dx + t * ddx, y: dy + t * ddy };
}
function makePoint(
id: PrimitiveID,
x: number,
y: number,
priority: number,
style: Style,
hitSlopPx = 0,
): PointPrim {
return { kind: "point", id, priority, style, hitSlopPx, x, y };
}