191 lines
7.1 KiB
TypeScript
191 lines
7.1 KiB
TypeScript
import { content, type Location } from "@deadhead/content";
|
|
import { realToGame } from "@deadhead/sim";
|
|
import { useState, type ReactNode } from "react";
|
|
import { Link } from "react-router";
|
|
import { useClock } from "../clock/clock";
|
|
import { formatGameTime } from "../clock/GameClock";
|
|
import { Countdown } from "../components/Countdown";
|
|
import { cargoName, formatMillionKm, locationName } from "../format";
|
|
import { distanceAt } from "../game";
|
|
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
|
import type { MapSelection } from "../map/hitTest";
|
|
import { MapCanvas } from "../map/MapCanvas";
|
|
import { modelsReachingFromEarth, upgradedShipsReachingFromEarth } from "../map/reach";
|
|
import { useLoadedPlayer } from "../state/actions";
|
|
import { Art, buttonClass, Icon } from "../ui";
|
|
|
|
const TYPE_LABELS: Record<Location["type"], string> = {
|
|
"atmospheric-world": "Atmospheric world",
|
|
"airless-body": "Airless body",
|
|
"cloud-platform": "Cloud platform",
|
|
"belt-station": "Belt station",
|
|
};
|
|
|
|
/** The solar system map (Phase 10). Tap a location or ship for details. */
|
|
export function MapScreen() {
|
|
const state = useLoadedPlayer();
|
|
const [selection, setSelection] = useState<MapSelection | null>(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={() => useCamera.setState(DEFAULT_CAMERA)}
|
|
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)} />
|
|
)}
|
|
{selection?.kind === "flight" && (
|
|
<FlightPanel dispatchId={selection.dispatchId} onClose={() => setSelection(null)} />
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Panel({
|
|
title,
|
|
art,
|
|
onClose,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
art?: ReactNode;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<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"
|
|
>
|
|
<div className="flex items-start gap-3">
|
|
{art}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<h2 className="font-display text-lg font-bold tracking-wide">{title}</h2>
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="-mt-1 -mr-1 grid size-9 shrink-0 place-items-center rounded-lg text-ink-400 hover:bg-white/5 hover:text-ink-50"
|
|
>
|
|
<Icon name="close" className="size-4" />
|
|
</button>
|
|
</div>
|
|
<div className="mt-1 space-y-1">{children}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A tapped location: details and a link to its contracts once discovered (CR-002). */
|
|
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
|
|
const state = useLoadedPlayer();
|
|
const now = useClock((s) => s.now);
|
|
const location = content.location(locationId)!;
|
|
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
|
|
const hasBay = state.bays.some((b) => b.locationId === locationId);
|
|
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
|
|
// Owned ships whose upgrades get them there when their model can't (CR-009).
|
|
const upgraded = discovered ? [] : upgradedShipsReachingFromEarth(state.ships, locationId, now);
|
|
const reachers = [
|
|
...reachable.map((m) => m.name),
|
|
...upgraded.map((s) => `your ${s.name} (upgraded)`),
|
|
];
|
|
|
|
return (
|
|
<Panel
|
|
title={location.name}
|
|
onClose={onClose}
|
|
art={
|
|
<Art
|
|
sprite={location.sprite}
|
|
className={`size-16 shrink-0 ${discovered ? "" : "opacity-40 grayscale"}`}
|
|
/>
|
|
}
|
|
>
|
|
{discovered ? (
|
|
<>
|
|
<p className="text-ink-200">
|
|
{TYPE_LABELS[location.type]} ·{" "}
|
|
{location.id === "earth"
|
|
? "Home"
|
|
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
|
</p>
|
|
<p className="text-ink-400">
|
|
Produces {location.cargoClassMix.produces.map(cargoName).join(", ") || "nothing"} ·
|
|
demands {location.cargoClassMix.demands.map(cargoName).join(", ") || "nothing"}
|
|
</p>
|
|
<p className={hasBay ? "text-emerald-300" : "text-ink-400"}>
|
|
{hasBay ? "Your HQ is here." : "No HQ here."}
|
|
</p>
|
|
<Link
|
|
to={`/contracts?location=${location.id}`}
|
|
className={`mt-2 ${buttonClass("primary", "sm")}`}
|
|
>
|
|
<Icon name="jobs" className="size-4" />
|
|
View contracts
|
|
</Link>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="flex items-center gap-1.5 text-ink-300">
|
|
<Icon name="lock" className="size-4 shrink-0" />
|
|
Undiscovered — deliver here to unlock.
|
|
</p>
|
|
<p aria-label="Reachable with" className="text-ink-200">
|
|
{reachers.length > 0
|
|
? `Reachable with: ${reachers.join(", ")}`
|
|
: "No ship can reach it from Earth right now."}
|
|
</p>
|
|
</>
|
|
)}
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
/** A tapped ship in flight: its route, pilot and ETA. */
|
|
function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) {
|
|
const state = useLoadedPlayer();
|
|
const dispatch = state.activeDispatches.find((d) => d.id === dispatchId);
|
|
if (!dispatch) return null;
|
|
const shipName = dispatch.tutorial
|
|
? "Company loaner"
|
|
: (state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "Ship");
|
|
const pilotName = state.pilots.find((p) => p.id === dispatch.pilotId)?.name ?? "Unknown pilot";
|
|
const shipSprite = dispatch.tutorial
|
|
? "ship-company-courier"
|
|
: (content.shipModel(state.ships.find((s) => s.id === dispatch.shipId)?.modelId ?? "")
|
|
?.sprite ?? "ship-courier");
|
|
|
|
return (
|
|
<Panel
|
|
title={shipName}
|
|
onClose={onClose}
|
|
art={<Art sprite={shipSprite} className="size-14 shrink-0 rotate-45" />}
|
|
>
|
|
<p className="font-display text-ink-50">
|
|
{locationName(dispatch.originId)} → {locationName(dispatch.destinationId)}
|
|
</p>
|
|
<p className="text-ink-300">Pilot: {pilotName}</p>
|
|
<p className="text-ink-300">
|
|
Arrives in{" "}
|
|
<span className="font-display text-cyan-200">
|
|
<Countdown to={dispatch.arrivalAt} />
|
|
</span>{" "}
|
|
({formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
|
</p>
|
|
</Panel>
|
|
);
|
|
}
|