Hunt the Wumpus, in two skins

A browser recreation of Gregory Yob's 1973 game. The rules are his and are
free to use; the sentences he wrote to explain them are not, and none of them
are here. Every line this game prints was written for it. NOTICE.md sets out
what is ours, what is not, and why -- the same argument the lemonade stand
makes about its own text, made before shipping rather than after.

Two skins over one game, and the period one is a Teletype rather than a
television. Wumpus is six years older than the Atari the lemonade stand runs
on. It lived on a timesharing service you telephoned and it printed on paper,
which is why the game asks you to remember things: there is no screen to look
back at. So 1973 is fanfold and black ink and a print head hammering out one
character at a time, and the remaster is the same cave with the lamp on. The
rules never differ, and neither does a single word.

The cave is proved rather than trusted. The adjacency table is typed out by
hand, because a generated dodecahedron comes out with an arbitrary numbering
and this is the numbering every listing has used since 1973 -- so a transcript
from a magazine still makes sense against it. Typed out means it can be typed
wrong, and a wrong table would not crash: it would quietly stop being a
dodecahedron while the game still played. cave.test.ts checks degree three,
symmetry, connectivity, a diameter of exactly five, and three pentagons
through every room.

The map is not allowed to know more than the paper. CaveMap draws only from
the rooms the hunter has stood in and the warnings those rooms gave, and it
marks suspects rather than answers. That guarantee is asserted in the engine
rather than in the component, because it is a property of the engine and would
still have to hold if the map were thrown away.

Drawing a solid flat took two attempts. Rooms 6 to 15 are not two pentagons at
different depths, they are one ten-room ring alternating inward and outward
wiring, and getting that wrong still draws and still looks cave-shaped. It is
much harder to read. The test checks the property that actually matters --
that no tunnel crosses another -- and was confirmed to fail against the bad
layout before it was believed.

The dial-in is a real Bell 103 call rather than a noise: dial tone at 350 and
440 Hz, Touch-Tone dialling, ringback at 440 and 480, the far modem's 2225 Hz
mark tone, then both ends keying at once through a telephone band. The screech
everybody remembers is a V.34 handshake from the 1990s and could not have
existed here.

