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

190 lines
7.4 KiB
TypeScript

import { content, type ShipModel } from "@deadhead/content";
import { useState } from "react";
import { formatCredits, formatMillionKm, locationName } from "../format";
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
import { Art, Button, Icon, Panel, Screen, ScreenHeader, Sheet, type IconName } from "../ui";
const LANDING_LABELS = { vacuum: "Vacuum", atmospheric: "Atmospheric", hardened: "Hardened" };
const MODELS = content.purchasableShipModels;
const best = {
speed: Math.max(...MODELS.map((m) => m.cruiseSpeedKmPerGameHour)),
capacity: Math.max(...MODELS.map((m) => m.cargoCapacityTonnes)),
range: Math.max(...MODELS.map((m) => m.fuelRangeMillionKm)),
};
/** Range spans 5 to 1,000M km, so its bar uses a log scale to stay readable. */
const rangeShare = (range: number) => Math.log10(1 + range) / Math.log10(1 + best.range);
/** Ship models for sale, delivered to the HQ (bay) the player picks (Steps 9.8, 10B.9). */
export function Market() {
const state = useLoadedPlayer();
const buy = useGameAction("buyShip");
const [buying, setBuying] = useState<ShipModel | null>(null);
return (
<Screen className="max-w-5xl">
<ScreenHeader
title="Market"
eyebrow="Shipyard"
icon="market"
subtitle="New ships are delivered with no pilot to one of your HQs."
/>
{buy.isError && (
<p role="alert" className="text-sm text-rose-300">
{errorMessage(buy.error)}
</p>
)}
{buy.isSuccess && (
<p
role="status"
className="flex items-center gap-2 rounded-lg border border-emerald-400/40 bg-emerald-950/50 px-3 py-2 text-sm text-emerald-200"
>
<Icon name="check" className="size-4 shrink-0" />
{buy.data.ship.name} is waiting at {locationName(buy.data.ship.locationId ?? "")}.
</p>
)}
<ul className="grid gap-4 md:grid-cols-3">
{MODELS.map((model) => {
const affordable = state.company.credits >= model.price;
return (
<Panel as="li" key={model.id} feature={affordable} className="flex flex-col text-sm">
<div className="relative -mx-4 -mt-4 grid h-40 place-items-center overflow-hidden rounded-t-xl bg-radial from-cyan-400/20 via-space-850/40 to-transparent to-75%">
<div className="absolute inset-x-8 bottom-4 h-3 rounded-[50%] bg-cyan-300/15 blur-md" />
<Art
sprite={model.sprite}
className="relative size-32 rotate-[30deg] drop-shadow-[0_10px_14px_rgb(0_0_0/0.8)]"
/>
</div>
<div className="mt-2 flex items-baseline justify-between gap-2">
<span className="font-display text-xl font-bold tracking-wide">{model.name}</span>
<span
className={`font-display text-base font-bold tabular-nums ${affordable ? "text-amber-300" : "text-ink-400"}`}
>
{formatCredits(model.price)}
</span>
</div>
<dl className="mt-3 space-y-2 text-ink-200">
<Spec
icon="speed"
label="Speed"
value={`${formatMillionKm(model.cruiseSpeedKmPerGameHour / 1e6)}/h`}
share={model.cruiseSpeedKmPerGameHour / best.speed}
/>
<Spec
icon="cargo"
label="Capacity"
value={`${model.cargoCapacityTonnes} t`}
share={model.cargoCapacityTonnes / best.capacity}
/>
<Spec
icon="range"
label="Range"
value={formatMillionKm(model.fuelRangeMillionKm)}
share={rangeShare(model.fuelRangeMillionKm)}
/>
<Spec icon="landing" label="Landing" value={LANDING_LABELS[model.landingClass]} />
</dl>
<Button
aria-label={`Buy ${model.name}`}
block
disabled={!affordable || state.bays.length === 0 || buy.isPending}
onClick={() => setBuying(model)}
className="mt-4"
>
{affordable ? "Buy" : "Not enough credits"}
</Button>
</Panel>
);
})}
</ul>
{buying && (
<DeliveryPicker
model={buying}
pending={buy.isPending}
onBuy={(bayId) =>
buy.mutate({ modelId: buying.id, bayId }, { onSettled: () => setBuying(null) })
}
onClose={() => setBuying(null)}
/>
)}
</Screen>
);
}
/** One spec line: label and value (dt directly before dd), with a bar relative to the best model. */
function Spec(props: { icon: IconName; label: string; value: string; share?: number }) {
return (
<div>
<div className="flex items-center justify-between gap-2">
<dt className="flex items-center gap-1.5 font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
<Icon name={props.icon} className="size-3.5" />
{props.label}
</dt>
<dd className="font-display font-semibold tabular-nums">{props.value}</dd>
</div>
{props.share !== undefined && (
<div aria-hidden="true" className="mt-1 h-1 overflow-hidden rounded-full bg-space-950/80">
<div
className="h-full rounded-full bg-linear-to-r from-cyan-500 to-sky-300"
style={{ width: `${Math.max(4, props.share * 100)}%` }}
/>
</div>
)}
</div>
);
}
/** "Deliver to which HQ?": choose the bay the new ship appears at (decision 0.5.35). */
function DeliveryPicker(props: {
model: ShipModel;
pending: boolean;
onBuy: (bayId: string) => void;
onClose: () => void;
}) {
const state = useLoadedPlayer();
const [bayId, setBayId] = useState(state.bays.length === 1 ? state.bays[0]!.id : null);
return (
<Sheet
titleId="delivery-title"
eyebrow="Delivery"
title="Deliver to which HQ?"
subtitle={`Your ${props.model.name} will wait there, idle and without a pilot.`}
>
<fieldset className="mt-4 space-y-2">
<legend className="sr-only">HQ</legend>
{state.bays.map((bay) => {
const sprite = content.location(bay.locationId)?.sprite;
return (
<label
key={bay.id}
className="flex cursor-pointer items-center gap-3 rounded-xl border border-space-600 bg-space-900/60 p-3 text-sm transition has-checked:border-amber-400 has-checked:bg-amber-500/10 has-checked:shadow-glow-amber"
>
<input
type="radio"
name="hq"
checked={bayId === bay.id}
onChange={() => setBayId(bay.id)}
className="size-4 accent-amber-400"
/>
{sprite && <Art sprite={sprite} className="size-8" />}
<span className="font-display font-semibold">{locationName(bay.locationId)}</span>
</label>
);
})}
</fieldset>
<Button
block
disabled={bayId === null || props.pending}
onClick={() => bayId && props.onBuy(bayId)}
className="mt-4"
>
Buy for {formatCredits(props.model.price)}
</Button>
<Button variant="secondary" block onClick={props.onClose} className="mt-2">
Cancel
</Button>
</Sheet>
);
}