import { useEffect, useState } from 'react' import { MAX_CAPTAINS, MAX_NAME, START_ENERGY, START_TORPEDOES } from '../game/constants' import { comparePatrols, outcomeText } from '../game/engine' import type { Score } from '../game/highscores' import type { Captain, Patrol } from '../game/types' import { isWin } from '../game/types' import { COMMANDS } from './BridgeScreen' import { Btn, Line } from './Frame' /** * Everything that is not the patrol itself. These are small enough that * keeping them in one file makes the shape of the game easier to see than * eight files would, and none of them holds a rule. */ // ------------------------------------------------------------------- boot /** * Dialling in. * * The lemonade stand loads from cassette because that is how an Atari got its * programs. This game is eight years older than that machine and did not live * on a machine you owned at all -- it lived on a mainframe at the end of a * telephone line, which you called. So the boot is a call, and it is the same * gesture the cave game opens with for the same reason. * * There is no sound yet. When the remaster lands it brings the synth with it, * and this schedule is already shaped to be played rather than only printed. */ const STAGES: [number, string][] = [ [0, 'DIAL TONE'], [1.1, 'DIALLING'], [2.6, 'RINGING'], [4.2, 'ANSWER TONE'], [5.2, 'CARRIER -- 110 BAUD, FULL DUPLEX'], [6.0, 'LOGIN ACCEPTED'], ] const BOOT_TOTAL = 6.8 export function BootScreen({ onLoaded }: { onLoaded: () => void }) { const [dialling, setDialling] = useState(false) const [stage, setStage] = useState(-1) useEffect(() => { if (!dialling) return const timers = STAGES.map(([at], i) => window.setTimeout(() => setStage(i), at * 1000)) const done = window.setTimeout(onLoaded, BOOT_TOTAL * 1000) return () => { for (const t of timers) window.clearTimeout(t) window.clearTimeout(done) } }, [dialling, onLoaded]) if (!dialling) { return (
LONG RANGE SCAN THE PROGRAM IS NOT ON THIS MACHINE. IT IS ON SOMEBODY ELSE'S. PUT THE HANDSET IN THE COUPLER AND CALL IT. setDialling(true)}> DIAL
) } return (
{STAGES.slice(0, stage + 1).map(([, what], i) => ( {what} ))}
) } // ------------------------------------------------------------------ title export function TitleScreen({ onStart, onInstructions, onScores, }: { onStart: () => void onInstructions: () => void onScores: () => void }) { return (

LONG RANGE SCAN

A PATROL, A CLOCK, AND SIXTY-FOUR QUADRANTS TO SEARCH. RAIDERS ARE SCATTERED THROUGH THE GALAXY. YOU HAVE ONE SHIP AND NOT LONG.
TAKE THE PATROL ORDERS BOARD
) } // ----------------------------------------------------------- instructions export function InstructionsScreen({ onDone }: { onDone: () => void }) { return (
YOUR ORDERS FIND EVERY RAIDER IN THE GALAXY AND DESTROY IT BEFORE YOUR TIME RUNS OUT. THE GALAXY EIGHT BY EIGHT QUADRANTS. EACH QUADRANT IS EIGHT BY EIGHT SECTORS. A LONG RANGE SCAN READS THREE DIGITS PER QUADRANT: RAIDERS, STARBASES, STARS. SO 205 IS TWO RAIDERS, NO BASE AND FIVE STARS. READING THOSE IS THE GAME. THE SHIP {START_ENERGY} UNITS OF ENERGY AND {START_TORPEDOES} TORPEDOES. ENERGY MOVES YOU, FIRES THE BEAMS AND FILLS THE SHIELDS -- IT IS ALL THE SAME POOL. EIGHT DEVICES CAN BE BROKEN BY A HIT. DAMAGE CONTROL WORKS ONLY WHILE YOU FLY. MOOR NEXT TO A STARBASE AND YOU ARE FULL AGAIN. THERE ARE NOT MANY OF THEM. STEERING COURSES RUN 1 TO 9 ANTICLOCKWISE. 1 IS ALONG THE ROW, 3 IS UP, 5 IS BACK, 7 IS DOWN, AND 9 IS 1 AGAIN. WARP 1 IS EIGHT SECTORS. THE COMMANDS {COMMANDS.map(([id, what]) => ( {id} {' '} {what} ))} UNDERSTOOD
) } // ------------------------------------------------------------------ setup export function SetupScreen({ onStart }: { onStart: (names: string[]) => void }) { const [names, setNames] = useState(['']) const set = (i: number, v: string) => setNames((n) => n.map((old, j) => (j === i ? v.slice(0, MAX_NAME) : old))) return (
WHO IS FLYING? EACH CAPTAIN GETS THEIR OWN GALAXY. YOU TAKE IT IN TURNS. {names.map((name, i) => (
set(i, e.target.value)} />
))}
{names.length < MAX_CAPTAINS && ( setNames((n) => [...n, ''])}>ONE MORE )} onStart(names)}> CAST OFF
) } // ----------------------------------------------------------------- report /** The end of one captain's patrol, before the next one sits down. */ export function ReportScreen({ patrol, captain, moreToCome, onNext, }: { patrol: Patrol captain: Captain moreToCome: boolean onNext: () => void }) { const won = isWin(patrol.outcome) return (
{patrol.outcome ? outcomeText(patrol.outcome) : ''} {captain.name} — {captain.killed} DESTROYED, {captain.daysLeft.toFixed(1)} DAYS LEFT,{' '} {captain.torpedoes} TORPEDO{captain.torpedoes === 1 ? '' : 'ES'} {patrol.raidersLeft} RAIDER{patrol.raidersLeft === 1 ? '' : 'S'} STILL OUT THERE. {moreToCome ? 'NEXT CAPTAIN' : 'THAT IS THAT'}
) } // --------------------------------------------------------------- standings export function GameOverScreen({ captains, onRestart, onScores, }: { captains: Captain[] onRestart: () => void onScores: () => void }) { const ranked = [...captains].sort(comparePatrols) const anyWon = captains.some((c) => isWin(c.outcome)) return (
{anyWon ? 'THE FLEET IS SATISFIED.' : 'THE RAIDERS ARE STILL OUT THERE.'} {ranked.map((c, i) => ( {String(i + 1).padStart(2)}. {c.name.padEnd(MAX_NAME)}{' '} {String(c.killed).padStart(2)} KILLED {c.daysLeft.toFixed(1).padStart(5)} DAYS{' '} {String(c.torpedoes).padStart(2)} TORP ))}
AGAIN BOARD
) } // ------------------------------------------------------------------- board export type BoardState = 'loading' | 'ready' | 'error' export function HighScoreScreen({ scores, state, highlight, onBack, onRetry, }: { scores: Score[] state: BoardState highlight: string[] onBack: () => void onRetry: () => void }) { return (
THE BOARD RANKED ON RAIDERS DESTROYED, THEN DAYS LEFT, THEN TORPEDOES. {state === 'loading' && ASKING THE MACHINE...} {state === 'error' && ( <> {/* The game is entirely playable without the board, and says so rather than pretending the patrol did not happen. */} THE BOARD IS NOT ANSWERING. YOUR PATROL STILL COUNTED. TRY AGAIN )} {state === 'ready' && scores.length === 0 && NOBODY HAS REPORTED IN YET.} {state === 'ready' && scores.map((s, i) => ( {String(i + 1).padStart(2)}. {s.name.padEnd(MAX_NAME)}{' '} {String(s.killed).padStart(2)} {s.daysLeft.toFixed(1).padStart(5)}{' '} {String(s.torpedoes).padStart(2)} ))} BACK
) }