Step 29.3: the map shell and panel host
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
47ea4987b5
commit
06bf2f17b1
|
|
@ -1,4 +1,4 @@
|
|||
import { lazy, Suspense, useEffect, type ReactNode } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { Route, Routes } from "react-router";
|
||||
import { useApi } from "./api/context";
|
||||
import { startClockTicker, syncClock } from "./clock/clock";
|
||||
|
|
@ -17,6 +17,7 @@ import { Fleet } from "./screens/Fleet";
|
|||
import { Dashboard } from "./screens/Dashboard";
|
||||
import { HQs } from "./screens/HQs";
|
||||
import { Locations } from "./screens/Locations";
|
||||
import { MapScreen } from "./screens/MapScreen";
|
||||
import { Market } from "./screens/Market";
|
||||
import { Pilots } from "./screens/Pilots";
|
||||
import { Logbook } from "./screens/Logbook";
|
||||
|
|
@ -27,16 +28,9 @@ import { usePlayerState, useRefetchOnArrival } from "./state/player";
|
|||
import { RadioCallSheet, useRefetchOnRadio } from "./components/RadioCalls";
|
||||
import { Button } from "./ui";
|
||||
|
||||
// The map pulls in PixiJS; load it only when the Map tab is opened.
|
||||
const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ default: m.MapScreen })));
|
||||
|
||||
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
||||
"/": <Dashboard />,
|
||||
"/map": (
|
||||
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
|
||||
<MapScreen />
|
||||
</Suspense>
|
||||
),
|
||||
"/map": <MapScreen />,
|
||||
"/contracts": <Contracts />,
|
||||
"/fleet": <Fleet />,
|
||||
"/pilots": <Pilots />,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Link, NavLink, Outlet } from "react-router";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
import { GameClock } from "../clock/GameClock";
|
||||
import { SCREENS, type ScreenPath } from "../routes";
|
||||
import { Icon } from "../ui";
|
||||
import { useTrackScreen } from "./lastScreen";
|
||||
import { MapBackdrop } from "./MapBackdrop";
|
||||
import { panelIsWide, PanelHost } from "./PanelHost";
|
||||
|
||||
export interface LayoutProps {
|
||||
/** Shown at the top right. Credits arrive with the player state (Step 8.5). */
|
||||
|
|
@ -16,9 +18,9 @@ export interface LayoutProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* HUD header with credits and game clock, the current screen, and icon
|
||||
* navigation: a bottom bar on phones, a left rail from `md` (CR-005).
|
||||
* `--header-h` and `--nav-h` let full-height screens (the map) fit exactly.
|
||||
* The shell (CR-029): the map behind everything, a floating glass header with
|
||||
* credits and game clock, the current screen in a panel, and icon navigation:
|
||||
* a bottom bar on phones, a floating rail from `md`.
|
||||
*/
|
||||
export function Layout({
|
||||
credits = "—",
|
||||
|
|
@ -27,53 +29,52 @@ export function Layout({
|
|||
attention = {},
|
||||
}: LayoutProps) {
|
||||
useTrackScreen();
|
||||
const { pathname } = useLocation();
|
||||
const panelWidth = panelIsWide(pathname) ? "45rem" : "26.25rem";
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col text-ink-50 [--header-h:3.5rem] [--nav-h:calc(4.25rem+env(safe-area-inset-bottom))] md:pl-24 md:[--nav-h:0px]">
|
||||
<header className="sticky top-0 z-10 flex h-(--header-h) items-center justify-between gap-2 border-b border-space-700/80 bg-space-950/80 px-3 backdrop-blur-xl sm:px-4">
|
||||
<div
|
||||
style={{ "--panel-w": panelWidth } as CSSProperties}
|
||||
className="fixed inset-0 overflow-hidden bg-space-950 text-ink-50 [--nav-h:calc(4rem+env(safe-area-inset-bottom))] md:[--nav-h:0px]"
|
||||
>
|
||||
<MapBackdrop />
|
||||
<header className="absolute inset-x-2 top-[max(0.5rem,env(safe-area-inset-top))] z-10 flex h-12 items-center justify-between gap-2 rounded-2xl border border-glass-line bg-glass px-3 md:top-4 md:right-[calc(var(--panel-w)+2rem)] md:left-24 md:backdrop-blur-xl">
|
||||
{/* The wordmark only fits beside the chips from 640 px; on phones the mark alone. */}
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<LogoMark />
|
||||
<span className="hidden font-display text-lg font-bold sm:inline">DEADHEAD</span>
|
||||
<span className="hidden font-display text-xs text-cyan-300/70 lg:inline">
|
||||
Space Freight
|
||||
</span>
|
||||
<span className="hidden font-title text-sm sm:inline">DEADHEAD</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5 sm:gap-2">
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-amber-400/45 bg-amber-500/10 px-2.5 py-1 text-amber-200 shadow-glow-amber">
|
||||
<Icon name="credits" className="size-4 text-amber-300" />
|
||||
<span aria-label="Credits" className="font-display text-sm font-bold tabular-nums">
|
||||
{credits}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 rounded-lg border border-space-600 bg-space-900/80 px-2.5 py-1 text-ink-300">
|
||||
<Icon name="clock" className="size-4 text-cyan-300" />
|
||||
<span aria-label="Game clock" className="font-display text-xs tabular-nums sm:text-sm">
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
<span className="flex items-center gap-1.5 text-ink-300">
|
||||
<span aria-label="Game clock" className="text-[13px] tabular-nums">
|
||||
{clock}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
<span aria-label="Credits" className="text-[15px] font-semibold tabular-nums">
|
||||
{credits}
|
||||
</span>
|
||||
<Link
|
||||
to="/settings"
|
||||
aria-label="Settings"
|
||||
className="grid size-8 place-items-center rounded-lg border border-space-600 bg-space-900/80 text-ink-300 transition hover:border-amber-400/60 hover:text-amber-200 sm:size-9"
|
||||
className="grid size-9 place-items-center rounded-lg text-ink-300 transition hover:bg-white/8 hover:text-ink-50"
|
||||
>
|
||||
<Icon name="gear" className="size-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
{banner}
|
||||
{banner && (
|
||||
<div className="absolute inset-x-2 top-16 z-10 overflow-hidden rounded-xl md:top-20 md:right-[calc(var(--panel-w)+2rem)] md:left-24">
|
||||
{banner}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 overflow-x-hidden pb-[calc(var(--nav-h)+1.5rem)]">
|
||||
<PanelHost>
|
||||
<Outlet />
|
||||
</main>
|
||||
</PanelHost>
|
||||
|
||||
<nav
|
||||
aria-label="Main"
|
||||
className="fixed inset-x-0 bottom-0 z-10 grid h-(--nav-h) grid-cols-8 border-t border-space-700/80 bg-space-950/85 pb-[env(safe-area-inset-bottom)] backdrop-blur-xl md:inset-x-auto md:top-0 md:left-0 md:flex md:h-dvh md:w-24 md:flex-col md:gap-0.5 md:overflow-y-auto md:border-t-0 md:border-r md:pt-[calc(var(--header-h)+0.75rem)] md:pb-4"
|
||||
className="absolute inset-x-0 bottom-0 z-10 flex h-(--nav-h) border-t border-glass-line bg-space-950/95 pb-[env(safe-area-inset-bottom)] md:top-4 md:right-auto md:bottom-4 md:left-4 md:h-auto md:w-16 md:flex-col md:gap-1 md:overflow-y-auto md:rounded-3xl md:border md:bg-glass md:py-2 md:backdrop-blur-xl"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 -top-px h-px bg-linear-to-r from-transparent via-amber-400/50 to-transparent md:hidden"
|
||||
/>
|
||||
{SCREENS.map((screen) => (
|
||||
<NavTab
|
||||
key={screen.path}
|
||||
|
|
@ -104,40 +105,24 @@ function NavTab({
|
|||
to={path}
|
||||
end={path === "/"}
|
||||
className={({ isActive }) =>
|
||||
`group relative flex flex-col items-center justify-center gap-0.5 transition-colors md:py-1.5 ${
|
||||
isActive ? "text-amber-300" : "text-ink-400 hover:text-ink-50"
|
||||
`relative flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors md:flex-none md:py-2 ${
|
||||
isActive ? "text-action" : "text-ink-300 hover:text-ink-50"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className="relative grid size-6 place-items-center">
|
||||
<Icon name={icon} className="size-5.5" />
|
||||
{badge > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`absolute top-0 h-0.5 w-8 rounded-full bg-amber-400 shadow-glow-amber transition-opacity md:top-1/2 md:left-0 md:h-8 md:w-0.5 md:-translate-y-1/2 ${
|
||||
isActive ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`relative grid size-10 place-items-center rounded-xl transition md:size-11 ${
|
||||
isActive
|
||||
? "bg-amber-500/15 shadow-glow-amber"
|
||||
: "group-hover:bg-white/5 group-active:scale-95"
|
||||
}`}
|
||||
data-testid={`badge-${label}`}
|
||||
className="absolute -top-1.5 -right-2 grid h-4 min-w-4 place-items-center rounded-full bg-problem px-1 text-xs leading-none font-bold text-on-problem"
|
||||
>
|
||||
<Icon name={icon} className="size-6" />
|
||||
{badge > 0 && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
data-testid={`badge-${label}`}
|
||||
className="absolute -top-1 -right-1 grid h-4.5 min-w-4.5 animate-glow place-items-center rounded-full bg-rose-500 px-1 font-display text-xs font-bold text-white ring-2 ring-space-950"
|
||||
>
|
||||
{badge > 9 ? "9+" : badge}
|
||||
</span>
|
||||
)}
|
||||
{badge > 9 ? "9+" : badge}
|
||||
</span>
|
||||
<span className="font-display text-xs font-semibold text-xs">{label}</span>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { lazy, Suspense } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { resetMapView } from "../map/camera";
|
||||
import { useMapSelection } from "../map/selection";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { buttonClass } from "../ui";
|
||||
|
||||
// The map pulls in PixiJS: load it after the first paint.
|
||||
const MapCanvas = lazy(() => import("../map/MapCanvas").then((m) => ({ default: m.MapCanvas })));
|
||||
|
||||
/**
|
||||
* The one map canvas of the session, behind every screen (decision 0.5.166).
|
||||
* A tap on a place or ship opens it in the map view.
|
||||
*/
|
||||
export function MapBackdrop() {
|
||||
const state = useLoadedPlayer();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const selection = useMapSelection((s) => s.selection);
|
||||
const select = useMapSelection((s) => s.select);
|
||||
const onMap = pathname === "/map";
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-0">
|
||||
<Suspense fallback={null}>
|
||||
<MapCanvas
|
||||
state={state}
|
||||
focused={onMap}
|
||||
selected={selection}
|
||||
onSelect={(next) => {
|
||||
select(next);
|
||||
if (next && !onMap) void navigate("/map");
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
{onMap && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetMapView}
|
||||
className={`absolute top-[calc(max(0.5rem,env(safe-area-inset-top))+3.5rem)] right-2 md:top-20 md:right-[calc(var(--panel-w)+2rem)] ${buttonClass("secondary", "sm")} bg-glass`}
|
||||
>
|
||||
Reset view
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { useMapFrame } from "../map/camera";
|
||||
|
||||
type Detent = "peek" | "half" | "full";
|
||||
|
||||
/** Screens that open tall on phones (decision 0.5.166). */
|
||||
const OPEN_FULL = new Set(["/settings", "/logbook", "/pilots", "/hqs", "/locations", "/market"]);
|
||||
/** Screens with extra columns: a wider panel on desktop. */
|
||||
const WIDE = new Set(["/contracts", "/logbook"]);
|
||||
|
||||
const PEEK_PX = 96;
|
||||
const HALF = 0.56;
|
||||
const HEADER_PX = 64;
|
||||
const NAV_PX = 64;
|
||||
|
||||
const detentPx = (detent: Detent, height: number) =>
|
||||
detent === "peek"
|
||||
? PEEK_PX
|
||||
: detent === "half"
|
||||
? Math.round(height * HALF)
|
||||
: height - HEADER_PX - NAV_PX;
|
||||
|
||||
export const panelIsWide = (pathname: string) => WIDE.has(pathname);
|
||||
|
||||
/**
|
||||
* The glass panel screens live in (decision 0.5.166): a bottom sheet with drag
|
||||
* detents on phones, a right-hand column from `md`. On `/map` it holds only the
|
||||
* selected place or ship. It tells the map where the uncovered area is.
|
||||
*/
|
||||
export function PanelHost({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const onMap = pathname === "/map";
|
||||
const [detent, setDetent] = useState<Detent>(OPEN_FULL.has(pathname) ? "full" : "half");
|
||||
const [dragPx, setDragPx] = useState<number | null>(null);
|
||||
const hostRef = useRef<HTMLElement>(null);
|
||||
const drag = useRef<{ startY: number; startPx: number; moved: number } | null>(null);
|
||||
|
||||
// Each screen opens at its own height.
|
||||
useEffect(() => {
|
||||
setDetent(OPEN_FULL.has(pathname) ? "full" : "half");
|
||||
}, [pathname]);
|
||||
|
||||
// Keep the map centered in what the panel leaves showing.
|
||||
useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
const measure = () => {
|
||||
const rect = host.getBoundingClientRect();
|
||||
const { innerWidth: width, innerHeight: height } = window;
|
||||
const desktop = width >= 768;
|
||||
const left = desktop ? 96 : 0;
|
||||
const right = desktop ? rect.left : width;
|
||||
const top = desktop ? 0 : HEADER_PX;
|
||||
const bottom = desktop ? height : onMap ? height - NAV_PX : rect.top;
|
||||
useMapFrame.setState({
|
||||
offsetX: (left + right) / 2 - width / 2,
|
||||
offsetY: (top + bottom) / 2 - height / 2,
|
||||
covered: !desktop && bottom - top < 40,
|
||||
});
|
||||
};
|
||||
measure();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(host);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, [onMap, pathname]);
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||
drag.current = {
|
||||
startY: e.clientY,
|
||||
startPx: detentPx(detent, window.innerHeight),
|
||||
moved: 0,
|
||||
};
|
||||
};
|
||||
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (!drag.current) return;
|
||||
const delta = drag.current.startY - e.clientY;
|
||||
drag.current.moved = Math.max(drag.current.moved, Math.abs(delta));
|
||||
if (drag.current.moved > 4) {
|
||||
const max = detentPx("full", window.innerHeight);
|
||||
setDragPx(Math.max(PEEK_PX, Math.min(max, drag.current.startPx + delta)));
|
||||
}
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
if (dragPx !== null) {
|
||||
const details: Detent[] = ["peek", "half", "full"];
|
||||
const nearest = details.reduce((best, d) =>
|
||||
Math.abs(detentPx(d, window.innerHeight) - dragPx) <
|
||||
Math.abs(detentPx(best, window.innerHeight) - dragPx)
|
||||
? d
|
||||
: best,
|
||||
);
|
||||
setDetent(nearest);
|
||||
}
|
||||
setDragPx(null);
|
||||
drag.current = null;
|
||||
};
|
||||
// A tap or the keyboard steps through the heights, so dragging is never required.
|
||||
const step = () => {
|
||||
if (drag.current && drag.current.moved > 4) return;
|
||||
setDetent((d) => (d === "half" ? "full" : d === "full" ? "peek" : "half"));
|
||||
};
|
||||
|
||||
const wide = WIDE.has(pathname);
|
||||
if (onMap) {
|
||||
return (
|
||||
<main
|
||||
ref={hostRef}
|
||||
className="pointer-events-none absolute inset-x-2 bottom-[calc(var(--nav-h)+0.5rem)] z-10 md:top-4 md:right-4 md:bottom-auto md:left-auto md:w-(--panel-w)"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<main
|
||||
ref={hostRef}
|
||||
style={
|
||||
{
|
||||
"--sheet-h": `${dragPx ?? detentPx(detent, globalThis.innerHeight ?? 800)}px`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`absolute inset-x-0 bottom-(--nav-h) z-10 flex h-(--sheet-h) flex-col rounded-t-3xl border-t border-glass-line bg-glass shadow-panel md:top-4 md:right-4 md:bottom-auto md:left-auto md:h-auto md:max-h-[calc(100%-2rem)] md:w-(--panel-w) md:rounded-3xl md:border md:backdrop-blur-xl ${
|
||||
dragPx === null ? "transition-[height] duration-300 ease-out" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Panel height: ${detent}. Change panel height`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onClick={step}
|
||||
className="flex h-6 shrink-0 touch-none items-center justify-center md:hidden"
|
||||
>
|
||||
<span aria-hidden="true" className="h-1 w-9 rounded-full bg-white/30" />
|
||||
</button>
|
||||
<div
|
||||
className={`@container min-h-0 flex-1 overflow-y-auto overscroll-contain ${wide ? "" : ""}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,13 +3,16 @@ import { Application, Assets, type Texture } from "pixi.js";
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { serverNow } from "../clock/clock";
|
||||
import { loadArt } from "./art/load";
|
||||
import { pan, useCamera, zoomAt, type Viewport } from "./camera";
|
||||
import { framed, pan, useCamera, useMapFrame, zoomAt, type Viewport } from "./camera";
|
||||
import { hitTest, type MapSelection } from "./hitTest";
|
||||
import { MapRenderer } from "./renderer";
|
||||
import { buildScene } from "./scene";
|
||||
import { mapStyle } from "./style";
|
||||
import { runWhileVisible } from "./ticker";
|
||||
|
||||
/** Frame rate while a panel is in front (decision 0.5.166). */
|
||||
const BEHIND_PANEL_FPS = 30;
|
||||
|
||||
/**
|
||||
* The system map canvas (Steps 10.1–10.2, CR-003). Loads the map art, then
|
||||
* draws locations and ships every frame from `state` and the server clock;
|
||||
|
|
@ -19,12 +22,15 @@ export function MapCanvas({
|
|||
state,
|
||||
onSelect,
|
||||
selected = null,
|
||||
focused = true,
|
||||
}: {
|
||||
state: PlayerState;
|
||||
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
|
||||
onSelect?: (selection: MapSelection | null) => void;
|
||||
/** Highlighted with a ring (Step 10C.8). */
|
||||
selected?: MapSelection | null;
|
||||
/** The map is what the player is looking at: full frame rate and a loading note. */
|
||||
focused?: boolean;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const stateRef = useRef(state);
|
||||
|
|
@ -34,12 +40,21 @@ export function MapCanvas({
|
|||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const tickerRef = useRef<{ maxFPS: number } | null>(null);
|
||||
const frameRate = focused ? 0 : BEHIND_PANEL_FPS;
|
||||
const frameRateRef = useRef(frameRate);
|
||||
frameRateRef.current = frameRate;
|
||||
|
||||
useEffect(() => {
|
||||
if (tickerRef.current) tickerRef.current.maxFPS = frameRate;
|
||||
}, [frameRate]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
let cancelled = false;
|
||||
let stopTicker = () => {};
|
||||
let stopFrameWatch = () => {};
|
||||
let renderer: MapRenderer | null = null;
|
||||
const app = new Application();
|
||||
|
||||
|
|
@ -48,6 +63,7 @@ export function MapCanvas({
|
|||
height: container.clientHeight,
|
||||
});
|
||||
|
||||
const camera = () => framed(useCamera.getState(), useMapFrame.getState());
|
||||
// Art and renderer load together; missing art falls back to placeholders (0.5.41).
|
||||
const art = loadArt<Texture>(Assets, window.devicePixelRatio);
|
||||
void Promise.all([
|
||||
|
|
@ -74,12 +90,29 @@ export function MapCanvas({
|
|||
map.draw(
|
||||
buildScene(stateRef.current, serverNow()),
|
||||
viewport(),
|
||||
useCamera.getState(),
|
||||
camera(),
|
||||
selectedRef.current,
|
||||
),
|
||||
);
|
||||
// Only draw while the page is visible (Step 10C.9).
|
||||
stopTicker = runWhileVisible(app.ticker);
|
||||
tickerRef.current = app.ticker;
|
||||
app.ticker.maxFPS = frameRateRef.current;
|
||||
// Only draw while the page is visible (Step 10C.9) and not fully covered (0.5.166).
|
||||
let wanted = false;
|
||||
const apply = () => {
|
||||
if (wanted && !useMapFrame.getState().covered) app.ticker.start();
|
||||
else app.ticker.stop();
|
||||
};
|
||||
stopFrameWatch = useMapFrame.subscribe(apply);
|
||||
stopTicker = runWhileVisible({
|
||||
start: () => {
|
||||
wanted = true;
|
||||
apply();
|
||||
},
|
||||
stop: () => {
|
||||
wanted = false;
|
||||
apply();
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
|
||||
|
|
@ -90,6 +123,13 @@ export function MapCanvas({
|
|||
|
||||
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
|
||||
// pointer that barely moves is a tap, which selects what is under it.
|
||||
const zoom = (factor: number, anchor: { x: number; y: number }) => {
|
||||
const frame = useMapFrame.getState();
|
||||
useCamera.setState((c) => {
|
||||
const next = zoomAt(framed(c, frame), factor, anchor, viewport());
|
||||
return { ...next, panX: next.panX - frame.offsetX, panY: next.panY - frame.offsetY };
|
||||
});
|
||||
};
|
||||
const pointers = new Map<number, { x: number; y: number }>();
|
||||
let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null;
|
||||
const local = (e: PointerEvent | WheelEvent) => {
|
||||
|
|
@ -118,7 +158,7 @@ export function MapCanvas({
|
|||
const before = Math.hypot(previous.x - other.x, previous.y - other.y);
|
||||
const after = Math.hypot(current.x - other.x, current.y - other.y);
|
||||
const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 };
|
||||
if (before > 0) useCamera.setState((c) => zoomAt(c, after / before, mid, viewport()));
|
||||
if (before > 0) zoom(after / before, mid);
|
||||
}
|
||||
pointers.set(e.pointerId, current);
|
||||
};
|
||||
|
|
@ -131,13 +171,13 @@ export function MapCanvas({
|
|||
return;
|
||||
}
|
||||
const scene = buildScene(stateRef.current, serverNow());
|
||||
onSelectRef.current?.(hitTest(scene, local(e), viewport(), useCamera.getState()));
|
||||
onSelectRef.current?.(hitTest(scene, local(e), viewport(), camera()));
|
||||
}
|
||||
if (tap?.id === e.pointerId) tap = null;
|
||||
};
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport()));
|
||||
zoom(Math.exp(-e.deltaY * 0.001), local(e));
|
||||
};
|
||||
container.addEventListener("pointerdown", onDown);
|
||||
container.addEventListener("pointermove", onMove);
|
||||
|
|
@ -148,6 +188,8 @@ export function MapCanvas({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
stopTicker();
|
||||
stopFrameWatch();
|
||||
tickerRef.current = null;
|
||||
container.removeEventListener("pointerdown", onDown);
|
||||
container.removeEventListener("pointermove", onMove);
|
||||
container.removeEventListener("pointerup", onUp);
|
||||
|
|
@ -163,7 +205,7 @@ export function MapCanvas({
|
|||
data-testid="map-canvas"
|
||||
className="relative h-full w-full touch-none overflow-hidden"
|
||||
>
|
||||
{loading && (
|
||||
{loading && focused && (
|
||||
<p
|
||||
role="status"
|
||||
className="absolute inset-x-0 top-4 text-center font-display text-sm text-ink-400"
|
||||
|
|
|
|||
|
|
@ -44,6 +44,21 @@ export function resetMapView(): void {
|
|||
usePreferences.getState().set({ mapCamera: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the map's center belongs: panels cover part of the screen, so the view
|
||||
* centers on what's left (decision 0.5.166). `covered`: nothing is showing.
|
||||
*/
|
||||
export const useMapFrame = create<{ offsetX: number; offsetY: number; covered: boolean }>(() => ({
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
covered: false,
|
||||
}));
|
||||
|
||||
/** The camera shifted by the frame, as drawn and hit-tested. */
|
||||
export function framed(camera: Camera, frame: { offsetX: number; offsetY: number }): Camera {
|
||||
return { ...camera, panX: camera.panX + frame.offsetX, panY: camera.panY + frame.offsetY };
|
||||
}
|
||||
|
||||
/** Pixels per map unit at this zoom. */
|
||||
export function pixelsPerUnit(viewport: Viewport, camera: Camera): number {
|
||||
return (Math.min(viewport.width, viewport.height) / 2) * FIT * camera.zoom;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import { create } from "zustand";
|
||||
import type { MapSelection } from "./hitTest";
|
||||
|
||||
/** What the player last tapped on the map, shown in the map view (decision 0.5.166). */
|
||||
export const useMapSelection = create<{
|
||||
selection: MapSelection | null;
|
||||
select: (selection: MapSelection | null) => void;
|
||||
}>((set) => ({
|
||||
selection: null,
|
||||
select: (selection) => set({ selection }),
|
||||
}));
|
||||
|
|
@ -93,15 +93,15 @@ export function Dashboard() {
|
|||
.sort((a, b) => a.inShopUntil! - b.inShopUntil!);
|
||||
|
||||
return (
|
||||
<Screen className="max-w-5xl">
|
||||
<Screen>
|
||||
<ScreenHeader title="Dashboard" eyebrow={state.company.name} icon="helm" />
|
||||
{/* While the fleet sleeps, first thing on the Bridge (CR-024). */}
|
||||
<FleetAsleep />
|
||||
{/* Calls waiting for an answer, each reopening its card (CR-026). */}
|
||||
<RadioCallsPanel />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<YourShip className="md:col-span-2" />
|
||||
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||
<YourShip className="@2xl:col-span-2" />
|
||||
<Alerts />
|
||||
</div>
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ export function Dashboard() {
|
|||
<StatTile label="Ships in flight" value={String(flying)} icon="send" tone="cyan" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-4 @2xl:grid-cols-2">
|
||||
<div className="space-y-4">
|
||||
{state.tutorial && (
|
||||
<Panel tone="amber">
|
||||
|
|
@ -349,7 +349,7 @@ function YourShip({ className = "" }: { className?: string }) {
|
|||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 @2xl:grid-cols-2">
|
||||
<div>
|
||||
<Eyebrow>Fuel</Eyebrow>
|
||||
{ship ? (
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function Fleet() {
|
|||
const discoveredIds = new Set(state.locations.filter((l) => l.discovered).map((l) => l.id));
|
||||
|
||||
return (
|
||||
<Screen className="max-w-4xl">
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="Fleet"
|
||||
eyebrow={
|
||||
|
|
@ -64,7 +64,7 @@ export function Fleet() {
|
|||
</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
||||
{state.ships.map((ship) => (
|
||||
<ShipRow
|
||||
key={ship.id}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const HQ_ALLOWS = "Hire pilots here and take delivery of new ships here."
|
|||
export function HQs() {
|
||||
const state = useLoadedPlayer();
|
||||
return (
|
||||
<Screen className="max-w-4xl">
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="HQs"
|
||||
eyebrow="Your bases"
|
||||
|
|
@ -34,7 +34,7 @@ export function HQs() {
|
|||
</Link>
|
||||
</Panel>
|
||||
) : (
|
||||
<ul className="grid gap-4 md:grid-cols-2">
|
||||
<ul className="grid gap-4 @2xl:grid-cols-2">
|
||||
{state.bays.map((bay) => (
|
||||
<HqCard key={bay.id} bay={bay} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ function LocationBoard({
|
|||
<div className="space-y-3">
|
||||
{refresh}
|
||||
{stuck}
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
||||
{board.data.contracts.map((contract) => {
|
||||
const requirements = requirementsText(contract);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -26,14 +26,14 @@ export function Locations() {
|
|||
const lease = useGameAction("leaseBay");
|
||||
|
||||
return (
|
||||
<Screen className="max-w-4xl">
|
||||
<Screen>
|
||||
<ScreenHeader title="Locations" eyebrow="Known space" icon="places" />
|
||||
{lease.isError && (
|
||||
<p role="alert" className="text-sm text-rose-300">
|
||||
{errorMessage(lease.error)}
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
||||
{content.locations.map((location) => {
|
||||
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
|
||||
if (!discovered) {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,13 @@
|
|||
import { content, type Location } from "@deadhead/content";
|
||||
import { FuelPrice } from "../components/Fuel";
|
||||
import { realToGame } from "@deadhead/sim";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { formatGameTime } from "../clock/GameClock";
|
||||
import { Countdown } from "../components/Countdown";
|
||||
import { cargoName, formatMillionKm, locationName } from "../format";
|
||||
import { distanceAt } from "../game";
|
||||
import { resetMapView } from "../map/camera";
|
||||
import type { MapSelection } from "../map/hitTest";
|
||||
import { MapCanvas } from "../map/MapCanvas";
|
||||
import { useMapSelection } from "../map/selection";
|
||||
import { reachersByOrigin } from "../map/reach";
|
||||
import { useLoadedPlayer } from "../state/actions";
|
||||
import { usePreferences } from "../state/preferences";
|
||||
|
|
@ -24,32 +22,22 @@ const TYPE_LABELS: Record<Location["type"], string> = {
|
|||
"surface-base": "Surface base",
|
||||
};
|
||||
|
||||
/** The solar system map (Phase 10). Tap a location or ship for details. */
|
||||
/** The map view (Phase 10): the map itself is the backdrop. Tap a place or ship for details. */
|
||||
export function MapScreen() {
|
||||
const state = useLoadedPlayer();
|
||||
const [selection, setSelection] = useState<MapSelection | null>(null);
|
||||
const selection = useMapSelection((s) => s.selection);
|
||||
const select = useMapSelection((s) => s.select);
|
||||
const close = () => select(null);
|
||||
|
||||
return (
|
||||
// Fill the space between the header and the bottom nav (none beside the
|
||||
// desktop rail); the negative margin cancels the layout's scroll padding so
|
||||
// the page never scrolls (CR-005).
|
||||
<section className="relative -mb-[calc(var(--nav-h)+1.5rem)] h-[calc(100dvh-var(--header-h)-var(--nav-h))]">
|
||||
<>
|
||||
<h1 className="sr-only">Map</h1>
|
||||
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetMapView}
|
||||
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
|
||||
>
|
||||
Reset view
|
||||
</button>
|
||||
{selection?.kind === "location" && (
|
||||
<LocationPanel locationId={selection.id} onClose={() => setSelection(null)} />
|
||||
<LocationPanel locationId={selection.id} onClose={close} />
|
||||
)}
|
||||
{selection?.kind === "flight" && (
|
||||
<FlightPanel dispatchId={selection.dispatchId} onClose={() => setSelection(null)} />
|
||||
<FlightPanel dispatchId={selection.dispatchId} onClose={close} />
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +56,7 @@ function Panel({
|
|||
<div
|
||||
role="dialog"
|
||||
aria-label={title}
|
||||
className="absolute inset-x-2 bottom-2 animate-sheet rounded-2xl border border-cyan-400/40 bg-space-900/90 p-4 text-sm shadow-panel backdrop-blur-xl md:inset-x-auto md:right-4 md:bottom-4 md:w-96"
|
||||
className="pointer-events-auto animate-sheet rounded-3xl border border-glass-line bg-glass p-4 text-sm shadow-panel md:backdrop-blur-xl"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{art}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export function Market() {
|
|||
const [buying, setBuying] = useState<ShipModel | null>(null);
|
||||
|
||||
return (
|
||||
<Screen className="max-w-5xl">
|
||||
<Screen>
|
||||
<ScreenHeader
|
||||
title="Market"
|
||||
eyebrow="Shipyard"
|
||||
|
|
@ -48,7 +48,7 @@ export function Market() {
|
|||
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid gap-4 md:grid-cols-3">
|
||||
<ul className="grid gap-4 @2xl:grid-cols-3">
|
||||
{MODELS.map((model) => {
|
||||
const affordable = state.company.credits >= model.price;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export function levelProgress(xp: number, level: number) {
|
|||
export function Pilots() {
|
||||
const state = useLoadedPlayer();
|
||||
return (
|
||||
<Screen className="max-w-4xl">
|
||||
<Screen>
|
||||
<ScreenHeader title="Pilots" eyebrow={`${state.pilots.length} on the roster`} icon="pilots" />
|
||||
<HirePilot />
|
||||
{!state.pilots.some((p) => !p.isAvatar) && (
|
||||
|
|
@ -36,7 +36,7 @@ export function Pilots() {
|
|||
No hired pilots yet. Hire one at a bay to crew another ship.
|
||||
</p>
|
||||
)}
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
||||
{state.pilots.map((pilot) => (
|
||||
<PilotCard key={pilot.id} pilot={pilot} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export function TutorialBoard() {
|
|||
{board.data.contracts.length === 0 && (
|
||||
<p className="text-sm text-ink-400">No company runs left this period. Check back soon.</p>
|
||||
)}
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
<ul className="grid gap-3 @2xl:grid-cols-2">
|
||||
{board.data.contracts.map((contract, index) => {
|
||||
// On the first run, suggest the lightest (fastest) contract.
|
||||
const suggested = firstRun && index === 0;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
/** A scrolling screen's content column: centred, padded, and rising in on open. */
|
||||
/** A screen's content inside its panel (decision 0.5.166): padded, and rising in on open. */
|
||||
export function Screen({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={`mx-auto w-full max-w-3xl animate-rise space-y-4 p-4 md:p-6 ${className}`}>
|
||||
<section className={`w-full animate-rise space-y-4 px-4 pt-1 pb-5 md:pt-4 ${className}`}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,10 +9,14 @@ import { buildScene } from "../src/map/scene";
|
|||
export function MapCanvas({
|
||||
state,
|
||||
onSelect,
|
||||
focused = true,
|
||||
}: {
|
||||
state: PlayerState;
|
||||
onSelect?: (selection: MapSelection | null) => void;
|
||||
focused?: boolean;
|
||||
}) {
|
||||
// Behind a panel, the screen's own controls are what tests look for.
|
||||
if (!focused) return <div data-testid="map-canvas" />;
|
||||
const scene = buildScene(state, Date.now());
|
||||
return (
|
||||
<div data-testid="map-canvas">
|
||||
|
|
|
|||
Loading…
Reference in New Issue