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

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