ui/phase-13: planet inspector — read-only
Plumbs the map → inspector pathway: a click on a planet selects it through the new SelectionStore, the sidebar Inspector tab swaps its empty-state copy for a per-kind read-only field set, and a mobile-only bottom-sheet mirrors the same content over the map. Field projection in api/game-state.ts now surfaces every documented planet field.
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
// Typed wrapper around `GalaxyClient.executeCommand("user.games.report",
|
||||
// ...)`. The signed-gRPC wire shape is the FlatBuffers
|
||||
// `report.GameReportRequest` for the request and `report.Report` for
|
||||
// the response (see `pkg/schema/fbs/report.fbs`). Phase 11 only
|
||||
// surfaces the planet subset of the response — full ship / fleet /
|
||||
// the response (see `pkg/schema/fbs/report.fbs`). Full ship / fleet /
|
||||
// science decoding lands in Phases 17-22.
|
||||
//
|
||||
// Phase 13 expanded the per-planet projection so the inspector can
|
||||
// render every documented field without a second round-trip. Each
|
||||
// planet field is optional: the FBS schema carries different field
|
||||
// sets for `LocalPlanet`, `OtherPlanet`, `UninhabitedPlanet`, and
|
||||
// `UnidentifiedPlanet`, and the wrapper preserves that nullability
|
||||
// instead of inventing zero values.
|
||||
|
||||
import { Builder, ByteBuffer } from "flatbuffers";
|
||||
|
||||
@@ -37,6 +43,16 @@ export interface ReportPlanet {
|
||||
owner: string | null;
|
||||
size: number | null;
|
||||
resources: number | null;
|
||||
// Engine field naming carries history: `capital` ($) is the
|
||||
// industry stockpile, `material` (M) is the materials stockpile.
|
||||
// `pkg/model/report/planet.go` is the source of truth for these.
|
||||
industryStockpile: number | null;
|
||||
materialsStockpile: number | null;
|
||||
industry: number | null;
|
||||
population: number | null;
|
||||
colonists: number | null;
|
||||
production: string | null;
|
||||
freeIndustry: number | null;
|
||||
}
|
||||
|
||||
export interface GameReport {
|
||||
@@ -85,6 +101,13 @@ function decodeReport(report: Report): GameReport {
|
||||
owner: null,
|
||||
size: p.size(),
|
||||
resources: p.resources(),
|
||||
industryStockpile: p.capital(),
|
||||
materialsStockpile: p.material(),
|
||||
industry: p.industry(),
|
||||
population: p.population(),
|
||||
colonists: p.colonists(),
|
||||
production: p.production() ?? null,
|
||||
freeIndustry: p.freeIndustry(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,6 +123,13 @@ function decodeReport(report: Report): GameReport {
|
||||
owner: p.owner() ?? null,
|
||||
size: p.size(),
|
||||
resources: p.resources(),
|
||||
industryStockpile: p.capital(),
|
||||
materialsStockpile: p.material(),
|
||||
industry: p.industry(),
|
||||
population: p.population(),
|
||||
colonists: p.colonists(),
|
||||
production: p.production() ?? null,
|
||||
freeIndustry: p.freeIndustry(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,6 +145,13 @@ function decodeReport(report: Report): GameReport {
|
||||
owner: null,
|
||||
size: p.size(),
|
||||
resources: p.resources(),
|
||||
industryStockpile: p.capital(),
|
||||
materialsStockpile: p.material(),
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,6 +167,13 @@ function decodeReport(report: Report): GameReport {
|
||||
owner: null,
|
||||
size: null,
|
||||
resources: null,
|
||||
industryStockpile: null,
|
||||
materialsStockpile: null,
|
||||
industry: null,
|
||||
population: null,
|
||||
colonists: null,
|
||||
production: null,
|
||||
freeIndustry: null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,10 +8,16 @@ the existing renderer instance alive). Empty-planet reports render
|
||||
the empty world without errors — the regression test in
|
||||
`tests/e2e/game-shell-map.spec.ts` covers this.
|
||||
|
||||
Phase 9 owns the renderer's hit-test and pan/zoom semantics; Phase 13
|
||||
will plug map clicks into the inspector. Phase 29 wires the wrap-mode
|
||||
toggle on top of the per-game `wrapMode` preference the store
|
||||
already manages.
|
||||
Phase 9 owns the renderer's hit-test and pan/zoom semantics. Phase 13
|
||||
plugs map clicks into the inspector by translating the renderer's
|
||||
`clicked` event into a hit-test, looking the planet up by id in the
|
||||
report, and calling `SelectionStore.selectPlanet`. The selection
|
||||
store, set in the layout, drives both the desktop sidebar inspector
|
||||
tab and the mobile bottom-sheet — the map view itself does not need
|
||||
to know which surface is showing the result.
|
||||
|
||||
Phase 29 wires the wrap-mode toggle on top of the per-game `wrapMode`
|
||||
preference the store already manages.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext, onDestroy, onMount, untrack } from "svelte";
|
||||
@@ -26,8 +32,13 @@ already manages.
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
type GameStateStore,
|
||||
} from "$lib/game-state.svelte";
|
||||
import {
|
||||
SELECTION_CONTEXT_KEY,
|
||||
type SelectionStore,
|
||||
} from "$lib/selection.svelte";
|
||||
|
||||
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
|
||||
const selection = getContext<SelectionStore | undefined>(SELECTION_CONTEXT_KEY);
|
||||
|
||||
let canvasEl: HTMLCanvasElement | null = $state(null);
|
||||
let containerEl: HTMLDivElement | null = $state(null);
|
||||
@@ -37,6 +48,7 @@ already manages.
|
||||
let mountedTurn: number | null = null;
|
||||
let mountedGameId: string | null = null;
|
||||
let onResize: (() => void) | null = null;
|
||||
let detachClick: (() => void) | null = null;
|
||||
let mounted = false;
|
||||
|
||||
$effect(() => {
|
||||
@@ -70,6 +82,10 @@ already manages.
|
||||
mode: "torus" | "no-wrap",
|
||||
): Promise<void> {
|
||||
if (canvasEl === null || containerEl === null) return;
|
||||
if (detachClick !== null) {
|
||||
detachClick();
|
||||
detachClick = null;
|
||||
}
|
||||
if (handle !== null) {
|
||||
handle.dispose();
|
||||
handle = null;
|
||||
@@ -92,6 +108,7 @@ already manages.
|
||||
);
|
||||
handle.viewport.setZoom(minScale * 1.05, true);
|
||||
if (mode === "no-wrap") handle.setMode("no-wrap");
|
||||
detachClick = handle.onClick(handleMapClick);
|
||||
mountedTurn = report.turn;
|
||||
mountedGameId = store?.gameId ?? "";
|
||||
mountError = null;
|
||||
@@ -100,6 +117,25 @@ already manages.
|
||||
}
|
||||
}
|
||||
|
||||
// handleMapClick translates a renderer click into a planet
|
||||
// selection. A click that misses every primitive (empty space) is
|
||||
// a deliberate no-op: the selection rule for Phase 13 is that
|
||||
// only the explicit close button on the mobile sheet clears the
|
||||
// current selection.
|
||||
function handleMapClick(cursorPx: { x: number; y: number }): void {
|
||||
if (handle === null || store?.report === undefined || store.report === null) {
|
||||
return;
|
||||
}
|
||||
if (selection === undefined) return;
|
||||
const hit = handle.hitAt(cursorPx);
|
||||
if (hit === null) return;
|
||||
if (hit.primitive.kind !== "point") return;
|
||||
const planetId = hit.primitive.id;
|
||||
const planet = store.report.planets.find((p) => p.number === planetId);
|
||||
if (planet === undefined) return;
|
||||
selection.selectPlanet(planet.number);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
onResize = (): void => {
|
||||
@@ -115,6 +151,10 @@ already manages.
|
||||
window.removeEventListener("resize", onResize);
|
||||
onResize = null;
|
||||
}
|
||||
if (detachClick !== null) {
|
||||
detachClick();
|
||||
detachClick = null;
|
||||
}
|
||||
if (handle !== null) {
|
||||
handle.dispose();
|
||||
handle = null;
|
||||
|
||||
@@ -124,6 +124,26 @@ const en = {
|
||||
"game.bottom_tabs.calc": "calc",
|
||||
"game.bottom_tabs.order": "order",
|
||||
"game.bottom_tabs.more": "more",
|
||||
|
||||
"game.inspector.planet.kind.local": "your planet",
|
||||
"game.inspector.planet.kind.other": "other race planet",
|
||||
"game.inspector.planet.kind.uninhabited": "uninhabited planet",
|
||||
"game.inspector.planet.kind.unidentified": "unidentified planet",
|
||||
"game.inspector.planet.field.name": "name",
|
||||
"game.inspector.planet.field.owner": "owner",
|
||||
"game.inspector.planet.field.coordinates": "coordinates",
|
||||
"game.inspector.planet.field.size": "size",
|
||||
"game.inspector.planet.field.population": "population",
|
||||
"game.inspector.planet.field.colonists": "colonists",
|
||||
"game.inspector.planet.field.industry": "industry",
|
||||
"game.inspector.planet.field.industry_stockpile": "industry stockpile ($)",
|
||||
"game.inspector.planet.field.materials_stockpile": "materials stockpile (M)",
|
||||
"game.inspector.planet.field.natural_resources": "natural resources",
|
||||
"game.inspector.planet.field.production": "current production",
|
||||
"game.inspector.planet.field.free_industry": "free production",
|
||||
"game.inspector.planet.production_none": "none",
|
||||
"game.inspector.planet.unidentified_no_data": "no data — only the location is known",
|
||||
"game.inspector.sheet_close": "close",
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -125,6 +125,26 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.bottom_tabs.calc": "калк",
|
||||
"game.bottom_tabs.order": "приказ",
|
||||
"game.bottom_tabs.more": "ещё",
|
||||
|
||||
"game.inspector.planet.kind.local": "ваша планета",
|
||||
"game.inspector.planet.kind.other": "планета другой расы",
|
||||
"game.inspector.planet.kind.uninhabited": "необитаемая планета",
|
||||
"game.inspector.planet.kind.unidentified": "неопознанная планета",
|
||||
"game.inspector.planet.field.name": "название",
|
||||
"game.inspector.planet.field.owner": "владелец",
|
||||
"game.inspector.planet.field.coordinates": "координаты",
|
||||
"game.inspector.planet.field.size": "размер",
|
||||
"game.inspector.planet.field.population": "население",
|
||||
"game.inspector.planet.field.colonists": "колонисты",
|
||||
"game.inspector.planet.field.industry": "промышленность",
|
||||
"game.inspector.planet.field.industry_stockpile": "запасы промышленности ($)",
|
||||
"game.inspector.planet.field.materials_stockpile": "запасы сырья (M)",
|
||||
"game.inspector.planet.field.natural_resources": "природные ресурсы",
|
||||
"game.inspector.planet.field.production": "текущее производство",
|
||||
"game.inspector.planet.field.free_industry": "свободные мощности",
|
||||
"game.inspector.planet.production_none": "не задано",
|
||||
"game.inspector.planet.unidentified_no_data": "нет данных — известно только местоположение",
|
||||
"game.inspector.sheet_close": "закрыть",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<!--
|
||||
Phase 13 mobile bottom-sheet that hosts the planet inspector when
|
||||
the user is on the map view on a small screen. Desktop and tablet
|
||||
breakpoints already render the inspector inside the sidebar, so
|
||||
the sheet is hidden via a media query above 768 px and the
|
||||
component is only mounted while the active tool is `map` (so it
|
||||
does not stack on top of the calc / order overlays).
|
||||
|
||||
Phase 13 ships the minimal dismissal surface: a close button (`✕`)
|
||||
that clears the selection. Swipe-to-dismiss and tap-outside-to-
|
||||
dismiss from the IA section §6 land in Phase 35 polish.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ReportPlanet } from "../../api/game-state";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import Planet from "./planet.svelte";
|
||||
|
||||
type Props = {
|
||||
planet: ReportPlanet | null;
|
||||
onMap: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
let { planet, onMap, onClose }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if planet !== null && onMap}
|
||||
<section
|
||||
class="sheet"
|
||||
aria-label={i18n.t("game.sidebar.tab.inspector")}
|
||||
data-testid="inspector-planet-sheet"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="close"
|
||||
data-testid="inspector-planet-sheet-close"
|
||||
aria-label={i18n.t("game.inspector.sheet_close")}
|
||||
onclick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<Planet {planet} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
.sheet {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 3.25rem;
|
||||
max-height: calc(100vh - 6rem);
|
||||
overflow-y: auto;
|
||||
background: #14182a;
|
||||
color: #e8eaf6;
|
||||
border-top: 1px solid #2a3150;
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
|
||||
z-index: 40;
|
||||
}
|
||||
}
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.4rem;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: transparent;
|
||||
color: #aab;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: #e8eaf6;
|
||||
border-color: #6d8cff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,197 @@
|
||||
<!--
|
||||
Phase 13 read-only planet inspector. Renders the documented field
|
||||
set for the planet kind in question:
|
||||
|
||||
- `local` / `other` carry the full economy: name, owner (other only),
|
||||
coordinates, size, population, colonists, industry, both stockpiles,
|
||||
natural resources, current production, free production potential.
|
||||
- `uninhabited` keeps name, coordinates, size, both stockpiles, and
|
||||
natural resources — the engine does not project industry or
|
||||
population for unowned planets.
|
||||
- `unidentified` is reduced to coordinates plus a no-data hint.
|
||||
|
||||
The component is purely presentational: the parent supplies a
|
||||
`ReportPlanet` snapshot resolved from `GameStateStore`, no store
|
||||
lookups happen here. Phase 14 will extend the same component with a
|
||||
`Rename` action; the read-only layout stays the structural baseline.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { ReportPlanet } from "../../api/game-state";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
|
||||
type Props = {
|
||||
planet: ReportPlanet;
|
||||
};
|
||||
let { planet }: Props = $props();
|
||||
|
||||
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
|
||||
local: "game.inspector.planet.kind.local",
|
||||
other: "game.inspector.planet.kind.other",
|
||||
uninhabited: "game.inspector.planet.kind.uninhabited",
|
||||
unidentified: "game.inspector.planet.kind.unidentified",
|
||||
};
|
||||
|
||||
const kindLabel = $derived(i18n.t(kindKeyMap[planet.kind]));
|
||||
const coordinates = $derived(
|
||||
`(${formatNumber(planet.x)}, ${formatNumber(planet.y)})`,
|
||||
);
|
||||
const productionLabel = $derived(productionDisplay(planet.production));
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function productionDisplay(value: string | null): string {
|
||||
if (value === null || value === "") {
|
||||
return i18n.t("game.inspector.planet.production_none");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="inspector"
|
||||
data-testid="inspector-planet"
|
||||
data-planet-id={planet.number}
|
||||
data-planet-kind={planet.kind}
|
||||
>
|
||||
<header>
|
||||
<p class="kind" data-testid="inspector-planet-kind">{kindLabel}</p>
|
||||
{#if planet.kind !== "unidentified"}
|
||||
<h3 class="name" data-testid="inspector-planet-name">{planet.name}</h3>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<dl class="fields">
|
||||
{#if planet.kind === "other" && planet.owner !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-owner">
|
||||
<dt>{i18n.t("game.inspector.planet.field.owner")}</dt>
|
||||
<dd>{planet.owner}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field" data-testid="inspector-planet-field-coordinates">
|
||||
<dt>{i18n.t("game.inspector.planet.field.coordinates")}</dt>
|
||||
<dd>{coordinates}</dd>
|
||||
</div>
|
||||
|
||||
{#if planet.size !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-size">
|
||||
<dt>{i18n.t("game.inspector.planet.field.size")}</dt>
|
||||
<dd>{formatNumber(planet.size)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planet.resources !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-natural_resources">
|
||||
<dt>{i18n.t("game.inspector.planet.field.natural_resources")}</dt>
|
||||
<dd>{formatNumber(planet.resources)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planet.population !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-population">
|
||||
<dt>{i18n.t("game.inspector.planet.field.population")}</dt>
|
||||
<dd>{formatNumber(planet.population)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planet.colonists !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-colonists">
|
||||
<dt>{i18n.t("game.inspector.planet.field.colonists")}</dt>
|
||||
<dd>{formatNumber(planet.colonists)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planet.industry !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-industry">
|
||||
<dt>{i18n.t("game.inspector.planet.field.industry")}</dt>
|
||||
<dd>{formatNumber(planet.industry)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planet.industryStockpile !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-industry_stockpile">
|
||||
<dt>{i18n.t("game.inspector.planet.field.industry_stockpile")}</dt>
|
||||
<dd>{formatNumber(planet.industryStockpile)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planet.materialsStockpile !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-materials_stockpile">
|
||||
<dt>{i18n.t("game.inspector.planet.field.materials_stockpile")}</dt>
|
||||
<dd>{formatNumber(planet.materialsStockpile)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planet.production !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-production">
|
||||
<dt>{i18n.t("game.inspector.planet.field.production")}</dt>
|
||||
<dd>{productionLabel}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if planet.freeIndustry !== null}
|
||||
<div class="field" data-testid="inspector-planet-field-free_industry">
|
||||
<dt>{i18n.t("game.inspector.planet.field.free_industry")}</dt>
|
||||
<dd>{formatNumber(planet.freeIndustry)}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
|
||||
{#if planet.kind === "unidentified"}
|
||||
<p class="hint" data-testid="inspector-planet-no-data">
|
||||
{i18n.t("game.inspector.planet.unidentified_no_data")}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.inspector {
|
||||
padding: 1rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.kind {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: #aab;
|
||||
}
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.fields {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
row-gap: 0.25rem;
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
.field {
|
||||
display: contents;
|
||||
}
|
||||
.field dt {
|
||||
color: #aab;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.field dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
// Per-game selection state: which on-map object the user is
|
||||
// currently inspecting. Phase 13 only models planet selection, so
|
||||
// the union has a single variant; later phases (Phase 19 ship-group
|
||||
// inspector) will widen it.
|
||||
//
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Selected describes the currently selected map object. Phase 13
|
||||
* ships only the planet variant; later inspector phases extend the
|
||||
* discriminated union (`ship-group`, etc.) without changing the
|
||||
* store's contract.
|
||||
*/
|
||||
export type Selected = { kind: "planet"; id: number };
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* clear drops the current selection. The mobile sheet's close
|
||||
* button calls this; otherwise selection persists across active-
|
||||
* view switches.
|
||||
*/
|
||||
clear(): void {
|
||||
if (this.destroyed) return;
|
||||
this.selected = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.destroyed = true;
|
||||
this.selected = null;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,66 @@
|
||||
<!--
|
||||
Phase 10 stub for the Inspector sidebar tool. The empty-state copy
|
||||
matches the IA section verbatim — `select an object on the map` —
|
||||
so the user understands the intended interaction before Phase 13
|
||||
wires real planet selection.
|
||||
Inspector sidebar tool. Reads the per-game `SelectionStore` and the
|
||||
`GameStateStore` from context (both set by the in-game shell layout).
|
||||
When a planet selection resolves to a live `ReportPlanet` in the
|
||||
current report, the tab swaps the empty-state copy for the read-
|
||||
only planet inspector. A selection that points at a planet missing
|
||||
from the current report (e.g. visibility lost between turns) falls
|
||||
back to the empty state instead of holding stale data.
|
||||
|
||||
The empty-state copy still matches the IA section verbatim — `select
|
||||
an object on the map` — so the no-selection experience is unchanged
|
||||
from the Phase 10 stub.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
type GameStateStore,
|
||||
} from "$lib/game-state.svelte";
|
||||
import {
|
||||
SELECTION_CONTEXT_KEY,
|
||||
type SelectionStore,
|
||||
} from "$lib/selection.svelte";
|
||||
import Planet from "$lib/inspectors/planet.svelte";
|
||||
|
||||
const gameState = getContext<GameStateStore | undefined>(
|
||||
GAME_STATE_CONTEXT_KEY,
|
||||
);
|
||||
const selection = getContext<SelectionStore | undefined>(
|
||||
SELECTION_CONTEXT_KEY,
|
||||
);
|
||||
|
||||
const selectedPlanet = $derived.by(() => {
|
||||
const sel = selection?.selected;
|
||||
if (sel === undefined || sel === null || sel.kind !== "planet") return null;
|
||||
const report = gameState?.report;
|
||||
if (report === undefined || report === null) return null;
|
||||
return report.planets.find((p) => p.number === sel.id) ?? null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="tool" data-testid="sidebar-tool-inspector">
|
||||
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
|
||||
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
|
||||
{#if selectedPlanet !== null}
|
||||
<Planet planet={selectedPlanet} />
|
||||
{:else}
|
||||
<h3>{i18n.t("game.sidebar.tab.inspector")}</h3>
|
||||
<p>{i18n.t("game.sidebar.empty.inspector")}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.tool {
|
||||
padding: 1rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
.tool h3 {
|
||||
.tool > h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
padding: 1rem 1rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.tool p {
|
||||
.tool > p {
|
||||
margin: 0;
|
||||
padding: 0 1rem 1rem;
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,6 +14,12 @@ The `historyMode` prop hides the Order tab when true: the tab-bar
|
||||
filters it out and any URL seed targeting `order` falls back to
|
||||
`inspector`. Phase 12 wires the prop through the layout as a
|
||||
constant `false`; Phase 26 flips it on for past-turn snapshots.
|
||||
|
||||
`activeTab` is a `$bindable` prop so the layout can drive it from
|
||||
external events (Phase 13 reveals the inspector tab when a planet
|
||||
is clicked on the map). The URL seed and the history-mode reset
|
||||
both mutate the bindable in place; the layout sees the change
|
||||
through the binding without extra plumbing.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
@@ -29,10 +35,14 @@ constant `false`; Phase 26 flips it on for past-turn snapshots.
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
historyMode?: boolean;
|
||||
activeTab?: SidebarTab;
|
||||
};
|
||||
let { open, onClose, historyMode = false }: Props = $props();
|
||||
|
||||
let activeTab: SidebarTab = $state("inspector");
|
||||
let {
|
||||
open,
|
||||
onClose,
|
||||
historyMode = false,
|
||||
activeTab = $bindable<SidebarTab>("inspector"),
|
||||
}: Props = $props();
|
||||
|
||||
function readUrlSeed(): SidebarTab | null {
|
||||
const v = page.url.searchParams.get("sidebar");
|
||||
|
||||
@@ -58,6 +58,18 @@ export interface RendererHandle {
|
||||
getViewport(): Viewport;
|
||||
getBackend(): "webgl" | "webgpu" | "canvas";
|
||||
hitAt(cursorPx: { x: number; y: number }): Hit | null;
|
||||
/**
|
||||
* onClick subscribes `cb` to a click on the map (a pointer-down /
|
||||
* pointer-up pair without enough drag to trigger pan). The cursor
|
||||
* is reported in canvas pixel coordinates so callers can hand it
|
||||
* straight to `hitAt`. Returns a function that detaches the
|
||||
* listener; the returned disposer is idempotent.
|
||||
*
|
||||
* Built on `pixi-viewport`'s `clicked` event, which already
|
||||
* applies the same drag threshold the pan plugin uses, so a
|
||||
* click here will not race a pan gesture.
|
||||
*/
|
||||
onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void;
|
||||
resize(widthPx: number, heightPx: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -222,6 +234,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
getBackend: () => rendererBackendName(app.renderer),
|
||||
hitAt: (cursorPx) =>
|
||||
hitTest(opts.world, handle.getCamera(), handle.getViewport(), cursorPx, mode),
|
||||
onClick: (cb) => {
|
||||
const handler = (e: { screen: { x: number; y: number } }): void => {
|
||||
cb({ x: e.screen.x, y: e.screen.y });
|
||||
};
|
||||
viewport.on("clicked", handler);
|
||||
return () => {
|
||||
viewport.off("clicked", handler);
|
||||
};
|
||||
},
|
||||
resize: (w, h) => {
|
||||
app.renderer.resize(w, h);
|
||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||
|
||||
@@ -11,18 +11,36 @@ layout owns:
|
||||
so navigating to any other view through the More drawer or the
|
||||
header view-menu naturally drops the overlay even if `mobileTool`
|
||||
was set on a previous tap.
|
||||
- `activeTab` — current sidebar tool (`calculator` / `inspector` /
|
||||
`order`). Held here, bound into the sidebar so a planet click on
|
||||
the map can flip it to `inspector` from the outside (Phase 13).
|
||||
- Per-game stores: `GameStateStore`, `OrderDraftStore`, and the
|
||||
Phase 13 `SelectionStore`. All three are exposed to descendants
|
||||
via Svelte context; their lifetimes match the layout instance,
|
||||
which itself stays mounted across active-view switches inside
|
||||
`/games/:id/*`.
|
||||
|
||||
Phase 11 adds the per-game `GameStateStore` instance owned by this
|
||||
Phase 11 added the per-game `GameStateStore` instance owned by this
|
||||
layout: it constructs the `GalaxyClient`, fetches the matching lobby
|
||||
record to discover `current_turn`, then loads the report. The store
|
||||
is shared with descendants via `setContext("gameState", ...)` so the
|
||||
header turn counter, the map view, and later inspector tabs all read
|
||||
header turn counter, the map view, and the inspector tab all read
|
||||
from the same snapshot.
|
||||
|
||||
Phase 13 adds the planet inspector. The layout watches the selection
|
||||
store and, on the null → planet transition, flips `activeTab` to
|
||||
`inspector` and `sidebarOpen` to `true` so the inspector becomes
|
||||
visible regardless of breakpoint (desktop already has the sidebar
|
||||
pinned; tablet needs the drawer to surface). On mobile the
|
||||
`<PlanetSheet />` overlay reads the same selection and displays a
|
||||
read-only sheet over the map; closing the sheet clears the
|
||||
selection.
|
||||
|
||||
State preservation across active-view switches works for free
|
||||
because SvelteKit keeps this layout instance mounted while children
|
||||
swap; navigating between games unmounts and remounts the layout, so
|
||||
the next game's snapshot is loaded fresh.
|
||||
the next game's snapshot — and the next game's selection — start
|
||||
fresh.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount, setContext } from "svelte";
|
||||
@@ -32,8 +50,13 @@ the next game's snapshot is loaded fresh.
|
||||
import BottomTabs from "$lib/sidebar/bottom-tabs.svelte";
|
||||
import Calculator from "$lib/sidebar/calculator-tab.svelte";
|
||||
import Order from "$lib/sidebar/order-tab.svelte";
|
||||
import type { MobileTool } from "$lib/sidebar/types";
|
||||
import PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
|
||||
import type { MobileTool, SidebarTab } from "$lib/sidebar/types";
|
||||
import { GameStateStore, GAME_STATE_CONTEXT_KEY } from "$lib/game-state.svelte";
|
||||
import {
|
||||
SelectionStore,
|
||||
SELECTION_CONTEXT_KEY,
|
||||
} from "$lib/selection.svelte";
|
||||
import {
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
OrderDraftStore,
|
||||
@@ -49,6 +72,7 @@ the next game's snapshot is loaded fresh.
|
||||
|
||||
let sidebarOpen = $state(false);
|
||||
let mobileTool: MobileTool = $state("map");
|
||||
let activeTab: SidebarTab = $state("inspector");
|
||||
// Phase 12 ships the prop wiring; Phase 26 replaces this constant
|
||||
// with the real history-mode signal from `lib/history-mode.ts`.
|
||||
const historyMode = false;
|
||||
@@ -63,6 +87,33 @@ the next game's snapshot is loaded fresh.
|
||||
setContext(GAME_STATE_CONTEXT_KEY, gameState);
|
||||
const orderDraft = new OrderDraftStore();
|
||||
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
|
||||
const selection = new SelectionStore();
|
||||
setContext(SELECTION_CONTEXT_KEY, selection);
|
||||
|
||||
// selectedPlanet resolves the current selection against the live
|
||||
// report so both the desktop sidebar and the mobile sheet display
|
||||
// the same snapshot. A selection that points at a planet missing
|
||||
// from the current report (e.g. visibility lost between turns)
|
||||
// reads as `null` here, which collapses the inspector and the
|
||||
// sheet without surfacing a stale row.
|
||||
const selectedPlanet = $derived.by(() => {
|
||||
const sel = selection.selected;
|
||||
if (sel === null || sel.kind !== "planet") return null;
|
||||
const report = gameState.report;
|
||||
if (report === null) return null;
|
||||
return report.planets.find((p) => p.number === sel.id) ?? null;
|
||||
});
|
||||
|
||||
// Reveal the inspector whenever a new planet selection lands.
|
||||
// Reading `selection.selected` once outside the effect keeps the
|
||||
// effect dependent on the rune transition and not on the derived
|
||||
// `selectedPlanet`, which can flicker as the report refreshes.
|
||||
$effect(() => {
|
||||
const sel = selection.selected;
|
||||
if (sel === null) return;
|
||||
activeTab = "inspector";
|
||||
sidebarOpen = true;
|
||||
});
|
||||
|
||||
function toggleSidebar(): void {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
@@ -107,6 +158,7 @@ the next game's snapshot is loaded fresh.
|
||||
onDestroy(() => {
|
||||
gameState.dispose();
|
||||
orderDraft.dispose();
|
||||
selection.dispose();
|
||||
});
|
||||
|
||||
function describeBootstrapError(err: unknown): string {
|
||||
@@ -135,6 +187,7 @@ the next game's snapshot is loaded fresh.
|
||||
open={sidebarOpen}
|
||||
onClose={() => (sidebarOpen = false)}
|
||||
{historyMode}
|
||||
bind:activeTab
|
||||
/>
|
||||
</div>
|
||||
<BottomTabs
|
||||
@@ -143,6 +196,11 @@ the next game's snapshot is loaded fresh.
|
||||
onSelectTool={(tool) => (mobileTool = tool)}
|
||||
hideOrder={historyMode}
|
||||
/>
|
||||
<PlanetSheet
|
||||
planet={selectedPlanet}
|
||||
onMap={effectiveTool === "map"}
|
||||
onClose={() => selection.clear()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
Reference in New Issue
Block a user