185 lines
6.0 KiB
TypeScript
185 lines
6.0 KiB
TypeScript
import { useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
import { WelcomeCards } from "../components/Welcome";
|
|
import { SpendingFloor } from "../components/IndependentPilots";
|
|
import { resetMapView } from "../map/camera";
|
|
import { About } from "./AboutSettings";
|
|
import { CompanyName, NewCompany } from "./CompanySettings";
|
|
import { DevTools } from "./DevTools";
|
|
import { ExportSave, ImportSave } from "./SaveSettings";
|
|
import { content } from "@deadhead/content";
|
|
import { sleepLabel } from "../components/FleetSleep";
|
|
import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions";
|
|
import { usePreferences, type ClockFormat, type CountdownFormat } from "../state/preferences";
|
|
import { Button, Panel, Screen, ScreenHeader } from "../ui";
|
|
|
|
/** Developer tools only exist in non-production builds (CR-011). */
|
|
export function isDevBuild(): boolean {
|
|
return !import.meta.env.PROD;
|
|
}
|
|
|
|
/** Settings (CR-011): company, display, help, about and, in development, developer tools. */
|
|
export function Settings() {
|
|
const state = useLoadedPlayer();
|
|
const prefs = usePreferences();
|
|
const [howToPlay, setHowToPlay] = useState(false);
|
|
|
|
return (
|
|
<Screen>
|
|
<ScreenHeader title="Settings" eyebrow={state.company.name} icon="gear" />
|
|
|
|
<Section title="Company">
|
|
<CompanyName />
|
|
<SpendingFloor />
|
|
<SleepThreshold />
|
|
<div className="flex flex-wrap gap-2">
|
|
<ExportSave />
|
|
<ImportSave hasCompany />
|
|
<NewCompany />
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title="Display">
|
|
<Choice<ClockFormat>
|
|
label="Clock"
|
|
value={prefs.clock}
|
|
options={[
|
|
["locale", "Device"],
|
|
["12h", "12-hour"],
|
|
["24h", "24-hour"],
|
|
]}
|
|
onChange={(clock) => prefs.set({ clock })}
|
|
/>
|
|
<Choice<CountdownFormat>
|
|
label="Countdowns"
|
|
value={prefs.countdown}
|
|
options={[
|
|
["real", "Real time"],
|
|
["game", "Game time"],
|
|
["both", "Both"],
|
|
]}
|
|
onChange={(countdown) => prefs.set({ countdown })}
|
|
/>
|
|
<Choice<"device" | "on" | "off">
|
|
label="Reduced motion"
|
|
value={prefs.reducedMotion === null ? "device" : prefs.reducedMotion ? "on" : "off"}
|
|
options={[
|
|
["device", "Device"],
|
|
["on", "On"],
|
|
["off", "Off"],
|
|
]}
|
|
onChange={(v) => prefs.set({ reducedMotion: v === "device" ? null : v === "on" })}
|
|
/>
|
|
</Section>
|
|
|
|
<Section title="Notifications">
|
|
<Choice<"on" | "off">
|
|
label="Arrivals, radio calls, rest, upgrades and a quiet fleet"
|
|
value={prefs.notifications ? "on" : "off"}
|
|
options={[
|
|
["on", "On"],
|
|
["off", "Off"],
|
|
]}
|
|
onChange={(v) => prefs.set({ notifications: v === "on" })}
|
|
/>
|
|
<p className="text-xs text-ink-400">On the iOS and Android apps only.</p>
|
|
</Section>
|
|
|
|
<Section title="Help">
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button variant="secondary" size="sm" onClick={resetMapView}>
|
|
Reset map view
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => prefs.showTipsAgain()}>
|
|
Show tips again
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onClick={() => setHowToPlay(true)}>
|
|
How to play
|
|
</Button>
|
|
</div>
|
|
{/* The welcome cards again, ending in Close (decision 0.5.132). */}
|
|
{howToPlay && <WelcomeCards final="close" onClose={() => setHowToPlay(false)} />}
|
|
</Section>
|
|
|
|
<Section title="About">
|
|
<About />
|
|
</Section>
|
|
|
|
{isDevBuild() && (
|
|
<Section title="Developer">
|
|
<DevTools />
|
|
</Section>
|
|
)}
|
|
</Screen>
|
|
);
|
|
}
|
|
|
|
function Section({ title, children }: { title: string; children: ReactNode }) {
|
|
const id = `settings-${title.toLowerCase()}`;
|
|
return (
|
|
<Panel as="section" aria-labelledby={id} className="space-y-3">
|
|
<h2 id={id} className="font-display text-sm font-bold text-ink-300">
|
|
{title}
|
|
</h2>
|
|
{children}
|
|
</Panel>
|
|
);
|
|
}
|
|
|
|
/** "Fleet goes quiet after": the sleep threshold (decision 0.5.137), a company setting. */
|
|
function SleepThreshold() {
|
|
const state = useLoadedPlayer();
|
|
const save = useGameAction("setSleepThreshold");
|
|
if (state.tutorial) return null;
|
|
return (
|
|
<div>
|
|
<Choice<string>
|
|
label="Fleet goes quiet after"
|
|
value={String(state.fleetSleep.afterHours)}
|
|
options={content.config.fleetSleep.optionsHours.map((h) => [String(h), sleepLabel(h)])}
|
|
onChange={(value) => save.mutate({ hours: Number(value) })}
|
|
/>
|
|
<p className="mt-1 text-xs text-ink-500">
|
|
Without any orders from you for this long, Independent pilots stop taking new jobs.
|
|
</p>
|
|
{save.isError && (
|
|
<p role="alert" className="mt-1 text-sm text-rose-300">
|
|
{errorMessage(save.error)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** A labelled radio group of a few options, styled as a segmented control. */
|
|
function Choice<T extends string>(props: {
|
|
label: string;
|
|
value: T;
|
|
options: [T, string][];
|
|
onChange: (value: T) => void;
|
|
}) {
|
|
return (
|
|
<fieldset className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
<legend className="float-left text-ink-400">{props.label}</legend>
|
|
<div className="flex overflow-hidden rounded-lg border border-glass-line">
|
|
{props.options.map(([value, text]) => (
|
|
<label
|
|
key={value}
|
|
className="cursor-pointer px-3 py-1.5 has-checked:bg-white/14 has-checked:text-ink-50 has-focus-visible:ring-2"
|
|
>
|
|
<input
|
|
type="radio"
|
|
name={props.label}
|
|
value={value}
|
|
checked={props.value === value}
|
|
onChange={() => props.onChange(value)}
|
|
className="sr-only"
|
|
/>
|
|
{text}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</fieldset>
|
|
);
|
|
}
|