38 tests. Typecheck clean.
This commit is contained in:
2026-09-08 22:32:31 -07:00
commit 6485f9b161
37 changed files with 8618 additions and 0 deletions
+681
View File
@@ -0,0 +1,681 @@
/* =====================================================================
Two skins, one game. Layout is shared; every colour and every piece of
chrome comes from a token that the skin swaps.
1973 is a Teletype Model 33: 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.
The remaster is underground: warm lamplight in a cold cave, flat cel fills
and one heavy outline on everything.
===================================================================== */
:root {
/* --- 1973: paper ---------------------------------------------------- */
--ink: #241f1a;
--ink-dim: #6b6157;
--ink-bright: #0d0b09;
--accent: #241f1a;
--warn: #241f1a;
--outline: #b6a992;
--screen-bg: #e8e0cc;
--screen-radius: 2px;
--paper-edge: #d6cbb2;
--glow: none;
--btn-radius: 0;
--btn-shadow: none;
--btn-bg: #f3ecd9;
--btn-fg: #241f1a;
--field-bg: #f7f1e0;
--field-fg: #241f1a;
--map-radius: 0;
--body-font: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace;
--line-height: 1.42;
--letter-spacing: 0.02em;
/* Cave map tokens, unused by the teletype but defined so nothing is
ever undefined when the skin flips mid-render. */
--rock: #2a2135;
--room-fill: #3d3350;
--room-line: #16111f;
--room-here: #ffd93d;
--room-safe: #7be495;
--room-suspect: #ff9f4a;
--tunnel: #4a3f5e;
--tunnel-lit: #ffd08a;
color-scheme: light;
}
html[data-skin='modern'] {
/* --- now: the cave -------------------------------------------------- */
--ink: #f4ece0;
--ink-dim: #a596b8;
--ink-bright: #ffffff;
--accent: #ffd93d;
--warn: #ff7a6b;
--outline: #16111f;
--screen-bg: linear-gradient(175deg, #3a2f4c 0%, #2a2135 50%, #1b1524 100%);
--screen-radius: 26px;
--paper-edge: transparent;
--glow: 0 0 18px rgba(255, 208, 138, 0.25);
--btn-radius: 999px;
--btn-shadow: 4px 4px 0 var(--outline);
--btn-bg: #f4ece0;
--btn-fg: #16111f;
--field-bg: #f4ece0;
--field-fg: #16111f;
--map-radius: 18px;
--body-font: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, sans-serif;
--line-height: 1.5;
--letter-spacing: 0;
color-scheme: dark;
}
/* =====================================================================
Layout
===================================================================== */
.app {
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.cabinet {
width: min(980px, 100%);
display: flex;
flex-direction: column;
gap: 12px;
}
.bezel {
position: relative;
padding: clamp(10px, 2vw, 20px);
border-radius: calc(var(--screen-radius) + 10px);
background: #cfc4ab;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
}
html[data-skin='modern'] .bezel {
background: linear-gradient(160deg, #241c30 0%, #150f1e 100%);
border: 4px solid var(--outline);
box-shadow: 0 22px 60px rgba(0, 0, 0, 0.6);
}
/* The platen: the roller the paper comes over, with the feed knob at the end. */
.platen {
height: 16px;
margin: -4px 0 10px;
border-radius: 8px;
background: linear-gradient(180deg, #4a4038 0%, #2b241e 60%, #1a1512 100%);
box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.5);
position: relative;
}
.platen::after {
content: '';
position: absolute;
right: -10px;
top: -5px;
width: 26px;
height: 26px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #6b5c4d, #241f1a 70%);
}
.screen {
position: relative;
overflow: hidden;
border-radius: var(--screen-radius);
background: var(--screen-bg);
aspect-ratio: 4 / 3;
box-shadow: var(--glow);
}
@media (max-width: 640px) {
/* A phone is taller than it is wide; a fixed 4:3 wastes most of it. */
.screen {
aspect-ratio: 3 / 4;
}
}
.screen-inner {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: clamp(12px, 2.6vw, 26px);
color: var(--ink);
font-family: var(--body-font);
font-size: clamp(11px, 1.55vw, 15px);
line-height: var(--line-height);
letter-spacing: var(--letter-spacing);
}
.screen-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Fit shrinks rather than clipping; see Frame.tsx. */
.screen-body > * {
transform: scale(var(--fit, 1));
transform-origin: top center;
width: 100%;
}
.stack {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
max-width: 72ch;
margin: 0 auto;
}
html[data-skin='modern'] .stack {
max-width: 60ch;
}
.line {
white-space: pre-wrap;
min-height: 1.2em;
}
.dim {
color: var(--ink-dim);
}
.accent {
color: var(--accent);
font-weight: 700;
}
.warn {
color: var(--warn);
font-weight: 700;
}
/* On paper there is no colour, so emphasis is weight and spacing only. */
:root:not([data-skin='modern']) .accent,
:root:not([data-skin='modern']) .warn {
letter-spacing: 0.14em;
}
/* --- paper chrome ---------------------------------------------------- */
.sprockets {
position: absolute;
top: 0;
bottom: 0;
width: 22px;
background-image: radial-gradient(circle at 50% 10px, #b6a992 3.5px, transparent 4px);
background-size: 22px 26px;
background-color: var(--paper-edge);
pointer-events: none;
}
.sprockets-left {
left: 0;
border-right: 1px dashed #c3b79e;
}
.sprockets-right {
right: 0;
border-left: 1px dashed #c3b79e;
}
/* Paper is not flat and not one colour; a faint fibre keeps it from reading
as a beige rectangle. */
.paper-grain {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0.35;
background-image:
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.025) 0 1px, transparent 1px 3px),
repeating-linear-gradient(90deg, rgba(0, 0, 0, 0.02) 0 1px, transparent 1px 4px);
}
:root:not([data-skin='modern']) .screen-inner {
padding-left: 34px;
padding-right: 34px;
}
/* --- cave chrome ------------------------------------------------------ */
.lamp {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(60% 50% at 50% 42%, rgba(255, 208, 138, 0.16), transparent 70%);
}
.vignette {
position: absolute;
inset: 0;
pointer-events: none;
box-shadow: inset 0 0 120px 40px rgba(0, 0, 0, 0.55);
}
/* =====================================================================
The transcript
===================================================================== */
.paper {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
gap: 1px;
}
.paper::-webkit-scrollbar {
display: none;
}
.printed {
white-space: pre-wrap;
min-height: 1.2em;
}
html[data-skin='modern'] .paper {
max-height: 9em;
color: var(--ink-dim);
font-size: 0.92em;
border-top: 2px solid rgba(244, 236, 224, 0.12);
padding-top: 8px;
margin-top: 8px;
}
/* The print head: a block where the next character is about to land. */
.head {
display: inline-block;
width: 0.62em;
height: 1em;
margin-left: 1px;
vertical-align: text-bottom;
background: currentColor;
opacity: 0.5;
}
html[data-skin='modern'] .head {
display: none;
}
/* =====================================================================
The map
===================================================================== */
.map-wrap {
width: 100%;
display: flex;
justify-content: center;
}
.cave-map {
width: min(100%, 46vh);
height: auto;
border-radius: var(--map-radius);
}
.tunnel {
stroke: var(--tunnel);
stroke-width: 5;
stroke-linecap: round;
}
.tunnel-walked {
stroke: #6a5c82;
}
.tunnel-lit {
stroke: var(--tunnel-lit);
stroke-width: 7;
}
.aim {
stroke: var(--accent);
stroke-width: 3.5;
stroke-dasharray: 7 6;
stroke-linecap: round;
stroke-linejoin: round;
}
.room-disc {
fill: var(--room-fill);
stroke: var(--room-line);
stroke-width: 3;
}
.room-label {
fill: var(--ink-dim);
font-size: 15px;
font-weight: 800;
text-anchor: middle;
font-family: var(--body-font);
}
.room-visited .room-disc {
fill: #4b5f4a;
}
.room-open .room-disc {
fill: #5b4b72;
stroke: var(--tunnel-lit);
}
.room-suspect .room-disc {
fill: #6b4630;
stroke: var(--room-suspect);
}
.room-here .room-disc {
fill: var(--room-here);
stroke: var(--outline);
}
.room-here .room-label {
fill: var(--outline);
}
.room-revealed .room-disc {
fill: #6b3040;
}
.room-visited .room-label,
.room-open .room-label {
fill: var(--ink);
}
.mark path {
fill: none;
stroke-width: 2;
stroke-linecap: round;
}
.mark-wumpus path {
fill: var(--warn);
stroke: var(--outline);
stroke-width: 1.2;
}
.mark-pit path {
stroke: #8fd0ff;
}
.mark-bats path {
stroke: #d7a6ff;
}
.truth path,
.truth ellipse {
stroke: var(--outline);
stroke-width: 1.6;
}
.truth-wumpus path {
fill: #9be36f;
}
.truth-wumpus .eye {
fill: var(--outline);
stroke: none;
}
.truth-wumpus .teeth {
fill: none;
stroke: var(--outline);
stroke-width: 1.2;
}
.truth-pit ellipse {
fill: #120d1a;
}
.truth-pit .deeper {
fill: #000;
}
.truth-bats path {
fill: #c58cff;
}
.hunter-hat {
fill: #ff7a6b;
stroke: var(--outline);
stroke-width: 2;
}
.hunter-crown {
fill: #ffd93d;
stroke: none;
}
/* The map is the remaster's, and the paper skin never draws one. The reveal
on the report screen is the exception, and it is drawn in ink. */
:root:not([data-skin='modern']) .map-reveal .cave-map {
filter: grayscale(1) contrast(1.35) brightness(1.15);
opacity: 0.9;
}
:root:not([data-skin='modern']) .map-reveal .room-disc {
fill: #e8e0cc;
stroke: #241f1a;
}
:root:not([data-skin='modern']) .map-reveal .tunnel {
stroke: #8c8272;
}
/* =====================================================================
The prompt
===================================================================== */
.prompt {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.prompt-q {
font-weight: 700;
}
.prompt-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.prompt-sep {
color: var(--ink-dim);
}
.prompt-note {
color: var(--ink-dim);
font-size: 0.85em;
max-width: 60ch;
}
.statusbar {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--outline);
color: var(--ink-dim);
font-size: 0.9em;
}
.statusbar .senses {
margin-left: auto;
color: var(--warn);
font-weight: 700;
}
.banner {
margin: 0;
font-family: var(--body-font);
font-weight: 900;
font-size: clamp(1.4rem, 5vw, 2.6rem);
letter-spacing: 0.08em;
line-height: 1.1;
color: var(--ink-bright);
}
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);
}
.field {
display: flex;
align-items: center;
gap: 10px;
margin: 3px 0;
}
.field label {
min-width: 10ch;
color: var(--ink-dim);
}
.field-input {
flex: 1;
max-width: 22ch;
padding: 5px 8px;
border: 2px solid var(--outline);
border-radius: var(--btn-radius);
background: var(--field-bg);
color: var(--field-fg);
font: inherit;
text-transform: uppercase;
}
/* =====================================================================
Buttons
===================================================================== */
.btn {
padding: 6px 14px;
border: 2px solid var(--outline);
border-radius: var(--btn-radius);
background: var(--btn-bg);
color: var(--btn-fg);
font: inherit;
font-weight: 700;
letter-spacing: 0.04em;
cursor: pointer;
box-shadow: var(--btn-shadow);
text-decoration: none;
display: inline-block;
transition:
transform 110ms ease,
box-shadow 110ms ease;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
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);
}
.btn-primary {
background: var(--accent);
}
:root:not([data-skin='modern']) .btn-primary {
background: #241f1a;
color: #f3ecd9;
}
.btn-ghost {
background: transparent;
color: var(--ink-dim);
border-color: transparent;
box-shadow: none;
font-weight: 600;
}
.btn-ghost:hover:not(:disabled) {
color: var(--ink);
}
.controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
color: var(--ink-dim);
font-family: var(--body-font);
font-size: 12px;
}
.seed {
margin-left: auto;
opacity: 0.7;
}
/* The stack is a column, so a button in it stretches to the full width unless
it is told not to. A button that wide reads as a banner rather than a
control -- and on the board screen it was wider than the board. */
.stack > .btn {
align-self: flex-start;
}
/* The hunter is two rings around the room, so the number stays legible. */
.hunter-ring {
fill: none;
stroke: var(--room-here);
stroke-width: 3;
}
.hunter-ring-outer {
stroke-width: 1.5;
opacity: 0.45;
}
/* =====================================================================
The footer
It sits outside the frame, on the page rather than on the paper -- so it
cannot use the ink tokens, which are chosen to be dark on a light sheet.
On the teletype skin that made the hovered control the same colour as the
room it is standing in, and the skin toggle disappeared entirely.
===================================================================== */
:root:not([data-skin='modern']) .controls {
color: #9a9186;
}
:root:not([data-skin='modern']) .controls .btn-ghost {
color: #9a9186;
}
:root:not([data-skin='modern']) .controls .btn-ghost:hover:not(:disabled) {
color: #f0e8d4;
}
+413
View File
@@ -0,0 +1,413 @@
import { useCallback, useEffect, useRef, useState, useReducer } from 'react'
import { DREAD_END, DREAD_HUNT, DREAD_TITLE } from './audio/dread'
import { END_TUNE, HUNT_TUNE, TITLE_TUNE } from './audio/tunes'
import { synth } from './audio/synth'
import { NO_TUNNEL_TEXT } from './game/constants'
import { describe, layCave, randomSeed, step } from './game/engine'
import { makeRng, type Rng } from './game/rng'
import { fetchScores, submitScores, type Score } from './game/highscores'
import {
currentHunter,
depthFor,
initialState,
livingHunters,
reducer,
} from './game/reducer'
import type { Command, Line } from './game/types'
import { useSkin } from './skin'
import { Btn, Fit, Frame } from './components/Frame'
import { HuntScreen } from './components/HuntScreen'
import {
BootScreen,
GameOverScreen,
HighScoreScreen,
InstructionsScreen,
ReportScreen,
SetupScreen,
TitleScreen,
type BoardState,
} from './components/screens'
import './App.css'
/**
* AGPL section 13: anyone playing this over a network is entitled to the
* source of the version they are playing, so the offer sits on every screen.
*/
const SOURCE_URL = 'https://github.com/Coffey-Labs/wumpus'
export default function App() {
const [seed] = useState(randomSeed)
const [state, dispatch] = useReducer(reducer, seed, initialState)
const rng = useRef<Rng>(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<Score[]>([])
const [boardState, setBoardState] = useState<BoardState>('loading')
const [freshScores, setFreshScores] = useState<string[]>([])
/** True while the teletype is mid-line, so no prompt appears over it. */
const [printing, setPrinting] = useState(false)
const who = currentHunter(state)
// ------------------------------------------------------------ audio glue
const wake = useCallback(() => {
if (started.current) return
started.current = true
synth.ensure()
synth.setMusic(music)
synth.setSfx(sfx)
}, [music, sfx])
// 1973 gets the chiptune it never had; the remaster gets the other band.
useEffect(() => {
if (!started.current) return
const front =
state.phase === 'title' || state.phase === 'instructions' || state.phase === 'setup'
const set =
skin === 'modern'
? { title: DREAD_TITLE, hunt: DREAD_HUNT, end: DREAD_END }
: { title: TITLE_TUNE, hunt: HUNT_TUNE, end: END_TUNE }
const tune = state.phase === 'gameover' ? set.end : front ? set.title : set.hunt
synth.playTune(tune, false)
}, [state.phase, skin])
useEffect(() => {
synth.setMusic(music)
}, [music])
useEffect(() => {
synth.setSfx(sfx)
}, [sfx])
const loadBoard = useCallback(async () => {
setBoardState('loading')
try {
setScores(await fetchScores())
setBoardState('ready')
} catch {
setBoardState('error')
}
}, [])
const blip = useCallback(() => synth.blip(), [])
// ---------------------------------------------------------------- turns
/**
* Lay a cave for whoever is up and print the first room.
*
* A cave is dealt off the same seeded stream as everything else, which is
* what makes a whole expedition -- four hunters, a dozen caves -- replayable
* from one number in the footer.
*/
const enterCave = useCallback((depth: number, arrows: number) => {
const cave = layCave(depth, arrows, rng.current)
const lines: Line[] = [
{ kind: 'system', text: `HUNT THE WUMPUS -- CAVE ${depth}` },
...describe(cave),
]
dispatch({ type: 'ENTER_CAVE', cave, lines })
}, [])
// Whenever it is somebody's turn and there is no cave, deal them one.
useEffect(() => {
if (state.phase !== 'hunting' || state.cave || !who) return
enterCave(depthFor(who), who.arrows)
}, [state.phase, state.cave, who, enterCave])
const command = (c: Command) => {
if (!state.cave) return
const { cave, lines } = step(state.cave, c, 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.
if (c.type === 'shoot') synth.arrow()
else if (lines.some((l) => l.kind === 'system' && l.text === NO_TUNNEL_TEXT)) synth.reject()
else synth.walk()
if (lines.some((l) => l.kind === 'bats')) synth.bats()
if (cave.outcome === 'fell-in-a-pit') synth.pit()
if (cave.outcome === 'eaten') synth.wumpus()
if (cave.outcome === 'shot-the-wumpus') synth.fanfare()
if (cave.outcome === 'shot-yourself' || cave.outcome === 'out-of-arrows') synth.sad()
// A room that refused the move prints its refusal and nothing else.
const next = cave.outcome
? { cave, lines }
: { cave, lines: c.type === 'move' && cave.you !== state.cave.you ? [...lines, ...describe(cave)] : lines }
dispatch({ type: 'ADVANCE', cave: next.cave, lines: next.lines })
}
// The report waits for the teletype to finish saying how it ended.
useEffect(() => {
if (state.phase !== 'resolve' || printing) return
const t = window.setTimeout(() => dispatch({ type: 'SHOW_REPORT' }), 500)
return () => window.clearTimeout(t)
}, [state.phase, printing])
const start = (names: string[]) => {
wake()
synth.select()
dispatch({ type: 'START', names })
}
const nextTurn = () => {
synth.select()
dispatch({ type: 'NEXT_TURN' })
}
/**
* Close the books. The standings show straight away; posting to the board
* happens behind them, so a slow or missing line out never holds up the end
* of an expedition.
*/
const post = useCallback(
(hunters: typeof state.hunters) => {
setBoardState('loading')
submitScores(
hunters.map((h) => ({
name: h.name,
bagged: h.bagged,
arrows: h.arrows,
roomsWalked: h.roomsWalked,
died: h.died,
})),
)
.then(({ ids, scores: table }) => {
setScores(table)
setFreshScores(ids)
setBoardState('ready')
})
.catch(() => setBoardState('error'))
},
[],
)
const climbOut = () => {
synth.fanfare()
dispatch({ type: 'CLIMB_OUT' })
post(state.hunters)
}
// An expedition that ends because everybody died posts itself.
const posted = useRef(false)
useEffect(() => {
if (state.phase !== 'gameover' || posted.current) return
posted.current = true
if (!state.climbedOut) post(state.hunters)
}, [state.phase, state.climbedOut, state.hunters, post])
const restart = () => {
const s = randomSeed()
rng.current = makeRng(s)
posted.current = false
setFreshScores([])
dispatch({ type: 'RESTART', seed: s })
}
const showScores = () => {
wake()
synth.select()
dispatch({ type: 'SHOW_SCORES' })
if (boardState !== 'ready') void loadBoard()
}
// --------------------------------------------------------------- render
const moreToCome = livingHunters(state).some((h) => h.id !== who?.id)
return (
<div className="app" onPointerDown={wake} onKeyDown={wake}>
<Frame
footer={
<div className="controls">
<Btn
kind="ghost"
onClick={() => {
wake()
setMusic((m) => !m)
}}
title="Background music"
>
MUSIC {music ? 'ON' : 'OFF'}
</Btn>
<Btn
kind="ghost"
onClick={() => {
wake()
setSfx((s) => !s)
}}
title="Sound effects"
>
SOUND {sfx ? 'ON' : 'OFF'}
</Btn>
<Btn
kind="ghost"
onClick={() => {
wake()
synth.select()
toggleSkin()
}}
title={skin === 'teletype' ? 'Switch to the remaster' : 'Switch to the 1973 Teletype'}
>
{skin === 'teletype' ? 'REMASTER' : '1973'}
</Btn>
<Btn kind="ghost" onClick={restart} title="Abandon this expedition">
NEW HUNT
</Btn>
<a
className="btn btn-ghost"
href={SOURCE_URL}
target="_blank"
rel="noreferrer noopener"
title="Free software, AGPL-3.0-or-later"
>
SOURCE
</a>
<span className="seed">SEED {state.seed}</span>
</div>
}
>
{state.phase === 'boot' && <BootScreen onLoaded={() => dispatch({ type: 'BOOTED' })} />}
<Fit>
{state.phase === 'title' && (
<TitleGate
onStart={() => {
wake()
synth.select()
dispatch({ type: 'SHOW_SETUP' })
}}
onInstructions={() => {
wake()
synth.select()
dispatch({ type: 'SHOW_INSTRUCTIONS' })
}}
onScores={showScores}
/>
)}
{state.phase === 'instructions' && (
<InstructionsScreen onDone={() => dispatch({ type: 'SHOW_SETUP' })} />
)}
{state.phase === 'setup' && <SetupScreen onStart={start} onBlip={blip} />}
{(state.phase === 'hunting' || state.phase === 'resolve') && state.cave && who && (
<HuntScreen
key={`${who.id}-${state.cave.depth}`}
cave={state.cave}
transcript={state.transcript}
hunterName={who.name}
busy={printing}
onCommand={command}
onBlip={blip}
onReject={() => synth.reject()}
/>
)}
{state.phase === 'report' && state.cave && who && (
<ReportScreen
cave={state.cave}
hunter={who}
moreToCome={moreToCome}
onNext={nextTurn}
onClimbOut={climbOut}
/>
)}
{state.phase === 'gameover' && (
<GameOverScreen
hunters={state.hunters}
climbedOut={state.climbedOut}
onRestart={restart}
onScores={showScores}
/>
)}
{state.phase === 'scores' && (
<HighScoreScreen
scores={scores}
state={boardState}
highlight={freshScores}
onBack={() => {
synth.select()
dispatch({ type: 'CLOSE_SCORES' })
}}
onRetry={() => {
synth.select()
void loadBoard()
}}
/>
)}
</Fit>
</Frame>
{/* Kept out of the tree above so a re-render of the game cannot reset it. */}
<PrintingWatcher lines={state.transcript} onChange={setPrinting} />
</div>
)
}
/**
* Whether the machine is still typing.
*
* The prompt must not appear while a line is half printed -- the whole point
* of the teletype is that you read the room before you answer it. This
* watches the transcript rather than the printer so both skins agree, and so
* the remaster's map does not become a way to skip the reading.
*
* It times the characters that have just *arrived*, not the whole transcript.
* Timing the whole thing is the obvious way to write this and it is wrong in
* a way that takes a few turns to notice: `Teletype` only prints lines it has
* not printed yet, so the wait kept growing while the actual printing stayed
* the same length, and by the fourth room the prompt was gone for five
* seconds after a print that took one.
*/
function PrintingWatcher({
lines,
onChange,
}: {
lines: Line[]
onChange: (busy: boolean) => void
}) {
const chars = lines.reduce((n, l) => n + l.text.length, 0)
const printed = useRef(0)
useEffect(() => {
// A new cave throws the transcript away; nothing is owed from the old one.
if (chars < printed.current) printed.current = 0
const fresh = chars - printed.current
printed.current = chars
if (fresh <= 0) {
onChange(false)
return
}
onChange(true)
// 42 characters a second, plus a beat to read the last line.
const t = window.setTimeout(() => onChange(false), (fresh / 42) * 1000 + 120)
return () => window.clearTimeout(t)
}, [chars, onChange])
return null
}
/** Enter or Space starts the hunt, the way a terminal would take it. */
function TitleGate(props: {
onStart: () => void
onInstructions: () => void
onScores: () => void
}) {
const { onStart } = props
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') onStart()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onStart])
return <TitleScreen {...props} />
}
+32
View File
@@ -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<Skin>(readSkin)
const setSkin = useCallback((s: Skin) => {
setSkinState(s)
writeSkin(s)
}, [])
// The attribute lives on <html> 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<SkinApi>(
() => ({ skin, setSkin, toggle: () => setSkin(skin === 'teletype' ? 'modern' : 'teletype') }),
[skin, setSkin],
)
return <SkinContext value={value}>{children}</SkinContext>
}
+165
View File
@@ -0,0 +1,165 @@
import type { Track, Tune } from './synth'
/**
* The remaster's band, and it is deliberately not the lemonade stand's.
*
* That one is a funk band on a hot street in daylight. This is underground:
* D phrygian, no shuffle at all, everything on the grid and slightly too
* slow. The engine is identical -- same voices, same kit, same scheduler --
* and every difference is in what is written for it.
*
* Three ideas, and nothing else, because the player is listening for a draft:
*
* - a sub bass that sits on the root and refuses to move, so the harmony
* never resolves anywhere;
* - a resonant filter sweep on a detuned saw, which is the sound of a
* space being bigger than you thought;
* - toms instead of a backbeat. A snare on two and four would make this
* music to walk to, and nothing down here walks in time.
*
* Sixteenths, sixteen to a bar.
*/
const bar = (...steps: string[]) => steps
const kick = (notes: string[]): Track => ({ wave: 'noise', gain: 0.46, notes })
const hats = (notes: string[]): Track => ({ wave: 'noise', gain: 0.09, notes })
/** Hunting. Two bars, almost empty, meant to be forgotten while you think. */
export const DREAD_HUNT: Tune = {
bpm: 76,
stepsPerBeat: 4,
tracks: [
// The drone. One note, held, and it is the room rather than the tune.
{
wave: 'saw',
gain: 0.13,
gate: 1,
detune: 7,
filter: { from: 260, to: 150, q: 3 },
notes: [
...bar('D1', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '='),
...bar('D1', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '='),
...bar('D1', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '='),
...bar('A#0', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '=', '='),
],
},
// The sweep. Phrygian second, which is the interval that sounds wrong.
{
wave: 'saw',
gain: 0.07,
gate: 0.9,
filter: { from: 3400, to: 320, q: 11 },
notes: [
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'D3/A3', '=', '=', '=', '=', '=', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', 'D#3/A3', '=', '=', '=', '=', '=', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', 'D3/G3', '=', '=', '.'),
],
},
// A thin bell, six steps apart, so it never lines up with the bar.
{
wave: 'pulse12',
gain: 0.05,
gate: 0.5,
notes: [
...bar('D5', '.', '.', '.', '.', '.', 'A4', '.', '.', '.', '.', '.', 'F4', '.', '.', '.'),
],
},
kick([
...bar('K', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', 'K', '.', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.'),
]),
hats([
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'O', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', 'H', '.'),
]),
],
}
/** Title. The same cave with something moving in it. */
export const DREAD_TITLE: Tune = {
bpm: 84,
stepsPerBeat: 4,
tracks: [
{
wave: 'saw',
gain: 0.2,
gate: 0.62,
detune: 6,
filter: { from: 1500, to: 190, q: 8 },
notes: [
...bar('D1', '.', '.', 'D1', '.', '.', 'D2', '.', '.', '.', 'A#1', '.', '.', 'D1', '.', '.'),
...bar('D1', '.', '.', 'D1', '.', '.', 'D2', '.', 'F1', '.', '.', 'G1', '.', '.', '.', '.'),
...bar('C1', '.', '.', 'C1', '.', '.', 'C2', '.', '.', '.', 'G1', '.', '.', 'C1', '.', '.'),
...bar('A#0', '.', '.', 'A#0', '.', '.', 'A#1', '.', 'C1', '.', '.', 'D1', '.', '.', 'D1', '.'),
],
},
// Stabs in the gaps. Minor seconds, held just long enough to be unpleasant.
{
wave: 'pulse25',
gain: 0.075,
gate: 0.34,
filter: { from: 2800, to: 700, q: 10 },
notes: [
...bar('.', '.', 'D4/F4/A4', '.', '.', 'D4/F4/A4', '.', '.', '.', '.', '.', '.', 'D#4/F4/A4', '.', '.', '.'),
...bar('.', '.', 'D4/F4/A4', '.', '.', '.', '.', 'D4/G4/A#4', '.', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', 'C4/E4/G4', '.', '.', 'C4/E4/G4', '.', '.', '.', '.', '.', '.', 'C4/F4/G4', '.', '.', '.'),
...bar('.', '.', 'A#3/D4/F4', '.', '.', '.', 'A#3/D4/F4', '.', '.', 'C4/D4/F4', '.', '.', '.', '.', '.', '.'),
],
},
// The lead only ever plays four notes, and always the same four.
{
wave: 'pulse12',
gain: 0.08,
gate: 0.8,
notes: [
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'A4', '.', 'A#4', '.', 'A4', '=', '.', '.'),
...bar('.', '.', '.', '.', 'F4', '.', 'D4', '=', '=', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.', 'G4', '.', 'A4', '.', 'G4', '=', '.', '.'),
...bar('F4', '.', 'D#4', '.', 'D4', '=', '=', '=', '.', '.', '.', '.', '.', '.', '.', '.'),
],
},
kick([
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', '.', '.', 'K', '.', '.', '.'),
...bar('K', '.', '.', '.', '.', '.', 'K', '.', '.', '.', 'K', '.', '.', '.', '.', '.'),
]),
// Toms, not a backbeat. Bandpassed noise reads as a floor tom well enough.
{ wave: 'noise', gain: 0.2, notes: [
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', 'S', '.'),
...bar('.', '.', '.', '.', 'S', '.', '.', '.', '.', '.', '.', '.', 'S', '.', '.', '.'),
] },
hats([
...bar('.', '.', 'H', '.', '.', '.', 'H', '.', '.', '.', 'H', '.', '.', '.', 'O', '.'),
]),
],
}
/** The standings. It stops stalking and just tolls. */
export const DREAD_END: Tune = {
bpm: 70,
stepsPerBeat: 4,
tracks: [
{
wave: 'saw',
gain: 0.17,
gate: 1,
detune: 5,
filter: { from: 900, to: 180, q: 4 },
notes: [
...bar('D1', '=', '=', '=', '=', '=', '=', '=', 'A#0', '=', '=', '=', '=', '=', '=', '='),
...bar('C1', '=', '=', '=', '=', '=', '=', '=', 'D1', '=', '=', '=', '=', '=', '=', '='),
],
},
{
wave: 'pulse25',
gain: 0.1,
gate: 0.85,
notes: [
...bar('D4', '=', '=', '=', '=', '=', '.', '.', 'F4', '=', '=', '=', '=', '=', '.', '.'),
...bar('E4', '=', '=', '=', '=', '=', '.', '.', 'D4', '=', '=', '=', '=', '=', '=', '='),
],
},
kick([...bar('K', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.', '.')]),
],
}
+678
View File
@@ -0,0 +1,678 @@
/**
* 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<string, number> = {
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 = {
/** 5551973. The last four are the year, which is the only joke in here. */
number: [5, 5, 5, 1, 9, 7, 3],
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<Record<Wave, PeriodicWave>> = {}
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)
}
/** Footsteps down a tunnel. Two dull knocks, the second lower. */
walk() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
for (const [i, f] of [190, 150].entries()) {
const osc = ctx.createOscillator()
osc.type = 'sine'
const t = at + i * 0.13
osc.frequency.setValueAtTime(f, t)
osc.frequency.exponentialRampToValueAtTime(f * 0.55, t + 0.1)
const env = ctx.createGain()
env.gain.setValueAtTime(0.16, t)
env.gain.exponentialRampToValueAtTime(0.0001, t + 0.13)
osc.connect(env).connect(this.sfxBus)
osc.start(t)
osc.stop(t + 0.15)
}
}
/**
* The arrow. A short noise burst swept downward through a bandpass, which
* is close enough to something crooked going past your ear in the dark.
*/
arrow() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
const band = ctx.createBiquadFilter()
band.type = 'bandpass'
band.Q.value = 4
band.frequency.setValueAtTime(2600, at)
band.frequency.exponentialRampToValueAtTime(420, at + 0.42)
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(0.3, at + 0.04)
env.gain.exponentialRampToValueAtTime(0.0001, at + 0.45)
src.connect(band).connect(env).connect(this.sfxBus)
src.start(at)
src.stop(at + 0.5)
}
/** Wings. Filtered noise pulsed at wingbeat rate, rising as they carry you. */
bats() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const src = ctx.createBufferSource()
src.buffer = this.noiseBuffer
src.loop = true
const band = ctx.createBiquadFilter()
band.type = 'bandpass'
band.Q.value = 1.6
band.frequency.setValueAtTime(900, at)
band.frequency.exponentialRampToValueAtTime(2600, at + 0.9)
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at)
// Twelve wingbeats a second, getting louder as they lift you.
for (let i = 0; i < 11; i++) {
const t = at + i * 0.082
env.gain.exponentialRampToValueAtTime(0.05 + i * 0.018, t + 0.03)
env.gain.exponentialRampToValueAtTime(0.012, t + 0.075)
}
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.0)
src.connect(band).connect(env).connect(this.sfxBus)
src.start(at)
src.stop(at + 1.05)
}
/** A pit. One long fall, no bottom, and then nothing. */
pit() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const osc = ctx.createOscillator()
osc.type = 'triangle'
osc.frequency.setValueAtTime(660, at)
osc.frequency.exponentialRampToValueAtTime(34, at + 1.5)
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(0.24, at + 0.06)
env.gain.setValueAtTime(0.24, at + 1.0)
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.6)
osc.connect(env).connect(this.sfxBus)
osc.start(at)
osc.stop(at + 1.7)
}
/** The wumpus, up close. A growl is a sawtooth an octave too low, wobbling. */
wumpus() {
const ctx = this.ensure()
const at = ctx.currentTime + 0.01
const osc = ctx.createOscillator()
osc.type = 'sawtooth'
osc.frequency.setValueAtTime(78, at)
osc.frequency.exponentialRampToValueAtTime(46, at + 1.1)
// The wobble is what makes it an animal rather than a chord.
const lfo = ctx.createOscillator()
lfo.type = 'sine'
lfo.frequency.value = 17
const depth = ctx.createGain()
depth.gain.value = 11
lfo.connect(depth).connect(osc.frequency)
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.Q.value = 5
lp.frequency.setValueAtTime(1500, at)
lp.frequency.exponentialRampToValueAtTime(280, at + 1.1)
const env = ctx.createGain()
env.gain.setValueAtTime(0.0001, at)
env.gain.exponentialRampToValueAtTime(0.3, at + 0.09)
env.gain.exponentialRampToValueAtTime(0.0001, at + 1.2)
osc.connect(lp).connect(env).connect(this.sfxBus)
osc.start(at)
lfo.start(at)
osc.stop(at + 1.3)
lfo.stop(at + 1.3)
}
/** 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 1973 and none of it could have: this
* game was played on a Model 33 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()
+147
View File
@@ -0,0 +1,147 @@
import type { Tune } from './synth'
/**
* The old-school music, and a word about what it is honestly claiming.
*
* Hunt the Wumpus was silent. It ran on a timesharing system through a
* Teletype, and a Teletype'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.
*
* What it is instead: the music the game would have got when it followed
* everybody home. The listing spread through People's Computer Company and
* Creative Computing and was typed into every machine of the next decade, and
* a 1981 home-computer port of a cave game sounded roughly like this -- two
* pulse voices, a triangle bass, filtered noise for a kit, and a minor key
* because the room next door has something in it.
*
* Written in eighths, eight to a bar, in D natural minor.
*/
const bar = (...notes: string[]) => notes
/** Title. A stalking figure that keeps arriving back where it started. */
export const TITLE_TUNE: Tune = {
bpm: 116,
stepsPerBeat: 2,
tracks: [
{
wave: 'pulse25',
gain: 0.15,
notes: [
...bar('D4', '.', 'F4', '.', 'A4', '.', 'F4', '.'),
...bar('E4', '.', 'G4', '.', 'A#4', '=', '=', '.'),
...bar('C4', '.', 'E4', '.', 'G4', '.', 'E4', '.'),
...bar('D4', '=', '=', '.', 'A3', '.', 'D4', '.'),
...bar('F4', '.', 'A4', '.', 'D5', '.', 'A4', '.'),
...bar('G4', '.', 'A#4', '.', 'E5', '=', '=', '.'),
...bar('F5', '.', 'E5', '.', 'D5', '.', 'C5', '.'),
...bar('D5', '=', '=', '=', '=', '.', '.', '.'),
],
},
{
wave: 'pulse12',
gain: 0.06,
notes: [
...bar('.', '.', 'A5', '.', '.', '.', 'D5', '.'),
...bar('.', '.', 'A#5', '.', '.', '.', 'G5', '.'),
...bar('.', '.', 'G5', '.', '.', '.', 'C5', '.'),
...bar('.', '.', 'A5', '.', '.', '.', 'F5', '.'),
...bar('.', '.', 'D6', '.', '.', '.', 'A5', '.'),
...bar('.', '.', 'E6', '.', '.', '.', 'A#5', '.'),
...bar('A5', '.', 'G5', '.', 'F5', '.', 'E5', '.'),
...bar('D5', '=', '=', '.', '.', '.', '.', '.'),
],
},
{
wave: 'triangle',
gain: 0.3,
notes: [
...bar('D2', '.', 'D3', '.', 'A2', '.', 'D3', '.'),
...bar('E2', '.', 'E3', '.', 'B2', '.', 'E3', '.'),
...bar('C2', '.', 'C3', '.', 'G2', '.', 'C3', '.'),
...bar('D2', '.', 'D3', '.', 'A2', '.', 'A2', '.'),
...bar('D2', '.', 'D3', '.', 'A2', '.', 'D3', '.'),
...bar('E2', '.', 'E3', '.', 'B2', '.', 'E3', '.'),
...bar('F2', '.', 'F3', '.', 'C3', '.', 'F3', '.'),
...bar('D2', '=', '=', '=', 'A2', '.', 'D2', '.'),
],
},
{
wave: 'noise',
gain: 0.14,
notes: [
...bar('K', '.', 'H', '.', 'S', '.', 'H', '.'),
...bar('K', '.', 'H', '.', 'S', '.', 'K', 'H'),
],
},
],
}
/**
* Walking music. Almost nothing: a bass on the beat and a hat every other
* bar, so the room has a pulse without arguing with the print head. The
* hunting screen is where somebody is actually thinking.
*/
export const HUNT_TUNE: Tune = {
bpm: 92,
stepsPerBeat: 2,
tracks: [
{
wave: 'triangle',
gain: 0.26,
notes: [
...bar('D2', '.', '.', '.', 'A2', '.', '.', '.'),
...bar('D2', '.', '.', '.', 'F2', '.', '.', '.'),
...bar('C2', '.', '.', '.', 'G2', '.', '.', '.'),
...bar('D2', '.', '.', '.', 'A2', '.', 'D2', '.'),
],
},
{
wave: 'pulse12',
gain: 0.05,
notes: [
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', 'A4', '.', '.', '.', 'F4', '.'),
...bar('.', '.', '.', '.', '.', '.', '.', '.'),
...bar('.', '.', 'E4', '.', '.', '.', 'D4', '.'),
],
},
{
wave: 'noise',
gain: 0.07,
notes: [
...bar('.', '.', 'H', '.', '.', '.', 'H', '.'),
...bar('.', '.', 'H', '.', '.', '.', 'H', 'H'),
],
},
],
}
/** Plays once behind the standings, whoever is left to read them. */
export const END_TUNE: Tune = {
bpm: 84,
stepsPerBeat: 2,
tracks: [
{
wave: 'pulse25',
gain: 0.15,
notes: [
...bar('A4', '.', 'D5', '=', 'F5', '.', 'A5', '='),
...bar('G5', '.', 'F5', '=', 'D5', '=', '=', '.'),
...bar('C5', '.', 'E5', '=', 'G5', '.', 'C6', '='),
...bar('A5', '.', 'F5', '=', 'D5', '=', '=', '.'),
],
},
{
wave: 'triangle',
gain: 0.28,
notes: [
...bar('D2', '.', '.', '.', 'D3', '.', '.', '.'),
...bar('G2', '.', '.', '.', 'D3', '.', '.', '.'),
...bar('C2', '.', '.', '.', 'C3', '.', '.', '.'),
...bar('D2', '.', '.', '.', 'A2', '.', 'D2', '.'),
],
},
],
}
+244
View File
@@ -0,0 +1,244 @@
import { EDGES, LAYOUT, ROOM_COUNT, tunnelsFrom } from '../game/cave'
import type { Cave, Hazard } from '../game/types'
/**
* The remaster: the cave, drawn.
*
* Every mark here is generated from `LAYOUT` and `EDGES` -- 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 `<circle>` and `<path>`.
*
* The hard rule is that this component may not show the player anything the
* teletype has not already printed. It takes `reveal` for the two screens
* that are allowed to lift that -- the death and the standings -- and
* everywhere else the wumpus, the pits and the bats are simply not in the
* output. A map that quietly knows more than the transcript would make the
* remaster an easier game than the original, and they are meant to be the
* same game.
*/
const R = 210 // Unit radius; the viewBox is a little larger to hold labels.
const ROOM_R = 15
type Known = { hazards: Hazard[]; visited: boolean; adjacent: boolean }
export function CaveMap({
cave,
reveal = false,
aim = [],
}: {
cave: Cave
/** Show where everything actually was. Only the end of a hunt earns this. */
reveal?: boolean
/** Rooms in the arrow path being typed, so a shot can be seen before it flies. */
aim?: readonly number[]
}) {
const at = (room: number): [number, number] => {
const [x, y] = LAYOUT[room] ?? [0, 0]
return [x * R, y * R]
}
const near = tunnelsFrom(cave.you)
const known = new Map<number, Known>()
for (let room = 1; room <= ROOM_COUNT; room++) {
known.set(room, {
hazards: cave.sensed[room] ? [...cave.sensed[room]] : [],
visited: cave.visited.includes(room),
adjacent: near.includes(room),
})
}
/**
* What a room is worth worrying about, from what has been printed only.
*
* A room the hunter has stood in is safe and is drawn so. A room next to a
* warning is suspect, and the map says which warning -- never which room
* the hazard is actually in, because nothing has told the hunter that.
*/
const suspicion = (room: number): Hazard[] => {
if (known.get(room)!.visited) return []
const out = new Set<Hazard>()
for (const from of cave.visited) {
if (!tunnelsFrom(from).includes(room)) continue
for (const h of cave.sensed[from] ?? []) out.add(h)
}
return [...out]
}
const aimPath = aim.length ? [cave.you, ...aim] : []
return (
<svg
className="cave-map"
viewBox={`${-R - 34} ${-R - 34} ${(R + 34) * 2} ${(R + 34) * 2}`}
role="img"
aria-label={`Cave map. You are in room ${cave.you}.`}
>
<defs>
{/* The lamp. A radial fill is the one gradient the style allows,
because it is light rather than shading. */}
<radialGradient id="lamp" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="#ffd97a" stopOpacity="0.30" />
<stop offset="60%" stopColor="#ffb347" stopOpacity="0.08" />
<stop offset="100%" stopColor="#ffb347" stopOpacity="0" />
</radialGradient>
</defs>
{/* Tunnels first, so the rooms sit on top of them. */}
<g className="tunnels">
{EDGES.map(([a, b]) => {
const [ax, ay] = at(a)
const [bx, by] = at(b)
const walked = cave.visited.includes(a) && cave.visited.includes(b)
const lit = a === cave.you || b === cave.you
return (
<line
key={`${a}-${b}`}
x1={ax}
y1={ay}
x2={bx}
y2={by}
className={`tunnel${lit ? ' tunnel-lit' : walked ? ' tunnel-walked' : ''}`}
/>
)
})}
</g>
{/* The arrow being aimed, drawn over the tunnels it is going down. */}
{aimPath.length > 1 && (
<polyline
className="aim"
points={aimPath.map((r) => at(r).join(',')).join(' ')}
fill="none"
/>
)}
<circle cx={at(cave.you)[0]} cy={at(cave.you)[1]} r={96} fill="url(#lamp)" />
<g className="rooms">
{Array.from({ length: ROOM_COUNT }, (_, i) => i + 1).map((room) => {
const k = known.get(room)!
const worry = suspicion(room)
const here = room === cave.you
const shown = reveal
? [
...(cave.wumpus === room ? (['wumpus'] as Hazard[]) : []),
...(cave.pits.includes(room) ? (['pit'] as Hazard[]) : []),
...(cave.bats.includes(room) ? (['bats'] as Hazard[]) : []),
]
: []
const [x, y] = at(room)
const cls = [
'room',
here && 'room-here',
k.visited && !here && 'room-visited',
k.adjacent && !here && 'room-open',
!reveal && worry.length > 0 && 'room-suspect',
reveal && shown.length > 0 && 'room-revealed',
]
.filter(Boolean)
.join(' ')
return (
<g key={room} className={cls} transform={`translate(${x} ${y})`}>
<circle className="room-disc" r={ROOM_R} />
<text className="room-label" y={4}>
{room}
</text>
{/* Warnings sit above the room as marks, never as the answer. */}
{!reveal && worry.length > 0 && (
<g className="marks" transform={`translate(0 ${-ROOM_R - 9})`}>
{worry.map((h, i) => (
<Mark key={h} hazard={h} x={(i - (worry.length - 1) / 2) * 15} />
))}
</g>
)}
{reveal &&
shown.map((h, i) => (
<g key={h} transform={`translate(${(i - (shown.length - 1) / 2) * 18} ${-ROOM_R - 12})`}>
<Truth hazard={h} />
</g>
))}
{here && <Hunter />}
</g>
)
})}
</g>
</svg>
)
}
/** A suspicion: the shape of a warning, not the thing itself. */
function Mark({ hazard, x }: { hazard: Hazard; x: number }) {
if (hazard === 'wumpus') {
// A nose. It is the smell that has been reported, so it is a nostril.
return (
<g transform={`translate(${x} 0)`} className="mark mark-wumpus">
<path d="M-5 3 Q0 -6 5 3 Q0 6 -5 3 Z" />
</g>
)
}
if (hazard === 'pit') {
// A draft. Three lines leaning the way the air is going.
return (
<g transform={`translate(${x} 0)`} className="mark mark-pit">
<path d="M-6 -3 H4 M-6 0 H6 M-6 3 H3" />
</g>
)
}
return (
<g transform={`translate(${x} 0)`} className="mark mark-bats">
<path d="M-6 2 Q-3 -4 0 1 Q3 -4 6 2" />
</g>
)
}
/** What was actually in the room, once the hunt is over and it can be told. */
function Truth({ hazard }: { hazard: Hazard }) {
if (hazard === 'wumpus') {
return (
<g className="truth truth-wumpus">
<path d="M-9 6 Q-11 -6 0 -8 Q11 -6 9 6 Z" />
<circle cx={-3.5} cy={-2} r={1.7} className="eye" />
<circle cx={3.5} cy={-2} r={1.7} className="eye" />
<path d="M-4 3 L-2 6 L0 3 L2 6 L4 3" className="teeth" />
</g>
)
}
if (hazard === 'pit') {
return (
<g className="truth truth-pit">
<ellipse rx={9} ry={5} />
<ellipse rx={5} ry={2.6} className="deeper" />
</g>
)
}
return (
<g className="truth truth-bats">
<path d="M-10 3 Q-6 -5 -2 1 Q0 -3 2 1 Q6 -5 10 3 Q5 0 0 4 Q-5 0 -10 3 Z" />
</g>
)
}
/**
* The hunter.
*
* Drawn as a ring around the room rather than a figure standing on it. A
* figure covered the room number, and the number is what the whole game is
* played in -- "TUNNELS LEAD TO 13 16 19" is useless if you cannot see which
* one you are standing in.
*/
function Hunter() {
return (
<g className="hunter">
<circle r={ROOM_R + 7} className="hunter-ring" />
<circle r={ROOM_R + 11} className="hunter-ring hunter-ring-outer" />
</g>
)
}
+97
View File
@@ -0,0 +1,97 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
import { useSkin } from '../skin'
/**
* The thing the game is happening inside.
*
* Both skins get a frame, and the frames are not the same object: 1973 is a
* Teletype, so it is a sheet of fanfold paper coming out of a machine, with
* the sprocket holes down both edges and a platen at the top. The remaster is
* a cave, so it is a lit chamber with rock around it. Everything inside is
* laid out identically -- only the box changes.
*/
export function Frame({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
const { skin } = useSkin()
return (
<div className="cabinet">
<div className="bezel">
{skin === 'teletype' && <div className="platen" aria-hidden />}
<div className="screen">
<div className="screen-inner">{children}</div>
{skin === 'teletype' ? (
<>
<div className="sprockets sprockets-left" aria-hidden />
<div className="sprockets sprockets-right" aria-hidden />
<div className="paper-grain" aria-hidden />
</>
) : (
<>
<div className="lamp" aria-hidden />
<div className="vignette" aria-hidden />
</>
)}
</div>
</div>
{footer}
</div>
)
}
/**
* A sheet of paper does not scroll and a cave mouth does not either. If the
* page comes out taller than the frame, shrink it rather than clipping it.
* Lifted from the lemonade stand, where the argument was about televisions.
*/
export function Fit({ children }: { children: ReactNode }) {
const box = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
useLayoutEffect(() => {
const measure = () => {
const el = box.current
const page = el?.firstElementChild as HTMLElement | null
if (!el || !page) return
// offsetHeight is the laid-out size and ignores the transform, so
// measuring here cannot feed back into itself.
const h = page.offsetHeight
const avail = el.clientHeight
setScale(h > 0 && avail > 0 ? Math.min(1, avail / h) : 1)
}
measure()
const ro = new ResizeObserver(measure)
if (box.current) ro.observe(box.current)
if (box.current?.firstElementChild) ro.observe(box.current.firstElementChild)
return () => ro.disconnect()
})
return (
<div className="screen-body" ref={box} style={{ '--fit': scale } as React.CSSProperties}>
{children}
</div>
)
}
export function Line({ children, className = '' }: { children?: ReactNode; className?: string }) {
return <div className={`line ${className}`}>{children ?? ' '}</div>
}
export function Btn({
children,
onClick,
kind = 'normal',
disabled,
title,
}: {
children: ReactNode
onClick: () => void
kind?: 'normal' | 'primary' | 'ghost'
disabled?: boolean
title?: string
}) {
return (
<button className={`btn btn-${kind}`} onClick={onClick} disabled={disabled} title={title}>
{children}
</button>
)
}
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useMemo, useState } from 'react'
import { MAX_ARROW_PATH } from '../game/constants'
import { areConnected, tunnelsFrom } from '../game/cave'
import type { Cave, Command, Line } from '../game/types'
import { useSkin } from '../skin'
import { CaveMap } from './CaveMap'
import { Btn } from './Frame'
import { Teletype } from './Teletype'
/**
* The screen where the game is played, and the only one either skin shows
* differently in any way that matters.
*
* 1973 gets the transcript and a prompt. The remaster gets the map, the same
* transcript underneath it, and buttons for the rooms you can reach -- which
* is a convenience over typing a number, not information. The three tunnels
* out of a room are printed in both skins, because the listing printed them.
*/
export function HuntScreen({
cave,
transcript,
hunterName,
busy,
onCommand,
onBlip,
onReject,
}: {
cave: Cave
transcript: Line[]
hunterName: string
/** True while the teletype is still printing; a prompt mid-line is a lie. */
busy: boolean
onCommand: (c: Command) => void
onBlip: () => void
onReject: () => void
}) {
const { skin } = useSkin()
const [mode, setMode] = useState<'move' | 'shoot'>('move')
const [path, setPath] = useState<number[]>([])
const exits = tunnelsFrom(cave.you)
// A new room is a fresh decision; never carry a half-typed shot into it.
useEffect(() => {
setMode('move')
setPath([])
}, [cave.you, cave.depth])
/** Where the arrow is now, so the next room can be offered from there. */
const arrowAt = path.length ? path[path.length - 1]! : cave.you
const arrowExits = tunnelsFrom(arrowAt)
const canFire = path.length > 0
const canExtend = path.length < MAX_ARROW_PATH
const shoot = () => {
if (!canFire) return onReject()
onCommand({ type: 'shoot', path })
setPath([])
setMode('move')
}
const move = (to: number) => {
if (!areConnected(cave.you, to)) return onReject()
onCommand({ type: 'move', to })
}
// Typing a room number works in both skins; it is how the game was played.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (busy) return
if (e.key === 'Escape') {
setPath([])
setMode('move')
return
}
if (e.key === 'Enter' && mode === 'shoot') {
shoot()
return
}
const n = Number(e.key)
if (!Number.isInteger(n)) return
// Room numbers run to 20, so a digit alone is ambiguous; the buttons
// and the number field are the honest ways in. This is a shortcut for
// the common case only: a single-digit room you can actually reach.
const target = mode === 'move' ? exits : arrowExits
const hit = target.find((r) => r === n)
if (hit === undefined) return
if (mode === 'move') move(hit)
else setPath((p) => (p.length < MAX_ARROW_PATH ? [...p, hit] : p))
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
})
const senses = useMemo(
() => transcript.filter((l) => l.kind === 'sense').slice(-3),
[transcript],
)
return (
<div className="stack hunt">
{skin === 'modern' && (
<div className="map-wrap">
<CaveMap cave={cave} aim={path} />
</div>
)}
<Teletype lines={transcript} />
{!busy && !cave.outcome && (
<div className="prompt">
{mode === 'move' ? (
<>
<div className="prompt-q">WALK, OR SHOOT?</div>
<div className="prompt-row">
<Btn
kind="primary"
onClick={() => {
onBlip()
setMode('shoot')
}}
title="Fire a crooked arrow"
>
SHOOT
</Btn>
<span className="prompt-sep">WHICH TUNNEL?</span>
{exits.map((r) => (
<Btn key={r} onClick={() => move(r)} title={`Walk into room ${r}`}>
{r}
</Btn>
))}
</div>
</>
) : (
<>
<div className="prompt-q">
AIM IT THROUGH HOW MANY ROOMS, UP TO {MAX_ARROW_PATH}?{' '}
{path.length ? path.join(' - ') : '--'}
</div>
<div className="prompt-row">
{canExtend ? (
arrowExits.map((r) => (
<Btn
key={r}
onClick={() => {
onBlip()
setPath((p) => [...p, r])
}}
title={`Send the arrow on to room ${r}`}
>
{r}
</Btn>
))
) : (
<span className="prompt-sep">THE ARROW WILL GO NO FURTHER</span>
)}
<Btn kind="primary" onClick={shoot} disabled={!canFire} title="Loose the arrow">
FIRE
</Btn>
<Btn
kind="ghost"
onClick={() => {
onBlip()
setPath([])
setMode('move')
}}
>
BACK
</Btn>
</div>
{/* The rule that makes long shots frightening, said out loud. */}
<div className="prompt-note">
NAME A ROOM THE ARROW CANNOT REACH AND IT PICKS ITS OWN TUNNEL.
</div>
</>
)}
</div>
)}
<div className="statusbar">
<span>{hunterName}</span>
<span>CAVE {cave.depth}</span>
<span>ARROWS {cave.arrows}</span>
<span className="senses">{senses.map((s) => s.text).join(' ') || ' '}</span>
</div>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react'
import { synth } from '../audio/synth'
import type { Line } from '../game/types'
/**
* The 1973 skin: everything the machine has said, printed.
*
* A Teletype prints at ten characters a second and cannot take any of it
* back, so this types rather than appears, and nothing already printed ever
* changes. That is not decoration -- it is the reason the game feels the way
* it does. Reading "I SMELL A WUMPUS" arrive one letter at a time is a
* different experience from finding it already on screen, and the whole game
* is three sentences arriving in the dark.
*
* The print head is played per character. At ten a second that is a lot of
* one-shot oscillators, which is why `synth.print()` is as small as it is.
*/
const CPS = 42 // Faster than a real ASR-33. A real one is 10, and it is a lot.
export function Teletype({ lines, onIdle }: { lines: Line[]; onIdle?: () => void }) {
const [printed, setPrinted] = useState<string[]>([])
const [partial, setPartial] = useState('')
const done = useRef(0)
const paper = useRef<HTMLDivElement>(null)
// A new expedition throws the old transcript away rather than scrolling it.
useEffect(() => {
if (lines.length < done.current) {
done.current = 0
setPrinted([])
setPartial('')
}
}, [lines.length])
useEffect(() => {
if (done.current >= lines.length) {
onIdle?.()
return
}
const text = lines[done.current]!.text
let at = 0
setPartial('')
const timer = window.setInterval(() => {
at += 1
setPartial(text.slice(0, at))
if (synth.sfxOn) synth.print()
if (at >= text.length) {
window.clearInterval(timer)
synth.carriage()
done.current += 1
setPartial('')
setPrinted((p) => [...p, text])
}
}, 1000 / CPS)
return () => window.clearInterval(timer)
// `printed` is the trigger: finishing one line starts the next.
}, [lines, printed.length, onIdle])
// The paper feeds up; you always read at the bottom.
useEffect(() => {
paper.current?.scrollTo({ top: paper.current.scrollHeight })
}, [printed.length, partial])
return (
<div className="paper" ref={paper}>
{printed.map((text, i) => (
<div className="printed" key={i}>
{text}
</div>
))}
{partial && (
<div className="printed">
{partial}
<span className="head" aria-hidden />
</div>
)}
</div>
)
}
+365
View File
@@ -0,0 +1,365 @@
import { useEffect, useRef, useState } from 'react'
import { DIAL_UP, synth } from '../audio/synth'
import { MAX_HUNTERS, MAX_NAME, STARTING_ARROWS } from '../game/constants'
import { ROOM_COUNT } from '../game/cave'
import { compareHunts, outcomeText } from '../game/engine'
import type { Score } from '../game/highscores'
import type { Cave, Hunter } from '../game/types'
import { isWin } from '../game/types'
import { CaveMap } from './CaveMap'
import { Btn, Line } from './Frame'
/**
* Everything that is not the hunt 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 six years older than that machine and did not live on
* a machine you owned at all -- it lived on a timesharing service you
* telephoned. So the boot is a real Bell 103 call, and pushing the handset
* into the coupler is the gesture that unlocks the audio.
*
* 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-1973'],
[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])
/** Nobody should have to sit through a handshake twice. */
const skip = () => {
stop.current?.()
onLoaded()
}
return (
<div className="stack boot">
<Line className="accent">PEOPLE&apos;S COMPUTER COMPANY</Line>
<Line> </Line>
{dialling ? (
<>
{STAGES.slice(0, stage + 1).map(([, label]) => (
<Line key={label}>{label}</Line>
))}
<Line> </Line>
<Btn kind="ghost" onClick={skip} title="Skip the handshake">
SKIP
</Btn>
</>
) : (
<>
<Line>TELEPHONE THE MACHINE TO BEGIN.</Line>
<Line> </Line>
<Btn
kind="primary"
onClick={() => setDialling(true)}
title="Dial in. This is also what lets the browser make a sound."
>
DIAL
</Btn>
<Line> </Line>
<Line className="dim">SOUND STARTS HERE, AS BROWSERS INSIST.</Line>
</>
)}
</div>
)
}
// ------------------------------------------------------------------ title
export function TitleScreen({
onStart,
onInstructions,
onScores,
}: {
onStart: () => void
onInstructions: () => void
onScores: () => void
}) {
return (
<div className="stack title">
<pre className="banner" aria-label="Hunt the Wumpus">{`
HUNT THE WUMPUS
`}</pre>
<Line className="dim">TWENTY ROOMS. THREE TUNNELS EACH. ONE OF IT.</Line>
<Line> </Line>
<div className="prompt-row">
<Btn kind="primary" onClick={onStart}>
HUNT
</Btn>
<Btn onClick={onInstructions}>INSTRUCTIONS</Btn>
<Btn onClick={onScores}>BOARD</Btn>
</div>
</div>
)
}
// ----------------------------------------------------------- instructions
/**
* The rules, on request, the way the machine offered them in 1973 -- but in
* our words rather than Yob's. See NOTICE.md: the rules themselves are ideas
* and free to use, the sentences he wrote to explain them are not.
*/
export function InstructionsScreen({ onDone }: { onDone: () => void }) {
return (
<div className="stack rules">
<Line className="accent">THE CAVE</Line>
<Line>
{ROOM_COUNT} ROOMS, EACH WITH EXACTLY THREE TUNNELS OUT OF IT. THE SHAPE NEVER CHANGES.
WHAT IS IN IT DOES.
</Line>
<Line> </Line>
<Line className="accent">WHAT IS DOWN THERE WITH YOU</Line>
<Line>ONE WUMPUS. IT SLEEPS. IT IS TOO HEAVY TO FALL DOWN A PIT AND TOO HEAVY FOR THE
BATS TO LIFT, SO NOTHING DOWN HERE TROUBLES IT BUT YOU.</Line>
<Line>TWO PITS. WALK INTO ONE AND YOU KEEP GOING.</Line>
<Line>TWO ROOSTS OF BATS. THEY WILL NOT HURT YOU. THEY WILL PICK YOU UP AND PUT YOU DOWN
SOMEWHERE ELSE, AND THEY DO NOT CARE WHAT IS ALREADY THERE.</Line>
<Line> </Line>
<Line className="accent">WHAT THE CAVE TELLS YOU</Line>
<Line>STANDING ONE TUNNEL AWAY FROM ANY OF THEM, YOU WILL KNOW SOMETHING IS CLOSE. YOU
WILL NEVER BE TOLD WHICH ROOM. THREE TUNNELS AND ONE WARNING IS THE WHOLE PUZZLE.</Line>
<Line> </Line>
<Line className="accent">YOUR TURN</Line>
<Line>WALK, OR SHOOT. YOU CARRY {STARTING_ARROWS} ARROWS AND THEY ARE CROOKED: EACH ONE
CAN BE AIMED THROUGH AS MANY AS FIVE ROOMS IN A ROW.</Line>
<Line>NAME A ROOM THE ARROW CANNOT REACH FROM WHERE IT IS AND IT WILL NOT STOP. IT WILL
TAKE A TUNNEL OF ITS OWN CHOOSING, AND ONE OF THOSE LEADS BACK TO YOU.</Line>
<Line>A SHOT THAT MISSES USUALLY WAKES THE WUMPUS. A WOKEN WUMPUS MOVES ONE ROOM, OR
STAYS WHERE IT IS. IF IT ARRIVES WHERE YOU ARE, THAT IS THE END OF IT.</Line>
<Line> </Line>
<Line className="accent">AFTERWARDS</Line>
<Line>KILL IT AND YOU GO DEEPER, KEEPING WHAT IS LEFT IN THE QUIVER. DIE AND YOU ARE
FINISHED. STOP WHENEVER YOU LIKE -- THAT IS WHAT CLIMB OUT IS FOR.</Line>
<Line> </Line>
<Btn kind="primary" onClick={onDone}>
GOT IT
</Btn>
</div>
)
}
// ------------------------------------------------------------------ setup
export function SetupScreen({
onStart,
onBlip,
}: {
onStart: (names: string[]) => void
onBlip: () => void
}) {
const [names, setNames] = useState<string[]>([''])
const set = (i: number, v: string) =>
setNames((n) => n.map((old, j) => (j === i ? v.slice(0, MAX_NAME) : old)))
return (
<div className="stack setup">
<Line className="accent">WHO IS GOING IN?</Line>
<Line className="dim">EACH HUNTER GETS THEIR OWN CAVE. YOU TAKE IT IN TURNS.</Line>
<Line> </Line>
{names.map((name, i) => (
<div className="field" key={i}>
<label htmlFor={`hunter-${i}`}>HUNTER {i + 1}</label>
<input
id={`hunter-${i}`}
className="field-input"
value={name}
maxLength={MAX_NAME}
placeholder={`HUNTER ${i + 1}`}
onChange={(e) => set(i, e.target.value)}
/>
</div>
))}
<Line> </Line>
<div className="prompt-row">
{names.length < MAX_HUNTERS && (
<Btn
onClick={() => {
onBlip()
setNames((n) => [...n, ''])
}}
>
ONE MORE
</Btn>
)}
<Btn kind="primary" onClick={() => onStart(names)}>
GO IN
</Btn>
</div>
</div>
)
}
// ----------------------------------------------------------------- report
/**
* The end of one hunt. This is the only screen other than the standings
* allowed to show where things actually were -- the hunt is over, so knowing
* costs nothing, and not showing it would waste the one moment the map is
* genuinely interesting.
*/
export function ReportScreen({
cave,
hunter,
moreToCome,
onNext,
onClimbOut,
}: {
cave: Cave
hunter: Hunter
/** True when somebody is still alive to take another turn. */
moreToCome: boolean
onNext: () => void
onClimbOut: () => void
}) {
const won = isWin(cave.outcome)
return (
<div className="stack report">
<Line className={won ? 'accent' : 'warn'}>{cave.outcome ? outcomeText(cave.outcome) : ''}</Line>
<Line> </Line>
<div className="map-wrap map-reveal">
<CaveMap cave={cave} reveal />
</div>
<Line> </Line>
<Line>
{hunter.name} {hunter.bagged} BAGGED, {hunter.arrows} ARROW
{hunter.arrows === 1 ? '' : 'S'} LEFT, {hunter.roomsWalked} ROOMS WALKED
</Line>
<Line className="dim">
THE WUMPUS WAS IN {cave.wumpus}. PITS: {cave.pits.join(' AND ')}. BATS:{' '}
{cave.bats.join(' AND ')}.
</Line>
<Line> </Line>
<div className="prompt-row">
<Btn kind="primary" onClick={onNext}>
{moreToCome ? 'NEXT HUNTER' : won ? 'GO DEEPER' : 'THAT IS THAT'}
</Btn>
<Btn onClick={onClimbOut} title="End the expedition and post the board">
CLIMB OUT
</Btn>
</div>
</div>
)
}
// --------------------------------------------------------------- standings
export function GameOverScreen({
hunters,
climbedOut,
onRestart,
onScores,
}: {
hunters: Hunter[]
climbedOut: boolean
onRestart: () => void
onScores: () => void
}) {
const ranked = [...hunters].sort(compareHunts)
return (
<div className="stack over">
<Line className="accent">{climbedOut ? 'YOU CLIMBED OUT.' : 'THE CAVE KEPT EVERYBODY.'}</Line>
<Line> </Line>
{ranked.map((h, i) => (
<Line key={h.id}>
{String(i + 1).padStart(2)}. {h.name.padEnd(MAX_NAME)} {String(h.bagged).padStart(2)}{' '}
BAGGED {String(h.arrows).padStart(2)} ARROWS {String(h.roomsWalked).padStart(3)} ROOMS
{h.died ? ` ${outcomeText(h.died)}` : ' WALKED OUT'}
</Line>
))}
<Line> </Line>
<div className="prompt-row">
<Btn kind="primary" onClick={onRestart}>
AGAIN
</Btn>
<Btn onClick={onScores}>BOARD</Btn>
</div>
</div>
)
}
// ------------------------------------------------------------------- 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 (
<div className="stack board">
<Line className="accent">THE BOARD</Line>
<Line className="dim">RANKED ON WUMPODES, THEN ARROWS LEFT, THEN ROOMS WALKED.</Line>
<Line> </Line>
{state === 'loading' && <Line>ASKING THE MACHINE...</Line>}
{state === 'error' && (
<>
{/* The game is entirely playable without the board, and says so
rather than pretending the run did not happen. */}
<Line className="warn">THE BOARD IS NOT ANSWERING. YOUR HUNT STILL COUNTED.</Line>
<Line> </Line>
<Btn onClick={onRetry}>TRY AGAIN</Btn>
</>
)}
{state === 'ready' && scores.length === 0 && <Line>NOBODY HAS COME BACK OUT YET.</Line>}
{state === 'ready' &&
scores.map((s, i) => (
<Line key={s.id} className={highlight.includes(s.id) ? 'accent' : ''}>
{String(i + 1).padStart(2)}. {s.name.padEnd(MAX_NAME)} {String(s.bagged).padStart(2)}{' '}
{String(s.arrows).padStart(2)} {String(s.roomsWalked).padStart(3)}
{s.died ? '' : ' *'}
</Line>
))}
<Line> </Line>
<Btn kind="primary" onClick={onBack}>
BACK
</Btn>
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest'
import { EDGES, LAYOUT, ROOM_COUNT, TUNNELS, areConnected, tunnelsFrom } from './cave'
/**
* The adjacency table is typed out by hand, which means it can be typed out
* wrong. A wrong table would not crash and would not look wrong on screen --
* it would just quietly stop being a dodecahedron, and the game would still
* play. So it gets proved rather than trusted.
*/
describe('the cave is a dodecahedron', () => {
it('has twenty rooms', () => {
expect(TUNNELS).toHaveLength(ROOM_COUNT)
})
it('gives every room exactly three tunnels', () => {
for (let room = 1; room <= ROOM_COUNT; room++) {
expect(tunnelsFrom(room)).toHaveLength(3)
}
})
it('never leads a room to itself or twice to the same place', () => {
for (let room = 1; room <= ROOM_COUNT; room++) {
const to = tunnelsFrom(room)
expect(to).not.toContain(room)
expect(new Set(to).size).toBe(3)
}
})
/** A tunnel you can walk down but not back up is not a tunnel. */
it('is symmetric', () => {
for (let a = 1; a <= ROOM_COUNT; a++) {
for (const b of tunnelsFrom(a)) {
expect(areConnected(b, a)).toBe(true)
}
}
})
it('has thirty tunnels, counted once each', () => {
expect(EDGES).toHaveLength(30)
const seen = new Set(EDGES.map(([a, b]) => `${a}-${b}`))
expect(seen.size).toBe(30)
})
/**
* Connected, and no shortcuts. A dodecahedron has a diameter of five: from
* any room, the furthest room is exactly five tunnels away. This catches
* the kind of typo that leaves the graph 3-regular and symmetric but wired
* to the wrong vertex -- which the tests above would all pass.
*/
it('is connected with a diameter of five', () => {
const distances = (from: number) => {
const dist = new Map<number, number>([[from, 0]])
const queue = [from]
while (queue.length) {
const at = queue.shift()!
for (const next of tunnelsFrom(at)) {
if (dist.has(next)) continue
dist.set(next, dist.get(at)! + 1)
queue.push(next)
}
}
return dist
}
for (let room = 1; room <= ROOM_COUNT; room++) {
const dist = distances(room)
expect(dist.size).toBe(ROOM_COUNT)
expect(Math.max(...dist.values())).toBe(5)
}
})
/**
* Every vertex of a dodecahedron lies on three pentagonal faces, so every
* room sits on three five-room loops. Nothing in the game reads this; it is
* here because it is the property that makes the shape a dodecahedron
* rather than merely a 3-regular graph of diameter five.
*/
it('puts every room on a five-room loop', () => {
const fives = (start: number) => {
let found = 0
const walk = (at: number, path: number[]) => {
if (path.length === 5) {
if (areConnected(at, start)) found++
return
}
for (const next of tunnelsFrom(at)) {
if (path.includes(next)) continue
walk(next, [...path, next])
}
}
walk(start, [start])
// Each pentagon is walked twice, once in each direction.
return found / 2
}
for (let room = 1; room <= ROOM_COUNT; room++) {
expect(fives(room)).toBe(3)
}
})
})
describe('the map it is drawn on', () => {
it('places every room', () => {
for (let room = 1; room <= ROOM_COUNT; room++) {
expect(LAYOUT[room]).toBeDefined()
}
})
it('never puts two rooms in the same place', () => {
const seen = new Set(Object.values(LAYOUT).map(([x, y]) => `${x.toFixed(3)},${y.toFixed(3)}`))
expect(seen.size).toBe(ROOM_COUNT)
})
/**
* The whole point of a Schlegel diagram: it is planar, so no two tunnels
* cross. A layout that crosses is still a layout -- it draws, it scales, it
* looks broadly cave-shaped -- and it is much harder to read than it looks
* in a screenshot, so the property is asserted rather than eyeballed.
*
* Tunnels that share a room meet at it and are skipped; anything else that
* touches is a crossing.
*/
it('draws no tunnel across another', () => {
const point = (room: number) => LAYOUT[room]!
const orientation = (
[ax, ay]: readonly [number, number],
[bx, by]: readonly [number, number],
[cx, cy]: readonly [number, number],
) => {
const v = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)
return Math.abs(v) < 1e-9 ? 0 : Math.sign(v)
}
for (const [a, b] of EDGES) {
for (const [c, d] of EDGES) {
if (a === c && b === d) continue
// Sharing an endpoint is a junction, not a crossing.
if (a === c || a === d || b === c || b === d) continue
const [p1, p2, p3, p4] = [point(a), point(b), point(c), point(d)]
const crosses =
orientation(p1, p2, p3) !== orientation(p1, p2, p4) &&
orientation(p3, p4, p1) !== orientation(p3, p4, p2)
expect(crosses, `tunnel ${a}-${b} crosses ${c}-${d}`).toBe(false)
}
}
})
})
+120
View File
@@ -0,0 +1,120 @@
/**
* The cave is a dodecahedron and always has been.
*
* Yob's note on the game says it plainly: he was tired of grid games, so he
* hung the rooms on the vertices of a dodecahedron. Twenty rooms, three
* tunnels from each, thirty tunnels in all. That is not a parameter and it is
* not generated -- it is the shape of the game, and it is the same shape in
* every cave you will ever walk into here.
*
* What changes between caves is only where the hazards sit. That is worth
* being clear about, because it is the whole reason the game is playable at
* all: you are not learning a maze, you are learning to read three sentences
* about a room you cannot see.
*/
/** Rooms are numbered 1..20, the way they are printed. */
export const ROOM_COUNT = 20
/**
* The adjacency Yob used, in the order the rooms print.
*
* Written out rather than derived. A dodecahedron can be generated, but the
* generated one comes out with an arbitrary numbering, and this numbering is
* the one every listing of this game has used since 1973 -- so room 1 has
* always led to 2, 5 and 8, and a transcript from a magazine still makes
* sense against it. `cave.test.ts` proves it is a real dodecahedron rather
* than trusting that it was typed correctly.
*/
export const TUNNELS: readonly (readonly number[])[] = Object.freeze([
Object.freeze([2, 5, 8]), // 1
Object.freeze([1, 3, 10]), // 2
Object.freeze([2, 4, 12]), // 3
Object.freeze([3, 5, 14]), // 4
Object.freeze([1, 4, 6]), // 5
Object.freeze([5, 7, 15]), // 6
Object.freeze([6, 8, 17]), // 7
Object.freeze([1, 7, 9]), // 8
Object.freeze([8, 10, 18]), // 9
Object.freeze([2, 9, 11]), // 10
Object.freeze([10, 12, 19]), //11
Object.freeze([3, 11, 13]), // 12
Object.freeze([12, 14, 20]), //13
Object.freeze([4, 13, 15]), // 14
Object.freeze([6, 14, 16]), // 15
Object.freeze([15, 17, 20]), //16
Object.freeze([7, 16, 18]), // 17
Object.freeze([9, 17, 19]), // 18
Object.freeze([11, 18, 20]), //19
Object.freeze([13, 16, 19]), //20
])
/** The three tunnels out of a room, in printing order. */
export function tunnelsFrom(room: number): readonly number[] {
return TUNNELS[room - 1] ?? []
}
export function areConnected(a: number, b: number): boolean {
return tunnelsFrom(a).includes(b)
}
export const isRoom = (n: unknown): n is number =>
typeof n === 'number' && Number.isInteger(n) && n >= 1 && n <= ROOM_COUNT
/**
* Where each room sits when the cave is drawn flat.
*
* A dodecahedron cannot be drawn on paper without lying about something, so
* this is the Schlegel diagram: one face pushed out to become the rim and the
* other eleven nested inside it. Done right, every tunnel is a straight line
* and no two of them cross -- which is the property `cave.test.ts` checks,
* because it is the one that makes the map readable and the one that is easy
* to break by moving a room.
*
* The rings are 5 / 10 / 5, and the middle one is the part that is easy to
* get wrong. Rooms 6 to 15 are not two pentagons at different depths; they
* are a single ten-room ring that alternates between the rooms wired inward
* to the middle pentagon (8, 10, 12, 14, 6) and the rooms wired outward to
* the rim (9, 11, 13, 15, 7). Drawing them as two rings is what puts crossed
* tunnels on the map.
*
* Unit coordinates about an origin at (0, 0); the map scales them.
*/
export const LAYOUT: Readonly<Record<number, readonly [number, number]>> = Object.freeze(
buildLayout(),
)
function buildLayout(): Record<number, [number, number]> {
/**
* Each ring in the order its rooms are wired together, so that the nth
* entry of one ring lines up radially with the room it is joined to. Room 1
* leads out to 8, and 8 leads out to 9 and back to 7, so 1 / 8 / 18 all sit
* on rays a fixed angle apart.
*/
const rings: [number[], number][] = [
[[1, 2, 3, 4, 5], 0.3],
[[8, 9, 10, 11, 12, 13, 14, 15, 6, 7], 0.66],
[[18, 19, 20, 16, 17], 1.0],
]
const out: Record<number, [number, number]> = {}
for (const [rooms, radius] of rings) {
const step = 360 / rooms.length
rooms.forEach((room, i) => {
// -90 puts the first room of each ring at the top rather than the right.
// The rim starts one half-step round, because 18 hangs off 9 rather
// than off 8.
const offset = rooms.length === 5 && radius === 1 ? 36 : 0
const rad = ((-90 + offset + step * i) * Math.PI) / 180
out[room] = [Math.cos(rad) * radius, Math.sin(rad) * radius]
})
}
return out
}
/** Every tunnel once, as a pair, so the map does not draw each of them twice. */
export const EDGES: readonly (readonly [number, number])[] = Object.freeze(
TUNNELS.flatMap((to, i) =>
to.filter((b) => i + 1 < b).map((b) => Object.freeze([i + 1, b]) as readonly [number, number]),
),
)
+81
View File
@@ -0,0 +1,81 @@
/**
* The numbers, all of them, in one place.
*
* Unlike the lemonade stand's demand curve there is almost nothing to
* reconstruct here: Yob's rules are arithmetic-free. Two pits, two bats, one
* wumpus, five arrows, an arrow that flies through five rooms, and a wumpus
* that wakes three times in four. The only judgement calls are marked.
*/
export const PIT_COUNT = 2
export const BAT_COUNT = 2
export const STARTING_ARROWS = 5
/** An arrow is crooked: it can be aimed through up to five rooms. */
export const MAX_ARROW_PATH = 5
/**
* A shot wakes the wumpus three times in four, and a woken wumpus moves one
* room -- or stays where it is, which is the fourth of its four choices.
*/
export const WUMPUS_WAKES = 0.75
export const MAX_HUNTERS = 4
export const MAX_NAME = 12
/**
* The one addition to the 1973 rules, and it is a frame rather than a rule.
*
* Yob's game is a single cave: you kill the wumpus or it kills you, and
* either way that is the end. That makes a fine two minutes and a poor
* leaderboard -- there are only two scores, and half the players get the
* lower one through no fault of their own.
*
* So a win is not the end here, it is a door. Bag the wumpus and you go
* deeper: a fresh cave, the same twenty rooms, hazards laid out again, and
* whatever arrows you did not spend. Death ends the expedition. The score is
* how many you bagged before one of them got you, which is a number a board
* can rank, and every hunt inside it is the 1973 game unaltered.
*
* Going deeper does not make the cave harder. It was already a coin toss
* dressed as a deduction; stacking hazards on top would only make a long run
* impossible rather than unlikely.
*/
export const ARROWS_PER_CAVE = 1
/** Never more than you started with, however many caves you clear. */
export const MAX_CARRIED_ARROWS = STARTING_ARROWS
/*
* Everything the machine says, and none of it is Yob's.
*
* The 1973 listing has famous lines in it and they are his writing, not ours.
* The sibling project went back and took the original's wording out of itself
* for exactly this reason -- see NOTICE.md, and the same paragraph in the
* lemonade stand's. A game released under a copyleft licence cannot go around
* licensing sentences somebody else wrote.
*
* So these are written for this project. They carry the same information in
* the same order, in the same shouted uppercase a printing terminal gave you,
* because that much is the medium rather than the author.
*/
export const SENSE_TEXT: Record<'wumpus' | 'pit' | 'bats', string> = {
wumpus: 'SOMETHING IN HERE SMELLS ALIVE',
pit: 'THE AIR MOVES. SOMETHING NEARBY IS OPEN',
bats: 'WINGS, SOMEWHERE CLOSE',
}
export const DEATH_TEXT: Record<string, string> = {
eaten: 'IT FINDS YOU IN THE DARK. IT IS NOT SLOW.',
'fell-in-a-pit': 'THE FLOOR IS NOT THERE. NEITHER IS ANYTHING ELSE.',
'shot-yourself': 'YOUR OWN ARROW COMES BACK OUT OF THE DARK.',
'out-of-arrows': 'THE QUIVER IS EMPTY. THE CAVE HAS ALL THE TIME IT NEEDS.',
}
export const WIN_TEXT = 'THE ARROW FINDS IT. THE CAVE GOES QUIET.'
export const BATS_TEXT = 'WINGS CLOSE ON YOU AND PUT YOU DOWN SOMEWHERE ELSE.'
/** A room with no tunnel to it. Refused, at no cost. */
export const NO_TUNNEL_TEXT = 'NO TUNNEL GOES THAT WAY'
+318
View File
@@ -0,0 +1,318 @@
import { describe, expect, it } from 'vitest'
import { ROOM_COUNT, tunnelsFrom } from './cave'
import { BAT_COUNT, NO_TUNNEL_TEXT, PIT_COUNT, SENSE_TEXT, STARTING_ARROWS } from './constants'
import { arrowsAfterWin, compareHunts, describe as describeRoom, layCave, sense, step } from './engine'
import { makeRng } from './rng'
import type { Cave } from './types'
/**
* The engine is the game, so this is where the rules are held to account.
*
* Most of these build a cave by hand rather than dealing one, because a rule
* about what happens when you walk into a pit is not a rule about where pits
* end up. The dealt caves are tested separately, once, for the things dealing
* has to guarantee.
*/
const caveWith = (over: Partial<Cave> = {}): Cave => ({
depth: 1,
wumpus: 20,
pits: [18, 19],
bats: [16, 17],
you: 1,
start: 1,
arrows: STARTING_ARROWS,
visited: [1],
sensed: {},
outcome: null,
...over,
})
/** A stream that hands back exactly the numbers a test wants, then zeros. */
const scripted = (...values: number[]) => {
let i = 0
return () => values[i++] ?? 0
}
describe('dealing a cave', () => {
it('gives everything its own room, including the hunter', () => {
for (let seed = 1; seed <= 400; seed++) {
const cave = layCave(1, STARTING_ARROWS, makeRng(seed))
const occupied = [cave.wumpus, ...cave.pits, ...cave.bats, cave.you]
expect(new Set(occupied).size).toBe(occupied.length)
expect(cave.pits).toHaveLength(PIT_COUNT)
expect(cave.bats).toHaveLength(BAT_COUNT)
}
})
it('lays the same cave twice from the same seed', () => {
const a = layCave(3, 4, makeRng(12345))
const b = layCave(3, 4, makeRng(12345))
expect(b).toEqual(a)
})
/**
* The first room is the one it is easy to forget, because `describe`
* recomputes the senses rather than reading them back -- so the transcript
* is right whether or not they were ever stored, and only the map notices.
*/
it('records what the first room senses, not just what it prints', () => {
let found = 0
for (let seed = 1; seed <= 200; seed++) {
const cave = layCave(1, STARTING_ARROWS, makeRng(seed))
expect(cave.sensed[cave.you]).toEqual(sense(cave))
if ((cave.sensed[cave.you] ?? []).length > 0) found++
}
// A start next to a hazard is common enough that the case is real.
expect(found).toBeGreaterThan(20)
})
it('starts the hunter somewhere they can be told about', () => {
const cave = layCave(1, STARTING_ARROWS, makeRng(7))
const lines = describeRoom(cave)
expect(lines.some((l) => l.text === `YOU ARE IN ROOM ${cave.you}`)).toBe(true)
expect(lines.some((l) => l.text.startsWith('TUNNELS LEAD TO'))).toBe(true)
})
})
describe('what a room tells you', () => {
it('reports a hazard one tunnel away and never says which room', () => {
// Room 1 leads to 2, 5 and 8. Put the wumpus in 5.
const cave = caveWith({ you: 1, wumpus: 5, pits: [18, 19], bats: [16, 17] })
expect(sense(cave)).toEqual(['wumpus'])
const printed = describeRoom(cave).map((l) => l.text)
expect(printed).toContain(SENSE_TEXT.wumpus)
expect(printed.join(' ')).not.toContain('ROOM 5')
})
it('says nothing about a hazard two tunnels away', () => {
// 12 is two from 1 (1-2-12), so it must be silent from room 1.
expect(tunnelsFrom(1)).not.toContain(12)
expect(sense(caveWith({ you: 1, wumpus: 12 }))).toEqual([])
})
it('reports all three at once, in the order the listing printed them', () => {
const cave = caveWith({ you: 1, wumpus: 2, pits: [5, 19], bats: [8, 17] })
expect(sense(cave)).toEqual(['wumpus', 'pit', 'bats'])
})
})
describe('moving', () => {
it('refuses a room with no tunnel to it, and does not cost a turn', () => {
const cave = caveWith({ you: 1 })
const { cave: after, lines } = step(cave, { type: 'move', to: 12 }, makeRng(1))
expect(after).toBe(cave)
expect(lines.map((l) => l.text)).toEqual([NO_TUNNEL_TEXT])
})
it('eats you when you walk in on the wumpus', () => {
const { cave } = step(caveWith({ you: 1, wumpus: 2 }), { type: 'move', to: 2 }, makeRng(1))
expect(cave.outcome).toBe('eaten')
})
it('drops you down a pit', () => {
const { cave } = step(caveWith({ you: 1, pits: [2, 19] }), { type: 'move', to: 2 }, makeRng(1))
expect(cave.outcome).toBe('fell-in-a-pit')
})
/**
* The bat rule is the one worth testing hardest: it is the only thing in
* the game that moves you somewhere you did not choose, and the room it
* picks can hold anything.
*/
it('lets a bat carry you somewhere else entirely', () => {
const cave = caveWith({ you: 1, bats: [2, 17], wumpus: 20, pits: [18, 19] })
const { cave: after, lines } = step(cave, { type: 'move', to: 2 }, makeRng(99))
expect(lines.some((l) => l.kind === 'bats')).toBe(true)
expect(after.you).not.toBe(2)
expect(after.outcome).toBeNull()
})
it('lets a bat drop you on something that kills you', () => {
// The first roll picks the destination out of the nineteen other rooms;
// 0 lands on room 1, where the pit is.
const cave = caveWith({ you: 5, bats: [6, 17], pits: [1, 19], wumpus: 20 })
const { cave: after } = step(cave, { type: 'move', to: 6 }, scripted(0))
expect(after.outcome).toBe('fell-in-a-pit')
})
it('never drops you back in the room the bat took you from', () => {
for (let seed = 1; seed <= 200; seed++) {
const cave = caveWith({ you: 1, bats: [2, 17], wumpus: 20, pits: [18, 19] })
const { cave: after } = step(cave, { type: 'move', to: 2 }, makeRng(seed))
if (after.outcome) continue
expect(after.you).not.toBe(2)
}
})
it('remembers every room it has stood in', () => {
let cave = caveWith({ you: 1, wumpus: 20, pits: [18, 19], bats: [16, 17] })
cave = step(cave, { type: 'move', to: 2 }, makeRng(1)).cave
cave = step(cave, { type: 'move', to: 3 }, makeRng(1)).cave
expect(cave.visited).toEqual([1, 2, 3])
})
})
describe('shooting', () => {
it('kills the wumpus when the arrow reaches it', () => {
const cave = caveWith({ you: 1, wumpus: 2 })
const { cave: after, lines } = step(cave, { type: 'shoot', path: [2] }, makeRng(1))
expect(after.outcome).toBe('shot-the-wumpus')
expect(lines.some((l) => l.kind === 'win')).toBe(true)
})
it('spends an arrow whether it hits or misses', () => {
const hit = step(caveWith({ you: 1, wumpus: 2 }), { type: 'shoot', path: [2] }, makeRng(1))
const miss = step(caveWith({ you: 1, wumpus: 20 }), { type: 'shoot', path: [2] }, makeRng(1))
expect(hit.cave.arrows).toBe(STARTING_ARROWS - 1)
expect(miss.cave.arrows).toBe(STARTING_ARROWS - 1)
})
/**
* The rule the whole game turns on. Room 1 leads to 2, 5 and 8; room 2
* leads to 1, 3 and 10. Naming 2 and then 8 asks the arrow to make a jump
* it cannot -- so it takes a tunnel of its own, and the scripted stream
* makes that tunnel the one back to room 1.
*/
it('sends a crooked arrow down a random tunnel when it cannot reach the room named', () => {
expect(tunnelsFrom(2)).not.toContain(8)
expect(tunnelsFrom(2)[0]).toBe(1)
const cave = caveWith({ you: 1, wumpus: 20 })
// First roll picks tunnel 0 out of room 2, which is room 1: the hunter.
const { cave: after } = step(cave, { type: 'shoot', path: [2, 8] }, scripted(0))
expect(after.outcome).toBe('shot-yourself')
})
it('will not fly further than five rooms', () => {
const cave = caveWith({ you: 1, wumpus: 20 })
const { lines } = step(
cave,
{ type: 'shoot', path: [2, 3, 4, 5, 6, 7, 8] },
// Every roll picks the first tunnel, so nothing is left to chance.
scripted(0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
)
const flight = lines.find((l) => l.kind === 'shot')!.text
expect(flight.split(' - ')).toHaveLength(5)
})
it('wakes the wumpus onto you when the roll says so', () => {
// Wumpus in 2, hunter in 1. The shot at 5 is a legal tunnel, so it costs
// no roll: the first roll is the wake (0.1 is under the 0.75 threshold)
// and the second picks from [1, 3, 10, 2] -- index 0 being room 1, which
// is where the hunter is standing.
const cave = caveWith({ you: 1, wumpus: 2 })
const { cave: after } = step(cave, { type: 'shoot', path: [5] }, scripted(0.1, 0))
expect(after.outcome).toBe('eaten')
})
it('leaves the wumpus asleep when the roll says so', () => {
const cave = caveWith({ you: 1, wumpus: 2 })
// A miss (path goes to 5), then 0.99 -- above the 0.75 wake threshold.
const { cave: after } = step(cave, { type: 'shoot', path: [5] }, scripted(0.9, 0.99))
expect(after.wumpus).toBe(2)
expect(after.outcome).toBeNull()
})
it('ends the hunt when the last arrow misses', () => {
const cave = caveWith({ you: 1, wumpus: 20, arrows: 1 })
const { cave: after } = step(cave, { type: 'shoot', path: [5] }, scripted(0.9, 0.99))
expect(after.outcome).toBe('out-of-arrows')
})
it('refuses a shot with nowhere to go, and does not cost an arrow', () => {
const cave = caveWith({ you: 1 })
const { cave: after } = step(cave, { type: 'shoot', path: [] }, makeRng(1))
expect(after.arrows).toBe(STARTING_ARROWS)
expect(after).toBe(cave)
})
})
describe('a hunt that has already ended', () => {
it('ignores everything after it', () => {
const dead = caveWith({ outcome: 'eaten' })
expect(step(dead, { type: 'move', to: 2 }, makeRng(1)).cave).toBe(dead)
expect(step(dead, { type: 'shoot', path: [2] }, makeRng(1)).cave).toBe(dead)
})
})
describe('the expedition around it', () => {
it('gives back one arrow for a kill, and never more than you started with', () => {
expect(arrowsAfterWin(2)).toBe(3)
expect(arrowsAfterWin(STARTING_ARROWS)).toBe(STARTING_ARROWS)
})
it('ranks on wumpodes first, then arrows left, then rooms walked', () => {
const a = { bagged: 3, arrows: 1, roomsWalked: 40 }
const b = { bagged: 2, arrows: 5, roomsWalked: 4 }
const c = { bagged: 3, arrows: 4, roomsWalked: 90 }
const d = { bagged: 3, arrows: 4, roomsWalked: 12 }
expect([a, b, c, d].sort(compareHunts)).toEqual([d, c, a, b])
})
})
/**
* The remaster must not be an easier game than the transcript.
*
* `CaveMap` decides what to draw from `cave.sensed` and `cave.visited`, so
* the guarantee it needs is that neither of those ever names a room the
* hunter has not been told about. That is asserted here rather than in the
* component, because it is a property of the engine and it would still have
* to hold if the map were thrown away tomorrow.
*/
describe('what the map is allowed to know', () => {
it('only ever records senses for rooms the hunter has stood in', () => {
for (let seed = 1; seed <= 120; seed++) {
const rng = makeRng(seed)
let cave = layCave(1, STARTING_ARROWS, rng)
for (let turn = 0; turn < 12 && !cave.outcome; turn++) {
const exits = tunnelsFrom(cave.you)
cave = step(cave, { type: 'move', to: exits[turn % 3]! }, rng).cave
}
for (const room of Object.keys(cave.sensed).map(Number)) {
expect(cave.visited).toContain(room)
}
}
})
it('never records a sense that the room would not have printed', () => {
const rng = makeRng(4242)
let cave = layCave(1, STARTING_ARROWS, rng)
for (let turn = 0; turn < 10 && !cave.outcome; turn++) {
const exits = tunnelsFrom(cave.you)
cave = step(cave, { type: 'move', to: exits[turn % 3]! }, rng).cave
}
for (const [room, hazards] of Object.entries(cave.sensed)) {
// Recompute from scratch: what the room says now is what was stored.
expect(sense(cave, Number(room))).toEqual(hazards)
}
})
})
describe('the whole thing, run a few hundred times', () => {
/**
* A hunter who walks at random and never shoots should die most of the
* time and should never hang, never leave the cave, and never end up in a
* room that does not exist. This is the cheap version of the lemonade
* stand's "run a few hundred seasons" check: it is not tuning anything,
* it is looking for a state the rules cannot produce.
*/
it('never leaves the cave or hangs', () => {
let deaths = 0
for (let seed = 1; seed <= 500; seed++) {
const rng = makeRng(seed)
let cave = layCave(1, STARTING_ARROWS, rng)
let turns = 0
while (!cave.outcome && turns < 200) {
const exits = tunnelsFrom(cave.you)
cave = step(cave, { type: 'move', to: exits[Math.floor(rng() * 3)]! }, rng).cave
expect(cave.you).toBeGreaterThanOrEqual(1)
expect(cave.you).toBeLessThanOrEqual(ROOM_COUNT)
turns++
}
if (cave.outcome) deaths++
}
// Walking blind into a cave with four lethal rooms out of twenty is not
// survivable for long; if this ever drops, something has stopped biting.
expect(deaths).toBeGreaterThan(480)
})
})
+279
View File
@@ -0,0 +1,279 @@
import { ROOM_COUNT, areConnected, isRoom, tunnelsFrom } from './cave'
import {
ARROWS_PER_CAVE,
BATS_TEXT,
BAT_COUNT,
DEATH_TEXT,
NO_TUNNEL_TEXT,
MAX_ARROW_PATH,
MAX_CARRIED_ARROWS,
PIT_COUNT,
SENSE_TEXT,
STARTING_ARROWS,
WIN_TEXT,
WUMPUS_WAKES,
} from './constants'
import { chance, type Rng } from './rng'
import type { Cave, Command, Hazard, Line, Outcome } from './types'
/**
* The whole game lives in this file and none of it knows what a screen is.
* `step` takes a cave and a command and hands back the cave that follows,
* plus the lines the machine would have printed. Both skins render those
* lines; neither one decides anything.
*/
// -------------------------------------------------------------- laying out
/**
* Deal the hazards.
*
* Yob's listing allowed the hunter to start in the same room as a bat or a
* pit, and several later ports quietly stopped doing that because starting a
* game by falling down a hole is not a game. It is not a rule so much as an
* oversight, so it is fixed here: every hazard and the hunter get their own
* room. The wumpus may still be one tunnel away on the first turn -- that
* part is the game.
*/
export function layCave(depth: number, arrows: number, rng: Rng): Cave {
const rooms = shuffle(
Array.from({ length: ROOM_COUNT }, (_, i) => i + 1),
rng,
)
let n = 0
const wumpus = rooms[n++]!
const pits = rooms.slice(n, (n += PIT_COUNT))
const bats = rooms.slice(n, (n += BAT_COUNT))
const you = rooms[n]!
const cave: Cave = {
depth,
wumpus,
pits,
bats,
you,
start: you,
arrows,
visited: [you],
sensed: {},
outcome: null,
}
/*
* Record what the first room says before handing it over.
*
* Every other room gets this from `enter`, and leaving the first one out is
* an easy thing not to notice: the transcript is right either way, because
* `describe` recomputes the senses rather than reading them back. What goes
* wrong is the map, which reads `sensed` -- so a hunter who starts next to a
* pit would be told about it in words and see nothing marked, which makes
* the remaster a harder game than the original rather than the same one.
*/
return { ...cave, sensed: { [you]: sense(cave) } }
}
/** Fisher-Yates, off the seeded stream so a cave replays exactly. */
function shuffle<T>(items: T[], rng: Rng): T[] {
const out = items.slice()
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1))
;[out[i], out[j]] = [out[j]!, out[i]!]
}
return out
}
const pick = <T,>(items: readonly T[], rng: Rng): T => items[Math.floor(rng() * items.length)]!
// ------------------------------------------------------------------ senses
/** What the hunter can smell, feel and hear from where they are standing. */
export function sense(cave: Cave, room = cave.you): Hazard[] {
const near = tunnelsFrom(room)
const out: Hazard[] = []
// Printed in this order every time, so the transcript reads the same way.
if (near.includes(cave.wumpus)) out.push('wumpus')
if (near.some((r) => cave.pits.includes(r))) out.push('pit')
if (near.some((r) => cave.bats.includes(r))) out.push('bats')
return out
}
/** The lines a room prints on arrival: what it senses, then where it leads. */
export function describe(cave: Cave): Line[] {
const lines: Line[] = sense(cave).map((h) => ({ kind: 'sense', text: SENSE_TEXT[h] }))
lines.push({ kind: 'system', text: `YOU ARE IN ROOM ${cave.you}` })
lines.push({ kind: 'system', text: `TUNNELS LEAD TO ${tunnelsFrom(cave.you).join(' ')}` })
return lines
}
/** Record what this room told us, so the map can show it later. */
function remember(cave: Cave): Cave {
const here = sense(cave)
return { ...cave, sensed: { ...cave.sensed, [cave.you]: here } }
}
// ------------------------------------------------------------------ moving
/**
* Walk into a room and find out what is in it.
*
* Bats are the awkward part and the reason this recurses: a bat drops you in
* a room chosen at random, and that room can hold the wumpus, a pit, or
* another bat. Yob's listing loops; so does this, with a stop after a handful
* of throws. Two bats in twenty rooms cannot actually bounce you forever, but
* an unbounded loop in a game nobody can inspect is worth not writing.
*/
function enter(cave: Cave, room: number, lines: Line[], rng: Rng, throws = 0): Cave {
const moved: Cave = {
...cave,
you: room,
visited: cave.visited.includes(room) ? cave.visited : [...cave.visited, room],
}
if (room === moved.wumpus) {
// Walking in on a sleeping wumpus wakes it, and it is faster than you.
lines.push({ kind: 'death', text: DEATH_TEXT.eaten! })
return { ...moved, outcome: 'eaten' }
}
if (moved.pits.includes(room)) {
lines.push({ kind: 'death', text: DEATH_TEXT['fell-in-a-pit']! })
return { ...moved, outcome: 'fell-in-a-pit' }
}
if (moved.bats.includes(room) && throws < 5) {
lines.push({ kind: 'bats', text: BATS_TEXT })
// Anywhere but where you already are -- being dropped back is not a snatch.
const elsewhere = Array.from({ length: ROOM_COUNT }, (_, i) => i + 1).filter((r) => r !== room)
return enter(moved, pick(elsewhere, rng), lines, rng, throws + 1)
}
return remember(moved)
}
// ----------------------------------------------------------------- shooting
/**
* Fly a crooked arrow.
*
* The hunter names up to five rooms. If the next named room is not reachable
* from the one the arrow is in, the arrow does not stop and it does not fail
* -- it blunders on into a random tunnel, which is how a crooked arrow ends
* up in the room behind you. That rule is the reason this game is frightening
* to play well: a long shot is a good way to shoot yourself.
*/
function flyArrow(cave: Cave, path: readonly number[], lines: Line[], rng: Rng): Cave {
let at = cave.you
const flown: number[] = []
for (const named of path.slice(0, MAX_ARROW_PATH)) {
const next = areConnected(at, named) ? named : pick(tunnelsFrom(at), rng)
flown.push(next)
at = next
if (at === cave.wumpus) {
lines.push({ kind: 'win', text: WIN_TEXT })
lines.push({ kind: 'system', text: 'THERE IS ANOTHER ONE FURTHER IN. THERE ALWAYS IS.' })
return { ...cave, arrows: cave.arrows - 1, outcome: 'shot-the-wumpus' }
}
if (at === cave.you) {
lines.push({ kind: 'death', text: DEATH_TEXT['shot-yourself']! })
return { ...cave, arrows: cave.arrows - 1, outcome: 'shot-yourself' }
}
}
lines.push({ kind: 'shot', text: `MISSED. THE ARROW WENT ${flown.join(' - ')}` })
return { ...cave, arrows: cave.arrows - 1 }
}
/**
* A shot that misses wakes the wumpus, and a woken wumpus takes one of four
* doors: the three tunnels out, or the floor it is already lying on. If it
* picks the room the hunter is standing in, the hunt is over.
*/
function stirWumpus(cave: Cave, lines: Line[], rng: Rng): Cave {
if (!chance(rng, WUMPUS_WAKES)) return cave
const options = [...tunnelsFrom(cave.wumpus), cave.wumpus]
const to = pick(options, rng)
const moved = { ...cave, wumpus: to }
if (to === cave.you) {
lines.push({ kind: 'death', text: DEATH_TEXT.eaten! })
return { ...moved, outcome: 'eaten' }
}
// It moved, and the only way to know is that the room smells different now.
return remember(moved)
}
// -------------------------------------------------------------------- step
export interface StepResult {
cave: Cave
lines: Line[]
}
/**
* One turn. Everything above, in the order the 1973 game does it.
*
* A command that does not make sense does not cost a turn and does not end
* the game -- it is refused with the same words the listing used, because a
* player who mistypes a room number has not made a decision worth punishing.
*/
export function step(cave: Cave, command: Command, rng: Rng): StepResult {
const lines: Line[] = []
if (cave.outcome) return { cave, lines }
if (command.type === 'move') {
if (!isRoom(command.to) || !areConnected(cave.you, command.to)) {
lines.push({ kind: 'system', text: NO_TUNNEL_TEXT })
return { cave, lines }
}
return { cave: enter(cave, command.to, lines, rng), lines }
}
const path = command.path.filter(isRoom).slice(0, MAX_ARROW_PATH)
if (path.length === 0) {
lines.push({ kind: 'system', text: NO_TUNNEL_TEXT })
return { cave, lines }
}
let next = flyArrow(cave, path, lines, rng)
if (next.outcome) return { cave: next, lines }
next = stirWumpus(next, lines, rng)
if (next.outcome) return { cave: next, lines }
if (next.arrows <= 0) {
lines.push({ kind: 'death', text: DEATH_TEXT['out-of-arrows']! })
return { cave: { ...next, outcome: 'out-of-arrows' }, lines }
}
return { cave: next, lines }
}
// ------------------------------------------------------------- expeditions
/** What a hunter carries into the next cave after bagging one. */
export const arrowsAfterWin = (left: number): number =>
Math.min(MAX_CARRIED_ARROWS, left + ARROWS_PER_CAVE)
export const startingArrows = () => STARTING_ARROWS
export const outcomeText = (o: Outcome): string =>
o === 'shot-the-wumpus' ? WIN_TEXT : (DEATH_TEXT[o] ?? '')
export const randomSeed = () => Math.floor(Math.random() * 1_000_000) + 1
/**
* A hunt is worth ranking on what it caught first, then on the two things
* that separate a careful hunter from a lucky one: arrows they did not need
* to fire, and rooms they did not need to walk.
*/
export function compareHunts(
a: { bagged: number; arrows: number; roomsWalked: number },
b: { bagged: number; arrows: number; roomsWalked: number },
): number {
if (a.bagged !== b.bagged) return b.bagged - a.bagged
if (a.arrows !== b.arrows) return b.arrows - a.arrows
return a.roomsWalked - b.roomsWalked
}
+91
View File
@@ -0,0 +1,91 @@
/**
* One board, shared by everybody, held by the scores service rather than the
* browser. Nothing about it is trusted on the way in: the server decides what
* is plausible, and the client re-checks the shape of whatever comes back
* before putting it on screen.
*
* The service is shared with the other games on the site rather than being one
* of ours -- see https://github.com/Coffey-Labs/games-scores. That is why
* every call names the game. Nothing else about it leaks in here: the rows
* come back in this game's own field names, so this file would be identical if
* the board were ours alone.
*/
/** Which board on the shared service is ours. */
export const GAME = 'wumpus'
const BASE = (import.meta.env.VITE_SCORES_API ?? '/api').replace(/\/+$/, '')
const TIMEOUT_MS = 8000
export const MAX_SCORES = 50
export interface Score {
id: string
name: string
/** Wumpodes bagged. The rank, before any tie-break. */
bagged: number
/** Arrows still in the quiver when the expedition ended. */
arrows: number
roomsWalked: number
/** How it ended, or null for a hunter who climbed out on their own legs. */
died: string | null
at: number
}
export type NewScore = Omit<Score, 'id' | 'at'>
function isScore(v: unknown): v is Score {
if (typeof v !== 'object' || v === null) return false
const s = v as Record<string, unknown>
return (
typeof s.id === 'string' &&
typeof s.name === 'string' &&
Number.isFinite(s.bagged) &&
Number.isFinite(s.arrows) &&
Number.isFinite(s.roomsWalked) &&
(s.died === null || typeof s.died === 'string') &&
Number.isFinite(s.at)
)
}
const parseBoard = (body: unknown): Score[] => {
const rows = (body as { scores?: unknown })?.scores
if (!Array.isArray(rows)) return []
return rows
.filter(isScore)
.map((s) => ({ ...s, name: s.name.slice(0, 12) }))
.slice(0, MAX_SCORES)
}
async function call(path: string, init?: RequestInit): Promise<unknown> {
const res = await fetch(`${BASE}${path}`, {
...init,
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'content-type': 'application/json', ...(init?.headers ?? {}) },
})
const body: unknown = await res.json().catch(() => null)
if (!res.ok) {
const why = (body as { error?: string })?.error
throw new Error(why ?? `scores service returned ${res.status}`)
}
return body
}
export async function fetchScores(): Promise<Score[]> {
return parseBoard(await call(`/scores?game=${GAME}`))
}
/** Posts every hunter in the party at once and returns the new board. */
export async function submitScores(
entries: NewScore[],
): Promise<{ ids: string[]; scores: Score[] }> {
const body = await call('/scores', {
method: 'POST',
body: JSON.stringify({ game: GAME, entries }),
})
const ids = (body as { ids?: unknown })?.ids
return {
ids: Array.isArray(ids) ? ids.filter((i): i is string => typeof i === 'string') : [],
scores: parseBoard(body),
}
}
+193
View File
@@ -0,0 +1,193 @@
import { STARTING_ARROWS } from './constants'
import { arrowsAfterWin } from './engine'
import type { Cave, Hunter, Line, Phase } from './types'
import { isWin } from './types'
/**
* The phase machine, and the only place a turn changes hands.
*
* A turn is one cave, not one move. A hunter walks their cave until it kills
* them or they kill it, and only then does the teletype go quiet and the next
* hunter sit down. Splitting turns move-by-move would be fairer in some
* abstract sense and unplayable in practice: this is a game about what you
* have worked out, and taking turns to walk into the same darkness would mean
* four people watching one person think.
*/
export interface GameState {
phase: Phase
seed: number
hunters: Hunter[]
/** Index into `hunters`; the dead are skipped, never removed. */
turn: number
cave: Cave | null
/** Everything the machine has printed for the cave now being walked. */
transcript: Line[]
/** Set once somebody calls the expedition off, so the standings say so. */
climbedOut: boolean
/** Where the board was opened from, so BACK has somewhere to go. */
scoresReturn: Phase
}
export const initialState = (seed: number, phase: Phase = 'boot'): GameState => ({
phase,
seed,
hunters: [],
turn: 0,
cave: null,
transcript: [],
climbedOut: false,
scoresReturn: 'title',
})
export type Action =
| { type: 'BOOTED' }
| { type: 'SHOW_INSTRUCTIONS' }
| { type: 'SHOW_SETUP' }
| { type: 'START'; names: string[] }
| { type: 'ENTER_CAVE'; cave: Cave; lines: Line[] }
| { type: 'PRINT'; lines: Line[] }
| { type: 'ADVANCE'; cave: Cave; lines: Line[] }
| { type: 'SHOW_REPORT' }
| { type: 'NEXT_TURN' }
| { type: 'CLIMB_OUT' }
| { type: 'SHOW_SCORES' }
| { type: 'CLOSE_SCORES' }
| { type: 'RESTART'; seed: number }
export const livingHunters = (s: GameState) => s.hunters.filter((h) => !h.dead)
/** Whose turn it is, or undefined once everybody is out of the cave. */
export const currentHunter = (s: GameState): Hunter | undefined => s.hunters[s.turn]
/** How deep the hunter about to play is going: one more than they have bagged. */
export const depthFor = (h: Hunter): number => h.bagged + 1
/**
* The next living hunter after `from`, wrapping once. Returns -1 when there
* is nobody left, which is what ends the expedition.
*/
export function nextLiving(hunters: Hunter[], from: number): number {
for (let i = 1; i <= hunters.length; i++) {
const at = (from + i) % hunters.length
if (!hunters[at]!.dead) return at
}
return -1
}
export function reducer(state: GameState, action: Action): GameState {
switch (action.type) {
case 'BOOTED':
return { ...state, phase: 'title' }
case 'SHOW_INSTRUCTIONS':
return { ...state, phase: 'instructions' }
case 'SHOW_SETUP':
return { ...state, phase: 'setup' }
case 'START':
return {
...state,
phase: 'hunting',
hunters: action.names.map((name, i) => ({
id: i,
name: name.trim().toUpperCase() || `HUNTER ${i + 1}`,
bagged: 0,
arrows: STARTING_ARROWS,
roomsWalked: 0,
dead: false,
died: null,
diedAtDepth: null,
})),
turn: 0,
cave: null,
transcript: [],
}
case 'ENTER_CAVE':
return {
...state,
phase: 'hunting',
cave: action.cave,
transcript: action.lines,
}
case 'PRINT':
return { ...state, transcript: [...state.transcript, ...action.lines] }
/**
* One turn's worth of consequences. The hunter's running totals are kept
* on the hunter rather than recomputed from the cave, because a cave is
* thrown away at the end of a hunt and the expedition is not.
*/
case 'ADVANCE': {
const before = state.cave
const cave = action.cave
const walked = before && cave.you !== before.you ? 1 : 0
const hunters = state.hunters.map((h) =>
h.id !== currentHunter(state)?.id
? h
: {
...h,
roomsWalked: h.roomsWalked + walked,
arrows: cave.arrows,
},
)
return {
...state,
cave,
hunters,
transcript: [...state.transcript, ...action.lines],
phase: cave.outcome ? 'resolve' : 'hunting',
}
}
/** The hunt has ended; settle it against the hunter and show the report. */
case 'SHOW_REPORT': {
const cave = state.cave
const who = currentHunter(state)
if (!cave?.outcome || !who) return { ...state, phase: 'report' }
const won = isWin(cave.outcome)
const hunters = state.hunters.map((h) =>
h.id !== who.id
? h
: {
...h,
bagged: won ? h.bagged + 1 : h.bagged,
arrows: won ? arrowsAfterWin(cave.arrows) : cave.arrows,
dead: !won,
died: won ? null : cave.outcome,
diedAtDepth: won ? null : cave.depth,
},
)
return { ...state, phase: 'report', hunters }
}
case 'NEXT_TURN': {
const at = nextLiving(state.hunters, state.turn)
if (at === -1) return { ...state, phase: 'gameover', cave: null }
return { ...state, phase: 'hunting', turn: at, cave: null, transcript: [] }
}
case 'CLIMB_OUT':
return { ...state, phase: 'gameover', climbedOut: true, cave: null }
case 'SHOW_SCORES':
return state.phase === 'scores'
? state
: { ...state, phase: 'scores', scoresReturn: state.phase }
case 'CLOSE_SCORES':
return { ...state, phase: state.scoresReturn }
case 'RESTART':
// The tape only loads once a session; a new expedition starts at the title.
return initialState(action.seed, 'title')
default:
return state
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* mulberry32 — small, fast, seedable PRNG.
* The 1973 listing ran on RND(1); we keep a seed so a cave can be replayed or
* shared, and so a test can lay out the same hazards twice.
*/
export function makeRng(seed: number) {
let a = seed >>> 0
return function rng(): number {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export type Rng = ReturnType<typeof makeRng>
export const chance = (rng: Rng, p: number) => rng() < p
+70
View File
@@ -0,0 +1,70 @@
export type Hazard = 'wumpus' | 'pit' | 'bats'
/** What is where. Never handed to a screen that has not earned it. */
export interface Cave {
/** Which cave of the expedition this is, counting from 1. */
depth: number
wumpus: number
pits: readonly number[]
bats: readonly number[]
/** Where the hunter is standing. */
you: number
/** Where the hunter started, so a transcript can say so. */
start: number
arrows: number
/** Rooms the hunter has stood in, in order, including the one they are in. */
visited: readonly number[]
/** Rooms the hunter has been warned about, whether or not they went. */
sensed: Readonly<Record<number, readonly Hazard[]>>
outcome: Outcome | null
}
export type Outcome =
| 'shot-the-wumpus'
| 'eaten'
| 'fell-in-a-pit'
| 'shot-yourself'
| 'out-of-arrows'
/** True when the hunt ended in a way the hunter walks away from. */
export const isWin = (o: Outcome | null): boolean => o === 'shot-the-wumpus'
export interface Hunter {
id: number
name: string
/** Wumpodes bagged. The score, before any tie-break. */
bagged: number
/** Arrows carried into the next cave; a win keeps what is left of them. */
arrows: number
/** Rooms entered across the whole expedition, for the tie-break. */
roomsWalked: number
dead: boolean
/** How the expedition ended, for the standings. */
died: Outcome | null
diedAtDepth: number | null
}
/**
* One line of the transcript. The teletype prints these and the map reads
* them for its log, so both skins say exactly the same things.
*/
export interface Line {
kind: 'system' | 'sense' | 'move' | 'shot' | 'death' | 'win' | 'prompt' | 'bats'
text: string
}
export type Phase =
| 'boot'
| 'title'
| 'instructions'
| 'setup'
| 'hunting'
| 'resolve'
| 'report'
| 'gameover'
| 'scores'
/** A move or a shot, before the cave has had its say. */
export type Command =
| { type: 'move'; to: number }
| { type: 'shoot'; path: readonly number[] }
+56
View File
@@ -0,0 +1,56 @@
:root {
font-synthesis: none;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
/* The root is a flex item of body; without this the cabinet shrinks to fit. */
#root {
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
}
/* The room the machine is standing in. 1973 is a fluorescent-lit terminal
room; the remaster is somewhere underground with a lamp. */
body {
background: radial-gradient(120% 80% at 50% 0%, #5a5a5a 0%, #3a3a3a 55%, #232323 100%);
color: #241f1a;
font-family: ui-monospace, "DejaVu Sans Mono", "Cascadia Mono", "Courier New", monospace;
display: flex;
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 20% 0%, rgba(255, 208, 138, 0.16) 0%, transparent 60%),
radial-gradient(70% 55% at 85% 20%, rgba(140, 90, 220, 0.22) 0%, transparent 55%),
radial-gradient(130% 95% at 50% 100%, #2a2135 0%, #191322 55%, #0c0812 100%);
color: #f4ece0;
font-family: ui-rounded, "Nunito", "Avenir Next", "Segoe UI", system-ui, -apple-system, sans-serif;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { SkinProvider } from './SkinProvider.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<SkinProvider>
<App />
</SkinProvider>
</StrictMode>,
)
+47
View File
@@ -0,0 +1,47 @@
import { createContext, useContext } from 'react'
/**
* Two skins over one game. "teletype" is 1973 as it actually was -- a
* printing terminal, no screen, no colour and no sound but the print head.
* "modern" is the same cave with the lamp on. The rules never differ between
* them, and neither does a single word the machine prints.
*
* Worth saying why this is a Teletype rather than a CRT, since the lemonade
* stand's period skin is an Atari television. Wumpus is six years older than
* that machine. It was written for a timesharing system and played on paper,
* which is why the game 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 = 'wumpus.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<SkinApi>({
skin: 'modern',
setSkin: () => {},
toggle: () => {},
})
export const useSkin = () => useContext(SkinContext)