17a3afd5e9
Nine BattleViewer refinements from the latest review pass: 1. Mass radii were uniform in synthetic mode because `+layout.svelte` skipped `loadCore()` on the synthetic branch. The wasm bridge to `pkg/calc/ship.go` now boots in both modes so `computeBattleGroupMass` resolves a real FullMass and `radiusForMass` produces a per-battle scale. 2. Phantom-destroy clamp in `buildFrames`. Legacy emitters (KNNTS041 planet #7) log many more `Destroyed` lines against a group than the group's initial population — at frame 406 of 2317 the race totals previously hit zero on phantom shots and the scene blanked while playback continued silently. We now only shrink the per-group remaining count and the race totals when the group still has ships. The line still draws on phantom frames; only the counters stay sane. 3. Vogel sunflower positions are now reassigned by inward dot product before being handed to ranks: the rank-0 bucket — the one with the largest initial ship count — always lands at the most-inward spiral slot. The previous quarter-step anchor bias was too weak; ranks r ≥ 2 routinely overtook rank-0 toward the planet. The anchor offset is gone. 4. Bucket order inside a cluster is locked at battle start by each bucket's *initial* ship count (`num`), not its live `numLeft`. The position of every class circle stays put for the whole battle; only the label number changes as ships die. 5. Shot line + defender flash blink on a per-frame timer during play. The line stays on for the first 90 % of frame duration, off for the last 10 %, so two consecutive shots from the same attacker on the same defender look like two distinct pulses. On pause the line and flash stay drawn for inspection. 6. The defender's class circle now flashes red (destroyed) or green (shielded) in sync with the shot line, so the eye catches *who* was hit, not just where the line lands. 7. Battle log rows are buttons. Click / Enter / Space pauses playback and seeks to that shot. The list also auto-scrolls the current row into view so the highlight does not race off the bottom on long battles. 8. Race labels now sit above the cloud's bounding top instead of a fixed offset, so a dense cluster does not swallow its own race name. 9. Planet glyph + label switch to neutral grey (`#2a2f40` / `#4a5066` / `#6d7388`), keeping the planet "in the background" rather than competing with the combatants. Step-back icon switched to `◀︎◀︎` to mirror step-forward. Tests: two new Vitest cases cover the phantom-destroy clamp (single-race wipe, mixed-class race survives a class wipe). The existing 642 Vitest tests stay green; all four `battle-viewer` Playwright cases pass. Docs: `ui/docs/battle-viewer-ux.md` rewrites the cluster section (locked order + Vogel reassignment), adds Playback Details (blink + flash semantics), and a Phantom Destroys section explaining the clamp. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
542 lines
18 KiB
Svelte
542 lines
18 KiB
Svelte
<!--
|
|
Phase 10 in-game shell. Composes the header, a conditionally-visible
|
|
sidebar (Calculator / Inspector / Order tabs), the active-view slot
|
|
filled by the child route, and a mobile-only bottom-tab bar. The
|
|
layout owns:
|
|
|
|
- `sidebarOpen` — tablet-only drawer toggle. Desktop keeps the
|
|
sidebar pinned via CSS; mobile hides it entirely.
|
|
- `mobileTool` — mobile-only tool overlay state. The tool only
|
|
visually overrides the active-view slot when the URL is `/map`,
|
|
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 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 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 — and the next game's selection — start
|
|
fresh.
|
|
-->
|
|
<script lang="ts">
|
|
import { onDestroy, onMount, setContext } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { page } from "$app/state";
|
|
import Header from "$lib/header/header.svelte";
|
|
import HistoryBanner from "$lib/header/history-banner.svelte";
|
|
import Sidebar from "$lib/sidebar/sidebar.svelte";
|
|
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 PlanetSheet from "$lib/inspectors/planet-sheet.svelte";
|
|
import ShipGroupSheet from "$lib/inspectors/ship-group-sheet.svelte";
|
|
import type { ShipGroupSelection } from "$lib/inspectors/ship-group.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 {
|
|
createRenderedReportSource,
|
|
RENDERED_REPORT_CONTEXT_KEY,
|
|
} from "$lib/rendered-report.svelte";
|
|
import {
|
|
ORDER_DRAFT_CONTEXT_KEY,
|
|
OrderDraftStore,
|
|
} from "../../../sync/order-draft.svelte";
|
|
import {
|
|
MAP_PICK_CONTEXT_KEY,
|
|
MapPickService,
|
|
} from "$lib/map-pick.svelte";
|
|
import {
|
|
GALAXY_CLIENT_CONTEXT_KEY,
|
|
GalaxyClientHolder,
|
|
} from "$lib/galaxy-client-context.svelte";
|
|
import {
|
|
CORE_CONTEXT_KEY,
|
|
CoreHolder,
|
|
} from "$lib/core-context.svelte";
|
|
import { session } from "$lib/session-store.svelte";
|
|
import { loadStore } from "../../../platform/store/index";
|
|
import { loadCore } from "../../../platform/core/index";
|
|
import { createEdgeGatewayClient } from "../../../api/connect";
|
|
import { GalaxyClient } from "../../../api/galaxy-client";
|
|
import { GATEWAY_BASE_URL, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
|
import {
|
|
getSyntheticReport,
|
|
isSyntheticGameId,
|
|
} from "../../../api/synthetic-report";
|
|
import {
|
|
eventStream,
|
|
type VerifiedEvent,
|
|
} from "../../../api/events.svelte";
|
|
import { toast } from "$lib/toast.svelte";
|
|
|
|
let { children } = $props();
|
|
|
|
let sidebarOpen = $state(false);
|
|
let mobileTool: MobileTool = $state("map");
|
|
let activeTab: SidebarTab = $state("inspector");
|
|
|
|
const gameId = $derived(page.params.id ?? "");
|
|
const isOnMap = $derived(/\/games\/[^/]+\/map\/?$/.test(page.url.pathname));
|
|
const effectiveTool: MobileTool = $derived.by(() =>
|
|
isOnMap ? mobileTool : "map",
|
|
);
|
|
|
|
const gameState = new GameStateStore();
|
|
setContext(GAME_STATE_CONTEXT_KEY, gameState);
|
|
const orderDraft = new OrderDraftStore();
|
|
setContext(ORDER_DRAFT_CONTEXT_KEY, orderDraft);
|
|
// Phase 26: the order tab vanishes from the sidebar and bottom-tabs
|
|
// when the player is viewing a past turn. The flag is owned by
|
|
// `GameStateStore` (single source of truth for "what turn are we
|
|
// looking at") and surfaced here so the Phase 12 sidebar wiring,
|
|
// the new `HistoryBanner`, and `orderDraft.bindClient` all read
|
|
// from the same derivation.
|
|
const historyMode = $derived(gameState.historyMode);
|
|
const selection = new SelectionStore();
|
|
setContext(SELECTION_CONTEXT_KEY, selection);
|
|
const renderedReport = createRenderedReportSource(gameState, orderDraft);
|
|
setContext(RENDERED_REPORT_CONTEXT_KEY, renderedReport);
|
|
const galaxyClient = new GalaxyClientHolder();
|
|
setContext(GALAXY_CLIENT_CONTEXT_KEY, galaxyClient);
|
|
const coreHolder = new CoreHolder();
|
|
setContext(CORE_CONTEXT_KEY, coreHolder);
|
|
// `MapPickService` lives at the layout so both the active map
|
|
// view (which binds the renderer-side resolver) and the
|
|
// inspector subsections (which call `pick(...)`) see the same
|
|
// instance via context — they sit on sibling branches of the
|
|
// component tree.
|
|
const mapPick = new MapPickService();
|
|
setContext(MAP_PICK_CONTEXT_KEY, mapPick);
|
|
|
|
// 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. The rendered report layers
|
|
// the local order draft on top so the player sees their pending
|
|
// renames immediately.
|
|
const selectedPlanet = $derived.by(() => {
|
|
const sel = selection.selected;
|
|
if (sel === null || sel.kind !== "planet") return null;
|
|
const report = renderedReport.report;
|
|
if (report === null) return null;
|
|
return report.planets.find((p) => p.number === sel.id) ?? null;
|
|
});
|
|
const selectedShipGroup: ShipGroupSelection | null = $derived.by(() => {
|
|
const sel = selection.selected;
|
|
if (sel === null || sel.kind !== "shipGroup") return null;
|
|
const report = renderedReport.report;
|
|
if (report === null) return null;
|
|
const ref = sel.ref;
|
|
switch (ref.variant) {
|
|
case "local": {
|
|
const group = report.localShipGroups.find((g) => g.id === ref.id);
|
|
if (group === undefined) return null;
|
|
return { variant: "local", group };
|
|
}
|
|
case "other": {
|
|
const group = report.otherShipGroups[ref.index];
|
|
if (group === undefined) return null;
|
|
return { variant: "other", group };
|
|
}
|
|
case "incoming": {
|
|
const group = report.incomingShipGroups[ref.index];
|
|
if (group === undefined) return null;
|
|
return { variant: "incoming", group };
|
|
}
|
|
case "unidentified": {
|
|
const group = report.unidentifiedShipGroups[ref.index];
|
|
if (group === undefined) return null;
|
|
return { variant: "unidentified", group };
|
|
}
|
|
}
|
|
});
|
|
const localShipClass = $derived(
|
|
renderedReport.report?.localShipClass ?? [],
|
|
);
|
|
const localScience = $derived(renderedReport.report?.localScience ?? []);
|
|
const inspectorPlanets = $derived(renderedReport.report?.planets ?? []);
|
|
const inspectorRoutes = $derived(renderedReport.report?.routes ?? []);
|
|
const inspectorMapWidth = $derived(renderedReport.report?.mapWidth ?? 1);
|
|
const inspectorMapHeight = $derived(renderedReport.report?.mapHeight ?? 1);
|
|
const inspectorLocalDrive = $derived(
|
|
renderedReport.report?.localPlayerDrive ?? 0,
|
|
);
|
|
const inspectorLocalWeapons = $derived(
|
|
renderedReport.report?.localPlayerWeapons ?? 0,
|
|
);
|
|
const inspectorLocalShields = $derived(
|
|
renderedReport.report?.localPlayerShields ?? 0,
|
|
);
|
|
const inspectorLocalCargo = $derived(
|
|
renderedReport.report?.localPlayerCargo ?? 0,
|
|
);
|
|
const inspectorLocalShipGroups = $derived(
|
|
renderedReport.report?.localShipGroups ?? [],
|
|
);
|
|
const inspectorOtherShipGroups = $derived(
|
|
renderedReport.report?.otherShipGroups ?? [],
|
|
);
|
|
const inspectorLocalFleets = $derived(
|
|
renderedReport.report?.localFleets ?? [],
|
|
);
|
|
const inspectorOtherRaces = $derived(
|
|
renderedReport.report?.otherRaces ?? [],
|
|
);
|
|
const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");
|
|
|
|
// 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;
|
|
}
|
|
|
|
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
|
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
|
return new Uint8Array(digest);
|
|
}
|
|
|
|
// `unsubTurnReady` / `unsubGamePaused` carry the
|
|
// `eventStream.on(...)` disposers for the game-scoped push
|
|
// handlers. The layout registers them once the local
|
|
// `GameStateStore` is initialised so an event arriving before
|
|
// `currentTurn` is known cannot misfire.
|
|
let unsubTurnReady: (() => void) | null = null;
|
|
let unsubGamePaused: (() => void) | null = null;
|
|
const turnReadyDecoder = new TextDecoder("utf-8");
|
|
|
|
function parseTurnReadyPayload(
|
|
event: VerifiedEvent,
|
|
): { gameId: string; turn: number } | null {
|
|
try {
|
|
const text = turnReadyDecoder.decode(event.payloadBytes);
|
|
const json: unknown = JSON.parse(text);
|
|
if (typeof json !== "object" || json === null) {
|
|
return null;
|
|
}
|
|
const record = json as Record<string, unknown>;
|
|
const eventGameId = record.game_id;
|
|
const eventTurn = record.turn;
|
|
if (
|
|
typeof eventGameId !== "string" ||
|
|
typeof eventTurn !== "number" ||
|
|
!Number.isFinite(eventTurn)
|
|
) {
|
|
return null;
|
|
}
|
|
return { gameId: eventGameId, turn: eventTurn };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseGamePausedPayload(
|
|
event: VerifiedEvent,
|
|
): { gameId: string; reason: string } | null {
|
|
try {
|
|
const text = turnReadyDecoder.decode(event.payloadBytes);
|
|
const json: unknown = JSON.parse(text);
|
|
if (typeof json !== "object" || json === null) {
|
|
return null;
|
|
}
|
|
const record = json as Record<string, unknown>;
|
|
const eventGameId = record.game_id;
|
|
if (typeof eventGameId !== "string") {
|
|
return null;
|
|
}
|
|
const reason = typeof record.reason === "string" ? record.reason : "";
|
|
return { gameId: eventGameId, reason };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
let activeTurnReadyToastId: string | null = null;
|
|
|
|
$effect(() => {
|
|
const pending = gameState.pendingTurn;
|
|
if (pending === null) {
|
|
if (activeTurnReadyToastId !== null) {
|
|
toast.dismiss(activeTurnReadyToastId);
|
|
activeTurnReadyToastId = null;
|
|
}
|
|
return;
|
|
}
|
|
activeTurnReadyToastId = toast.show({
|
|
messageKey: "game.events.turn_ready.message",
|
|
messageParams: { turn: String(pending) },
|
|
actionLabelKey: "game.events.turn_ready.action",
|
|
onAction: () => {
|
|
void gameState.advanceToPending();
|
|
},
|
|
durationMs: null,
|
|
});
|
|
});
|
|
|
|
onMount(() => {
|
|
(async (): Promise<void> => {
|
|
// DEV-only synthetic-report path. The lobby's "Load
|
|
// synthetic report" affordance navigates here with a
|
|
// `synthetic-<uuid>` id and the matching report
|
|
// pre-registered in an in-memory map. A page reload
|
|
// loses the map entry; that case redirects to /lobby
|
|
// so the user reloads the JSON.
|
|
if (isSyntheticGameId(gameId)) {
|
|
const report = getSyntheticReport(gameId);
|
|
if (report === undefined) {
|
|
await goto("/lobby");
|
|
return;
|
|
}
|
|
try {
|
|
// Synthetic mode still needs the wasm `Core` so
|
|
// components that bridge to `pkg/calc/ship.go`
|
|
// (designer preview, BattleViewer mass radii) can
|
|
// resolve their math against the same engine helpers
|
|
// the live path uses. The live branch below also
|
|
// calls `loadCore()`; without it here the Battle
|
|
// Viewer rendered every ship-class circle at
|
|
// MAX_RADIUS in synthetic mode.
|
|
const [{ cache }, core] = await Promise.all([
|
|
loadStore(),
|
|
loadCore(),
|
|
]);
|
|
coreHolder.set(core);
|
|
await Promise.all([
|
|
gameState.initSynthetic({ cache, gameId, report }),
|
|
orderDraft.init({ cache, gameId }),
|
|
]);
|
|
// Deliberately no `galaxyClient.set` and no
|
|
// `orderDraft.bindClient`: synthetic mode never
|
|
// sends to the gateway. The auto-sync pipeline
|
|
// already short-circuits via the UUID guard in
|
|
// `scheduleSync`, but skipping the bind keeps
|
|
// the path simple to reason about.
|
|
} catch (err) {
|
|
gameState.failBootstrap(describeBootstrapError(err));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (
|
|
session.keypair === null ||
|
|
session.deviceSessionId === null ||
|
|
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
|
) {
|
|
return;
|
|
}
|
|
const keypair = session.keypair;
|
|
const deviceSessionId = session.deviceSessionId;
|
|
try {
|
|
const [{ cache }, core] = await Promise.all([loadStore(), loadCore()]);
|
|
coreHolder.set(core);
|
|
const client = new GalaxyClient({
|
|
core,
|
|
edge: createEdgeGatewayClient(GATEWAY_BASE_URL),
|
|
signer: (canonical) => keypair.sign(canonical),
|
|
sha256,
|
|
deviceSessionId,
|
|
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
|
});
|
|
// Register the `game.turn.ready` dispatch before the
|
|
// network roundtrips below so an event delivered
|
|
// while `gameState.init` is still in flight is not
|
|
// dropped by the singleton stream. `markPendingTurn`
|
|
// already protects against turns that do not advance
|
|
// past the current snapshot. Phase 25: a turn-ready
|
|
// frame arriving while the draft is in `conflict` or
|
|
// `paused` state also resets the draft and rehydrates
|
|
// from the server for the new turn — the old commands
|
|
// became history at the cutoff.
|
|
unsubTurnReady = eventStream.on("game.turn.ready", (event) => {
|
|
const parsed = parseTurnReadyPayload(event);
|
|
if (parsed === null || parsed.gameId !== gameId) {
|
|
return;
|
|
}
|
|
gameState.markPendingTurn(parsed.turn);
|
|
if (
|
|
orderDraft.syncStatus === "conflict" ||
|
|
orderDraft.syncStatus === "paused"
|
|
) {
|
|
void orderDraft.resetForNewTurn({
|
|
client,
|
|
turn: parsed.turn,
|
|
});
|
|
}
|
|
});
|
|
unsubGamePaused = eventStream.on("game.paused", (event) => {
|
|
const parsed = parseGamePausedPayload(event);
|
|
if (parsed === null || parsed.gameId !== gameId) {
|
|
return;
|
|
}
|
|
orderDraft.markPaused({ reason: parsed.reason });
|
|
});
|
|
await Promise.all([
|
|
gameState.init({ client, cache, gameId }),
|
|
orderDraft.init({ cache, gameId }),
|
|
]);
|
|
galaxyClient.set(client);
|
|
orderDraft.bindClient(client, {
|
|
getCurrentTurn: () => gameState.currentTurn,
|
|
getHistoryMode: () => gameState.historyMode,
|
|
});
|
|
// The server is always polled at game boot — its
|
|
// stored order may be fresher than the local cache
|
|
// (e.g. user is on a new device), and an offline
|
|
// edit must catch up at re-sync time. The hydration
|
|
// is non-fatal: a network error keeps the local
|
|
// cache and surfaces through `draft.syncStatus`.
|
|
await orderDraft.hydrateFromServer({
|
|
client,
|
|
turn: gameState.currentTurn,
|
|
});
|
|
} catch (err) {
|
|
gameState.failBootstrap(describeBootstrapError(err));
|
|
}
|
|
})();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (unsubTurnReady !== null) {
|
|
unsubTurnReady();
|
|
unsubTurnReady = null;
|
|
}
|
|
if (unsubGamePaused !== null) {
|
|
unsubGamePaused();
|
|
unsubGamePaused = null;
|
|
}
|
|
gameState.dispose();
|
|
orderDraft.dispose();
|
|
selection.dispose();
|
|
});
|
|
|
|
function describeBootstrapError(err: unknown): string {
|
|
if (err instanceof Error) return err.message;
|
|
return "request failed";
|
|
}
|
|
</script>
|
|
|
|
<div class="game-shell" data-testid="game-shell">
|
|
<Header
|
|
{gameId}
|
|
{sidebarOpen}
|
|
onToggleSidebar={toggleSidebar}
|
|
/>
|
|
<HistoryBanner />
|
|
<div class="body">
|
|
<main class="active-view-host" data-testid="active-view-host">
|
|
{#if effectiveTool === "calc"}
|
|
<Calculator />
|
|
{:else if effectiveTool === "order"}
|
|
<Order />
|
|
{:else}
|
|
{@render children()}
|
|
{/if}
|
|
</main>
|
|
<Sidebar
|
|
open={sidebarOpen}
|
|
onClose={() => (sidebarOpen = false)}
|
|
{historyMode}
|
|
bind:activeTab
|
|
/>
|
|
</div>
|
|
<BottomTabs
|
|
{gameId}
|
|
activeTool={effectiveTool}
|
|
onSelectTool={(tool) => (mobileTool = tool)}
|
|
hideOrder={historyMode}
|
|
/>
|
|
<PlanetSheet
|
|
planet={selectedPlanet}
|
|
{localShipClass}
|
|
{localScience}
|
|
routes={inspectorRoutes}
|
|
planets={inspectorPlanets}
|
|
mapWidth={inspectorMapWidth}
|
|
mapHeight={inspectorMapHeight}
|
|
localPlayerDrive={inspectorLocalDrive}
|
|
localShipGroups={inspectorLocalShipGroups}
|
|
otherShipGroups={inspectorOtherShipGroups}
|
|
localRace={inspectorLocalRace}
|
|
onMap={effectiveTool === "map"}
|
|
onClose={() => selection.clear()}
|
|
/>
|
|
<ShipGroupSheet
|
|
selection={selectedShipGroup}
|
|
planets={inspectorPlanets}
|
|
{localShipClass}
|
|
localFleets={inspectorLocalFleets}
|
|
otherRaces={inspectorOtherRaces}
|
|
mapWidth={inspectorMapWidth}
|
|
mapHeight={inspectorMapHeight}
|
|
localPlayerDrive={inspectorLocalDrive}
|
|
localPlayerWeapons={inspectorLocalWeapons}
|
|
localPlayerShields={inspectorLocalShields}
|
|
localPlayerCargo={inspectorLocalCargo}
|
|
onMap={effectiveTool === "map"}
|
|
onClose={() => selection.clear()}
|
|
/>
|
|
</div>
|
|
|
|
<style>
|
|
.game-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
background: #0a0e1a;
|
|
color: #e8eaf6;
|
|
}
|
|
.body {
|
|
flex: 1;
|
|
display: flex;
|
|
min-height: 0;
|
|
}
|
|
.active-view-host {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow-y: auto;
|
|
}
|
|
@media (max-width: 767.98px) {
|
|
.body {
|
|
padding-bottom: 3.25rem;
|
|
}
|
|
}
|
|
</style>
|