deadhead-space-freight-tycoon/apps/web/src/screens/MapScreen.tsx

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>
);
}