import { useEffect, useRef, useState } from 'react' import { DIAL_UP, synth } from '../audio/synth' 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 real Bell 103 call, and * pushing the handset into the coupler is the gesture that unlocks the audio: * browsers want a gesture before they will make a sound, and that happens to * be exactly the gesture the game already wanted. * * The stages are printed as they happen, from the same schedule the audio is * built on, so what is on the paper is what is on the line. See DIAL_UP. */ const STAGES: [number, string][] = [ [0, 'DIAL TONE'], [DIAL_UP.dialTone, 'DIALLING 555-1971'], [DIAL_UP.ringAt, 'RINGING'], [DIAL_UP.answerAt, 'ANSWER TONE 2225 HZ'], [DIAL_UP.answerAt + DIAL_UP.answer, 'CARRIER -- 110 BAUD, FULL DUPLEX'], ] export function BootScreen({ onLoaded }: { onLoaded: () => void }) { const [dialling, setDialling] = useState(false) const [stage, setStage] = useState(-1) const stop = useRef<(() => void) | null>(null) useEffect(() => { if (!dialling) return stop.current = synth.dialUp() const timers = STAGES.map(([at], i) => window.setTimeout(() => setStage(i), at * 1000)) const done = window.setTimeout(() => { stop.current?.() onLoaded() }, DIAL_UP.total * 1000) return () => { for (const t of timers) window.clearTimeout(t) window.clearTimeout(done) stop.current?.() } }, [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. { synth.ensure() 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, onBlip, }: { onStart: (names: string[]) => void onBlip: () => 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 && ( { onBlip() 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
) }