diff --git a/NOTICE.md b/NOTICE.md index 03a3302..7c9f26f 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -60,8 +60,15 @@ licence below meaningful: a repair takes — these are in `src/game/constants.ts`, each with the reasoning next to it, and the places where a judgement call was needed are marked as judgement calls. -- **The artwork is ours.** There are no imported assets of any kind. The one - drawing in the repository is `public/favicon.svg`, which is a scan grid. +- **The artwork and the music are ours.** There are no imported assets of any + kind. Every mark on the remaster's sensors is SVG generated from the game + state in `src/components/TacticalView.tsx`, and every note is generated by + the synth in `src/audio/`. The only drawing in the repository is + `public/favicon.svg`, which is a scan grid. +- **The synth is shared with its siblings.** `src/audio/synth.ts` is the same + engine as the one in [lemonade](https://github.com/Coffey-Labs/lemonade) and + [wumpus](https://github.com/Coffey-Labs/wumpus) -- same author, same + licence. What is written for it here is new. ## The words on screen are ours, deliberately @@ -88,6 +95,27 @@ Two things are not original, and neither is a copyright question: own, and changing it would break the one piece of shared literacy this genre has. +## The music is ours too, and this is the same argument + +The score is not the television theme, or any part of it, or a paraphrase of +one. It would be a strange sort of care to rename the enemy and then quote +sixteen bars of somebody's music. Every note in `src/audio/deepspace.ts` and +`src/audio/tunes.ts` was written for this project. + +What it borrows is the idiom, which belongs to nobody: brass in a major key, +a melody built on rising fourths and fifths that holds its top note longer +than a pop tune would dare, strings moving one chord a bar, timpani. That is +how film and television have said "space, and it matters" since the 1950s, +and it is older than any of them -- it is Holst, and before him Wagner. +Nobody owns a perfect fifth. + +The 1971 skin plays the same theme as a three-channel chiptune, which is a +claim about history rather than about the original: the game itself was +silent, played on a printing terminal whose only instrument was its own print +head. What the chiptune represents is the port it would have got when the +listing followed everybody home, and `tunes.ts` says so at the top rather +than leaving it to be assumed. + ## One rule that is ours The original ends when the clock does. So does this. What is new is the diff --git a/README.md b/README.md index 45564c7..ea9fc0a 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,20 @@ digits at a time. It is called *Long Range Scan* rather than the name you are thinking of, and [NOTICE.md](NOTICE.md) explains why at length. The short version: the rules are free to rebuild and the trademarks are not, so the mechanics are exact -and the vocabulary is ours. +and the vocabulary is ours. The same goes for the music -- it is the idiom, +written from scratch, not the theme. -React + TypeScript + Vite, and not one binary asset. +It ships in two skins over one game. **1971** is the machine as it was: a +printing terminal, black ink on fanfold paper, the print head hammering out +one character at a time and nothing on screen you have not read. **Remaster** +is the same patrol with the sensors drawn -- a bridge console with the ship +down one side, a cel-shaded grid in the middle and the log down the other. +The rules never differ between them, and neither does a single word the +machine says. Switch any time with the button under the frame. + +React + TypeScript + Vite, and not one binary asset: every mark on the +sensors is SVG generated from the game state, and every note is generated by +a small synth on the Web Audio API. ## Playing @@ -83,9 +94,11 @@ off the bottom by a better one. | `src/game/galaxy.ts` | the compass, the scatter, laying out a quadrant | | `src/game/engine.ts` | the rules: warp, beams, torpedoes, being shot at | | `src/game/reducer.ts` | phases, and the only place a turn changes hands | -| `src/components/` | the paper, the console, and the screens either side | +| `src/audio/` | the synth, and the two bands written for it | +| `src/components/` | the paper, the bridge, the console, and the screens either side | `npm test` proves the parts that are arithmetic rather than judgement: that the compass closes, that a scattered galaxy is always worth flying, that a -warp stops at the rim and short of anything in the way, and that every way a -patrol can end, ends it. +warp stops at the rim and short of anything in the way, that every way a +patrol can end, ends it, and that every note in every tune is one the synth +can find. diff --git a/src/App.css b/src/App.css index 304419c..3bfb046 100644 --- a/src/App.css +++ b/src/App.css @@ -1,13 +1,16 @@ /* ===================================================================== - One skin so far, and it is the machine as it was. + Two skins, one game. Layout is shared; every colour and every piece of + chrome comes from a token that the skin swaps. 1971 is a printing terminal: black ink on fanfold paper, one weight, one family, no colour at all. Anything that wants to be emphatic has to do it with capitals or spacing, which is exactly the constraint the original was written under. - Everything the skin owns is a token, so that the remaster can swap the lot - under `html[data-skin='modern']` without touching a rule below. + The remaster is a lit bridge at night: deep blue glass, amber for + everything that is yours, cyan for everything that will help you, red for + everything that will not. Flat cel fills and one heavy outline on + everything, the same house style the cave has. ===================================================================== */ :root { @@ -28,12 +31,58 @@ --field-bg: #f7f1e0; --field-fg: #241f1a; --body-font: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace; + /* + * The transcript's font, which does not follow the skin. + * + * It is machine output, and the machine prints a short range scan as eight + * rows of three-character columns that only line up in one width. Letting + * the remaster's rounded face have the log turned the grid into confetti. + * The bridge has a monospaced console; that is not an anachronism, it is + * what consoles are. + */ + --mono: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace; --line-height: 1.42; --letter-spacing: 0.02em; + /* Sensor tokens, unused by the teletype but defined so nothing is ever + undefined when the skin flips mid-render. */ + --grid-bg: #0f1830; + --grid-rule: #1e2c4c; + /* A star is not the same colour as your ship. The first pass had both in + amber and a sector read as seven of you. */ + --star: #f1eddc; + --raider: #ff6b5d; + --base: #5ad0ff; + --ship: #ffc247; + color-scheme: light; } +html[data-skin='modern'] { + /* --- now: the bridge ------------------------------------------------ */ + --ink: #e8f1ff; + --ink-dim: #8ea6c8; + --ink-bright: #ffffff; + --accent: #ffc247; + --warn: #ff6b5d; + --outline: #070c18; + --screen-bg: linear-gradient(175deg, #1a2647 0%, #101a35 55%, #070d1e 100%); + --screen-radius: 26px; + --paper-edge: transparent; + --glow: 0 0 22px rgba(90, 208, 255, 0.16); + --btn-radius: 999px; + --btn-shadow: 4px 4px 0 var(--outline); + --btn-bg: #e8f1ff; + --btn-fg: #070c18; + --field-bg: #e8f1ff; + --field-fg: #070c18; + --body-font: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif; + --line-height: 1.5; + --letter-spacing: 0; + + color-scheme: dark; +} + /* ===================================================================== Layout ===================================================================== */ @@ -61,6 +110,12 @@ box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); } +html[data-skin='modern'] .bezel { + background: linear-gradient(160deg, #16203c 0%, #0a1122 100%); + border: 4px solid var(--outline); + box-shadow: 0 22px 60px rgba(0, 0, 0, 0.65); +} + /* The platen: the roller the paper comes over, with the feed knob at the end. */ .platen { height: 16px; @@ -103,7 +158,7 @@ inset: 0; display: flex; flex-direction: column; - padding: clamp(12px, 2.6vw, 26px) 34px; + padding: clamp(12px, 2.6vw, 26px) clamp(12px, 2.6vw, 26px); color: var(--ink); font-family: var(--body-font); font-size: clamp(10px, 1.45vw, 14px); @@ -111,6 +166,12 @@ letter-spacing: var(--letter-spacing); } +/* The paper has sprocket holes down both edges; the bridge does not. */ +:root:not([data-skin='modern']) .screen-inner { + padding-left: 34px; + padding-right: 34px; +} + .screen-body { flex: 1; min-height: 0; @@ -151,6 +212,15 @@ letter-spacing: 0.14em; } +html[data-skin='modern'] .accent { + letter-spacing: 0; +} + +html[data-skin='modern'] .warn { + color: var(--warn); + letter-spacing: 0; +} + /* --- paper chrome ---------------------------------------------------- */ .sprockets { @@ -186,6 +256,28 @@ repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.02) 0 1px, transparent 1px 4px); } +/* --- bridge chrome ---------------------------------------------------- */ + +/* Glass, not paper: a faint horizontal structure and a darkened rim. */ +.scanlines { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.35; + background-image: repeating-linear-gradient( + 0deg, + rgba(255, 255, 255, 0.035) 0 1px, + transparent 1px 3px + ); +} + +.vignette { + position: absolute; + inset: 0; + pointer-events: none; + box-shadow: inset 0 0 120px 34px rgba(0, 0, 0, 0.55); +} + /* ===================================================================== The transcript ===================================================================== */ @@ -214,6 +306,7 @@ .printed { white-space: pre; min-height: 1.2em; + font-family: var(--mono); } /* The print head: a block where the next character is about to land. */ @@ -227,6 +320,484 @@ opacity: 0.5; } +html[data-skin='modern'] .head { + display: none; +} + +/* ===================================================================== + The bridge + + Three columns: the ship down the left, the sensors in the middle, the log + down the right. A 4:3 screen is wide, and the paper skin's single column + throws that width away -- which is fine for paper, since paper is a column, + and wrong for a screen. + ===================================================================== */ + +/* + * The middle column is nearly twice either side, and the rows stretch rather + * than hugging the top. Both of those are the same decision: the sensors are + * what the game is, so they get the space, and the two panels are read at a + * glance rather than studied. Aligning to the start left a third of the + * screen empty under everything, which is the one thing a bridge should + * never look like. + */ +.bridge { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 0.76fr) minmax(0, 1.95fr) minmax(0, 0.98fr); + gap: clamp(8px, 1.4vw, 16px); + align-items: stretch; +} + +/* A phone is a column whatever the skin, and the sensors go first because + they are what the game is. */ +@media (max-width: 720px) { + .bridge { + grid-template-columns: 1fr; + grid-template-areas: 'map' 'ship' 'log'; + } + + .tactical { + grid-area: map; + } + + .ship-panel { + grid-area: ship; + } + + .log-panel { + grid-area: log; + } +} + +.panel { + min-width: 0; + min-height: 0; + padding: 9px 11px; + border: 2px solid var(--outline); + border-radius: 14px; + background: rgba(12, 20, 42, 0.72); + display: flex; + flex-direction: column; + gap: 8px; +} + +.panel-title { + margin: 0; + font-size: 0.82em; + font-weight: 800; + letter-spacing: 0.14em; + color: var(--accent); + text-transform: uppercase; +} + +.panel-label { + font-size: 0.72em; + font-weight: 800; + letter-spacing: 0.12em; + color: var(--ink-dim); +} + +/* --- the ship --------------------------------------------------------- */ + +.condition { + align-self: flex-start; + padding: 2px 12px; + border: 2px solid var(--outline); + border-radius: 999px; + font-size: 0.78em; + font-weight: 900; + letter-spacing: 0.12em; + color: var(--outline); + background: var(--ink-dim); +} + +.cond-green { + background: #6fe39a; +} + +.cond-yellow { + background: var(--accent); +} + +.cond-red { + background: var(--warn); + color: #ffffff; +} + +.cond-docked { + background: var(--base); +} + +.meter { + display: flex; + flex-direction: column; + gap: 3px; +} + +.meter-head { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.meter-value { + font-variant-numeric: tabular-nums; + font-weight: 800; +} + +.bar { + height: 10px; + border: 2px solid var(--outline); + border-radius: 999px; + background: #0b1224; + overflow: hidden; +} + +.bar span { + display: block; + height: 100%; + transition: width 220ms ease; +} + +.bar-energy span { + background: var(--accent); +} + +.bar-shields span { + background: var(--base); +} + +.readout { + display: grid; + grid-template-columns: auto 1fr; + gap: 1px 10px; + margin: 0; + font-size: 0.86em; +} + +.readout dt { + color: var(--ink-dim); + letter-spacing: 0.06em; +} + +.readout dd { + margin: 0; + text-align: right; + font-variant-numeric: tabular-nums; + font-weight: 700; +} + +.readout dd.big { + color: var(--accent); + font-weight: 900; +} + +.readout dd.low { + color: var(--warn); +} + +.torps { + display: flex; + flex-direction: column; + gap: 4px; +} + +.pips { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.pip { + width: 8px; + height: 14px; + border: 2px solid var(--outline); + border-radius: 3px; + background: #0d1730; +} + +.pip.on { + background: var(--ink); +} + +.damage-list { + display: flex; + flex-direction: column; + gap: 3px; + font-size: 0.8em; +} + +.all-well { + margin: 0; + color: var(--ink-dim); +} + +.damage-list ul { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 2px; +} + +.damage-list li { + display: flex; + justify-content: space-between; + gap: 8px; + color: var(--warn); +} + +.damage-list .days { + font-variant-numeric: tabular-nums; +} + +/* --- the log ---------------------------------------------------------- */ + +.log-panel { + max-height: 100%; +} + +/* The log gives up width to the map and stays readable on the strength of + line height rather than size. */ +.log-lines { + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.8em; + line-height: 1.45; +} + +@media (max-width: 720px) { + .log-lines { + max-height: 9em; + } +} + +.log-line { + margin: 0; + color: var(--ink-dim); +} + +.log-fire { + color: var(--accent); +} + +.log-hit { + color: var(--warn); + font-weight: 700; +} + +.log-damage { + color: #ff9f6b; +} + +.log-move { + color: var(--ink); +} + +.log-win { + color: #6fe39a; + font-weight: 800; +} + +.log-end { + color: var(--warn); + font-weight: 800; +} + +/* ===================================================================== + The sensors + + Two panels, and each one is a device: break the short range sensors and + the sector grid goes dark, break the computer and the chart does. See + TacticalView.tsx, which will not draw anything the transcript has not + already been told. + ===================================================================== */ + +.tactical { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-height: 0; +} + +.panel-tabs { + display: flex; + gap: 4px; +} + +.tab { + padding: 2px 12px; + font-size: 0.85em; + letter-spacing: 0.08em; + border-bottom: 2px solid transparent; + border-radius: 0; +} + +.tab.on { + color: var(--accent); + border-bottom-color: var(--accent); +} + +/* + * The map takes the whole cell it is given, in both directions. An SVG with a + * viewBox letterboxes itself, so this is safe: it grows to whichever of the + * two runs out first and stays square, which is why the column can be sized + * in fractions rather than in vh. + */ +.grid-map { + flex: 1; + min-height: 0; + width: 100%; + height: 100%; +} + +@media (max-width: 720px) { + .grid-map { + flex: none; + width: min(100%, 62vh); + height: auto; + } +} + +.grid-bg { + fill: var(--grid-bg); + stroke: var(--outline); + stroke-width: 3; +} + +.grid-bg.dead { + fill: #0a0f1c; +} + +.dead-label { + fill: var(--warn); + font-family: var(--body-font); + font-size: 15px; + font-weight: 800; + text-anchor: middle; + letter-spacing: 0.06em; +} + +.rule { + stroke: var(--grid-rule); + stroke-width: 1.5; +} + +/* Cel shading: one flat fill, one heavy outline, and a highlight that is a + second flat fill rather than a gradient. */ +.mark path, +.mark circle, +.mark ellipse { + stroke: var(--outline); + stroke-width: 2.5; + stroke-linejoin: round; +} + +.mark .lit { + stroke: none; + fill: rgba(255, 255, 255, 0.5); +} + +.mark-star .lit { + fill: #ffffff; +} + +.mark-star circle { + fill: var(--star); +} + +.mark-raider path { + fill: var(--raider); +} + +.mark-base path { + fill: var(--base); +} + +.mark-base .lit { + fill: #07243a; +} + +.mark-ship .saucer { + fill: var(--ship); +} + +.mark-ship .hull { + fill: #d99a1f; +} + +.mark-ship .ring { + fill: none; + stroke: var(--ship); + stroke-width: 1.5; + opacity: 0.4; +} + +.mark-ship.red .ring { + stroke: var(--warn); + stroke-width: 2.5; + opacity: 0.9; +} + +/* --- the chart -------------------------------------------------------- */ + +.quad rect { + fill: #16203c; + stroke: var(--outline); + stroke-width: 2; +} + +.quad text { + fill: var(--ink-dim); + font-family: var(--body-font); + font-size: 13px; + font-weight: 800; + text-anchor: middle; + letter-spacing: 0.04em; +} + +.quad.unknown rect { + fill: #0d1428; +} + +.quad.unknown text { + fill: #40527a; +} + +.quad.hostile rect { + fill: #4a1f24; +} + +.quad.hostile text { + fill: var(--warn); +} + +.quad.has-base rect { + stroke: var(--base); + stroke-width: 3; +} + +/* Where you are, in the colour everything of yours is drawn in. It wins + over the hostile fill on purpose: you already know there are raiders here, + the scan is printed above. */ +.quad.here rect { + fill: var(--ship); + stroke-width: 3; +} + +.quad.here text { + fill: var(--outline); +} + /* ===================================================================== The prompt ===================================================================== */ @@ -278,6 +849,18 @@ letter-spacing: 0.14em; } +html[data-skin='modern'] .statusbar .senses { + color: var(--warn); + letter-spacing: 0; +} + +html[data-skin='modern'] .banner { + color: var(--accent); + -webkit-text-stroke: 3px var(--outline); + paint-order: stroke fill; + text-shadow: 5px 5px 0 var(--outline); +} + .banner { margin: 0; font-family: var(--body-font); @@ -336,11 +919,43 @@ cursor: default; } +html[data-skin='modern'] .btn { + transition: + transform 110ms ease, + box-shadow 110ms ease; +} + +html[data-skin='modern'] .btn:hover:not(:disabled) { + transform: translate(-2px, -2px); + box-shadow: 6px 6px 0 var(--outline); +} + +html[data-skin='modern'] .btn:active:not(:disabled) { + transform: translate(2px, 2px); + box-shadow: 2px 2px 0 var(--outline); +} + +html[data-skin='modern'] .tab, +html[data-skin='modern'] .btn-ghost { + box-shadow: none; +} + +html[data-skin='modern'] .tab:hover:not(:disabled), +html[data-skin='modern'] .btn-ghost:hover:not(:disabled) { + transform: none; + box-shadow: none; +} + .btn-primary { background: #241f1a; color: #f3ecd9; } +html[data-skin='modern'] .btn-primary { + background: var(--accent); + color: var(--outline); +} + .btn-ghost { background: transparent; color: var(--ink-dim); @@ -387,10 +1002,15 @@ /* * The way back. The arrow is a pseudo-element rather than part of the label so - * that it can be the right arrow for the machine you are looking at: the two - * ASCII characters a printing terminal could actually strike. + * that it can be the right arrow for the machine you are looking at: a chevron + * on the bridge, and the two ASCII characters a printing terminal could + * actually strike on the paper skin. */ .controls .back::before { + content: '\2039\00a0'; +} + +:root:not([data-skin='modern']) .controls .back::before { content: '<-\00a0'; } diff --git a/src/App.tsx b/src/App.tsx index b27c209..ecf480e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,14 @@ import { useCallback, useEffect, useRef, useState, useReducer } from 'react' +import { DEEP_END, DEEP_PATROL, DEEP_TITLE } from './audio/deepspace' +import { END_TUNE, PATROL_TUNE, TITLE_TUNE } from './audio/tunes' +import { synth } from './audio/synth' import { HAIL_TEXT } from './game/constants' import { makePatrol, randomSeed, shortRangeScan, step } from './game/engine' import { fetchScores, submitScores, type Score } from './game/highscores' import { makeRng, type Rng } from './game/rng' import { currentCaptain, initialState, reducer } from './game/reducer' import type { Command, Line } from './game/types' +import { useSkin } from './skin' import { Btn, Fit, Frame } from './components/Frame' import { BridgeScreen } from './components/BridgeScreen' import { @@ -40,6 +44,10 @@ export default function App() { const [seed] = useState(randomSeed) const [state, dispatch] = useReducer(reducer, seed, initialState) const rng = useRef(makeRng(seed)) + const [music, setMusic] = useState(true) + const [sfx, setSfx] = useState(true) + const started = useRef(false) + const { skin, toggle: toggleSkin } = useSkin() const [scores, setScores] = useState([]) const [boardState, setBoardState] = useState('loading') const [freshScores, setFreshScores] = useState([]) @@ -48,6 +56,50 @@ export default function App() { const who = currentCaptain(state) + // ------------------------------------------------------------ audio glue + + const wake = useCallback(() => { + if (started.current) return + started.current = true + synth.ensure() + synth.setMusic(music) + synth.setSfx(sfx) + }, [music, sfx]) + + /** + * 1971 gets the chiptune it never had; the remaster gets the orchestra. + * + * It is the same theme in both, in the same key -- see `audio/tunes.ts`. + * Switching skin mid-patrol re-scores the piece rather than changing the + * record, which is the one thing that makes the toggle feel like a remaster + * rather than a second game. + */ + useEffect(() => { + if (!started.current) return + const front = + state.phase === 'title' || state.phase === 'instructions' || state.phase === 'setup' + const set = + skin === 'modern' + ? { title: DEEP_TITLE, patrol: DEEP_PATROL, end: DEEP_END } + : { title: TITLE_TUNE, patrol: PATROL_TUNE, end: END_TUNE } + const tune = + state.phase === 'gameover' || state.phase === 'report' + ? set.end + : front + ? set.title + : set.patrol + synth.playTune(tune, false) + }, [state.phase, skin]) + + useEffect(() => { + synth.setMusic(music) + }, [music]) + useEffect(() => { + synth.setSfx(sfx) + }, [sfx]) + + const blip = useCallback(() => synth.blip(), []) + const loadBoard = useCallback(async () => { setBoardState('loading') try { @@ -90,6 +142,28 @@ export default function App() { const command = (cmd: Command, echo: Line[]) => { if (!state.patrol) return const { patrol, lines } = step(state.patrol, cmd, rng.current) + + /* + * The sound belongs to what happened, so it is chosen from the lines the + * engine produced rather than guessed at from the command. Firing the + * beams and hitting something with them are two different noises, and + * only the engine knows whether the second one happened. + */ + const said = (t: string) => lines.some((l) => l.text.includes(t)) + + if (cmd.type === 'nav') synth.warp() + else if (cmd.type === 'beams') synth.beams() + else if (cmd.type === 'torpedo') synth.torpedo() + + if (said('COMES APART') || said('DIRECT HIT') || said('IS GONE')) synth.explode() + if (said('CONDITION RED')) synth.alert() + if (said('UNIT HIT')) synth.hit() + if (said('DAMAGED.')) synth.damaged() + if (said('MOORED')) synth.dock() + + if (patrol.outcome === 'mission-complete') synth.fanfare() + else if (patrol.outcome) synth.sad() + dispatch({ type: 'ADVANCE', patrol, lines: [...echo, ...lines] }) } @@ -132,6 +206,7 @@ export default function App() { }, [state.phase, state.captains, post]) const restart = () => { + synth.select() const s = randomSeed() rng.current = makeRng(s) posted.current = false @@ -140,6 +215,8 @@ export default function App() { } const showScores = () => { + wake() + synth.select() dispatch({ type: 'SHOW_SCORES' }) if (boardState !== 'ready') void loadBoard() } @@ -149,7 +226,7 @@ export default function App() { const moreToCome = state.captains.some((c) => !c.done && c.id !== who?.id) return ( -
+
@@ -157,6 +234,37 @@ export default function App() { GAMES + { + wake() + setMusic((m) => !m) + }} + title="Background music" + > + MUSIC {music ? 'ON' : 'OFF'} + + { + wake() + setSfx((v) => !v) + }} + title="Sound effects" + > + SOUND {sfx ? 'ON' : 'OFF'} + + { + wake() + synth.select() + toggleSkin() + }} + title={skin === 'teletype' ? 'Switch to the remaster' : 'Switch to the 1971 terminal'} + > + {skin === 'teletype' ? 'REMASTER' : '1971'} + NEW PATROL @@ -178,8 +286,16 @@ export default function App() { {state.phase === 'title' && ( dispatch({ type: 'SHOW_SETUP' })} - onInstructions={() => dispatch({ type: 'SHOW_INSTRUCTIONS' })} + onStart={() => { + wake() + synth.select() + dispatch({ type: 'SHOW_SETUP' }) + }} + onInstructions={() => { + wake() + synth.select() + dispatch({ type: 'SHOW_INSTRUCTIONS' }) + }} onScores={showScores} /> )} @@ -189,7 +305,14 @@ export default function App() { )} {state.phase === 'setup' && ( - dispatch({ type: 'START', names })} /> + { + wake() + synth.select() + dispatch({ type: 'START', names }) + }} + onBlip={blip} + /> )} {(state.phase === 'patrol' || state.phase === 'resolve') && state.patrol && who && ( @@ -201,6 +324,7 @@ export default function App() { over={state.phase === 'resolve'} onCommand={command} onPrint={(lines) => dispatch({ type: 'PRINT', lines })} + onBlip={blip} /> )} @@ -209,7 +333,10 @@ export default function App() { patrol={state.patrol} captain={state.captains[who.id]!} moreToCome={moreToCome} - onNext={() => dispatch({ type: 'NEXT_TURN' })} + onNext={() => { + synth.select() + dispatch({ type: 'NEXT_TURN' }) + }} /> )} @@ -226,8 +353,14 @@ export default function App() { scores={scores} state={boardState} highlight={freshScores} - onBack={() => dispatch({ type: 'CLOSE_SCORES' })} - onRetry={() => void loadBoard()} + onBack={() => { + synth.select() + dispatch({ type: 'CLOSE_SCORES' }) + }} + onRetry={() => { + synth.select() + void loadBoard() + }} /> )} diff --git a/src/SkinProvider.tsx b/src/SkinProvider.tsx new file mode 100644 index 0000000..b6c8562 --- /dev/null +++ b/src/SkinProvider.tsx @@ -0,0 +1,32 @@ +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { SKIN_KEY, SkinContext, readSkin, writeSkin, type Skin, type SkinApi } from './skin' + +export function SkinProvider({ children }: { children: ReactNode }) { + const [skin, setSkinState] = useState(readSkin) + + const setSkin = useCallback((s: Skin) => { + setSkinState(s) + writeSkin(s) + }, []) + + // The attribute lives on so the page background can follow the skin. + useEffect(() => { + document.documentElement.dataset.skin = skin + }, [skin]) + + // Keep sibling tabs in the same browser in step. + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === SKIN_KEY || e.key === null) setSkinState(readSkin()) + } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + + const value = useMemo( + () => ({ skin, setSkin, toggle: () => setSkin(skin === 'teletype' ? 'modern' : 'teletype') }), + [skin, setSkin], + ) + + return {children} +} diff --git a/src/audio/deepspace.ts b/src/audio/deepspace.ts new file mode 100644 index 0000000..0bf5f84 --- /dev/null +++ b/src/audio/deepspace.ts @@ -0,0 +1,215 @@ +import type { Tune } from './synth' + +/** + * The remaster's score, and what it is and is not. + * + * It is *not* the television theme, or any part of it, or a paraphrase of + * one. The reason this game is called Long Range Scan at all is that the + * setting's names belong to somebody, and it would be a strange sort of care + * to rename the enemy and then quote sixteen bars of the music. Every note + * below was written for this project. + * + * What it *is* is the idiom, which belongs to nobody: brass in a major key, + * a melody built on rising fourths and fifths that holds its top note far + * longer than a pop tune would dare, strings underneath moving one chord a + * bar, and timpani. That is how film and television have said "space, and it + * matters" since the 1950s, and it predates all of them -- it is Holst, and + * before him it is Wagner. Nobody owns a perfect fifth. + * + * The instruments are the same six voices the sibling games use, leant on + * differently: + * + * - **brass** is a detuned saw with the filter closing slightly across + * each note, which is what a horn does on the attack; + * - **strings** are a quiet pulse-50 playing actual chords -- the engine + * stacks them with slashes -- held the whole bar with gate 1; + * - **timpani** is the kit's kick, played sparingly enough to read as a + * drum somebody is hitting rather than a beat. + * + * Eighths, eight to a bar, D major, and slow enough to be serious. + */ + +const bar = (...steps: string[]) => steps + +/** A chord held for a whole bar. */ +const held = (chord: string) => [chord, '=', '=', '=', '=', '=', '=', '='] + +/** + * The theme. + * + * Eight bars. It opens on a pickup a fifth below the tonic and holds the + * arrival for most of a bar, climbs to the high G in bar three and sits + * there, then comes down. The second half is the same shape starting a + * fourth lower, so it can climb again and land properly. + */ +export const DEEP_TITLE: Tune = { + bpm: 72, + stepsPerBeat: 2, + tracks: [ + { + wave: 'saw', + gain: 0.13, + gate: 0.96, + detune: 6, + filter: { from: 1500, to: 900, q: 1.2 }, + notes: [ + ...bar('.', '.', '.', 'A4', 'D5', '=', '=', '='), + ...bar('D5', '=', '=', '=', 'E5', '.', 'F#5', '.'), + ...bar('G5', '=', '=', '=', '=', '=', '.', '.'), + ...bar('F#5', '.', 'E5', '.', 'D5', '=', '=', '.'), + ...bar('.', '.', '.', 'A4', 'B4', '=', '=', '='), + ...bar('B4', '=', '=', '.', 'C#5', '.', 'D5', '.'), + ...bar('E5', '=', '=', '=', 'D5', '.', 'C#5', '.'), + ...bar('D5', '=', '=', '=', '=', '=', '=', '.'), + ], + }, + { + // Strings. One chord a bar, and the whole harmonic argument is here: + // D, D, G, D, Bm, G, A, D. + wave: 'pulse50', + gain: 0.045, + gate: 1, + notes: [ + ...held('D4/F#4/A4'), + ...held('D4/F#4/A4'), + ...held('G3/B3/D4'), + ...held('D4/F#4/A4'), + ...held('B3/D4/F#4'), + ...held('G3/B3/D4'), + ...held('A3/C#4/E4'), + ...held('D4/F#4/A4'), + ], + }, + { + wave: 'triangle', + gain: 0.24, + gate: 1, + notes: [ + ...held('D2'), + ...held('D2'), + ...held('G1'), + ...held('D2'), + ...held('B1'), + ...held('G1'), + ...held('A1'), + ...held('D2'), + ], + }, + { + // Timpani. Two strokes a bar where the melody is holding, a run of + // four where it is coming home, and nothing at all in between. + wave: 'noise', + gain: 0.4, + notes: [ + ...bar('K', '.', '.', '.', 'K', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', '.', '.'), + ...bar('K', '.', '.', '.', 'K', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', '.', '.'), + ...bar('K', '.', '.', '.', 'K', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', '.', '.'), + ...bar('K', '.', 'K', '.', 'K', '.', 'K', '.'), + ...bar('K', '.', '.', '.', '.', '.', '.', '.'), + ], + }, + ], +} + +/** + * On patrol. + * + * The theme is not here, deliberately. What plays while you are counting + * quadrants has to be furniture -- you are reading a chart over the top of + * it, and a melody would be unbearable by the fourth warp. So: the same + * harmony, moving half as fast, with one distant horn call every second bar + * to say the theme is still out there. + */ +export const DEEP_PATROL: Tune = { + bpm: 66, + stepsPerBeat: 2, + tracks: [ + { + wave: 'saw', + gain: 0.055, + gate: 0.9, + detune: 5, + filter: { from: 1100, to: 700, q: 1.2 }, + notes: [ + ...bar('.', '.', '.', '.', '.', '.', 'A4', '.'), + ...bar('D5', '=', '=', '=', '.', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', 'F#4', '.'), + ...bar('B4', '=', '=', '=', '.', '.', '.', '.'), + ], + }, + { + wave: 'pulse50', + gain: 0.04, + gate: 1, + notes: [ + ...held('D4/F#4/A4'), + ...held('D4/G4/A4'), + ...held('B3/D4/F#4'), + ...held('G3/B3/D4'), + ], + }, + { + wave: 'triangle', + gain: 0.22, + gate: 1, + notes: [...held('D2'), ...held('D2'), ...held('B1'), ...held('G1')], + }, + ], +} + +/** + * The end of it, either way. + * + * A rising line over G, A, Bm, D -- the lift rather than the fanfare, + * because it plays over the standings and half the captains reading them + * did not make it back. + */ +export const DEEP_END: Tune = { + bpm: 72, + stepsPerBeat: 2, + tracks: [ + { + wave: 'saw', + gain: 0.12, + gate: 0.96, + detune: 6, + filter: { from: 1500, to: 900, q: 1.2 }, + notes: [ + ...bar('D5', '=', '=', '=', '=', '=', '=', '.'), + ...bar('E5', '=', '=', '=', '=', '=', '=', '.'), + ...bar('F#5', '=', '=', '=', '=', '=', 'G5', '.'), + ...bar('A5', '=', '=', '=', '=', '=', '=', '.'), + ], + }, + { + wave: 'pulse50', + gain: 0.05, + gate: 1, + notes: [ + ...held('G3/B3/D4'), + ...held('A3/C#4/E4'), + ...held('B3/D4/F#4'), + ...held('D4/F#4/A4'), + ], + }, + { + wave: 'triangle', + gain: 0.26, + gate: 1, + notes: [...held('G1'), ...held('A1'), ...held('B1'), ...held('D2')], + }, + { + wave: 'noise', + gain: 0.36, + notes: [ + ...bar('K', '.', '.', '.', '.', '.', '.', '.'), + ...bar('K', '.', '.', '.', '.', '.', '.', '.'), + ...bar('K', '.', '.', '.', 'K', '.', '.', '.'), + ...bar('K', '.', 'K', '.', 'K', '.', '.', '.'), + ], + }, + ], +} diff --git a/src/audio/synth.ts b/src/audio/synth.ts new file mode 100644 index 0000000..c9e577c --- /dev/null +++ b/src/audio/synth.ts @@ -0,0 +1,776 @@ +/** + * The synth, shared with its siblings. + * + * This engine is the one written for the lemonade stand and carried into the + * cave game -- same author, same licence, and deliberately not forked: a + * scheduler, six voices and a small kit are not worth having three of. What + * is written *for* it here is new, and lives in `tunes.ts` and `deepspace.ts` + * alongside the effects below. + * + * A very small chiptune engine: pulse waves for voices, filtered white noise + * for percussion, and a look-ahead scheduler so patterns stay in time even + * when React is busy re-rendering. + * + * Shared with the lemonade stand, note for note -- same author, same licence, + * and a synth is not the part of a game worth writing twice. What is not + * shared is everything under "sfx" below: a cave needs wings, a fall and a + * growl, and it needs a Teletype. + */ + +const NOTE_INDEX: Record = { + C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, + 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11, +} + +/** "C#4" -> Hz. A4 = 440. */ +export function noteToFreq(note: string): number { + const m = /^([A-G]#?)(-?\d)$/.exec(note) + if (!m) return 0 + const semis = NOTE_INDEX[m[1]] + (Number(m[2]) + 1) * 12 + return 440 * Math.pow(2, (semis - 69) / 12) +} + +export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noise' + +export interface Track { + wave: Wave + gain: number + /** + * One entry per step. '.' rest, '=' sustain previous, otherwise a note. + * Slashes stack notes into a chord: "F4/A4/C5". On a noise track the + * letter picks the drum: K kick, S snare, C clap, H closed hat, O open hat. + */ + notes: string[] + /** Sweeping lowpass, the whole point of a funk bass. */ + filter?: { from: number; to: number; q?: number } + /** Fraction of the note's length actually sounded; low values are stabs. */ + gate?: number + /** Cents, for a fatter unison. */ + detune?: number +} + +export interface Tune { + bpm: number + stepsPerBeat: number + tracks: Track[] + /** 0 is straight, ~0.15 is a light funk shuffle. Delays every other step. */ + swing?: number +} + +/** Fourier series for a pulse wave of the given duty cycle. */ +function pulseWave(ctx: AudioContext, duty: number, harmonics = 24): PeriodicWave { + const real = new Float32Array(harmonics + 1) + const imag = new Float32Array(harmonics + 1) + for (let n = 1; n <= harmonics; n++) { + imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty) + } + return ctx.createPeriodicWave(real, imag, { disableNormalization: false }) +} + +/** + * The dial-in, as a schedule rather than a number buried in the audio. + * + * The boot screen prints what is happening while it happens, so both have to + * agree about when each stage starts -- and the only way to keep two clocks in + * step is to have one clock. All values are seconds from the moment of DIAL. + */ +export const DIAL_UP = { + /** 5551971. The last four are the year, which is the only joke in here. */ + number: [5, 5, 5, 1, 9, 7, 1], + dialTone: 0.62, + ringAt: 2.0, + ring: 1.0, + answerAt: 3.35, + answer: 0.95, + total: 5.5, +} as const + +export class Synth { + private ctx: AudioContext | null = null + private master!: GainNode + private musicBus!: GainNode + private sfxBus!: GainNode + private waves: Partial> = {} + private noiseBuffer!: AudioBuffer + + private tune: Tune | null = null + private step = 0 + private nextStepTime = 0 + private timer: number | null = null + + musicOn = true + sfxOn = true + + /** Must be called from a user gesture the first time. */ + ensure(): AudioContext { + if (this.ctx) { + if (this.ctx.state === 'suspended') void this.ctx.resume() + return this.ctx + } + const ctx = new AudioContext() + this.ctx = ctx + this.master = ctx.createGain() + this.master.gain.value = 0.5 + this.master.connect(ctx.destination) + + this.musicBus = ctx.createGain() + this.musicBus.gain.value = 0.55 + this.musicBus.connect(this.master) + + this.sfxBus = ctx.createGain() + this.sfxBus.gain.value = 0.9 + this.sfxBus.connect(this.master) + + this.waves.pulse12 = pulseWave(ctx, 0.125) + this.waves.pulse25 = pulseWave(ctx, 0.25) + this.waves.pulse50 = pulseWave(ctx, 0.5) + + const len = Math.floor(ctx.sampleRate * 1.5) + const buf = ctx.createBuffer(1, len, ctx.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1 + this.noiseBuffer = buf + + return ctx + } + + setMusic(on: boolean) { + this.musicOn = on + if (!this.ctx) return + this.musicBus.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05) + } + + setSfx(on: boolean) { + this.sfxOn = on + if (!this.ctx) return + this.sfxBus.gain.setTargetAtTime(on ? 0.9 : 0, this.ctx.currentTime, 0.02) + } + + // ---------------------------------------------------------------- voices + + private voice( + dest: AudioNode, + wave: Wave, + freq: number, + at: number, + dur: number, + gain: number, + opts: { filter?: Track['filter']; detune?: number } = {}, + ) { + const ctx = this.ensure() + const osc = ctx.createOscillator() + if (wave === 'triangle') osc.type = 'triangle' + else if (wave === 'saw') osc.type = 'sawtooth' + else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!) + osc.frequency.setValueAtTime(freq, at) + if (opts.detune) osc.detune.setValueAtTime(opts.detune, at) + + const env = ctx.createGain() + const peak = Math.max(0.0001, gain) + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(peak, at + 0.008) + env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6)) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + + let node: AudioNode = osc + if (opts.filter) { + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.Q.value = opts.filter.q ?? 6 + lp.frequency.setValueAtTime(opts.filter.from, at) + lp.frequency.exponentialRampToValueAtTime( + Math.max(60, opts.filter.to), + at + Math.max(0.05, dur), + ) + osc.connect(lp) + node = lp + } + + node.connect(env).connect(dest) + osc.start(at) + osc.stop(at + dur + 0.02) + } + + /** A small kit: pitched-sine kick, noise-and-tone snare, clap, two hats. */ + private drum(dest: AudioNode, kind: string, at: number, gain: number) { + const ctx = this.ensure() + + if (kind === 'K') { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(130, at) + osc.frequency.exponentialRampToValueAtTime(42, at + 0.11) + const env = ctx.createGain() + env.gain.setValueAtTime(gain * 1.5, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.24) + osc.connect(env).connect(dest) + osc.start(at) + osc.stop(at + 0.26) + return + } + + const noise = ctx.createBufferSource() + noise.buffer = this.noiseBuffer + const filter = ctx.createBiquadFilter() + const env = ctx.createGain() + let dur = 0.05 + + if (kind === 'S' || kind === 'C') { + filter.type = 'bandpass' + filter.frequency.value = kind === 'S' ? 1900 : 1300 + filter.Q.value = kind === 'S' ? 0.9 : 2.4 + dur = kind === 'S' ? 0.16 : 0.1 + if (kind === 'S') { + // A little body under the crack. + const tone = ctx.createOscillator() + tone.type = 'triangle' + tone.frequency.setValueAtTime(210, at) + tone.frequency.exponentialRampToValueAtTime(150, at + 0.09) + const tenv = ctx.createGain() + tenv.gain.setValueAtTime(gain * 0.6, at) + tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.1) + tone.connect(tenv).connect(dest) + tone.start(at) + tone.stop(at + 0.12) + } + } else { + filter.type = 'highpass' + filter.frequency.value = 7200 + dur = kind === 'O' ? 0.22 : 0.035 + } + + env.gain.setValueAtTime(gain, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + noise.connect(filter).connect(env).connect(dest) + noise.start(at) + noise.stop(at + dur + 0.02) + } + + // ------------------------------------------------------------- sequencer + + playTune(tune: Tune, restart = true) { + this.ensure() + if (this.tune === tune && this.timer !== null && !restart) return + this.stopTune() + this.tune = tune + this.step = 0 + this.nextStepTime = this.ctx!.currentTime + 0.08 + this.timer = window.setInterval(() => this.schedule(), 25) + } + + stopTune() { + if (this.timer !== null) window.clearInterval(this.timer) + this.timer = null + this.tune = null + } + + get playing() { + return this.timer !== null + } + + private schedule() { + const ctx = this.ctx + const tune = this.tune + if (!ctx || !tune) return + const stepDur = 60 / tune.bpm / tune.stepsPerBeat + const length = Math.max(...tune.tracks.map((t) => t.notes.length)) + const swing = tune.swing ?? 0 + + while (this.nextStepTime < ctx.currentTime + 0.2) { + // A shuffle pushes every other step late without moving the downbeats. + const at = this.nextStepTime + (this.step % 2 === 1 ? swing * stepDur : 0) + + for (const track of tune.tracks) { + const note = track.notes[this.step % track.notes.length] + if (!note || note === '.' || note === '=') continue + + // A note runs until the next step that is not a sustain marker. + let held = 1 + for (let i = 1; i < length; i++) { + if (track.notes[(this.step + i) % track.notes.length] === '=') held++ + else break + } + const dur = held * stepDur * (track.gate ?? 0.95) + + if (track.wave === 'noise') { + this.drum(this.musicBus, note, at, track.gain) + continue + } + + for (const part of note.split('/')) { + const f = noteToFreq(part) + if (!f) continue + this.voice(this.musicBus, track.wave, f, at, dur, track.gain, { + filter: track.filter, + detune: track.detune, + }) + } + } + this.nextStepTime += stepDur + this.step = (this.step + 1) % length + } + } + + // ------------------------------------------------------------------ sfx + + private seq(notes: [string, number][], wave: Wave = 'pulse25', gain = 0.22) { + const ctx = this.ensure() + let t = ctx.currentTime + 0.01 + for (const [note, dur] of notes) { + if (note !== '.') this.voice(this.sfxBus, wave, noteToFreq(note), t, dur, gain) + t += dur + } + } + + blip() { + this.seq([['E5', 0.05]], 'pulse12', 0.15) + } + + select() { + this.seq([['C5', 0.05], ['G5', 0.09]], 'pulse25', 0.18) + } + + /** A room that will not take you: the listing just said NOT POSSIBLE. */ + reject() { + this.seq([['A3', 0.09], ['E3', 0.16]], 'saw', 0.2) + } + + /** + * Warp. The engines wind up, hold, and drop you somewhere else. + * + * A detuned pair of saws swept up through a closing lowpass, with the + * filter doing most of the work -- an oscillator sweep alone reads as a + * slide whistle, and the difference between those two things is the whole + * character of the sound. + */ + warp(sectors = 8) { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const dur = Math.min(1.1, 0.35 + sectors * 0.02) + + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.Q.value = 6 + lp.frequency.setValueAtTime(220, at) + lp.frequency.exponentialRampToValueAtTime(2400, at + dur * 0.6) + lp.frequency.exponentialRampToValueAtTime(300, at + dur) + + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(0.22, at + 0.08) + env.gain.setValueAtTime(0.22, at + dur * 0.7) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + lp.connect(env).connect(this.sfxBus) + + for (const detune of [-9, 9]) { + const osc = ctx.createOscillator() + osc.type = 'sawtooth' + osc.detune.value = detune + osc.frequency.setValueAtTime(72, at) + osc.frequency.exponentialRampToValueAtTime(148, at + dur * 0.6) + osc.frequency.exponentialRampToValueAtTime(96, at + dur) + osc.connect(lp) + osc.start(at) + osc.stop(at + dur + 0.05) + } + } + + /** The beams: energy leaving the ship, all of it at once, downward. */ + beams() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + + const band = ctx.createBiquadFilter() + band.type = 'bandpass' + band.Q.value = 5 + band.frequency.setValueAtTime(3200, at) + band.frequency.exponentialRampToValueAtTime(420, at + 0.5) + + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(0.26, at + 0.02) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.55) + band.connect(env).connect(this.sfxBus) + + const osc = ctx.createOscillator() + osc.type = 'sawtooth' + osc.frequency.setValueAtTime(880, at) + osc.frequency.exponentialRampToValueAtTime(180, at + 0.5) + osc.connect(band) + osc.start(at) + osc.stop(at + 0.6) + + const hiss = ctx.createBufferSource() + hiss.buffer = this.noiseBuffer + hiss.connect(band) + hiss.start(at) + hiss.stop(at + 0.6) + } + + /** A torpedo away: the tube thumps, then something small leaves fast. */ + torpedo() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + + const thump = ctx.createOscillator() + thump.type = 'sine' + thump.frequency.setValueAtTime(160, at) + thump.frequency.exponentialRampToValueAtTime(48, at + 0.14) + const tenv = ctx.createGain() + tenv.gain.setValueAtTime(0.3, at) + tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.16) + thump.connect(tenv).connect(this.sfxBus) + thump.start(at) + thump.stop(at + 0.18) + + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + const band = ctx.createBiquadFilter() + band.type = 'bandpass' + band.Q.value = 8 + band.frequency.setValueAtTime(700, at + 0.04) + band.frequency.exponentialRampToValueAtTime(3400, at + 0.42) + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at + 0.04) + env.gain.exponentialRampToValueAtTime(0.13, at + 0.1) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.45) + src.connect(band).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + 0.5) + } + + /** Something comes apart. Noise, opened wide and then closed down on. */ + explode() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + src.playbackRate.value = 0.7 + + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.Q.value = 1.5 + lp.frequency.setValueAtTime(4200, at) + lp.frequency.exponentialRampToValueAtTime(180, at + 0.9) + + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(0.34, at + 0.03) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.95) + + src.connect(lp).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + 1) + + // The body of it: a low sine dropping away underneath the noise. + const boom = ctx.createOscillator() + boom.type = 'sine' + boom.frequency.setValueAtTime(110, at) + boom.frequency.exponentialRampToValueAtTime(34, at + 0.6) + const benv = ctx.createGain() + benv.gain.setValueAtTime(0.26, at) + benv.gain.exponentialRampToValueAtTime(0.0001, at + 0.7) + boom.connect(benv).connect(this.sfxBus) + boom.start(at) + boom.stop(at + 0.75) + } + + /** Taking a hit. A hard knock on the hull and the shields ringing after. */ + hit() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + + const knock = ctx.createBufferSource() + knock.buffer = this.noiseBuffer + knock.playbackRate.value = 0.5 + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.setValueAtTime(1400, at) + lp.frequency.exponentialRampToValueAtTime(200, at + 0.25) + const env = ctx.createGain() + env.gain.setValueAtTime(0.26, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.3) + knock.connect(lp).connect(env).connect(this.sfxBus) + knock.start(at) + knock.stop(at + 0.32) + + const ring = ctx.createOscillator() + ring.type = 'triangle' + ring.frequency.setValueAtTime(520, at) + ring.frequency.exponentialRampToValueAtTime(360, at + 0.35) + const renv = ctx.createGain() + renv.gain.setValueAtTime(0.0001, at) + renv.gain.exponentialRampToValueAtTime(0.09, at + 0.02) + renv.gain.exponentialRampToValueAtTime(0.0001, at + 0.38) + ring.connect(renv).connect(this.sfxBus) + ring.start(at) + ring.stop(at + 0.4) + } + + /** + * The klaxon. Two tones, alternating, and no attempt at subtlety -- this + * plays when you come out of warp into a quadrant with something in it, + * which is the one moment the game wants you to look up. + */ + alert() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (const [i, f] of [740, 560, 740, 560].entries()) { + const t = at + i * 0.17 + const osc = ctx.createOscillator() + osc.type = 'square' + osc.frequency.value = f + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 1800 + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, t) + env.gain.exponentialRampToValueAtTime(0.13, t + 0.02) + env.gain.setValueAtTime(0.13, t + 0.12) + env.gain.exponentialRampToValueAtTime(0.0001, t + 0.16) + osc.connect(lp).connect(env).connect(this.sfxBus) + osc.start(t) + osc.stop(t + 0.18) + } + } + + /** A device going. A short buzz falling off a cliff. */ + damaged() { + this.seq([['D#4', 0.09], ['C#4', 0.09], ['A3', 0.22]], 'saw', 0.16) + } + + /** Mooring. Two clean tones up, and the clunk of being made fast. */ + dock() { + this.seq([['G4', 0.1], ['C5', 0.1], ['.', 0.05], ['C3', 0.22]], 'triangle', 0.2) + } + + /** The kill. */ + fanfare() { + this.seq( + [['C5', 0.11], ['E5', 0.11], ['G5', 0.11], ['C6', 0.11], ['G5', 0.11], ['C6', 0.4]], + 'pulse25', + 0.2, + ) + } + + sad() { + this.seq([['G4', 0.12], ['F#4', 0.12], ['F4', 0.12], ['E4', 0.4]], 'triangle', 0.22) + } + + /** The Teletype's key-click. */ + beep() { + this.seq([['A5', 0.06]], 'pulse50', 0.14) + } + + /** + * The print head. + * + * An ASR-33 printed ten characters a second and you could hear every one of + * them: a solenoid thump with a little bandpassed clatter on top. Called + * once per character while a line is being typed out, so the transcript + * sounds like it is being printed rather than appearing. + */ + print() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.001 + + const thump = ctx.createOscillator() + thump.type = 'square' + thump.frequency.setValueAtTime(320 + Math.random() * 90, at) + const tenv = ctx.createGain() + tenv.gain.setValueAtTime(0.05, at) + tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.02) + thump.connect(tenv).connect(this.sfxBus) + thump.start(at) + thump.stop(at + 0.03) + + const clack = ctx.createBufferSource() + clack.buffer = this.noiseBuffer + clack.playbackRate.value = 0.8 + Math.random() * 0.5 + const band = ctx.createBiquadFilter() + band.type = 'bandpass' + band.frequency.value = 2600 + band.Q.value = 1.2 + const cenv = ctx.createGain() + cenv.gain.setValueAtTime(0.06, at) + cenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.018) + clack.connect(band).connect(cenv).connect(this.sfxBus) + clack.start(at) + clack.stop(at + 0.03) + } + + /** Carriage return and line feed: the head slams back, the platen turns. */ + carriage() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.005 + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + src.playbackRate.value = 0.55 + const band = ctx.createBiquadFilter() + band.type = 'bandpass' + band.frequency.setValueAtTime(1500, at) + band.frequency.exponentialRampToValueAtTime(600, at + 0.13) + band.Q.value = 0.8 + const env = ctx.createGain() + env.gain.setValueAtTime(0.11, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.15) + src.connect(band).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + 0.17) + } + + /** + * Dialling in, properly. + * + * The sound everybody means by "modem" is a V.32/V.34 handshake from the + * 1990s -- the ascending probe tones and the long grinding echo-cancel + * training. None of that existed in 1971 and none of it could have: this + * game was played on a printing terminal at 110 baud through an acoustic + * coupler, + * two rubber cups you pushed a telephone handset into. What that sounded + * like is Bell 103, and Bell 103 is a much quieter animal. + * + * So this is the real sequence, in order, at the real frequencies: + * + * 1. Dial tone. North American precise tone plan: 350 Hz + 440 Hz. + * 2. Dialling. Touch-Tone, which existed from 1963 -- each digit is one + * row tone and one column tone from the DTMF grid, together. + * 3. Ringback. 440 Hz + 480 Hz, the same pair the far end hears as ring. + * 4. The answer tone. The far modem puts up its mark frequency, 2225 Hz, + * and holds it while the near end decides it has found a friend. + * 5. Data, both ways at once, which is the part people forget. Bell 103 + * is full duplex on two separate channels: the originating end keys + * 1070 Hz for a space and 1270 Hz for a mark, the answering end 2025 + * and 2225. Both are chattering simultaneously and the beating + * between them is most of the texture. + * + * Everything runs through a 300-3400 Hz telephone band, because that is all + * a phone line passes, with a peak around 900 Hz and a good deal of hiss for + * the two rubber cups and the room they are sitting in. + */ + dialUp(): () => void { + const ctx = this.ensure() + const t0 = ctx.currentTime + 0.03 + const stop: { stop: (when?: number) => void }[] = [] + + // --- the telephone line itself ------------------------------------ + const line = ctx.createGain() + line.gain.value = 1 + const hp = ctx.createBiquadFilter() + hp.type = 'highpass' + hp.frequency.value = 300 + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 3400 + // The handset in the cups: a resonance, not a clean channel. + const cup = ctx.createBiquadFilter() + cup.type = 'peaking' + cup.frequency.value = 900 + cup.Q.value = 1.1 + cup.gain.value = 5 + line.connect(hp).connect(lp).connect(cup).connect(this.sfxBus) + + /** A pure tone on the line for a fixed window. */ + const tone = (freq: number, from: number, to: number, gain: number) => { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = freq + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, from) + env.gain.exponentialRampToValueAtTime(gain, from + 0.012) + env.gain.setValueAtTime(gain, to - 0.012) + env.gain.exponentialRampToValueAtTime(0.0001, to) + osc.connect(env).connect(line) + osc.start(from) + osc.stop(to + 0.02) + stop.push(osc) + } + + // --- 1. dial tone -------------------------------------------------- + const DIAL_END = t0 + DIAL_UP.dialTone + tone(350, t0, DIAL_END, 0.075) + tone(440, t0, DIAL_END, 0.075) + + // --- 2. Touch-Tone dialling ---------------------------------------- + const ROW = [941, 697, 697, 697, 770, 770, 770, 852, 852, 852] + const COL = [1336, 1209, 1336, 1477, 1209, 1336, 1477, 1209, 1336, 1477] + let at = DIAL_END + 0.12 + for (const digit of DIAL_UP.number) { + const end = at + 0.09 + tone(ROW[digit]!, at, end, 0.085) + tone(COL[digit]!, at, end, 0.085) + at = end + 0.055 + } + + // --- 3. ringback --------------------------------------------------- + const RING_START = t0 + DIAL_UP.ringAt + const RING_END = RING_START + DIAL_UP.ring + tone(440, RING_START, RING_END, 0.07) + tone(480, RING_START, RING_END, 0.07) + + // --- 4. the far end answers ---------------------------------------- + const ANSWER_START = t0 + DIAL_UP.answerAt + const ANSWER_END = ANSWER_START + DIAL_UP.answer + tone(2225, ANSWER_START, ANSWER_END, 0.055) + + // --- 5. both ends talking at once ---------------------------------- + /* + * One oscillator per direction, keyed bit by bit. 110 baud is 9.09 ms a + * bit, and the bits are random because what is actually going down the + * line at this point is a login banner nobody has typed yet -- scrambled + * to the ear either way. + */ + const DATA_START = ANSWER_END - 0.1 + const DATA_END = t0 + DIAL_UP.total + const BIT = 1 / 110 + + const channel = (mark: number, space: number, gain: number) => { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(mark, DATA_START) + for (let t = DATA_START; t < DATA_END; t += BIT) { + osc.frequency.setValueAtTime(Math.random() < 0.5 ? mark : space, t) + } + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, DATA_START) + env.gain.exponentialRampToValueAtTime(gain, DATA_START + 0.05) + env.gain.setValueAtTime(gain, DATA_END - 0.08) + env.gain.exponentialRampToValueAtTime(0.0001, DATA_END) + osc.connect(env).connect(line) + osc.start(DATA_START) + osc.stop(DATA_END + 0.02) + stop.push(osc) + } + + channel(1270, 1070, 0.05) // originating end: the coupler on this desk + channel(2225, 2025, 0.042) // answering end: the machine at the other + + // --- line noise, under all of it ----------------------------------- + const hiss = ctx.createBufferSource() + hiss.buffer = this.noiseBuffer + hiss.loop = true + const hissGain = ctx.createGain() + hissGain.gain.setValueAtTime(0.012, t0) + // The line gets noisier once it is carrying something. + hissGain.gain.setValueAtTime(0.012, DATA_START) + hissGain.gain.linearRampToValueAtTime(0.03, DATA_START + 0.2) + hissGain.gain.linearRampToValueAtTime(0.0001, DATA_END) + hiss.connect(hissGain).connect(line) + hiss.start(t0) + hiss.stop(DATA_END + 0.05) + stop.push(hiss) + + return () => { + for (const node of stop) { + try { + node.stop() + } catch { + // Already stopped; nothing to do. + } + } + } + } +} + +export const synth = new Synth() diff --git a/src/audio/tunes.test.ts b/src/audio/tunes.test.ts new file mode 100644 index 0000000..381b57f --- /dev/null +++ b/src/audio/tunes.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { DEEP_END, DEEP_PATROL, DEEP_TITLE } from './deepspace' +import { noteToFreq, type Tune } from './synth' +import { END_TUNE, PATROL_TUNE, TITLE_TUNE } from './tunes' + +/** + * The music cannot be listened to by a test, but it can be proved to be + * playable, which is the half that fails silently. + * + * A mistyped note name does not throw: `noteToFreq` returns 0, the oscillator + * is set to 0 Hz, and that voice simply is not there for the rest of the + * piece. You would have to notice a missing inner part by ear, in a tune you + * have heard forty times while testing something else. So every step of every + * track is checked here instead. + */ + +const KIT = new Set(['K', 'S', 'C', 'H', 'O']) +const TUNES: [string, Tune][] = [ + ['TITLE_TUNE', TITLE_TUNE], + ['PATROL_TUNE', PATROL_TUNE], + ['END_TUNE', END_TUNE], + ['DEEP_TITLE', DEEP_TITLE], + ['DEEP_PATROL', DEEP_PATROL], + ['DEEP_END', DEEP_END], +] + +describe.each(TUNES)('%s', (_name, tune) => { + it('names a note the synth can find, on every step of every track', () => { + for (const track of tune.tracks) { + for (const step of track.notes) { + if (step === '.' || step === '=') continue + if (track.wave === 'noise') { + expect(KIT).toContain(step) + continue + } + // Chords are stacked with slashes; every voice in one has to parse. + for (const note of step.split('/')) { + expect(noteToFreq(note), `${note} in ${step}`).toBeGreaterThan(0) + } + } + } + }) + + it('is the same length on every track, so the loop does not drift', () => { + const lengths = new Set(tune.tracks.map((t) => t.notes.length)) + expect(lengths.size).toBe(1) + }) + + it('starts on a note rather than a tie', () => { + // '=' sustains the previous step, and there is no previous step at zero. + for (const track of tune.tracks) expect(track.notes[0]).not.toBe('=') + }) +}) diff --git a/src/audio/tunes.ts b/src/audio/tunes.ts new file mode 100644 index 0000000..0212a03 --- /dev/null +++ b/src/audio/tunes.ts @@ -0,0 +1,173 @@ +import type { Tune } from './synth' + +/** + * The 1971 skin's music, and a word about what it is honestly claiming. + * + * The original was silent. It ran on a mainframe through a printing + * terminal, and a printing terminal's only instrument is its own print head + * -- which this game does play, in `synth.print()`. So a chiptune here is not + * a restoration of anything, and is not pretending to be. + * + * What it is instead: the music the game would have got when it followed + * everybody home. The listing spread through Creative Computing and was typed + * into every machine of the next decade, and somebody porting a space game to + * a home computer in 1981 had three channels and a noise generator and every + * intention of sounding like a film. + * + * So this is the same theme the remaster plays -- same key, same eight bars, + * same chords underneath -- arranged for what that machine actually had. The + * remaster is a remaster of the music too, which is the point: switching skin + * re-scores the piece rather than changing the record. + * + * See `deepspace.ts` for the theme itself and for why an idiom is not a + * quotation. Eighths, eight to a bar, D major. + */ + +const bar = (...notes: string[]) => notes + +/** The theme, on the loudest voice the machine has. */ +export const TITLE_TUNE: Tune = { + bpm: 132, + stepsPerBeat: 2, + tracks: [ + { + wave: 'pulse25', + gain: 0.16, + notes: [ + ...bar('.', '.', '.', 'A4', 'D5', '=', '=', '='), + ...bar('D5', '=', '=', '=', 'E5', '.', 'F#5', '.'), + ...bar('G5', '=', '=', '=', '=', '=', '.', '.'), + ...bar('F#5', '.', 'E5', '.', 'D5', '=', '=', '.'), + ...bar('.', '.', '.', 'A4', 'B4', '=', '=', '='), + ...bar('B4', '=', '=', '.', 'C#5', '.', 'D5', '.'), + ...bar('E5', '=', '=', '=', 'D5', '.', 'C#5', '.'), + ...bar('D5', '=', '=', '=', '=', '=', '=', '.'), + ], + }, + { + /* + * The strings, as a home computer had to do them: no chord voice, so + * the chord is arpeggiated fast underneath and your ear assembles it. + * This is the single most 1981 thing in the repository. + */ + wave: 'pulse12', + gain: 0.05, + notes: [ + ...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'), + ...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'), + ...bar('G3', 'B3', 'D4', 'B3', 'G3', 'B3', 'D4', 'B3'), + ...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'), + ...bar('B3', 'D4', 'F#4', 'D4', 'B3', 'D4', 'F#4', 'D4'), + ...bar('G3', 'B3', 'D4', 'B3', 'G3', 'B3', 'D4', 'B3'), + ...bar('A3', 'C#4', 'E4', 'C#4', 'A3', 'C#4', 'E4', 'C#4'), + ...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'), + ], + }, + { + wave: 'triangle', + gain: 0.21, + notes: [ + ...bar('D2', '.', 'D2', '.', 'A2', '.', 'D2', '.'), + ...bar('D2', '.', 'D2', '.', 'A2', '.', 'D2', '.'), + ...bar('G1', '.', 'G1', '.', 'D2', '.', 'G1', '.'), + ...bar('D2', '.', 'D2', '.', 'A2', '.', 'D2', '.'), + ...bar('B1', '.', 'B1', '.', 'F#2', '.', 'B1', '.'), + ...bar('G1', '.', 'G1', '.', 'D2', '.', 'G1', '.'), + ...bar('A1', '.', 'A1', '.', 'E2', '.', 'A1', '.'), + ...bar('D2', '.', 'A1', '.', 'D2', '=', '=', '.'), + ], + }, + { + // The timpani, as noise, because that is what there was. + wave: 'noise', + gain: 0.34, + notes: [ + ...bar('K', '.', 'H', '.', 'K', '.', 'H', '.'), + ...bar('.', '.', 'H', '.', '.', '.', 'H', '.'), + ...bar('K', '.', 'H', '.', 'K', '.', 'H', '.'), + ...bar('.', '.', 'H', '.', '.', '.', 'H', 'H'), + ...bar('K', '.', 'H', '.', 'K', '.', 'H', '.'), + ...bar('.', '.', 'H', '.', '.', '.', 'H', '.'), + ...bar('K', '.', 'K', '.', 'K', '.', 'K', '.'), + ...bar('K', '.', '.', '.', 'S', '.', '.', '.'), + ], + }, + ], +} + +/** + * On patrol. Four bars, almost empty, with the horn call from the theme + * every second bar and nothing else. Anything more would be unbearable by + * the fourth warp. + */ +export const PATROL_TUNE: Tune = { + bpm: 112, + stepsPerBeat: 2, + tracks: [ + { + wave: 'pulse50', + gain: 0.07, + notes: [ + ...bar('.', '.', '.', '.', '.', '.', 'A4', '.'), + ...bar('D5', '=', '=', '=', '.', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', 'F#4', '.'), + ...bar('B4', '=', '=', '=', '.', '.', '.', '.'), + ], + }, + { + wave: 'triangle', + gain: 0.18, + notes: [ + ...bar('D2', '=', '=', '=', '.', '.', 'D2', '.'), + ...bar('D2', '=', '=', '=', '.', '.', 'A1', '.'), + ...bar('B1', '=', '=', '=', '.', '.', 'B1', '.'), + ...bar('G1', '=', '=', '=', '.', '.', 'G1', '.'), + ], + }, + { + wave: 'noise', + gain: 0.15, + notes: Array.from({ length: 4 }, () => + bar('.', '.', 'H', '.', '.', '.', 'H', '.'), + ).flat(), + }, + ], +} + +/** The end of it, either way: the same rising cadence the remaster ends on. */ +export const END_TUNE: Tune = { + bpm: 120, + stepsPerBeat: 2, + tracks: [ + { + wave: 'pulse25', + gain: 0.16, + notes: [ + ...bar('D5', '=', '=', '=', '=', '=', '=', '.'), + ...bar('E5', '=', '=', '=', '=', '=', '=', '.'), + ...bar('F#5', '=', '=', '=', '=', '=', 'G5', '.'), + ...bar('A5', '=', '=', '=', '=', '=', '=', '.'), + ], + }, + { + wave: 'pulse12', + gain: 0.05, + notes: [ + ...bar('G3', 'B3', 'D4', 'B3', 'G3', 'B3', 'D4', 'B3'), + ...bar('A3', 'C#4', 'E4', 'C#4', 'A3', 'C#4', 'E4', 'C#4'), + ...bar('B3', 'D4', 'F#4', 'D4', 'B3', 'D4', 'F#4', 'D4'), + ...bar('D4', 'F#4', 'A4', 'F#4', 'D4', 'F#4', 'A4', 'F#4'), + ], + }, + { + wave: 'triangle', + gain: 0.22, + notes: [ + ...bar('G1', '=', '=', '=', 'G1', '.', '.', '.'), + ...bar('A1', '=', '=', '=', 'A1', '.', '.', '.'), + ...bar('B1', '=', '=', '=', 'B1', '.', '.', '.'), + ...bar('D2', '=', '=', '=', '=', '=', '=', '.'), + ], + }, + ], +} diff --git a/src/components/BridgeScreen.tsx b/src/components/BridgeScreen.tsx index 2d0a0f5..987f3f5 100644 --- a/src/components/BridgeScreen.tsx +++ b/src/components/BridgeScreen.tsx @@ -1,6 +1,10 @@ import { useEffect, useRef, useState } from 'react' import { condition, daysLeft } from '../game/engine' import type { Command, Line, Patrol } from '../game/types' +import { useSkin } from '../skin' +import { LogPanel } from './LogPanel' +import { ShipPanel } from './ShipPanel' +import { TacticalView } from './TacticalView' import { Teletype } from './Teletype' /** @@ -11,6 +15,18 @@ import { Teletype } from './Teletype' * because a printing terminal has no form to fill in and no way back to a * field you have already left. That is kept exactly, and it is why this * component is a tiny state machine rather than a set of inputs. + * + * Both skins get that same one-line console, and it is the same console: + * whatever the screen around it looks like, the game is played by answering + * one question at a time. + * + * What changes is everything else. The paper is a single column because a + * roll of paper is a single column. The remaster is a bridge, which means + * three: the ship down the left, the sensors in the middle, the log down the + * right. The first draft put the sensors in the middle of a paper-shaped + * column with the transcript squeezed underneath, and it was unreadable for + * exactly the reason you would expect -- a four-by-three screen is wide, and + * a column throws the width away. */ type Ask = 'command' | 'nav-course' | 'nav-warp' | 'tor-course' | 'beams' | 'shields' @@ -43,6 +59,7 @@ export function BridgeScreen({ over, onCommand, onPrint, + onBlip, }: { patrol: Patrol transcript: Line[] @@ -51,7 +68,10 @@ export function BridgeScreen({ over: boolean onCommand: (cmd: Command, echo: Line[]) => void onPrint: (lines: Line[]) => void + onBlip: () => void }) { + const { skin } = useSkin() + const [panel, setPanel] = useState<'sector' | 'galaxy'>('sector') const [ask, setAsk] = useState('command') const [course, setCourse] = useState(0) const [text, setText] = useState('') @@ -103,6 +123,11 @@ export function BridgeScreen({ return } + // Asking for a scan should show you that scan. The panel follows the + // command rather than making the player find the right tab afterwards. + if (cmd === 'LRS' || cmd === 'COM') setPanel('galaxy') + if (cmd === 'SRS') setPanel('sector') + const simple: Record = { SRS: { type: 'srs' }, LRS: { type: 'lrs' }, @@ -128,6 +153,7 @@ export function BridgeScreen({ break case 'nav-warp': onCommand({ type: 'nav', course, warp: value }, [echo(answer)]) + setPanel('sector') setAsk('command') break case 'tor-course': @@ -147,26 +173,39 @@ export function BridgeScreen({ return (
- + {skin === 'modern' ? ( +
+ + + +
+ ) : ( + <> + - {/* - A concession, and the only one: the machine printed the ship's numbers - beside a short range scan and nowhere else, so the 1971 way to know - your energy was to remember it or spend a command asking. That is fine - on paper you can hold and unkind on a phone, where the answer has - already scrolled. The scan is still the only place the *galaxy* - appears; this strip only ever says what is true of the ship. - */} -
- {captainName} - STARDATE {patrol.day.toFixed(1)} - DAYS {daysLeft(patrol).toFixed(1)} - ENERGY {Math.round(patrol.energy)} - SHIELDS {Math.round(patrol.shields)} - TORP {patrol.torpedoes} - RAIDERS {patrol.raidersLeft} - {condition(patrol)} -
+ {/* + A concession, and the only one on this skin: the machine printed + the ship's numbers beside a short range scan and nowhere else, so + the 1971 way to know your energy was to remember it or spend a + command asking. That is fine on paper you can hold and unkind on a + phone, where the answer has already scrolled. The scan is still + the only place the *galaxy* appears; this strip only ever says + what is true of the ship. + */} +
+ {captainName} + STARDATE {patrol.day.toFixed(1)} + DAYS {daysLeft(patrol).toFixed(1)} + ENERGY {Math.round(patrol.energy)} + SHIELDS {Math.round(patrol.shields)} + TORP {patrol.torpedoes} + RAIDERS {patrol.raidersLeft} + + {condition(patrol)} + +
+ + )} {/* The prompt never goes away while the patrol is running, even mid-print. @@ -207,7 +246,10 @@ export function BridgeScreen({ key={id} className="btn btn-ghost key" title={what} - onClick={() => submit(id)} + onClick={() => { + onBlip() + submit(id) + }} > {id} diff --git a/src/components/Frame.tsx b/src/components/Frame.tsx index d108aa3..033812f 100644 --- a/src/components/Frame.tsx +++ b/src/components/Frame.tsx @@ -1,24 +1,36 @@ import { useLayoutEffect, useRef, useState, type ReactNode } from 'react' +import { useSkin } from '../skin' /** * The thing the game is happening inside. * - * 1971 is a printing terminal on the end of a telephone line, so the frame is - * a sheet of fanfold paper coming out of a machine: sprocket holes down both - * edges, a platen at the top, and no screen anywhere. When the remaster - * arrives it will bring its own frame and everything inside will be laid out - * identically -- only the box will change. + * Both skins get a frame, and the frames are not the same object. 1971 is a + * printing terminal on the end of a telephone line, so it is a sheet of + * fanfold paper coming out of a machine: sprocket holes down both edges, a + * platen at the top, and no screen anywhere. The remaster is a lit bridge, so + * it is a screen in a housing with the glass curving away at the edges. + * Everything inside is laid out identically -- only the box changes. */ export function Frame({ children, footer }: { children: ReactNode; footer?: ReactNode }) { + const { skin } = useSkin() return (
-
+ {skin === 'teletype' &&
}
{children}
-
-
-
+ {skin === 'teletype' ? ( + <> +
+
+
+ + ) : ( + <> +
+
+ + )}
{footer} diff --git a/src/components/LogPanel.tsx b/src/components/LogPanel.tsx new file mode 100644 index 0000000..c4ac3e3 --- /dev/null +++ b/src/components/LogPanel.tsx @@ -0,0 +1,43 @@ +import { useEffect, useRef } from 'react' +import type { Line } from '../game/types' + +/** + * The remaster's right-hand column: what the machine has said. + * + * Two deliberate differences from the paper, and they are the whole reason + * the remaster is readable: + * + * **The scans are not in here.** A short range scan is eight rows of + * three-character columns and a long range scan is a nine-cell block of + * digits; on paper that is the only way to show a grid, and on a screen it is + * a picture of a grid rendered in text next to an actual grid. So `scan` + * lines are drawn by `TacticalView` instead. Both skins are told exactly the + * same things -- one of them draws the part that is a diagram. + * + * **Nothing types.** The print head is 1971's, and it is the right sound and + * the right speed for paper. Here the lines land, because you are reading + * this column while looking at the grid next to it, and a line that arrives + * one character at a time in the corner of your eye is a distraction rather + * than a machine. + */ +export function LogPanel({ lines }: { lines: Line[] }) { + const box = useRef(null) + const shown = lines.filter((l) => l.kind !== 'scan') + + useEffect(() => { + box.current?.scrollTo({ top: box.current.scrollHeight, behavior: 'smooth' }) + }, [shown.length]) + + return ( + + ) +} diff --git a/src/components/ShipPanel.tsx b/src/components/ShipPanel.tsx new file mode 100644 index 0000000..0c0ffb3 --- /dev/null +++ b/src/components/ShipPanel.tsx @@ -0,0 +1,108 @@ +import { DEVICE_NAMES, START_ENERGY, START_TORPEDOES } from '../game/constants' +import { condition, daysLeft } from '../game/engine' +import { DEVICES, type Patrol } from '../game/types' + +/** + * The remaster's left-hand column: everything true of the ship. + * + * On paper this was a strip of text down the side of a short range scan, + * eight facts on eight rows, because that is what a printing terminal could + * do. Given a screen and no printer, the two numbers you actually watch -- + * energy and shields -- become bars, because a bar answers "how much is + * left" without being read, and that is the question you ask forty times a + * patrol. + * + * Nothing here is new information. It is the same eight facts, plus the + * damage report, which in 1971 cost you a command to ask for and here does + * not. + */ +export function ShipPanel({ patrol, captainName }: { patrol: Patrol; captainName: string }) { + const cond = condition(patrol) + const broken = DEVICES.filter((d) => patrol.damage[d] > 0) + + return ( + + ) +} + +function Bar({ + label, + value, + of, + kind, +}: { + label: string + value: number + of: number + kind: string +}) { + const pct = Math.max(0, Math.min(100, (value / of) * 100)) + return ( +
+
+ {label} + {Math.round(value)} +
+
+ +
+
+ ) +} diff --git a/src/components/TacticalView.tsx b/src/components/TacticalView.tsx new file mode 100644 index 0000000..d594527 --- /dev/null +++ b/src/components/TacticalView.tsx @@ -0,0 +1,214 @@ +import { GALAXY, SECTORS } from '../game/constants' +import { censusCode } from '../game/galaxy' +import { condition } from '../game/engine' +import type { Patrol } from '../game/types' + +/** + * The remaster: the sensors, drawn. + * + * Every mark here is generated from the game state -- there is no artwork + * file in this repository and there is not going to be one. Cel shading in + * practice means flat fills, one heavy outline, and a highlight that is a + * second flat fill rather than a gradient, which is a style that happens to + * suit a thing made entirely of `` and ``. + * + * The hard rule is that this component may not show the player anything the + * teletype has not already offered, and here that rule has teeth, because + * these two panels *are* two of the ship's devices: + * + * - the sector grid is the short range sensors. Break them and it goes + * dark, exactly as SRS refuses to print; + * - the chart is the computer, and it draws `patrol.chart` -- what has + * been scanned -- rather than `patrol.galaxy`, which is the truth. An + * unscanned quadrant is three dots on both skins. + * + * Drawing the truth would make the remaster an easier game than the + * original, and they are meant to be the same game. + */ + +const CELL = 40 +const HALF = CELL / 2 + +export function TacticalView({ + patrol, + panel, + onPanel, +}: { + patrol: Patrol + panel: 'sector' | 'galaxy' + onPanel: (p: 'sector' | 'galaxy') => void +}) { + return ( +
+
+ + +
+ {panel === 'sector' ? : } +
+ ) +} + +/** The quadrant you are standing in, at sector resolution. */ +function SectorGrid({ patrol }: { patrol: Patrol }) { + const size = CELL * SECTORS + + if (patrol.damage.srs > 0) { + return + } + + const at = (c: { row: number; col: number }) => ({ + x: c.col * CELL + HALF, + y: c.row * CELL + HALF, + }) + + return ( + + + + + {patrol.stars.map((s, i) => { + const { x, y } = at(s) + return ( + + + {/* The highlight is a second flat fill, not a gradient. */} + + + ) + })} + + {patrol.base && ( + + + + + )} + + {patrol.raiders.map((k, i) => { + const { x, y } = at(k.at) + return ( + + {/* A dart, turned to face the ship, so a quadrant reads as a + fight rather than as a list of objects. Which way a raider is + pointing is decoration -- it has no facing in the rules -- but + it is decoration that tells the truth: it is coming for you. */} + + + ) + })} + + {/* + Your ship, pointing up and staying that way. The first draft drew a + saucer with a hull under it and at this size it read as a hamburger: + two stacked shapes of similar width, the lower one darker. An + arrowhead has one silhouette and survives being sixteen pixels tall, + which is the only test that matters here. + */} + + + + + + + ) +} + +/** The chart: what has been scanned, three digits at a time. */ +function GalaxyGrid({ patrol }: { patrol: Patrol }) { + const size = CELL * GALAXY + + if (patrol.damage.computer > 0) { + return + } + + const cells = [] + for (let row = 0; row < GALAXY; row++) { + for (let col = 0; col < GALAXY; col++) { + const census = patrol.chart[row]![col]! + const here = row === patrol.quadrant.row && col === patrol.quadrant.col + const classes = [ + 'quad', + census === null ? 'unknown' : '', + census && census.raiders > 0 ? 'hostile' : '', + census?.base ? 'has-base' : '', + here ? 'here' : '', + ] + .filter(Boolean) + .join(' ') + + cells.push( + + + + {census === null ? '···' : censusCode(census)} + + , + ) + } + } + + return ( + + + {cells} + + ) +} + +/** A device that is out. The panel is the device, so it goes dark with it. */ +function Dark({ label, size }: { label: string; size: number }) { + return ( + + + + {label} + + + ) +} + +function Rules({ n, size }: { n: number; size: number }) { + const lines = [] + for (let i = 1; i < n; i++) { + lines.push() + lines.push() + } + return {lines} +} + +/** Degrees from a raider towards the ship, for the dart to point along. */ +function angle(from: { row: number; col: number }, to: { row: number; col: number }): number { + return (Math.atan2(to.row - from.row, to.col - from.col) * 180) / Math.PI +} diff --git a/src/components/Teletype.tsx b/src/components/Teletype.tsx index 00d6886..a708a25 100644 --- a/src/components/Teletype.tsx +++ b/src/components/Teletype.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import { synth } from '../audio/synth' import type { Line } from '../game/types' /** @@ -14,10 +15,15 @@ import type { Line } from '../game/types' * A scan is exempt. Eight rows of it at reading speed is most of a minute, * and the original's printer was hammering all eight out while the captain * was already deciding -- so the grid lands whole and the sentences type. + * + * The print head is played per character, which at this rate is a lot of + * one-shot oscillators -- that is why `synth.print()` is as small as it is. + * Only the 1971 skin plays it: the remaster has a screen, and a screen does + * not clatter. */ const CPS = 60 // Faster than a real ASR-33. A real one is 10, and it is a lot. -export function Teletype({ lines }: { lines: Line[] }) { +export function Teletype({ lines, clatter = false }: { lines: Line[]; clatter?: boolean }) { const [printed, setPrinted] = useState([]) const [partial, setPartial] = useState('') const done = useRef(0) @@ -49,8 +55,10 @@ export function Teletype({ lines }: { lines: Line[] }) { const timer = window.setInterval(() => { at += 1 setPartial(text.slice(0, at)) + if (clatter && synth.sfxOn) synth.print() if (at >= text.length) { window.clearInterval(timer) + if (clatter && synth.sfxOn) synth.carriage() done.current += 1 setPartial('') setPrinted((p) => [...p, text]) @@ -59,7 +67,7 @@ export function Teletype({ lines }: { lines: Line[] }) { return () => window.clearInterval(timer) // `printed` is the trigger: finishing one line starts the next. - }, [lines, printed.length]) + }, [lines, printed.length, clatter]) // The paper feeds up; you always read at the bottom. useEffect(() => { diff --git a/src/components/screens.tsx b/src/components/screens.tsx index 1d61dd8..a804ee4 100644 --- a/src/components/screens.tsx +++ b/src/components/screens.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState } from 'react' +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' @@ -21,36 +22,41 @@ import { Btn, Line } from './Frame' * 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. + * 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. * - * 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. + * 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'], - [1.1, 'DIALLING'], - [2.6, 'RINGING'], - [4.2, 'ANSWER TONE'], - [5.2, 'CARRIER -- 110 BAUD, FULL DUPLEX'], - [6.0, 'LOGIN ACCEPTED'], + [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'], ] -const BOOT_TOTAL = 6.8 - 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(onLoaded, BOOT_TOTAL * 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]) @@ -62,7 +68,13 @@ export function BootScreen({ onLoaded }: { onLoaded: () => void }) { THE PROGRAM IS NOT ON THIS MACHINE. IT IS ON SOMEBODY ELSE'S. PUT THE HANDSET IN THE COUPLER AND CALL IT. - setDialling(true)}> + { + synth.ensure() + setDialling(true) + }} + > DIAL
@@ -150,7 +162,13 @@ export function InstructionsScreen({ onDone }: { onDone: () => void }) { // ------------------------------------------------------------------ setup -export function SetupScreen({ onStart }: { onStart: (names: string[]) => void }) { +export function SetupScreen({ + onStart, + onBlip, +}: { + onStart: (names: string[]) => void + onBlip: () => void +}) { const [names, setNames] = useState(['']) const set = (i: number, v: string) => @@ -177,7 +195,14 @@ export function SetupScreen({ onStart }: { onStart: (names: string[]) => void })
{names.length < MAX_CAPTAINS && ( - setNames((n) => [...n, ''])}>ONE MORE + { + onBlip() + setNames((n) => [...n, '']) + }} + > + ONE MORE + )} onStart(names)}> CAST OFF diff --git a/src/game/highscores.ts b/src/game/highscores.ts index ce77105..67171df 100644 --- a/src/game/highscores.ts +++ b/src/game/highscores.ts @@ -62,7 +62,7 @@ async function call(path: string, init?: RequestInit): Promise { const res = await fetch(`${BASE}${path}`, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS), - headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) }, + headers: { 'content-type': 'application/json', ...init?.headers }, }) const body: unknown = await res.json().catch(() => null) if (!res.ok) { diff --git a/src/index.css b/src/index.css index 76e7965..50ebf40 100644 --- a/src/index.css +++ b/src/index.css @@ -23,8 +23,9 @@ body, justify-content: center; } -/* The room the machine is standing in: a terminal room in a university - basement, lit by the ceiling and nothing else. */ +/* The room the machine is standing in. 1971 is a terminal room in a + university basement, lit by the ceiling and nothing else; the remaster is a + bridge at night, lit by its own instruments. */ body { background: radial-gradient(120% 80% at 50% 0%, #5a5a5a 0%, #3a3a3a 55%, #232323 100%); color: #241f1a; @@ -33,6 +34,16 @@ body { align-items: center; justify-content: center; padding: clamp(8px, 2vw, 28px); + transition: background 400ms ease; +} + +html[data-skin='modern'] body { + background: + radial-gradient(80% 55% at 18% 0%, rgba(90, 208, 255, 0.14) 0%, transparent 60%), + radial-gradient(70% 55% at 86% 18%, rgba(255, 194, 71, 0.12) 0%, transparent 55%), + radial-gradient(130% 95% at 50% 100%, #131e3c 0%, #0b1226 55%, #04070f 100%); + color: #e8f1ff; + font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif; } @media (prefers-reduced-motion: reduce) { diff --git a/src/main.tsx b/src/main.tsx index 60d0162..bbff60a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,17 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' - -/** - * There is one skin so far, and it is the machine as it was. The remaster - * will bring a provider and a toggle with it; until then the attribute is set - * once here so the stylesheet has something to key off and nothing has to - * pretend there is a choice. - */ -document.documentElement.dataset.skin = 'teletype' +import { SkinProvider } from './SkinProvider.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/skin.ts b/src/skin.ts new file mode 100644 index 0000000..5981888 --- /dev/null +++ b/src/skin.ts @@ -0,0 +1,47 @@ +import { createContext, useContext } from 'react' + +/** + * Two skins over one game. "teletype" is 1971 as it actually was -- a + * printing terminal, no screen, no colour and no sound but the print head. + * "modern" is the same patrol with the sensors drawn. The rules never differ + * between them, and neither does a single word the machine prints. + * + * Worth saying why this is a printing terminal rather than a CRT, since the + * lemonade stand's period skin is an Atari television. This game is eight + * years older than that machine. It was written for a timesharing system and + * played on paper, which is why it asks you to remember things: there is no + * screen to look back at, only what you have already torn off. + */ +export type Skin = 'teletype' | 'modern' + +export const SKIN_KEY = 'scan.skin.v1' + +export function readSkin(): Skin { + try { + return window.localStorage.getItem(SKIN_KEY) === 'teletype' ? 'teletype' : 'modern' + } catch { + return 'modern' + } +} + +export function writeSkin(s: Skin): void { + try { + window.localStorage.setItem(SKIN_KEY, s) + } catch { + // A skin that does not stick is a small loss; carry on. + } +} + +export interface SkinApi { + skin: Skin + setSkin: (s: Skin) => void + toggle: () => void +} + +export const SkinContext = createContext({ + skin: 'modern', + setSkin: () => {}, + toggle: () => {}, +}) + +export const useSkin = () => useContext(SkinContext)