146 lines
5.2 KiB
TypeScript
146 lines
5.2 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 } from "../map/reach";
|
|
import { useLoadedPlayer } from "../state/actions";
|
|
|
|
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 (
|
|
<section className="relative h-[calc(100dvh-7.5rem)]">
|
|
<h1 className="sr-only">Map</h1>
|
|
<MapCanvas state={state} onSelect={setSelection} />
|
|
<button
|
|
type="button"
|
|
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
|
className="absolute right-3 top-3 rounded-md bg-slate-900/80 px-2 py-1 text-xs text-slate-300"
|
|
>
|
|
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,
|
|
onClose,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
role="dialog"
|
|
aria-label={title}
|
|
className="absolute inset-x-2 bottom-2 rounded-xl border border-slate-700 bg-slate-900/95 p-3 text-sm"
|
|
>
|
|
<div className="flex items-baseline justify-between gap-2">
|
|
<h2 className="font-semibold">{title}</h2>
|
|
<button type="button" onClick={onClose} aria-label="Close" className="px-2 text-slate-400">
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div className="mt-1">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A tapped location: details if discovered, and a link to its board if there is a bay. */
|
|
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);
|
|
|
|
return (
|
|
<Panel title={location.name} onClose={onClose}>
|
|
{discovered ? (
|
|
<>
|
|
<p className="text-slate-300">
|
|
{TYPE_LABELS[location.type]} ·{" "}
|
|
{location.id === "earth"
|
|
? "Home"
|
|
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
|
</p>
|
|
<p className="text-slate-400">
|
|
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
|
{location.demands.map(cargoName).join(", ") || "nothing"}
|
|
</p>
|
|
{hasBay ? (
|
|
<Link
|
|
to={`/contracts?bay=${location.id}`}
|
|
className="mt-2 inline-block font-medium text-amber-400 underline"
|
|
>
|
|
View contract board
|
|
</Link>
|
|
) : (
|
|
<p className="mt-2 text-slate-400">No bay here yet.</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
|
|
<p aria-label="Reachable with" className="mt-1 text-slate-300">
|
|
{reachable.length > 0
|
|
? `Reachable with: ${reachable.map((m) => m.name).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";
|
|
|
|
return (
|
|
<Panel title={shipName} onClose={onClose}>
|
|
<p className="text-slate-300">
|
|
{locationName(dispatch.originId)} → {locationName(dispatch.destinationId)}
|
|
</p>
|
|
<p className="text-slate-400">Pilot: {pilotName}</p>
|
|
<p className="text-slate-400">
|
|
Arrives in <Countdown to={dispatch.arrivalAt} /> (
|
|
{formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
|
</p>
|
|
</Panel>
|
|
);
|
|
}
|