80ed11e3b6
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>
158 lines
5.6 KiB
TypeScript
158 lines
5.6 KiB
TypeScript
// Per-game selection state: which on-map object the user is
|
|
// currently inspecting. Phase 13 modelled planets only; Phase 19
|
|
// widened the union to ship groups (own / foreign / incoming /
|
|
// unidentified).
|
|
//
|
|
// The store is in-memory only: lifetime matches the in-game shell
|
|
// layout instance, which itself is preserved across active-view
|
|
// switches inside `/games/:id/*`. Persisting selection across
|
|
// reloads is intentionally out of scope — the Phase 13 acceptance
|
|
// criterion calls out "across view switches", and survival across a
|
|
// reload would be a surprising contrast with the empty-state copy
|
|
// users see on first load.
|
|
//
|
|
// Like `GameStateStore` and `OrderDraftStore`, the store is
|
|
// instantiated by the layout and shared with descendants through
|
|
// Svelte context. The map view pushes selection events into it; the
|
|
// inspector tab and the mobile bottom-sheet read from it.
|
|
//
|
|
// The store deliberately carries no Svelte component imports so it
|
|
// can be tested directly without rendering any UI.
|
|
|
|
/**
|
|
* ShipGroupRef identifies a ship group inside the current report.
|
|
* `local` groups carry a stable engine UUID (passed through
|
|
* `report.localGroup.id` and used by the upcoming Phase 20 order
|
|
* envelopes). The remaining variants do not — they are addressed by
|
|
* their position in the matching report array, which is fine for
|
|
* the read-only inspector: a new report load reseeds the store and
|
|
* any stale index resolves to a missing entry on lookup, collapsing
|
|
* the inspector cleanly.
|
|
*/
|
|
export type ShipGroupRef =
|
|
| { variant: "local"; id: string }
|
|
| { variant: "other"; index: number }
|
|
| { variant: "incoming"; index: number }
|
|
| { variant: "unidentified"; index: number };
|
|
|
|
/**
|
|
* Selected describes the currently selected map object. The
|
|
* discriminated union is closed: every map-clickable surface maps
|
|
* to one of these variants. Future phases (e.g. fleet selection)
|
|
* extend by adding a new branch — extension is purely additive.
|
|
*/
|
|
export type Selected =
|
|
| { kind: "planet"; id: number }
|
|
| { kind: "shipGroup"; ref: ShipGroupRef };
|
|
|
|
/**
|
|
* SELECTION_CONTEXT_KEY is the Svelte context key the in-game shell
|
|
* layout uses to expose its `SelectionStore` instance to descendants.
|
|
* Map view, inspector tab, and the mobile bottom-sheet resolve the
|
|
* store via `getContext(SELECTION_CONTEXT_KEY)`.
|
|
*/
|
|
export const SELECTION_CONTEXT_KEY = Symbol("selection");
|
|
|
|
export class SelectionStore {
|
|
selected: Selected | null = $state(null);
|
|
|
|
// pendingFocus is a transient one-shot request to centre the map
|
|
// camera on a selection. F8-10 tables raise it together with the
|
|
// view switch to map; map.svelte consumes it once on mount (see
|
|
// `consumePendingFocus`) and clears it. Held in memory only — not
|
|
// persisted, so an F5 after a click-through does not re-centre.
|
|
#pendingFocus: Selected | null = $state(null);
|
|
|
|
// pendingCenter is the coord-only sibling of pendingFocus: it
|
|
// asks map.svelte to centre on a world point without touching
|
|
// the selection. F8-10 uses it for in-space fleet rows (the
|
|
// `Selected` union has no "fleet" variant, but the user still
|
|
// expects the camera to find them). Also one-shot and transient.
|
|
#pendingCenter: { x: number; y: number } | null = $state(null);
|
|
|
|
private destroyed = false;
|
|
|
|
/**
|
|
* selectPlanet sets the active selection to the planet identified
|
|
* by its engine `number`. A no-op once the store has been disposed.
|
|
*/
|
|
selectPlanet(id: number): void {
|
|
if (this.destroyed) return;
|
|
this.selected = { kind: "planet", id };
|
|
}
|
|
|
|
/**
|
|
* selectShipGroup sets the active selection to a ship group. The
|
|
* `ref` discriminator carries the variant + the right id shape for
|
|
* lookup against the current report.
|
|
*/
|
|
selectShipGroup(ref: ShipGroupRef): void {
|
|
if (this.destroyed) return;
|
|
this.selected = { kind: "shipGroup", ref };
|
|
}
|
|
|
|
/**
|
|
* focus sets the active selection to `target` and queues a one-shot
|
|
* camera-centre request for whichever next mount of the map view
|
|
* picks it up via `consumePendingFocus`. Used by the F8-10 tables to
|
|
* navigate from a row click to the map. A no-op once the store has
|
|
* been disposed.
|
|
*/
|
|
focus(target: Selected): void {
|
|
if (this.destroyed) return;
|
|
this.selected = target;
|
|
this.#pendingFocus = target;
|
|
}
|
|
|
|
/**
|
|
* consumePendingFocus returns the queued focus target (if any) and
|
|
* clears it in the same call. Designed for `map.svelte` to invoke
|
|
* once after the renderer mounts.
|
|
*/
|
|
consumePendingFocus(): Selected | null {
|
|
const target = this.#pendingFocus;
|
|
this.#pendingFocus = null;
|
|
return target;
|
|
}
|
|
|
|
/**
|
|
* focusPoint queues a one-shot camera-centre on a free-form world
|
|
* coordinate, without altering selection. Used by the fleets table
|
|
* when the user clicks an in-space fleet (there is no `"fleet"`
|
|
* variant in `Selected`, but the camera should still find it).
|
|
* A no-op once the store has been disposed.
|
|
*/
|
|
focusPoint(x: number, y: number): void {
|
|
if (this.destroyed) return;
|
|
this.#pendingCenter = { x, y };
|
|
}
|
|
|
|
/**
|
|
* consumePendingCenter returns the queued centre point (if any)
|
|
* and clears it.
|
|
*/
|
|
consumePendingCenter(): { x: number; y: number } | null {
|
|
const point = this.#pendingCenter;
|
|
this.#pendingCenter = null;
|
|
return point;
|
|
}
|
|
|
|
/**
|
|
* clear drops the current selection. The mobile sheet's close
|
|
* button calls this; otherwise selection persists across active-
|
|
* view switches. Does not affect any queued pending focus — that
|
|
* is a transient delivery channel, not selection state.
|
|
*/
|
|
clear(): void {
|
|
if (this.destroyed) return;
|
|
this.selected = null;
|
|
}
|
|
|
|
dispose(): void {
|
|
this.destroyed = true;
|
|
this.selected = null;
|
|
this.#pendingFocus = null;
|
|
this.#pendingCenter = null;
|
|
}
|
|
}
|