Step 10E.8: locations, map panels, title screen and dialogs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
45a21a8684
commit
c11d52f96f
|
|
@ -24,7 +24,7 @@ const MapScreen = lazy(() => import("./screens/MapScreen").then((m) => ({ defaul
|
||||||
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
const SCREEN_COMPONENTS: Record<(typeof SCREENS)[number]["path"], ReactNode> = {
|
||||||
"/": <HQ />,
|
"/": <HQ />,
|
||||||
"/map": (
|
"/map": (
|
||||||
<Suspense fallback={<p className="p-4 text-slate-400">Loading map…</p>}>
|
<Suspense fallback={<p className="p-4 text-ink-400">Loading map…</p>}>
|
||||||
<MapScreen />
|
<MapScreen />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useNavigate } from "react-router";
|
||||||
import { formatCredits, locationName } from "../format";
|
import { formatCredits, locationName } from "../format";
|
||||||
import { formatLocalTime } from "../screens/DispatchSheet";
|
import { formatLocalTime } from "../screens/DispatchSheet";
|
||||||
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
import { useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Button, Icon, Sheet } from "../ui";
|
||||||
|
|
||||||
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */
|
/** "While you were away": deliveries resolved since the player last looked (Step 9.2). */
|
||||||
export function AwaySummary() {
|
export function AwaySummary() {
|
||||||
|
|
@ -31,87 +32,91 @@ export function AwaySummary() {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-20 flex items-end justify-center bg-black/60 p-4 sm:items-center">
|
<Sheet titleId="away-title" eyebrow="Logbook" title="While you were away">
|
||||||
<div
|
<ul className="mt-4 space-y-3">
|
||||||
role="dialog"
|
{summaries.map((s) => (
|
||||||
aria-modal="true"
|
<li
|
||||||
aria-labelledby="away-title"
|
key={s.dispatchId}
|
||||||
className="max-h-[80dvh] w-full max-w-md overflow-y-auto rounded-xl border border-slate-700 bg-slate-900 p-4"
|
className="rounded-xl border border-space-600 bg-space-950/50 p-3 text-sm"
|
||||||
>
|
>
|
||||||
<h2 id="away-title" className="text-lg font-semibold">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
While you were away
|
<span className="font-display font-bold tracking-wide">
|
||||||
</h2>
|
{shipName(s.shipId, s.tutorial)}
|
||||||
<ul className="mt-3 space-y-3">
|
</span>
|
||||||
{summaries.map((s) => (
|
<span className="text-ink-400">
|
||||||
<li key={s.dispatchId} className="rounded-lg border border-slate-800 p-3 text-sm">
|
|
||||||
<div className="font-medium">{shipName(s.shipId, s.tutorial)}</div>
|
|
||||||
<div className="text-slate-400">
|
|
||||||
{locationName(s.originId)} → {locationName(s.destinationId)}
|
{locationName(s.originId)} → {locationName(s.destinationId)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-4 font-display text-lg font-bold tabular-nums">
|
||||||
|
<span className={s.credits >= 0 ? "text-emerald-300" : "text-rose-300"}>
|
||||||
|
{s.credits >= 0 ? "+" : ""}
|
||||||
|
{formatCredits(s.credits)}
|
||||||
|
</span>
|
||||||
|
<span className="text-violet-300">+{s.pilotXp} XP</span>
|
||||||
|
</div>
|
||||||
|
{s.loanRepayment > 0 && (
|
||||||
|
<div className="mt-1 text-ink-400">
|
||||||
|
{formatCredits(s.loanRepayment)} went to the lender
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex gap-4 tabular-nums">
|
)}
|
||||||
<span className={s.credits >= 0 ? "text-emerald-400" : "text-red-400"}>
|
{s.scriptedId && (
|
||||||
{s.credits >= 0 ? "+" : ""}
|
<p className="mt-1 text-fuchsia-200 italic">
|
||||||
{formatCredits(s.credits)}
|
{
|
||||||
</span>
|
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
|
||||||
<span>+{s.pilotXp} XP</span>
|
?.flavorMessage
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{s.discoveredLocationId && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 font-display font-semibold text-amber-300">
|
||||||
|
<Icon name="star" className="size-4" />
|
||||||
|
Discovered {locationName(s.discoveredLocationId)}!
|
||||||
</div>
|
</div>
|
||||||
{s.loanRepayment > 0 && (
|
)}
|
||||||
<div className="mt-1 text-slate-400">
|
{s.restEndsAt !== null && (
|
||||||
{formatCredits(s.loanRepayment)} went to the lender
|
<div className="mt-1 flex items-center gap-1.5 text-sky-300">
|
||||||
</div>
|
<Icon name="rest" className="size-4 shrink-0" />
|
||||||
)}
|
<span>
|
||||||
{s.scriptedId && (
|
|
||||||
<p className="mt-1 italic text-fuchsia-200">
|
|
||||||
{
|
|
||||||
content.scriptedTutorialContracts.find((c) => c.id === s.scriptedId)
|
|
||||||
?.flavorMessage
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{s.discoveredLocationId && (
|
|
||||||
<div className="mt-1 text-amber-300">
|
|
||||||
Discovered {locationName(s.discoveredLocationId)}!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{s.restEndsAt !== null && (
|
|
||||||
<div className="mt-1 text-sky-300">
|
|
||||||
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting
|
{state.pilots.find((p) => p.id === s.pilotId)?.name ?? "The pilot"} is resting
|
||||||
until {formatLocalTime(s.restEndsAt)}
|
until {formatLocalTime(s.restEndsAt)}
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</li>
|
)}
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
{readyGoals.length > 0 && (
|
</ul>
|
||||||
<div className="mt-3 rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
|
{readyGoals.length > 0 && (
|
||||||
<h3 className="font-semibold text-emerald-300">Goals ready to claim</h3>
|
<div className="mt-3 rounded-xl border border-emerald-400/40 bg-emerald-950/40 p-3 text-sm shadow-glow-emerald">
|
||||||
<ul aria-label="Ready goals" className="mt-1 space-y-0.5">
|
<h3 className="flex items-center gap-1.5 font-display font-bold text-emerald-300">
|
||||||
{readyGoals.map((goal) => (
|
<Icon name="target" className="size-4" />
|
||||||
<li key={goal.id}>
|
Goals ready to claim
|
||||||
{goal.title} <span className="text-slate-400">+{formatCredits(goal.reward)}</span>
|
</h3>
|
||||||
</li>
|
<ul aria-label="Ready goals" className="mt-1 space-y-0.5">
|
||||||
))}
|
{readyGoals.map((goal) => (
|
||||||
</ul>
|
<li key={goal.id}>
|
||||||
<button
|
{goal.title}{" "}
|
||||||
type="button"
|
<span className="font-display text-amber-300/90">
|
||||||
onClick={() => {
|
+{formatCredits(goal.reward)}
|
||||||
dismiss();
|
</span>
|
||||||
void navigate({ pathname: "/", hash: "#goals" });
|
</li>
|
||||||
}}
|
))}
|
||||||
className="mt-2 font-medium text-emerald-300 underline"
|
</ul>
|
||||||
>
|
<Button
|
||||||
Claim on HQ
|
variant="success"
|
||||||
</button>
|
size="sm"
|
||||||
</div>
|
onClick={() => {
|
||||||
)}
|
dismiss();
|
||||||
<button
|
void navigate({ pathname: "/", hash: "#goals" });
|
||||||
type="button"
|
}}
|
||||||
onClick={dismiss}
|
className="mt-3"
|
||||||
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950"
|
>
|
||||||
>
|
Claim on HQ
|
||||||
Got it
|
</Button>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
<Button block onClick={dismiss} className="mt-4">
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useApi } from "../api/context";
|
||||||
import { formatCredits } from "../format";
|
import { formatCredits } from "../format";
|
||||||
import { errorMessage, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useLoadedPlayer } from "../state/actions";
|
||||||
import { PLAYER_STATE_KEY } from "../state/player";
|
import { PLAYER_STATE_KEY } from "../state/player";
|
||||||
|
import { Art, Button, Sheet } from "../ui";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one-time deal (decision 0.5.30): shown when the server says it is
|
* The one-time deal (decision 0.5.30): shown when the server says it is
|
||||||
|
|
@ -30,46 +31,53 @@ export function DealModal() {
|
||||||
const courier = content.shipModel("courier")!;
|
const courier = content.shipModel("courier")!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-30 flex items-end justify-center bg-black/70 p-4 sm:items-center">
|
<Sheet
|
||||||
<div
|
titleId="deal-title"
|
||||||
role="dialog"
|
eyebrow="Incoming transmission"
|
||||||
aria-modal="true"
|
title="An offer from Halvorsen"
|
||||||
aria-labelledby="deal-title"
|
tone="amber"
|
||||||
className="w-full max-w-md rounded-xl border border-amber-500/50 bg-slate-900 p-4"
|
layer="z-30"
|
||||||
>
|
>
|
||||||
<h2 id="deal-title" className="text-lg font-semibold text-amber-300">
|
<div className="mt-3 flex items-center gap-3">
|
||||||
An offer from Halvorsen
|
<Art sprite={courier.sprite} className="size-20 shrink-0 -rotate-12" />
|
||||||
</h2>
|
<p className="text-sm text-ink-200">
|
||||||
<p className="mt-2 text-sm text-slate-300">
|
|
||||||
Old Halvorsen is retiring. He'll sell you his Courier and his Earth bay. Pay him back
|
Old Halvorsen is retiring. He'll sell you his Courier and his Earth bay. Pay him back
|
||||||
out of every job.
|
out of every job.
|
||||||
</p>
|
</p>
|
||||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
|
||||||
<dt className="text-slate-400">You get</dt>
|
|
||||||
<dd>A {courier.name} and a bay at Earth</dd>
|
|
||||||
<dt className="text-slate-400">Loan</dt>
|
|
||||||
<dd aria-label="Loan amount" className="tabular-nums">
|
|
||||||
{formatCredits(dealLoanAmount)}
|
|
||||||
</dd>
|
|
||||||
<dt className="text-slate-400">Repayment</dt>
|
|
||||||
<dd aria-label="Repayment share">
|
|
||||||
{Math.round(loanRepaymentShare * 100)}% of every payout until it's paid off
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
{accept.isError && (
|
|
||||||
<p role="alert" className="mt-3 text-sm text-red-400">
|
|
||||||
{errorMessage(accept.error)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => accept.mutate()}
|
|
||||||
disabled={accept.isPending}
|
|
||||||
className="mt-4 w-full rounded-lg bg-amber-500 py-2 font-semibold text-slate-950 disabled:opacity-60"
|
|
||||||
>
|
|
||||||
Shake on it
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<dl className="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 rounded-xl bg-space-950/60 p-3 text-sm ring-1 ring-amber-400/25">
|
||||||
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
You get
|
||||||
|
</dt>
|
||||||
|
<dd>A {courier.name} and a bay at Earth</dd>
|
||||||
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">Loan</dt>
|
||||||
|
<dd
|
||||||
|
aria-label="Loan amount"
|
||||||
|
className="font-display font-semibold text-amber-200 tabular-nums"
|
||||||
|
>
|
||||||
|
{formatCredits(dealLoanAmount)}
|
||||||
|
</dd>
|
||||||
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Repayment
|
||||||
|
</dt>
|
||||||
|
<dd aria-label="Repayment share">
|
||||||
|
{Math.round(loanRepaymentShare * 100)}% of every payout until it's paid off
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
{accept.isError && (
|
||||||
|
<p role="alert" className="mt-3 text-sm text-rose-300">
|
||||||
|
{errorMessage(accept.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
onClick={() => accept.mutate()}
|
||||||
|
disabled={accept.isPending}
|
||||||
|
className="mt-5"
|
||||||
|
>
|
||||||
|
Shake on it
|
||||||
|
</Button>
|
||||||
|
</Sheet>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,10 @@ export function MapCanvas({
|
||||||
className="relative h-full w-full touch-none overflow-hidden"
|
className="relative h-full w-full touch-none overflow-hidden"
|
||||||
>
|
>
|
||||||
{loading && (
|
{loading && (
|
||||||
<p role="status" className="absolute inset-x-0 top-4 text-center text-sm text-slate-400">
|
<p
|
||||||
|
role="status"
|
||||||
|
className="absolute inset-x-0 top-4 text-center font-display text-sm tracking-widest text-ink-400 uppercase"
|
||||||
|
>
|
||||||
Loading map…
|
Loading map…
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useClock } from "../clock/clock";
|
||||||
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
import { cargoName, formatCredits, formatMillionKm } from "../format";
|
||||||
import { distanceAt } from "../game";
|
import { distanceAt } from "../game";
|
||||||
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Art, Button, buttonClass, Icon, Panel, Pill, Screen, ScreenHeader } from "../ui";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<Location["type"], string> = {
|
const TYPE_LABELS: Record<Location["type"], string> = {
|
||||||
"atmospheric-world": "Atmospheric world",
|
"atmospheric-world": "Atmospheric world",
|
||||||
|
|
@ -24,72 +25,120 @@ export function Locations() {
|
||||||
const lease = useGameAction("leaseBay");
|
const lease = useGameAction("leaseBay");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3 p-4">
|
<Screen className="max-w-4xl">
|
||||||
<h1 className="text-xl font-semibold">Locations</h1>
|
<ScreenHeader title="Locations" eyebrow="Known space" icon="places" />
|
||||||
{lease.isError && (
|
{lease.isError && (
|
||||||
<p role="alert" className="text-sm text-red-400">
|
<p role="alert" className="text-sm text-rose-300">
|
||||||
{errorMessage(lease.error)}
|
{errorMessage(lease.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="space-y-2">
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
{content.locations.map((location) => {
|
{content.locations.map((location) => {
|
||||||
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
|
const discovered = state.locations.find((l) => l.id === location.id)?.discovered;
|
||||||
if (!discovered) {
|
if (!discovered) {
|
||||||
return (
|
return (
|
||||||
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
<Panel
|
||||||
<div className="font-medium">{location.name}</div>
|
as="li"
|
||||||
<div className="text-slate-400">Undiscovered — deliver here to unlock.</div>
|
key={location.id}
|
||||||
</li>
|
tone="muted"
|
||||||
|
className="flex items-center gap-4 text-sm"
|
||||||
|
>
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<Art sprite={location.sprite} className="size-16 opacity-40 grayscale" />
|
||||||
|
<Icon
|
||||||
|
name="lock"
|
||||||
|
className="absolute top-1/2 left-1/2 size-6 -translate-1/2 text-ink-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-display text-lg font-bold tracking-wide text-ink-300">
|
||||||
|
{location.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-ink-400">Undiscovered — deliver here to unlock.</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const hasBay = state.bays.some((b) => b.locationId === location.id);
|
const hasBay = state.bays.some((b) => b.locationId === location.id);
|
||||||
const affordable = state.company.credits >= location.bayLeasePrice;
|
const affordable = state.company.credits >= location.bayLeasePrice;
|
||||||
return (
|
return (
|
||||||
<li key={location.id} className="rounded-lg border border-slate-800 p-3 text-sm">
|
<Panel
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
as="li"
|
||||||
<span className="font-medium">{location.name}</span>
|
key={location.id}
|
||||||
<span className="text-slate-400">
|
tone={hasBay ? "emerald" : "default"}
|
||||||
{location.id === "earth"
|
className="flex flex-col text-sm"
|
||||||
? "Home"
|
>
|
||||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
<div className="flex items-center gap-4">
|
||||||
</span>
|
<Art
|
||||||
|
sprite={location.sprite}
|
||||||
|
className="size-20 shrink-0 drop-shadow-[0_0_14px_rgb(125_211_252/0.35)]"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="font-display text-xl font-bold tracking-wide">
|
||||||
|
{location.name}
|
||||||
|
</span>
|
||||||
|
{hasBay && (
|
||||||
|
<Pill tone="emerald" icon="hq">
|
||||||
|
HQ
|
||||||
|
</Pill>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="font-display text-xs tracking-wider text-cyan-300/80">
|
||||||
|
{location.id === "earth"
|
||||||
|
? "Home"
|
||||||
|
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-slate-300">
|
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-lg bg-space-950/50 p-2.5 text-ink-200 ring-1 ring-space-700">
|
||||||
<dt className="text-slate-400">Type</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Type
|
||||||
|
</dt>
|
||||||
<dd>{TYPE_LABELS[location.type]}</dd>
|
<dd>{TYPE_LABELS[location.type]}</dd>
|
||||||
<dt className="text-slate-400">Landing</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Landing
|
||||||
|
</dt>
|
||||||
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
|
<dd>{LANDING_LABELS[location.landingRequirement]}</dd>
|
||||||
<dt className="text-slate-400">Produces</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Produces
|
||||||
|
</dt>
|
||||||
<dd>{location.produces.map(cargoName).join(", ") || "—"}</dd>
|
<dd>{location.produces.map(cargoName).join(", ") || "—"}</dd>
|
||||||
<dt className="text-slate-400">Demands</dt>
|
<dt className="font-display text-[11px] tracking-[0.14em] text-ink-400 uppercase">
|
||||||
|
Demands
|
||||||
|
</dt>
|
||||||
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
|
<dd>{location.demands.map(cargoName).join(", ") || "—"}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<Link
|
<Link
|
||||||
to={`/contracts?location=${location.id}`}
|
to={`/contracts?location=${location.id}`}
|
||||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
className={`mt-3 ${buttonClass("secondary", "sm")}`}
|
||||||
>
|
>
|
||||||
|
<Icon name="jobs" className="size-4" />
|
||||||
View contracts
|
View contracts
|
||||||
</Link>
|
</Link>
|
||||||
{hasBay ? (
|
{hasBay ? (
|
||||||
<p className="mt-2 font-medium text-emerald-400">Your HQ is here</p>
|
<p className="mt-3 flex items-center gap-1.5 font-display font-semibold text-emerald-300">
|
||||||
|
<Icon name="check" className="size-4" />
|
||||||
|
Your HQ is here
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="mt-2 text-slate-400">{HQ_EXPLANATION}</p>
|
<p className="mt-3 text-ink-400">{HQ_EXPLANATION}</p>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
aria-label={`Lease bay at ${location.name}`}
|
aria-label={`Lease bay at ${location.name}`}
|
||||||
|
block
|
||||||
disabled={!affordable || lease.isPending}
|
disabled={!affordable || lease.isPending}
|
||||||
onClick={() => lease.mutate({ locationId: location.id })}
|
onClick={() => lease.mutate({ locationId: location.id })}
|
||||||
className="mt-2 w-full rounded-md bg-amber-500 py-1.5 font-semibold text-slate-950 disabled:opacity-40"
|
className="mt-2"
|
||||||
>
|
>
|
||||||
Lease bay ({formatCredits(location.bayLeasePrice)})
|
Lease bay ({formatCredits(location.bayLeasePrice)})
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</li>
|
</Panel>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type { MapSelection } from "../map/hitTest";
|
||||||
import { MapCanvas } from "../map/MapCanvas";
|
import { MapCanvas } from "../map/MapCanvas";
|
||||||
import { modelsReachingFromEarth } from "../map/reach";
|
import { modelsReachingFromEarth } from "../map/reach";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
import { Art, buttonClass, Icon } from "../ui";
|
||||||
|
|
||||||
const TYPE_LABELS: Record<Location["type"], string> = {
|
const TYPE_LABELS: Record<Location["type"], string> = {
|
||||||
"atmospheric-world": "Atmospheric world",
|
"atmospheric-world": "Atmospheric world",
|
||||||
|
|
@ -35,7 +36,7 @@ export function MapScreen() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
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"
|
className={`absolute top-3 right-3 ${buttonClass("secondary", "sm")} bg-space-900/80 backdrop-blur`}
|
||||||
>
|
>
|
||||||
Reset view
|
Reset view
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -51,10 +52,12 @@ export function MapScreen() {
|
||||||
|
|
||||||
function Panel({
|
function Panel({
|
||||||
title,
|
title,
|
||||||
|
art,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
|
art?: ReactNode;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -62,15 +65,25 @@ function Panel({
|
||||||
<div
|
<div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
className="absolute inset-x-2 bottom-2 rounded-xl border border-slate-700 bg-slate-900/95 p-3 text-sm"
|
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-baseline justify-between gap-2">
|
<div className="flex items-start gap-3">
|
||||||
<h2 className="font-semibold">{title}</h2>
|
{art}
|
||||||
<button type="button" onClick={onClose} aria-label="Close" className="px-2 text-slate-400">
|
<div className="min-w-0 flex-1">
|
||||||
✕
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
</button>
|
<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>
|
||||||
<div className="mt-1">{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -85,31 +98,46 @@ function LocationPanel({ locationId, onClose }: { locationId: string; onClose: (
|
||||||
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
|
const reachable = discovered ? [] : modelsReachingFromEarth(locationId, now);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title={location.name} onClose={onClose}>
|
<Panel
|
||||||
|
title={location.name}
|
||||||
|
onClose={onClose}
|
||||||
|
art={
|
||||||
|
<Art
|
||||||
|
sprite={location.sprite}
|
||||||
|
className={`size-16 shrink-0 ${discovered ? "" : "opacity-40 grayscale"}`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
{discovered ? (
|
{discovered ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-slate-300">
|
<p className="text-ink-200">
|
||||||
{TYPE_LABELS[location.type]} ·{" "}
|
{TYPE_LABELS[location.type]} ·{" "}
|
||||||
{location.id === "earth"
|
{location.id === "earth"
|
||||||
? "Home"
|
? "Home"
|
||||||
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-slate-400">
|
<p className="text-ink-400">
|
||||||
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
||||||
{location.demands.map(cargoName).join(", ") || "nothing"}
|
{location.demands.map(cargoName).join(", ") || "nothing"}
|
||||||
</p>
|
</p>
|
||||||
|
<p className={hasBay ? "text-emerald-300" : "text-ink-400"}>
|
||||||
|
{hasBay ? "Your HQ is here." : "No HQ here."}
|
||||||
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to={`/contracts?location=${location.id}`}
|
to={`/contracts?location=${location.id}`}
|
||||||
className="mt-2 inline-block font-medium text-amber-400 underline"
|
className={`mt-2 ${buttonClass("primary", "sm")}`}
|
||||||
>
|
>
|
||||||
|
<Icon name="jobs" className="size-4" />
|
||||||
View contracts
|
View contracts
|
||||||
</Link>
|
</Link>
|
||||||
<p className="mt-1 text-slate-400">{hasBay ? "Your HQ is here." : "No HQ here."}</p>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
|
<p className="flex items-center gap-1.5 text-ink-300">
|
||||||
<p aria-label="Reachable with" className="mt-1 text-slate-300">
|
<Icon name="lock" className="size-4 shrink-0" />
|
||||||
|
Undiscovered — deliver here to unlock.
|
||||||
|
</p>
|
||||||
|
<p aria-label="Reachable with" className="text-ink-200">
|
||||||
{reachable.length > 0
|
{reachable.length > 0
|
||||||
? `Reachable with: ${reachable.map((m) => m.name).join(", ")}`
|
? `Reachable with: ${reachable.map((m) => m.name).join(", ")}`
|
||||||
: "No ship can reach it from Earth right now."}
|
: "No ship can reach it from Earth right now."}
|
||||||
|
|
@ -129,16 +157,27 @@ function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: ()
|
||||||
? "Company loaner"
|
? "Company loaner"
|
||||||
: (state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "Ship");
|
: (state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "Ship");
|
||||||
const pilotName = state.pilots.find((p) => p.id === dispatch.pilotId)?.name ?? "Unknown pilot";
|
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 (
|
return (
|
||||||
<Panel title={shipName} onClose={onClose}>
|
<Panel
|
||||||
<p className="text-slate-300">
|
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)}
|
{locationName(dispatch.originId)} → {locationName(dispatch.destinationId)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-slate-400">Pilot: {pilotName}</p>
|
<p className="text-ink-300">Pilot: {pilotName}</p>
|
||||||
<p className="text-slate-400">
|
<p className="text-ink-300">
|
||||||
Arrives in <Countdown to={dispatch.arrivalAt} /> (
|
Arrives in{" "}
|
||||||
{formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
<span className="font-display text-cyan-200">
|
||||||
|
<Countdown to={dispatch.arrivalAt} />
|
||||||
|
</span>{" "}
|
||||||
|
({formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
||||||
</p>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { PLAYER_STATE_KEY } from "../state/player";
|
import { PLAYER_STATE_KEY } from "../state/player";
|
||||||
|
import { Art, Button, Icon } from "../ui";
|
||||||
|
|
||||||
/** First launch: create a guest account (decision: guests first, accounts later). */
|
/** First launch: create a guest account (decision: guests first, accounts later). */
|
||||||
export function StartScreen() {
|
export function StartScreen() {
|
||||||
|
|
@ -19,25 +20,46 @@ export function StartScreen() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-dvh flex-col items-center justify-center gap-6 bg-slate-950 p-6 text-center text-slate-100">
|
<main className="relative flex min-h-dvh flex-col items-center justify-center gap-8 overflow-hidden p-6 text-center text-ink-50">
|
||||||
<div>
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
||||||
<h1 className="text-3xl font-bold tracking-wide">DEADHEAD</h1>
|
<Art
|
||||||
<p className="mt-1 text-slate-400">Space Freight Tycoon</p>
|
sprite="sun"
|
||||||
|
className="absolute -top-24 -right-24 size-72 opacity-80 blur-[1px] sm:size-96"
|
||||||
|
/>
|
||||||
|
<Art sprite="body-earth" className="absolute bottom-[14%] -left-10 size-40 sm:size-56" />
|
||||||
|
<Art
|
||||||
|
sprite="body-moon"
|
||||||
|
className="absolute bottom-[34%] left-[30%] size-10 sm:left-[18%]"
|
||||||
|
/>
|
||||||
|
<Art
|
||||||
|
sprite="ship-courier"
|
||||||
|
className="absolute top-[22%] left-[12%] size-16 rotate-[60deg] drop-shadow-[0_0_10px_rgb(251_191_36/0.5)]"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-xs text-sm text-slate-300">
|
|
||||||
|
<div className="relative animate-rise">
|
||||||
|
<h1 className="bg-linear-to-b from-amber-100 via-amber-300 to-amber-600 bg-clip-text font-display text-5xl font-bold tracking-[0.2em] text-transparent drop-shadow-[0_0_24px_rgb(251_191_36/0.35)] sm:text-7xl">
|
||||||
|
DEADHEAD
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 font-display text-sm tracking-[0.45em] text-cyan-300/80 uppercase">
|
||||||
|
Space Freight Tycoon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="relative max-w-sm animate-rise text-base text-ink-200">
|
||||||
Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
|
Fly company runs between Earth and the Moon, save up for your own ship, and build a freight
|
||||||
empire across the solar system.
|
empire across the solar system.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
size="lg"
|
||||||
onClick={() => start.mutate()}
|
onClick={() => start.mutate()}
|
||||||
disabled={start.isPending}
|
disabled={start.isPending}
|
||||||
className="rounded-lg bg-amber-500 px-6 py-3 font-semibold text-slate-950 disabled:opacity-60"
|
className="relative animate-rise px-10"
|
||||||
>
|
>
|
||||||
|
<Icon name="send" className="size-5" />
|
||||||
{start.isPending ? "Starting…" : "Start your company"}
|
{start.isPending ? "Starting…" : "Start your company"}
|
||||||
</button>
|
</Button>
|
||||||
{start.isError && (
|
{start.isError && (
|
||||||
<p role="alert" className="text-sm text-red-400">
|
<p role="alert" className="relative text-sm text-rose-300">
|
||||||
Could not start a company. Check your connection and try again.
|
Could not start a company. Check your connection and try again.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -186,3 +186,4 @@ Spec: `memory-bank/change-requests/CR-005-ui-overhaul.md`. New decisions recorde
|
||||||
- [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9).
|
- [x] **Step 10E.5 — Contracts and dispatch.** Contracts uses `Screen`/`ScreenHeader` ("Freight market", or "Halvorsen Lunar Freight" in the tutorial). Location switcher: scrollable planet-art chips with cyan idle counts, amber glow when pressed. Contract cards (`Panel as="li"`, two columns from `sm`): route and large emerald payout, `RouteStrip`, cargo line, requirements in amber, expiry, and a Dispatch button; first contact cards are amber feature panels with a star pill. Board refresh countdown and the "nothing here" prompt (cyan panel, icon buttons) restyled. Tutorial board: intro panel with the loaner's art; suggested (amber, glowing) and scripted (fuchsia) feature cards with Pay/Time/XP stats and Fly it. Dispatch sheet on `Sheet` (route strip; each ship with its model art, the amber-glowing selection, problems as ✕ lines, numbers as stat cells). `PairOption.modelSprite` added. **`Sheet` now portals to `body`**: the screens' rise-in transform made the sheet's `fixed` overlay position inside the content column. Existing contracts, tutorial, dispatch, duty and board refresh tests pass unchanged. **Manual check:** scratchpad screenshots reviewed (final set in 10E.9).
|
||||||
- [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
- [x] **Step 10E.6 — Fleet and Market.** Fleet: header eyebrow "N ships · M flying"; ship cards (two columns from `md`) with the model's art on a cyan glow (tilted in flight), an Idle/In flight pill, route or location and the pilot (amber "No pilot"), a cyan `Meter` with percent and arrival countdown, and full-width "Find work here" / "Deadhead (fly empty)" buttons; an empty fleet shows a dimmed Courier. Deadhead picker on `Sheet`, each destination with its planet art. Market ("Shipyard"): showroom cards (three columns from `md`) with large tilted ship art over a glow, price (amber when affordable), spec lines with icons and bars relative to the best model (range on a log scale, since it spans 5–1,000M km), and a full-width Buy. Delivery picker on `Sheet`, radio rows with planet art that glow when checked. Existing fleet and market tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
||||||
- [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
- [x] **Step 10E.7 — Pilots.** Header eyebrow "N on the roster". Hire panel is an amber feature panel with an icon, styled select and button. Pilot cards (two columns from `md`): initials disc (amber glow for you, cyan for hires) with a level badge, "Level N", XP text and a decorative violet XP bar (not a `progressbar`: a resting pilot's card must have none), an In flight pill or location, the ship; skills on an inset panel as "Name n/max" with pips and a glowing + raise button; skill points in amber when available; duty as an amber `Meter` that turns rose from 85 %; rest as a sky panel with a moon icon; Rest now / confirmation and assign with kit buttons and selects. Existing pilots and duty tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
||||||
|
- [x] **Step 10E.8 — Locations, map panels, start screen and dialogs.** Locations ("Known space"): planet-art cards (two columns from `md`), emerald tone and an HQ pill where you have a bay, distance in cyan, specs on an inset `dl`, View contracts as a button, Lease bay as a full-width primary; undiscovered places dimmed and greyed with a padlock. Map: Reset view and the tap panels restyled (glass panel with the location's or ship's art, icon close button, View contracts as a button; a side card from `md`). Start screen is a title screen: gradient DEADHEAD wordmark, the Sun, Earth, Moon and a Courier around it, and a large Start button. "While you were away" and the deal are on `Sheet` ("Logbook" with big emerald credits and violet XP, discoveries with a star, goals panel with a success button; the deal as an amber "Incoming transmission" with the Courier's art and a large "Shake on it"). The last `slate-*` classes are gone from `apps/web/src`. Existing locations, map, onboarding, away and deal tests pass unchanged. **Manual check:** scratchpad screenshots reviewed.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue