diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d637eb7..2361dc5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -36,7 +36,7 @@ "style": { "type": "string", "title": "Scene", - "description": "fire: a ░▒▓█ fire with sparks and smoke; warp: stars that speed up to hyperspace; avalon: a colony ship whose shield burns up the asteroids that hit it; balloon: a hot-air balloon that climbs from the grass to space; engine: a steampunk engine that runs up to full speed; falcon: a Falcon 9 carrying Dragon: its booster lands on its legs, Dragon comes home under parachutes to a splashdown; starship: a bigger rocket that hot-stages: its booster is caught by the tower's arms, the Ship splashes down; surf: a surfer and the swell; ski: a skier down the mountain; bubbles: bubbles fizzing up; train: a train through the countryside, faster with the work", + "description": "fire: a ░▒▓█ fire with sparks and smoke; warp: stars that speed up to hyperspace; avalon: a colony ship whose shield burns up the asteroids that hit it; balloon: a hot-air balloon that climbs from the grass to space; engine: a steampunk engine that runs up to full speed; falcon: a Falcon 9 carrying Dragon: its booster lands on its legs, Dragon comes home under parachutes to a splashdown; starship: a bigger rocket that hot-stages: its booster is caught by the tower's arms, the Ship splashes down; surf: a surfer and the swell; ski: a skier down the mountain; bubbles: bubbles fizzing up; train: a train through the countryside, faster with the work; earthrise: the Earth over the moon, rising and turning faster with the work; doom: a space marine fighting through a 1993 techbase, faster and harder with the work; doom3d: the same fight through his own eyes, in a taller band", "default": "fire", "options": [ "fire", @@ -49,7 +49,10 @@ "surf", "ski", "bubbles", - "train" + "train", + "earthrise", + "doom", + "doom3d" ] }, "idle": { diff --git a/AGENTS.md b/AGENTS.md index 92ddc48..01375d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,9 +35,10 @@ hooks/ fire.ts the fire scene's automaton (its heat grid), glyphs and 256-color ramps fire-palette.ts the fire scene's truecolor ramps .ts one file per scene: starfield (warp), colony, balloon + sky + clouds/, - engine, rocket (falcon, starship), surf, ski, bubbles, train + engine, rocket (falcon, starship), surf, ski, bubbles, train, doom (doom3d) pi/ index.ts pi adapter (widget above the editor, /flow, ~/.pi/agent/flow.json) + picture.ts a scene's `picture` as a kitty image: whether the terminal draws them, its size, the escape sequence session.ts pi's settings for a session: flow.json's defaults and the session's own (pure) ansi.ts mapping.ts types.ts sounds/ the soundscapes' clips (AAC), built by scripts/make-sounds.ts @@ -58,16 +59,17 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` - **Dials**, set every frame by `SceneDriver.dial()`: - `strength` 0–10: 0 draws nothing, every cell blank; 1 is calm idle; 10 is the busiest. Ease changes; never jump. - `coverageBoost`: above 0 while subagents run (15 each; in manual mode too). Add company (more streams, thicker steam). - - `agents`: each running subagent on its own (`AgentDial`: a stable `id`, `state` `'working' | 'idle' | 'waiting' | 'done'`, `ok` once done, its `task` and `type`, `ms` run), in the order they started (in manual mode too: holding a level holds how busy it looks, not who's about); `[]` in pi. The adapter derives it (`Roster`, `agents.ts`) from `agent.list()` polls (teammates left out) and each agent's own events by `agentId`: a model step or streamed output or a tool call is working, a tool running keeps it working, nothing for `QUIET_MS` (20 s) or a held/between-turns status is idle, a dialog put to the person is waiting (a permission once `classic.PermissionRequest` shows its dialog, since auto mode's classifier settles most asks alone, matched to that loop's running call of the tool (by its input when the loop runs several), each noted as `tool.call` starts it, so another call ending never ends it; `AskUserQuestion`/`ExitPlanMode` from the start; it ends with the call, a refusal too since the permission prompt runs beneath `tool.call`, or its `ToolProgress` pill; one tracker, `Waits` in `waits.ts`, keeps every loop's waits for the roster and the `waiting` dial alike), its `turn.complete` (or its status ending, or its leaving the list) is done, kept `DONE_MS` (6 s) so its companion can leave. What's heard from a loop before a poll names it (tools running, waits) is kept and counts once it is. A scene that gives each agent a companion keeps a `Crew` (`crew.ts`): set `crew.room` to how many companions this layout can actually show (the spine fits fewer), `update(this.agents, this.coverageBoost)` once a frame, then draw `crew.mates`, every one of them on screen: each keeps its `slot` (its colors, its place; only slots below `room` are given, and a layout with less room sends the rest back to wait) while on screen, `here` eases 0 → 1 as it arrives and back to 0 as it leaves (then its slot frees for an agent waiting past `capacity`), `busy` eases 1 working / 0 resting, `waiting` and `leaving`/`ok` say the rest (`resting`, `beckon` for a waiting one's blink, `finished`, `failed`, `easeTo` and `crew.inSlot` say them alike in every scene). Arrive from an edge, work visibly, rest visibly (and show waiting on you), leave out of sight (failed, another way); never pop. An adapter with no list (only a count) still gets anonymous working companions from `coverageBoost`. Note each companion's cells with `crew.mark` while drawing and return `crew.marks` from `agentMarks()`: desktop puts the agent's hover card there. Fire, surf, ski, balloon, falcon, starship (a mini rocket each: a hosted `LaunchSite` flying the same code, a few frames late, in the host's world and pixel layer), engine, train and avalon (probes launched from the ship) have companions (the fire's are small fires of their own beside the main one, which narrows to make room: one automaton, its burners shaped column by column); warp and bubbles keep plain coverage (a first-person stream, anonymous bubbles: nothing stays put to be someone). + - `agents`: each running subagent on its own (`AgentDial`: a stable `id`, `state` `'working' | 'idle' | 'waiting' | 'done'`, `ok` once done, its `task` and `type`, `ms` run), in the order they started (in manual mode too: holding a level holds how busy it looks, not who's about); `[]` in pi. The adapter derives it (`Roster`, `agents.ts`) from `agent.list()` polls (teammates left out) and each agent's own events by `agentId`: a model step or streamed output or a tool call is working, a tool running keeps it working, nothing for `QUIET_MS` (20 s) or a held/between-turns status is idle, a dialog put to the person is waiting (a permission once `classic.PermissionRequest` shows its dialog, since auto mode's classifier settles most asks alone, matched to that loop's running call of the tool (by its input when the loop runs several), each noted as `tool.call` starts it, so another call ending never ends it; `AskUserQuestion`/`ExitPlanMode` from the start; it ends with the call, a refusal too since the permission prompt runs beneath `tool.call`, or its `ToolProgress` pill; one tracker, `Waits` in `waits.ts`, keeps every loop's waits for the roster and the `waiting` dial alike), its `turn.complete` (or its status ending, or its leaving the list) is done, kept `DONE_MS` (6 s) so its companion can leave. What's heard from a loop before a poll names it (tools running, waits) is kept and counts once it is. A scene that gives each agent a companion keeps a `Crew` (`crew.ts`): set `crew.room` to how many companions this layout can actually show (the spine fits fewer), `update(this.agents, this.coverageBoost)` once a frame, then draw `crew.mates`, every one of them on screen: each keeps its `slot` (its colors, its place; only slots below `room` are given, and a layout with less room sends the rest back to wait) while on screen, `here` eases 0 → 1 as it arrives and back to 0 as it leaves (then its slot frees for an agent waiting past `capacity`), `busy` eases 1 working / 0 resting, `waiting` and `leaving`/`ok` say the rest (`resting`, `beckon` for a waiting one's blink, `finished`, `failed`, `easeTo` and `crew.inSlot` say them alike in every scene). Arrive from an edge, work visibly, rest visibly (and show waiting on you), leave out of sight (failed, another way); never pop. An adapter with no list (only a count) still gets anonymous working companions from `coverageBoost`. Note each companion's cells with `crew.mark` while drawing and return `crew.marks` from `agentMarks()`: desktop puts the agent's hover card there. Fire, surf, ski, balloon, falcon, starship (a mini rocket each: a hosted `LaunchSite` flying the same code, a few frames late, in the host's world and pixel layer), engine, train, avalon (probes launched from the ship) and doom and doom3d (marines of a co-op squad) have companions (the fire's are small fires of their own beside the main one, which narrows to make room: one automaton, its burners shaped column by column); warp and bubbles keep plain coverage (a first-person stream, anonymous bubbles: nothing stays put to be someone). - `tint`: `'smoke'` (a failed command or compaction) or `'blue'` (context nearly full). Must be clearly visible in every scene, at every level, in both layouts, and to colour-blind eyes: move lightness or blue-yellow, not only red-green (white to cyan, say, is a change of red alone, and red-green colour blindness can't see it). `npm run cvd` measures it. - `night`: for scenes with a night version. Ease a `kNight` toward it with `easeNight` rather than switching in one frame. Use `night.ts` so every night matches, and falls at the same pace. - `waiting`: Claude waits on the person (a dialog is up for them: a permission, its question, a plan to approve; auto mode only, never in pi). Any loop's dialog counts (a background subagent's too); the main turn's 30 s clock stops for the main loop's own waits and for a dialog up, not for a subagent's ask the mode is still deciding, and the main turn ending forgets only its own. A wait is its session's: the adapter forgets them when the session ends, so a /clear or a resume never carries one over. The level drops to a calm 2 by itself (`WAIT_LEVEL`). Every scene eases a `kWait` toward it (`easeWait`) and ends `grid()` with `waitTone(out, kWait, t)`: the frame turns sepia and breathes, the same in every scene, so it reads at a glance and never like smoke or blue (`PixelScene` does both; `d.wait` says how far). Then settle and hold your own way, never as if working: a place the level stands for holds (the balloon hovers, a rocket keeps its stage and doesn't launch), what moves comes to rest where that's natural (the skier stops, the engine runs down, the stars stop, the train draws up at a signal or a platform). A light can shine through the sepia, only breathing (`waitTone`'s `lamps`; in a `PixelScene`, paint it with `px.lamp`), as the train's signals do. A warm scene can skip the sepia (`waitTone(..., 0)`) but must breathe, as the fire's coals do. - **Sound** (`hooks/sound.ts`, see *Soundscapes* below): a scene's bed is layers of clips (`LAYERS`) at gains from the level and its `ambience()`, mixed ahead for each of its moods (`MOODS`); its events (`sounds`, an array the scene pushes `{ kind, v }` onto as things happen on screen, through `hear`, which caps it, so an adapter that never takes them stays bounded; pi takes and drops them each frame) play a clip (`EVENTS`) or, small and dense ones, a synthesized burst (`VOICES`). A scene without layers is silent. -- **Sizes**: the band is 5 rows × 80–250 columns, above the prompt. The spine is a pane about 13–22 columns × 30–60 rows. Every scene must look right in both. +- **Sizes**: the band is 5 rows × 80–250 columns, above the prompt. The spine is a pane about 13–22 columns × 30–60 rows. Every scene must look right in both. A scene with a view taller than the band's (`Scene.taller`: doom3d, the marine's own eyes) is given more above the prompt: in pi flow.json's `rows` (14 unless it says, at most half the terminal), in Claude Code as many as the prompt leaves, up to 14 (`TALL_ROWS`); `npm run check` times it at 250 × 14. It must still look right at 5 (a short terminal, the picker's thumbnail). Its first-person view, wider than tall, is a panorama (each column its own ray, about 140° across a wide band) rather than the spine's flat projection, so the far edges don't stretch. - **Drawing**: - Most scenes paint a pixel layer at 2 × 2 pixels per cell, then fold it into quadrant glyphs with `fitQuad`. A cell holds only two colors; give `fitQuad` a sprite's pixels as its `keep` so a small figure never drops out of its own cell. - Braille (`BRAILLE`, 2 × 4 dots a cell) is for fine specks: spray, stars, bubbles. - `DEFAULT_COLOR` (0x01000000) is the terminal's own color, i.e. transparent. + - A scene can also draw itself at real pixels (`Scene.picture(w, h, budget)`: RGBA, the open sky transparent), which pi shows as a kitty image in a terminal that draws them (`pi/picture.ts`); earthrise alone does so far, as a halftone like its original (each cell of a square grid a dot sized by its light, Bayer-dithered, in the original's palette), its ground built at the dots' grid, not at every pixel. A new size may take a while to make: `picture` makes `budget` of it a call and returns undefined until it's done (pi makes it between frames, showing the cells meanwhile). What took long goes in `pictureCache()` under `pictureKey(w, h)`, and pi keeps it in `~/.cache/flow` (`restorePicture` takes it back): bump the scene's version of it when what it makes changes. - **Limits**: - Claude Code's `Raster` paints at most 1024 distinct (fg, bg) pairs a frame and nearest-maps the rest, so quantize gradients. - Keep `step()` + `grid()` under ~2 ms at 250 × 5 and 22 × 60; frames run at ~14 fps (8 when calm). @@ -83,7 +85,7 @@ A scene implements `Scene` (`hooks/styles.ts`). Its file exports a `SceneDef` (` - **Someone there**: nothing is heard till someone's been seen at the session: a key in the prompt (`prompt.edit`), a turn or a `/flow`, kept in `state` (`present`) so a reload keeps it. Claude Code warms spare sessions in the background, each with a terminal no one sees, that load Flow and show its scene. - **The chime**: when a wait on the person begins (the driver's `waiting()`), one clip for every scene (`CHIME`) at a fixed gain, not the scene's master: about as loud as a scene at 6, a cue over the waiting bed's calm 2, never an alarm. `chimeStep` plays it once a wait, `CHIME_DELAY_MS` in, and not for a wait beginning within `CHIME_QUIET_MS` of the last one's end (back-to-back asks find the person there). It's an event clip (the player rules above), part of the soundscape, so it follows the session's own sound setting: no setting of its own. - **Building**: `npm run sounds [folders]` (needs `sox` and `ffmpeg`) runs every recipe in `scripts/make-sounds.ts` into `.sound-cache/` (WAV), mixes and encodes `sounds/` (AAC: beds 32 kbps, events 64) and writes `hooks/sound-files.ts` (the clips and `BED_GAINS`); `npm run sounds fire` rebuilds one folder or scene, `npm run sounds events/chime` just those clips. sox dithers at random, so every rebuild differs: rebuild only what you changed. A partial rebuild takes the beds' gains from `.sound-cache/bed-gains.json`: without it (a fresh checkout) the manifest comes out with none, so copy `.sound-cache/` over first. Commit `sounds/` and the manifest. -- **Tuning** a scene (how these were made): get a real recording of the thing (public-domain or CC0 is easiest; it's only measured, never shipped), measure it and the scene's mix the same way (octave bands, spectral centre, crackle kurtosis and bursts a second, onsets, flutter depth and rate, rhythm), and adjust the recipe until they match within a few dB. The references: rockets, NASA's STS-131 and SDO/Atlas V launch recordings; fire, a campfire; surf, a calm tropical beach and rough sea waves; engine, two steam engines; bubbles, water bubbling, boiling and big bubbles; balloon, strong wind and a pressurized gas flame; ski, crunchy snow; booms, a sonic boom; train, wheels knocking over jointed track heard from a carriage, a freight train passing (and its horn), a diesel locomotive idling and a diesel railcar pulling away (all on Wikimedia Commons). The colony ship and the warp drive have no real counterpart: theirs are designed, after spacecraft cabin noise. +- **Tuning** a scene (how these were made): get a real recording of the thing (public-domain or CC0 is easiest; it's only measured, never shipped), measure it and the scene's mix the same way (octave bands, spectral centre, crackle kurtosis and bursts a second, onsets, flutter depth and rate, rhythm), and adjust the recipe until they match within a few dB. The references: rockets, NASA's STS-131 and SDO/Atlas V launch recordings; fire, a campfire; surf, a calm tropical beach and rough sea waves; engine, two steam engines; bubbles, water bubbling, boiling and big bubbles; balloon, strong wind and a pressurized gas flame; ski, crunchy snow; booms, a sonic boom; train, wheels knocking over jointed track heard from a carriage, a freight train passing (and its horn), a diesel locomotive idling and a diesel railcar pulling away (all on Wikimedia Commons). The colony ship, the warp drive and earthrise have no real counterpart: theirs are designed, after spacecraft cabin noise (earthrise's only steady low tones: no air on the moon, so no noise or wind). Doom's are designed too, after a plant room, gunshots in a hard corridor and generic sci-fi, never the game's own sounds; doom3d sounds as doom (`soundsAs`: its beds and master are doom's). A doom marine with a target come into sight takes aim for the player's start-up time first, so his shot's sound, sent then, lands with its flash. A gun firing faster than the player can take clips (doom's chaingun and plasma rifle) is heard a volley at a time, one clip of about 1.4 s of shots at its rate, the next sent as the last runs out; when the gun stops early a `cease` event stops that clip (a stop takes no start-up, so it lands on time). What's dense and secondary (squad shots, falls, doors, footsteps in a fight) is thinned or left out so the four places go to what matters. ## Mod API rules diff --git a/README.md b/README.md index 7e98ae8..241fce2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Flow's fire above the prompt: pilot lights at idle, climbing with each of Claude's tool calls, an inferno with three subagents, then back down when Claude is done -Ambient scenes in your terminal that move with the work your coding agent is doing: a fire, fizzing bubbles, the surf, a ski run, two rockets, a hot-air balloon, a steam engine, a train through the countryside, and a colony ship behind its shield. They run above the prompt of Claude Code (in the terminal or the Claude desktop app) or [pi](https://github.com/badlogic/pi-mono), or in a tall pane beside Claude Code's transcript. +Ambient scenes in your terminal that move with the work your coding agent is doing: a fire, fizzing bubbles, the surf, a ski run, two rockets, a hot-air balloon, a steam engine, a train through the countryside, a colony ship behind its shield, the Earth rising over the moon, and a space marine fighting through a 1993 techbase. They run above the prompt of Claude Code (in the terminal or the Claude desktop app) or [pi](https://github.com/badlogic/pi-mono), or in a tall pane beside Claude Code's transcript. Each scene is drawn in 24-bit color from Unicode block, quadrant and braille characters, so several pixels share each terminal cell. In the Claude desktop app, which has no character grid to draw into, the same frames are drawn as images. @@ -69,6 +69,21 @@ Every scene has a level from 0 (off) to 10 (as busy as it gets). | `ski` | an easy run | a steep mogul run at speed | | `bubbles` | a couple of lazy strings of bubbles | a rolling, fizzing boil | | `train` | waiting at a red signal or a platform, its diesel idling (when the work starts the signal clears, the horn sounds and it pulls away; when it winds down the train pulls up again) | flat out past fields, villages, woods, rivers and stations, the poles and the line beside it a blur | +| `earthrise` | the Earth half risen over a cratered lunar horizon in long, low sunlight, turning once in about two minutes, under a still sky of a few breathing stars (after [earthrise](https://ascii.rest/earthrise/) by Shubham on [ascii.rest](https://github.com/bas3line/ascii), MIT: its notice is in `THIRD-PARTY-NOTICES`) | the Earth risen clear of the horizon, spinning once every four seconds, its clouds running ahead; more stars, drifting past in layers, shooting stars, and now and then a comet trailing its tails | +| `doom` | a space marine in green armor plodding down a techbase corridor, pistol lowered, raising it now and then at a lone zombieman far down the hall | running and gunning with a plasma rifle, imps hurling fireballs, pinky demons charging, cacodemons floating in and barrels going up among them; the busier the work, the faster he moves, the faster he shoots and the heavier his gun (pistol, then shotgun from 3, chaingun from 6, plasma rifle from 9). In the tall pane it's his own view: down the corridor, doors rising as he nears them, his gun bobbing and the status bar with his ammo, health and face | +| `doom3d` | the same fight through his own eyes, everywhere: above the prompt too, in a taller band (14 rows where there's room) that sweeps wide down the corridor, his ammo in one corner, his face and health in the other | as `doom` | + +**`earthrise`**: the Earth rising over the moon and turning faster as the work picks up, drawn here in pi as a halftone picture. + +https://github.com/user-attachments/assets/afe7e272-0a48-4668-b106-f2ae4732e977 + +**`doom`**: the marine side-on above the prompt, moving faster and changing to heavier guns as the work grows, imps, pinky demons and cacodemons coming down the hall at him. + +https://github.com/user-attachments/assets/2e9e4247-8f1f-42b6-b0d1-60598e6e4957 + +**`doom3d`**: the same fight through his own eyes, in pi's taller 14-row band, his ammo in one corner and his face and health in the other. + +https://github.com/user-attachments/assets/20196ad3-8dae-4e6e-99db-83fcd560b20e `/flow pick` shows them all at once, each one live, to choose from with the arrow keys and Enter (in pi, a list). @@ -88,9 +103,9 @@ Balloon, falcon, starship, surf, ski and train also have a **night** version: st | running commands | sparks; a slow command keeps it ticking over | | reading or searching, or using any other tool (an MCP server's too) | a small spark each | | running subagents | busier with each one (with diminishing returns), and each one gets a companion of its own (see below); the rest add company: more stars, more lit windows, more streams of bubbles, more lights on the launch tower | -| hitting a failed command, or compacting | smoke for a moment: smoky flame tips, sooty steam, a grey sky, a wipeout, a rocket's plume sputtering grey, black smoke pouring from the train's diesel | -| near a full context (≥85%) | blue: a blue-white flame, a storm, dusk on the slopes, a blue gas flame, rain driving past the train | -| waiting on you: a permission to grant, its question, a plan to approve | settles to a calm 2 and holds, the whole scene turned a warm sepia that breathes slowly, about every 4 s: the fire banks to a bed of glowing coals, the stars stop, the colony ship comes to rest, the balloon hovers with its pilot light glowing, the engine runs down and lets off steam, a rocket holds its stage with amber lights up the tower, the surfer sits up waiting for a wave, the skier stops, the bubbles slow, the train draws up at a red signal or a platform, its headlight and the signal glowing with each breath. With sound on, a soft chime as the wait begins | +| hitting a failed command, or compacting | smoke for a moment: smoky flame tips, sooty steam, a grey sky, a wipeout, a rocket's plume sputtering grey, black smoke pouring from the train's diesel, a pall of moondust over the lunar horizon, the techbase's lights going out under drifting smoke (and the marine's face bloodied) | +| near a full context (≥85%) | blue: a blue-white flame, a storm, dusk on the slopes, a blue gas flame, rain driving past the train, cold blue light over the moon, the techbase flooded with cold blue light | +| waiting on you: a permission to grant, its question, a plan to approve | settles to a calm 2 and holds, the whole scene turned a warm sepia that breathes slowly, about every 4 s: the fire banks to a bed of glowing coals, the stars stop, the colony ship comes to rest, the balloon hovers with its pilot light glowing, the engine runs down and lets off steam, a rocket holds its stage with amber lights up the tower, the surfer sits up waiting for a wave, the skier stops, the bubbles slow, the train draws up at a red signal or a platform, its headlight and the signal glowing with each breath, the Earth stops turning, the marine stops and lowers his gun as the monsters slink back into the dark, the computer panels glowing with each breath. With sound on, a soft chime as the wait begins | A plain answer sits around 5, edit-and-test loops reach about 8, and 10 takes several subagents editing in parallel, or a long turn: it climbs a level every 30 s it keeps going. @@ -98,7 +113,7 @@ Waiting on you shows only while a dialog is actually up for you: a permission as ### Subagents, one by one -In fire, surf, ski, balloon, falcon, starship, engine, train and avalon, each running subagent has a companion of its own, in its own colors or its own place, so you can tell at a glance which are busy, which have gone quiet or are waiting on you, and when each finishes: +In fire, surf, ski, balloon, falcon, starship, engine, train, avalon, doom and doom3d, each running subagent has a companion of its own, in its own colors or its own place, so you can tell at a glance which are busy, which have gone quiet or are waiting on you, and when each finishes: | Scene | Arrives | Working | Quiet (nothing for 20 s) | Waiting on you (a permission dialog, its question) | Done | |---|---|---|---|---|---| @@ -109,9 +124,10 @@ In fire, surf, ski, balloon, falcon, starship, engine, train and avalon, each ru | `falcon`, `starship` | a mini rocket (the same rocket, smaller) on a little launch site of its own beside the big one | flies the mission with the big one, a beat behind: lifts off, stages (its booster coming home beside the big one's: falcon's on its legs, starship's into its own tower's arms), keeps formation into orbit and comes home with it (Dragon under its own chutes; the Ship belly-first, carried home and stacked) | coasts, dropped back, a light blinking on its nose; on the ground it doesn't launch | an amber beacon flashes | fades as it pulls away ahead (failed: tumbles away in smoke) | | `engine` | its lamps on the bed plate warm up | a light runs along its lamps | its lamps glow low | its lamps flash together | its lamps fade out (failed: fading red) | | `train` | a short train of its own draws up alongside from out of sight | keeps pace on the next track, gaining and losing a little, headlamp lit | drops back a little, headlamp out | its cab flashes amber | falls back out of sight | +| `doom`, `doom3d` | another marine in co-op colors (indigo, brown, red, grey) runs in from behind (through his eyes, past him) and falls in with the squad | fires with him at whatever comes | drops back a little, gun lowered | a lamp blinks amber over its head | teleports out in a shimmer of green fog (failed: falls where it stood, and the squad walks on) | | `avalon` | a probe launches from a bay on the ship's hull (the hatch glowing in its color) and flies up beside it | holds station by the ship on its own flickering drive | drive off, falls back along the ship, a running light blinking | an amber beacon flashes | turns about and burns away aft, off toward home (failed: drive dead, tumbles away dark) | -A scene has room for a few (the fire 4 to 6 small fires across the band, 1 or 2 in the spine, surf 5, ski 4, balloon 4, the rockets 3 mini rockets (one of them behind the big launch site in the spine; 1 in a spine narrower than 19 columns), the engine 6 lamp groups, the train up to 4 trains, avalon 4 probes; fewer in the narrow spine or a narrow band: only as many as it shows whole); more wait for a place to come free. Warp and bubbles keep the plain company above: they're a stream of stars, a glass of anonymous bubbles, with nothing that stays put long enough to be someone. In the Claude desktop app, put the pointer over a companion to see its subagent's task, what it's doing and for how long. +A scene has room for a few (the fire 4 to 6 small fires across the band, 1 or 2 in the spine, surf 5, ski 4, balloon 4, the rockets 3 mini rockets (one of them behind the big launch site in the spine; 1 in a spine narrower than 19 columns), the engine 6 lamp groups, the train up to 4 trains, avalon 4 probes, doom 4 marines (2 in a spine narrower than 14 columns); fewer in the narrow spine or a narrow band: only as many as it shows whole); more wait for a place to come free. Warp and bubbles keep the plain company above: they're a stream of stars, a glass of anonymous bubbles, with nothing that stays put long enough to be someone. In the Claude desktop app, put the pointer over a companion to see its subagent's task, what it's doing and for how long. ## The command @@ -141,7 +157,7 @@ A new session starts on your defaults: the rows in `/config` (*Scene mode*, *Sce ## Sound -`/flow sound` (or *Sound* in `/config`) turns on a soundscape for each scene, and turns it off again, each one tuned against real recordings so it sounds like the thing itself: a campfire's sparse, bright crackle over the soft lick of its flames (and a bonfire's roar at the top), a beach's wash and its waves breaking now and then on a calm day, often on a rough one, a steam engine's chuffs in time with its crank, water bubbling and boiling, wind gusting round a balloon and its burner roaring, skis carving through snow, a train's diesel idling at the signal, and the roar of its wheels and the wind rising as it runs (matched to recordings made in a carriage and beside the line), a rocket's roar and crackle matched to NASA's launch recordings. The colony ship and the warp drive are science fiction, so theirs are designed: the hull's hum, the shield fizzing, and a low, muffled boom through the hull as each rock blows up on it; the drive's hum climbing with speed. +`/flow sound` (or *Sound* in `/config`) turns on a soundscape for each scene, and turns it off again, each one tuned against real recordings so it sounds like the thing itself: a campfire's sparse, bright crackle over the soft lick of its flames (and a bonfire's roar at the top), a beach's wash and its waves breaking now and then on a calm day, often on a rough one, a steam engine's chuffs in time with its crank, water bubbling and boiling, wind gusting round a balloon and its burner roaring, skis carving through snow, a train's diesel idling at the signal, and the roar of its wheels and the wind rising as it runs (matched to recordings made in a carriage and beside the line), a rocket's roar and crackle matched to NASA's launch recordings. The colony ship and the warp drive are science fiction, so theirs are designed: the hull's hum, the shield fizzing, and a low, muffled boom through the hull as each rock blows up on it; the drive's hum climbing with speed. So are the Earth rising over the moon's (no air on the moon, so no wind: only steady low tones, a deep drone and its fifth, a chord filling in with the work, a soft low tone for each shooting star and a long low chord for each comet) and the techbase's (its machinery humming, computer banks chirping, nukage bubbling, the fighting farther off; each gun its own shot as it fires, landing with its muzzle flash, monsters going down, barrels going up, fireballs thrown, doors grinding up, a squad marine teleporting out, the marine's footsteps), all generic: none of the game's own sounds. The background is long takes that blend one into the next at random moments, so nothing comes round on a beat, and a fresh one crossfades in whenever the level or the scene changes (a rocket on the pad, climbing, in orbit, coming home). On top of it you hear what happens on screen, as it happens: bubbles bursting, rocks blowing up on the shield, sparks, a wave breaking, each ski turn, the hammer, the train's horn as it pulls away and before each level crossing, and the rockets' ignition, staging, the booster's sonic boom, the catch, parachutes and splashdown. Every scene follows the same loudness from level 1 (quiet) to 10. @@ -169,13 +185,17 @@ The same scenes run as a pi extension (`pi/index.ts`): a widget above pi's edito | `session_compact` | so does a compaction | | `ctx.getContextUsage()` | a nearly-full context shows as blue | +In a terminal that draws images (Ghostty, kitty, WezTerm, Warp; not under tmux), `earthrise` shows in pi as a picture at the terminal's own pixels rather than in characters, drawn as the original is: a halftone of dots sized by brightness in the original's 38 colours, 14 rows tall (never more than half the terminal). Set `"rows"` in `~/.pi/agent/flow.json` for another height, or `"pictures": false` to keep the characters. The first time pi opens at a new width the characters show for a moment while the picture's ground is made. Flow keeps the ground of the last few sizes in `~/.cache/flow`, so the picture shows at once the next time. The other scenes stay in characters. + +`doom3d` takes the same height in characters (`"rows"` sets it too), for the view through the marine's eyes. In Claude Code its band is as tall as the prompt leaves room for, up to 14 rows. + pi tells an extension nothing of a wait on you, so there the scenes never show one. Nor has it a player, so the scenes are silent there: `/flow sound` says so. As in Claude Code, each session keeps its own settings, kept in the session itself (an entry the model never sees), so resuming or forking it brings them back. `~/.pi/agent/flow.json` holds the defaults new sessions start with; `/flow save` writes it. To try it without installing: `pi --extension ./pi/index.ts`. ## What Flow does on your machine -**Privacy:** Flow collects nothing. It has no servers, sends no data anywhere, and keeps what little it stores (below) on your machine, in Claude Code's own storage for the plugin. +**Privacy:** Flow collects nothing. It has no servers, sends no data anywhere, and keeps what little it stores (below) on your machine: in Claude Code's own storage for the plugin, or in pi under `~/.pi/agent/flow.json` and `~/.cache/flow`. **Network:** none. Flow sends nothing anywhere and contacts no hosts. Everything it draws and plays is computed locally from the plugin's own files. The one call that takes anything out of the plugin is `$.audio.play`, which hands one of the plugin's own clips from `sounds/` (and a volume) to the local audio player, `afplay`: nothing Flow reads (settings, the conversation, your files) goes into it, and it reaches no address. diff --git a/THIRD-PARTY-NOTICES b/THIRD-PARTY-NOTICES new file mode 100644 index 0000000..b5ca6c3 --- /dev/null +++ b/THIRD-PARTY-NOTICES @@ -0,0 +1,27 @@ +Third-party notices + +The earthrise scene (hooks/earthrise.ts) is adapted from "earthrise" by Shubham on +ascii.rest (https://ascii.rest/earthrise/, source https://github.com/bas3line/ascii), +used under its license: + +MIT License + +Copyright (c) 2026 bas3line (https://github.com/bas3line) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/hooks/doom.ts b/hooks/doom.ts new file mode 100644 index 0000000..bccf791 --- /dev/null +++ b/hooks/doom.ts @@ -0,0 +1,1643 @@ +// REVISION: flow-v178-doom-volleys +// +// Doom (the `doom` scene): a space marine fighting his way through a 1993 +// techbase, on the same dials as the fire; the level is how hard he goes at +// it. At 1 he plods along with his pistol lowered, now and then raising it at +// a lone zombieman far down the hall. The busier the work, the faster he moves +// and the faster he shoots, and the heavier the gun: the pistol up to 2, the +// pump shotgun to 5, the chaingun to 8, then the plasma rifle, with imps +// hurling fireballs, pinky demons charging, cacodemons floating in, and +// barrels going up among them. When it eases off he slows and lowers his gun +// again; between enemies he keeps walking. +// +// Each subagent is another marine in co-op colors (crew.ts): indigo, brown, +// red, grey. One runs in from behind when its agent starts and falls in with +// the squad, firing with him while its agent works; quiet, it drops back a +// little, gun lowered; waiting on you, a lamp blinks amber over its head. Done, +// it teleports out in a shimmer of green fog (failed: it falls where it +// stood, and the squad walks on). +// +// A failed command (or a compaction) puts the lights out: the base goes grey +// and dim, smoke drifting under the ceiling (and in the tall view the +// marine's face is bloodied). A nearly-full context floods it with cold blue +// light. While Claude waits on the person he stops and lowers his gun, the +// monsters slink back into the dark and the fireballs fizzle; the computer +// panels and lamps glow through the sepia with each slow breath. +// +// The band is side-on: the corridor's back wall streaming past (tan and grey +// panels, computer banks blinking, doors, windows onto the mountains, wall +// lamps, nukage pools in the floor, flickering lights), the marine on the +// left with the squad behind him, the monsters coming from the right. The +// tall spine is his own view, as the game was: down a corridor fading into +// the dark, doors rising as he nears them, his gun bobbing at the bottom with +// its muzzle flash, and the status bar beneath with his ammo, his health and +// his face, glancing about, grimacing as he fires, grinning at a new gun. +// `doom3d` is the same scene through his eyes everywhere, the band too: it +// asks the adapter for a taller one (`taller`), and a view wider than tall is +// a panorama, each column its own ray (about 140° across a wide band), so +// its edges don't stretch as a flat projection's would; too short for the +// status bar, his ammo, face and health sit in its corners. +// Heard (sound.ts): each shot as it fires, the gun's own (his sent the +// player's start-up time ahead, so it lands with the flash; a fast gun's a +// volley at a time, stopped as he stops), monsters going down, barrels going +// up, fireballs thrown, a door rising, a gun changed, a marine teleporting +// out, his footsteps while it's calm, over the base's hum and its computers +// chirping. Only so much at once: Claude Code plays four clips at a time, so +// squad shots, falls and doors are thinned out. All generic: none of the +// game's own sounds. +// Painted as pixels (2 × 2 a cell) by PixelScene, which eases the level, +// folds the pixels into glyphs and blanks it all at level 0. Everything here +// is drawn from scratch in the spirit of the original; no game data is used. + +import type { AgentDial } from './agents' +import { Rng } from './cells' +import { beckon, Crew, easeTo, failed, finished, type AgentMark, type Mate } from './crew' +import { PixelScene, type Dials, type Painter } from './pixel-scene' +import { defineScene } from './scene-def' +import { approach, clamp, grey, hash, hash1, luma, mix, noise1, noise2, rampAt, retain } from './pixels' +import { hear, leadFrames, type SoundEvent, type SoundKind } from './sound' + +// ── Sprites ────────────────────────────────────────────────────────────── + +/** A sprite: palette indices, 0 transparent. */ +type Sprite = { readonly w: number; readonly h: number; readonly p: Uint8Array } + +/** A sprite from rows of characters: '.' transparent, '1'-'9' (and on) a palette index. */ +function sprite(rows: readonly string[]): Sprite { + const h = rows.length + const w = Math.max(...rows.map(r => r.length)) + const p = new Uint8Array(w * h) + rows.forEach((r, y) => { + for (let x = 0; x < r.length; x++) p[y * w + x] = r[x] === '.' ? 0 : parseInt(r[x]!, 36) + }) + return { w, h, p } +} + +// The marine, side-on, facing right: 1 armor, 2 its shade, 3 visor, 4 gloves and boots, 5 belt. +const M_TOP = ['..2222..', '.211133.', '.2111112', '21111144', '2111112.', '.25552..'] +const M_TOP_REST = ['..2222..', '.211133.', '.2111112', '2111112.', '21111144', '.25552..'] +const M_LEGS = [ + ['.11.11..', '.12.12..', '.44.44..'], + ['.11..11.', '12....12', '44....44'], + ['..111...', '..112...', '..4444..'], +] +/** Ready (gun up) and at rest (gun lowered), each standing, striding and passing. */ +const MARINE = [M_TOP, M_TOP_REST].map(top => M_LEGS.map(legs => sprite([...top, ...legs]))) +/** Where the hand is that holds the gun: ready, and at rest. */ +const HAND = [ + [7, 3], + [7, 4], +] as const +/** Doom's co-op colors: the marine's green, then the squad's indigo, brown, red and grey (armor, its shade). */ +const ARMOR = [ + [0x3e9a30, 0x24601c], + [0x4a4ab8, 0x2a2a76], + [0x94703c, 0x5c4422], + [0xbc3024, 0x761a12], + [0xa0a09c, 0x62625e], +] as const +const marinePal = (i: number): number[] => [0, ARMOR[i]![0], ARMOR[i]![1], 0x1c1814, 0x5a4026, 0x7c5c2c] +const MARINE_PAL = ARMOR.map((_, i) => marinePal(i)) + +/** The four guns, side-on: 3 dark steel, 4 steel, 5 bright steel, 6 wood, 8 plasma. `ax`: how far back of the tip the hand is. */ +const PISTOL = 0 +const SHOTGUN = 1 +const CHAINGUN = 2 +const PLASMA = 3 +/** Each gun's shot, heard. */ +const SHOT_SOUND: readonly SoundKind[] = ['pistol', 'shotgun', 'chaingun', 'plasma'] +/** + * A fast gun is heard a volley at a time: one clip of about 1.4 s of its shots at its rate (frames between + * shots → the clip), as many shots as `volleyShots` (make-sounds.ts makes them alike). Claude Code plays four + * clips at once, each held for its length and afplay's drain: a clip a shot would cut each other off unheard. + */ +const VOLLEY: Readonly>>> = { + [CHAINGUN]: { 3: 'rattle3', 4: 'rattle4', 5: 'rattle5' }, + [PLASMA]: { 2: 'bolts2', 3: 'bolts3' }, +} +const volleyShots = (r: number) => Math.round(1400 / (r * 70)) +/** Frames between the sounds of squad shots, and of monsters going down (more would crowd the player out). */ +const SQUAD_HEARD = 14 +const FALL_HEARD = 12 +const GUNS = [ + { s: sprite(['345']), ax: 0 }, + { s: sprite(['6633445']), ax: 2 }, + { s: sprite(['3345454', '3333...']), ax: 1 }, + { s: sprite(['33444588', '333333..']), ax: 1 }, +] +const GUN_PAL = [0, 0, 0, 0x2e2e30, 0x5c5c60, 0x9a9aa0, 0x7a4a22, 0x4e2c12, 0x50b0ff, 0xd8f0ff] +/** Muzzle flashes: 1 orange, 2 yellow, 3 white (the plasma rifle's in blues). */ +const FLASH_SIDE = sprite(['.2.', '232', '.2.']) +const FLASH_PAL = [0, 0xff8020, 0xffd040, 0xfff8d8] +const PLASMA_FLASH_PAL = [0, 0x3070ff, 0x70c0ff, 0xe0f4ff] + +// The monsters, facing left. Each: walking (two frames), attacking. +const ZOMBIE = 0 +const IMP = 1 +const PINKY = 2 +const CACO = 3 +const FOES = [ + { + // A zombieman: 1 khaki shirt, 2 pale skin, 3 hair, 4 rifle, 5 green trousers, 6 boots. + frames: [ + ['..33...', '.2233..', '.222...', '..11...', '44411..', '.1111..', '.5..5..', '.5..5..', '66..66.'], + ['..33...', '.2233..', '.222...', '..11...', '44411..', '.1111..', '..55...', '..5.5..', '.66.66.'], + ['..33...', '.2233..', '.222...', '..11...', '44441..', '.1111..', '.5..5..', '.5..5..', '66..66.'], + ].map(sprite), + pal: [0, 0x9c8458, 0xb4a48c, 0x2a2218, 0x3a3a3c, 0x4c5c34, 0x2a2418], + hp: 2, + speed: 0.3, + stand: 72, + near: 36, + float: 0, + }, + { + // An imp: 1 brown hide, 2 its shade, 3 bone spikes, 4 eyes. + frames: [ + ['3.....3', '.11111.', '.14141.', '3111113', '1.333.1', '..111..', '.11.11.', '.1...1.', '33...33'], + ['3.....3', '.11111.', '.14141.', '3111113', '1.333.1', '..111..', '..1.1..', '..1.1..', '.33.33.'], + ['1.3.3.1', '1.111.1', '1141411', '.11111.', '..333..', '..111..', '.11.11.', '.1...1.', '33...33'], + ].map(sprite), + pal: [0, 0x86502a, 0x4e2c14, 0xdcc8a2, 0xffc828], + hp: 3.5, + speed: 0.36, + stand: 56, + near: 30, + float: 0, + }, + { + // A pinky demon: 1 pink hide, 2 its shade, 3 teeth, 4 eye. + frames: [ + ['...111111.', '..14111111', '.333111111', '.222111111', '.333211111', '..1111111.', '..22..22..'], + ['...111111.', '..14111111', '.333111111', '.222111111', '.333211111', '..1111111.', '...22.22..'], + ['...111111.', '..14111111', '3332111111', '2...211111', '3332111111', '..1111111.', '..22..22..'], + ].map(sprite), + pal: [0, 0xd47c8a, 0x904454, 0xf2ead2, 0xffe040], + hp: 7, + speed: 0.8, + stand: 14, + near: 14, + float: 0, + }, + { + // A cacodemon: 1 red hide, 2 its shade, 3 green eye, 4 mouth, 5 teeth, 6 horns. + frames: [ + ['.6.....6.', '..11111..', '.1113111.', '111333111', '115555511', '114444411', '.1155511.', '..22222..'], + ['.6.....6.', '..11111..', '.1113111.', '111333111', '115555511', '114444411', '.1155511.', '..22222..'], + ['.6.....6.', '..11111..', '.1113111.', '115555511', '144444441', '144444441', '.1555551.', '..22222..'], + ].map(sprite), + pal: [0, 0xc8301e, 0x861a10, 0x48e048, 0x1c1c60, 0xf0f0e0, 0xd8c8a0], + hp: 9, + speed: 0.26, + stand: 60, + near: 34, + float: 3, + }, +] as const + +/** The monsters' colors when a blast has torn them apart. */ +const GIB_PAL = FOES.map(k => k.pal.map((c, i) => (i ? mix(c, 0x9a1010, 0.6) : c))) + +/** A barrel of nukage: 1 its drum, 2 the bands, 3 the glowing slime on top. */ +const BARREL = sprite(['3333', '1221', '1111', '1221', '1111', '2222']) +const BARREL_PAL = [0, 0x6c7258, 0x3e4434, 0x5cdc3a] +/** What flies: an imp's fireball, a cacodemon's lightning, the plasma rifle's bolt. */ +const BALL = sprite(['.121.', '12321', '23332', '12321', '.121.']) +/** ...and as small as the band can show it. */ +const BALL_SMALL = sprite(['121', '232', '121']) +const FIRE_PAL = [0, 0xd03010, 0xff8a20, 0xfff0a0] +const CACO_PAL = [0, 0x9020a0, 0xe050d0, 0xffd0ff] +const BOLT = sprite(['12', '21']) +const BOLT_PAL = [0, 0x3a8aff, 0xd8f4ff] + +// The tall view's own: the gun in his hands, its flash, his face, and the status bar's digits. +const FP_GUNS = [ + ['.....55.....', '.....45.....', '.....43.....', '....4433....', '....4433....', '...114431...', '..1111311...', '..1121111...', '.111211111..', '.112211111..', '1122111111..', 'aa222111aa..'], + ['.....55.....', '.....45.....', '.....45.....', '.....43.....', '....4433....', '....6677....', '....6677....', '...166771...', '..11667711..', '..11444411..', '.1114433111.', '.1124433211.', 'aa22443322aa'], + ['....5.5.5....', '....4.4.4....', '....45454....', '....45454....', '...3454543...', '...3333333...', '..134444431..', '..113333311..', '.11143334111.', '.11243334211.', 'aa224333422aa'], + ['.....898.....', '.....989.....', '....49894....', '....45554....', '...4455544...', '...3444443...', '..133333331..', '..113333311..', '.11122222111.', 'aa112222211aa'], +].map(sprite) +/** 1 skin, 2 its shade, 3 dark steel, 4 steel, 5 bright steel, 6 wood, 7 dark wood, 8 and 9 the plasma rifle's glow, a his sleeves. */ +const FP_PAL = [0, 0xc08a62, 0x8a5a3a, 0x2e2e30, 0x5c5c60, 0x9a9aa0, 0x7a4a22, 0x4e2c12, 0x50b0ff, 0xd8f0ff, 0x2e6a2a] +const FP_FLASH = sprite(['..1.1..', '.12221.', '1223221', '.12221.', '..121..']) + +/** His face: 1 hair, 2 skin, 3 its shade, 4 eye whites, 5 pupils, 6 mouth, 7 blood, 8 teeth, 9 brows. */ +const FACE_PAL = [0, 0x5a3a1c, 0xc8946a, 0x946442, 0xece6dc, 0x1a1410, 0x5a2016, 0xb01010, 0xe8e0c8, 0x3a2410] +const FACE_ROWS = (eyes: string, mouth: string, under = '..322223..', brows = '1299229921') => [ + '..111111..', + '.11111111.', + '1122222211', + brows, + '12' + eyes.slice(0, 2) + '22' + eyes.slice(2) + '21', + '1222332221', + '.32222223.', + mouth, + under, + '...3333...', +] +const LOOK_LEFT = 0 +const LOOK_AHEAD = 1 +const LOOK_RIGHT = 2 +const GRIN = 3 +const OUCH = 4 +const RAGE = 5 +const FACES = [ + FACE_ROWS('5454', '.32666623.'), + FACE_ROWS('4554', '.32666623.'), + FACE_ROWS('4545', '.32666623.'), + FACE_ROWS('4554', '.36888863.', '..366663..'), + FACE_ROWS('4444', '.32266223.', '..326623..', '1222222221'), + FACE_ROWS('4554', '.38888883.', '..322223..', '1999999991'), +].map(sprite) +/** Where blood shows on his face when things go badly. */ +const BLOODY = [ + [2, 2], + [3, 2], + [2, 3], + [7, 5], + [8, 6], + [7, 6], + [5, 8], +] as const +const DIGITS = ['111101101101111', '010110010010111', '111001111100111', '111001111001111', '101101111001001', '111100111001111', '111100111101111', '111001001001001', '111101111101111', '111101111001111'] + +// ── The world ──────────────────────────────────────────────────────────── + +/** A stretch of wall, in band pixels: one panel's kind and one sector's light. */ +const SEG = 32 +/** Band pixels to one unit of the tall view's world (the corridor is two wide). */ +const UNIT = 16 +/** A sprite pixel, in the tall view's units: the marine stands 0.85 tall, his eyes at 0.62. */ +const SPX = 0.095 + +const TAN = 0 +const COMPUTER = 1 +const WINDOW = 2 +const DOOR = 3 +const LAMP = 4 +const GREY = 5 + +function segKind(seg: number, seed: number): number { + const h = hash1(seg * 7919 + seed) + return h < 0.32 ? TAN : h < 0.48 ? COMPUTER : h < 0.6 ? WINDOW : h < 0.7 ? DOOR : h < 0.84 ? LAMP : GREY +} + +/** A sector's light: most steady, a few flickering as Doom's broken lights do. */ +function segLight(seg: number, t: number, seed: number): number { + let l = 0.6 + 0.24 * hash1(seg * 977 + seed) + if (segKind(seg, seed) === LAMP) l += 0.2 + if (hash1(seg * 131 + seed + 5) < 0.14 && hash1(((t >> 1) * 7 + seg * 13) ^ seed) < 0.22) l *= 0.5 + return l +} + +/** What the last texel was: a light of its own (a lamp: shines through the sepia) or glowing (unlit by the sector). */ +let lampHit = false +let glowHit = false +/** A window's texel: the sky beyond, drawn by the caller. */ +const SKY = -2 + +/** The wall at `lx` across its stretch (0..SEG), `v` up it (0 the floor, 1 the ceiling). */ +function wallTex(kind: number, lx: number, v: number, seg: number, t: number, seed: number): number { + lampHit = false + glowHit = false + const p = lx & 15 + switch (kind) { + case COMPUTER: { + if (lx < 2 || lx > 29 || v < 0.16 || v > 0.86) return lx === 1 || lx === 30 ? 0x3a3a38 : 0x5e5e5a + const row = Math.floor(v * 11) + if ((lx & 3) === 2 && (row & 1) === 0) { + const h = hash(seg * 64 + lx, row, seed) + if (hash(lx + (t >> 3) * 17, row + seg * 5, seed) < 0.72) { + lampHit = true + return h < 0.4 ? 0x38e848 : h < 0.65 ? 0xe83020 : h < 0.85 ? 0xf0d040 : 0x40d8e8 + } + return 0x2a3034 + } + return 0x1c2024 + } + case WINDOW: + if (lx < 4 || lx > 27) return lx === 0 || lx === 31 ? 0x3e2e1a : (lx & 1) && ((v * 9) | 0) % 3 === 0 ? 0x4a3820 : 0x70563a + if (v < 0.2 || v > 0.86) return v < 0.12 ? 0x5c503e : 0x7e705a + if (lx === 4 || lx === 27 || v < 0.25 || v > 0.81) return 0x2a2622 + return SKY + case DOOR: + if (v >= 0.82) return p === 0 ? 0x3e352a : 0x7f705a + if (lx === 4 || lx === 5 || lx === 26 || lx === 27) return (lx + Math.floor(v * 12)) & 1 ? 0xd8b020 : 0x201c14 + if (lx === 6 || lx === 25) return 0x4c4c48 + if (lx === 15 || lx === 16) return 0x5a5852 + return Math.floor(v * 16) % 3 === 0 ? 0x6a6862 : 0x8c8a82 + case LAMP: + if (lx >= 13 && lx <= 18 && v >= 0.5 && v <= 0.7) { + if (lx >= 14 && lx <= 17 && v >= 0.54 && v <= 0.66) { + lampHit = true + return 0xfff4d0 + } + return 0x6a6a64 + } + break + case GREY: + if (p === 0) return 0x3e3e3a + if (p === 1) return 0x9c9c96 + if (v < 0.14) return 0x50504c + return hash(lx + seg * SEG, Math.floor(v * 12), seed) < 0.1 ? 0x6c6c68 : 0x7c7c78 + } + // Tan panels (and round the lamps). + if (p === 0) return 0x3e352a + if (p === 1) return 0x9e8d70 + if (v < 0.14) return 0x5a4e3e + if (v > 0.6 && v < 0.72) return 0x625642 + return hash(lx + seg * SEG, Math.floor(v * 12), seed) < 0.1 ? 0x706450 : 0x7f705a +} + +/** Through a window: grey-blue sky over brown mountains (`sx` across the view, `v` up the window, 0..1). */ +function skyTex(sx: number, v: number, seed: number): number { + glowHit = true + const m = 0.3 + 0.32 * noise1(sx * 0.07, seed) + 0.12 * noise1(sx * 0.23, seed + 3) + if (v < m) return v < m - 0.12 ? 0x4a3e32 : 0x64544a + return v - m > 0.3 ? 0xa0a8b4 : 0x7c8898 +} + +/** The floor at `a` along and `lat` across (band pixels): brown tiles, now and then a pool of nukage. */ +function floorTex(a: number, lat: number, t: number, seed: number): number { + lampHit = false + glowHit = false + const seg = Math.floor(a / SEG) + const lx = a - seg * SEG + if (hash1(seg * 31 + 7 + seed) < 0.16 && lx > 3 && lx < 28 && Math.abs(lat) < 12) { + if (lx === 4 || lx === 27 || Math.abs(lat) >= 11) return 0x4a4a44 + glowHit = true + const n = noise2(a * 0.22 + t * 0.05, lat * 0.25 - t * 0.03, seed) + return n < 0.4 ? 0x1e6a10 : n < 0.68 ? 0x36a01c : 0x6ad83a + } + const l = Math.floor(lat) + if ((a & 15) === 0 || (l & 15) === 0) return 0x3a3428 + // Each tile its own shade, worn in patches. + const tile = hash(a >> 4, l >> 4, seed) < 0.5 + const worn = hash(a >> 2, l >> 2, seed + 1) < 0.22 + return tile ? (worn ? 0x52483a : 0x5c5242) : worn ? 0x4c4436 : 0x564c3e +} + +/** The ceiling: dark tiles, a light panel every other stretch. */ +function ceilTex(a: number, lat: number): number { + lampHit = false + glowHit = false + const m = ((a % 64) + 64) % 64 + if (m >= 28 && m < 36 && Math.abs(lat) < 4) { + glowHit = true + return 0xf0ecd8 + } + const l = Math.floor(lat) + if ((a & 15) === 0 || (l & 15) === 0) return 0x2c2822 + return hash(a >> 2, l >> 2, 77) < 0.2 ? 0x36302a : 0x3c362e +} + +/** `c` lit at `l` (1 its own color). */ +function lit(c: number, l: number): number { + if (l === 1) return c + const r = Math.min(255, ((c >> 16) & 255) * l) | 0 + const g = Math.min(255, ((c >> 8) & 255) * l) | 0 + const b = Math.min(255, (c & 255) * l) | 0 + return (r << 16) | (g << 8) | b +} + +/** Light in eighths: Raster paints at most 1024 color pairs a frame. */ +const q8 = (l: number) => Math.round(l * 8) / 8 + +/** The cold blue light of a nearly-full context, by brightness (lifting the dark: it floods the base). */ +const BLUE = [0x0c1a48, 0x2048a8, 0x5a8ae8, 0xb4d4ff, 0xf0f6ff] + +/** `c` under the tints: `s` of the way to grey and dim (smoke), `b` of the way to cold blue. */ +function tone(c: number, s: number, b: number): number { + if (s > 0) c = mix(c, lit(grey(c, 1), 0.42), s) + if (b > 0) c = mix(c, rampAt(BLUE, 0.22 + 0.78 * (luma(c) / 255)), b) + return c +} + +// ── What moves ─────────────────────────────────────────────────────────── + +/** How fast he goes at each level (band pixels a frame): a plod at 1, running at 10. */ +const SPEED = [0, 0.15, 0.24, 0.34, 0.46, 0.6, 0.78, 1, 1.28, 1.62, 2.05] +/** Frames between shots, by gun, at the bottom and the top of its range. */ +const RATE = [ + [20, 12], + [16, 11], + [5, 3], + [3, 2], +] as const +/** Damage a shot does, by gun (a zombieman takes 2, a cacodemon 9). */ +const DAMAGE = [1, 4, 1, 1.6] +/** Where each gun's range of levels starts and ends. */ +const GUN_FROM = [0, 2.5, 5.5, 8.5, 10] +/** He stands his ground when a monster's this close (band pixels). */ +const HOLD = 28 +/** ...and in the tall view, where they come nearer to be seen. */ +const HOLD_NEAR = 22 +/** Frames a weapon switch takes (lowered, then raised). */ +const SWITCH = 14 +/** Frames a monster takes to fall. */ +const DIE_T = 8 + +const WALK = 0 +const ATTACK = 1 +const PAIN = 2 +const DIE = 3 +const DEAD = 4 +const FLEE = 5 + +type Foe = { kind: number; x: number; u: number; hp: number; st: number; t: number; walk: number; cool: number; flash: number; gib: boolean; seed: number } +type Missile = { kind: number; x: number; u: number; h: number; v: number; t: number } +type Puff = { kind: number; x: number; u: number; h: number; t: number; life: number; seed: number } +type Barrel = { x: number; u: number; fuse: number } +type Door = { x: number; open: number } + +const BALL_FIRE = 0 +const BALL_CACO = 1 +const BALL_PLASMA = 2 +const BLOOD = 0 +const DUST = 1 +const BOOM = 2 +const SPARK = 3 + +/** The squad: as many as can follow him, frames to arrive and to leave. */ +const SQUAD = 4 +const ARRIVE = 30 +const LEAVE = 34 + +function speedAt(level: number): number { + const l = clamp(level, 0, 10) + const i = Math.min(9, Math.floor(l)) + return SPEED[i]! + (SPEED[i + 1]! - SPEED[i]!) * (l - i) +} + +function gunFor(level: number, now: number): number { + // A little either side of each change, so a level hovering there doesn't swap guns back and forth. + let g = now + while (g < PLASMA && level > GUN_FROM[g + 1]! + 0.25) g++ + while (g > PISTOL && level < GUN_FROM[g]! - 0.25) g-- + return g +} + +function rateOf(gun: number, level: number): number { + const k = clamp((level - GUN_FROM[gun]!) / Math.max(0.5, GUN_FROM[gun + 1]! - GUN_FROM[gun]!)) + return Math.round(RATE[gun]![0] + (RATE[gun]![1] - RATE[gun]![0]) * k) +} + +export class Doom extends PixelScene { + /** The subagents, one by one: each a marine of the squad. */ + agents: readonly AgentDial[] = [] + /** The squad (`x`: how far along from him, band pixels; `y`: frames to its next shot). */ + private crew = new Crew(SQUAD, ARRIVE, LEAVE) + private rng: Rng + /** How far he's come, band pixels, and how fast he's going. */ + private pos = 0 + private v = 0 + /** How far his legs have gone: their stride. */ + private walk = 0 + private gun = PISTOL + private nextGun = PISTOL + private switchT = 0 + private cool = 0 + private flash = 0 + private recoil = 0 + /** Frames since he last fired, or had something in his sights. */ + private sinceShot = 999 + private sinceSighted = 999 + private ammo = [50, 20, 120, 100] + private hp = 100 + private ouch = 0 + private grin = 0 + private look = LOOK_AHEAD + private lookT = 0 + private spawnT = 40 + private foes: Foe[] = [] + private missiles: Missile[] = [] + private puffs: Puff[] = [] + private barrels: Barrel[] = [] + private doors: Door[] = [] + /** The last stretch of corridor given its barrels (and, in the tall view, its doors). */ + private laid = Number.NaN + /** The smoke and the blue, eased, and in sixths. */ + private kSmk = 0 + private kBlu = 0 + private hold = false + + // Layout, from the grid. + private W = 0 + private H = 0 + private tall = false + /** Band: his column. Tall: the view's height (above the status bar), the status bar's. */ + private mx = 0 + private hv = 0 + private sb = 0 + /** Tall: each pixel's depth (sprites hide behind a closed door, and behind each other). */ + private zb = new Float32Array(0) + private sp: number[] = new Array(16).fill(0) + private sp2: number[] = new Array(16).fill(0) + /** + * Tall: how the view is projected. In a pane taller than wide, flat (as the + * game: `fy` pixels a unit at a unit away, across and down); in a view wider + * than tall, a panorama (`fth` pixels a radian across, so the far edges + * don't stretch): each column's ray, `ux` across and `uz` ahead. + */ + private pano = false + private fy = 1 + private fth = 1 + private ux = new Float32Array(0) + private uz = new Float32Array(0) + + /** Whether it asks for a taller band than the side-on view's: through his eyes everywhere. */ + readonly taller: boolean + + /** `firstPerson`: his own view in every layout, the band too (doom3d), not only in a tall pane. */ + constructor(seed = 1, readonly firstPerson = false) { + super(seed) + this.taller = firstPerson + this.rng = new Rng(Math.imul(seed, 2654435761) ^ 0x51ed27) + } + + /** The gun he has now: 0 the pistol, 1 the shotgun, 2 the chaingun, 3 the plasma rifle. */ + get weapon(): number { + return this.gun + } + + /** How fast he's going (band pixels a frame). */ + get speed(): number { + return this.v + } + + /** How far he's come (band pixels). */ + get distance(): number { + return this.pos + } + + /** The monsters coming at him (not falling, fallen, or slinking away). */ + get coming(): number { + return this.foes.filter(f => f.st < DIE).length + } + + /** Shots he's fired since the scene began. */ + shots = 0 + /** What happened on screen, to be heard (taken by the adapter each frame). */ + sounds: SoundEvent[] = [] + /** His next shot's sound sent ahead (see leadFrames), and the stride his last step was heard at. */ + private primed = false + private stride = 0 + /** Frames gone; the frame his fast gun's volleys are heard to (`cease` stops one early); till when a squad shot isn't heard; the last fall heard. */ + private tick = 0 + private heardTo = 0 + private quietTo = 0 + private fallHeard = -99 + /** The frames between shots his last volley's clip was made for, and the frame of its first. */ + private volleyR = 0 + private volleyFrom = 0 + /** The squad marines whose leaving has been heard. */ + private gone = new WeakSet() + + agentMarks(): readonly AgentMark[] { + return this.strength > 0 ? this.crew.marks : [] + } + + protected resize(d: Dials): void { + this.W = d.columns * 2 + this.H = d.rows * 2 + this.tall = d.tall || this.firstPerson + this.mx = clamp(Math.round(this.W * 0.32), 8, Math.max(8, this.W - 16)) + this.sb = this.tall && this.H >= 50 ? clamp(Math.round(this.H * 0.13), 12, 14) : 0 + this.hv = this.H - this.sb + this.zb = new Float32Array(this.W * Math.max(1, this.hv)) + this.project() + this.laid = Number.NaN + this.doors.length = 0 + this.barrels.length = 0 + } + + /** The tall view's projection for this size (see `pano`). */ + private project(): void { + const W = this.W + const HV = Math.max(1, this.hv) + this.pano = this.tall && W > HV + this.ux = new Float32Array(W) + this.uz = new Float32Array(W) + if (this.pano) { + // Wider the view, wider the sweep it takes in (to about 140° across a wide band); down it, a little + // more than his height between the floor at its foot and the ceiling at its top. + const fov = clamp(0.9 + 0.27 * (W / (2 * HV)), 1.4, 2.4) + this.fth = W / fov + this.fy = HV + for (let x = 0; x < W; x++) { + const a = (x + 0.5 - W / 2) / this.fth + this.ux[x] = Math.sin(a) + this.uz[x] = Math.cos(a) + } + } else { + this.fy = this.fth = W * 0.72 + for (let x = 0; x < W; x++) { + this.ux[x] = (x + 0.5 - W / 2) / this.fy + this.uz[x] = 1 + } + } + } + + /** Where a point `x` along (band pixels), `u` across lands in the tall view: false if out of sight. Sets `px`, `pt` (its distance), `pk` (pixels a unit there). */ + private onView(x: number, u: number, nearest: number): boolean { + const z = (x - this.pos) / UNIT + if (z < nearest || z > 12) return false + if (this.pano) { + this.pt = Math.hypot(u, z) + this.ps = this.W / 2 + Math.atan2(u, z) * this.fth + } else { + this.pt = z + this.ps = this.W / 2 + (this.fth * u) / z + } + this.pk = this.fy / this.pt + return true + } + private ps = 0 + private pt = 0 + private pk = 0 + + /** How far ahead of him a monster comes into sight (band pixels). */ + private ahead(): number { + return this.tall ? 11 * UNIT : this.W - this.mx + } + + /** How far he can shoot. */ + private range(): number { + return this.tall ? 6.5 * UNIT : Math.max(36, this.ahead() * 0.82) + } + + /** How far behind him things are out of sight. */ + private behind(): number { + return this.tall ? 8 : this.mx + 16 + } + + /** How many of the squad this layout shows: behind him in the band, ahead of him in the tall view. */ + private room(): number { + if (this.tall) return this.W >= 28 ? SQUAD : 2 + return clamp(Math.floor((this.mx - 6) / 11), 0, SQUAD) + } + + /** A closed door between him and `x` (the tall view's): nothing beyond it can be seen or shot. */ + private blocked(x: number): boolean { + for (const dr of this.doors) if (dr.x > this.pos && dr.x < x && dr.open < 0.7) return true + return false + } + + // ── Each frame ── + + protected update(d: Dials): void { + const L = d.level + this.kSmk = approach(this.kSmk, d.tint === 'smoke' ? 1 : 0, 0.1) + this.kBlu = approach(this.kBlu, d.tint === 'blue' ? 1 : 0, 0.1) + this.hold = d.wait > 0.3 + this.lay() + this.updateGun(L) + this.updateHim(L) + this.updateFoes(L) + this.updateSquad(L, d) + this.updateMissiles() + this.updateDoors() + for (const p of this.puffs) p.t++ + retain(this.puffs, p => p.t < p.life) + for (const b of this.barrels) if (b.fuse > 0 && --b.fuse === 0) this.explode(b) + const gone = this.pos - this.behind() - 24 + retain(this.barrels, b => b.fuse >= 0 && b.x > gone) + retain(this.foes, f => (f.st === DEAD ? f.x > gone : f.st !== FLEE || f.x - this.pos < this.ahead() + 20)) + // (No more than a few dozen fallen lying about: the oldest go first.) + let dead = 0 + for (let i = this.foes.length - 1; i >= 0; i--) if (this.foes[i]!.st === DEAD && ++dead > 30) this.foes.splice(i, 1) + this.updateFace(d) + } + + /** Barrels along the walls (and, in the tall view, doors across the corridor) for the stretches coming into sight. */ + private lay(): void { + const to = Math.floor((this.pos + this.ahead() + 40) / SEG) + if (Number.isNaN(this.laid)) this.laid = Math.floor((this.pos - this.behind()) / SEG) - 1 + while (this.laid < to) { + const seg = ++this.laid + if (hash1(seg * 53 + this.seed) < 0.24) + this.barrels.push({ x: seg * SEG + 6 + Math.floor(hash1(seg * 59 + this.seed) * 20), u: hash1(seg * 61 + this.seed) < 0.5 ? -0.78 : 0.78, fuse: 0 }) + if (this.tall && seg * SEG > this.pos + 2 * UNIT && hash1(seg * 41 + this.seed + 9) < 0.2) this.doors.push({ x: seg * SEG, open: 0 }) + } + } + + private updateGun(L: number): void { + const want = gunFor(L, this.nextGun) + if (want !== this.nextGun && this.switchT === 0) { + this.nextGun = want + this.switchT = 1 + hear(this.sounds, { kind: 'reload', v: 1 }) + } + if (this.switchT > 0) { + this.switchT++ + if (this.switchT === SWITCH >> 1) { + this.gun = this.nextGun + this.grin = 22 + } + if (this.switchT >= SWITCH) this.switchT = 0 + } + } + + /** The nearest monster still coming at him: how far ahead (Infinity: none). */ + private nearest(): number { + let best = Infinity + for (const f of this.foes) if (f.st < DIE && f.x - this.pos > -4) best = Math.min(best, f.x - this.pos) + return best + } + + /** What he (or one of the squad, from `from`) shoots at: a barrel with a monster by it, else the nearest monster. */ + private target(from: number, barrels: boolean): Foe | Barrel | undefined { + const range = this.range() + if (barrels) + for (const b of this.barrels) { + const db = b.x - from + if (b.fuse !== 0 || db < 34 || db > range || this.blocked(b.x)) continue + for (const f of this.foes) if (f.st < DIE && Math.abs(f.x - b.x) < 16) return b + } + let best: Foe | undefined + for (const f of this.foes) { + if (f.st >= DIE) continue + const df = f.x - from + if (df < -2 || df > range || this.blocked(f.x)) continue + if (!best || f.x < best.x) best = f + } + return best + } + + private updateHim(L: number): void { + let goal = this.hold ? 0 : speedAt(L) + if (this.nearest() < (this.tall ? HOLD_NEAR : HOLD)) goal = 0 + this.v += clamp(goal - this.v, -0.06, 0.035) + if (Math.abs(this.v) < 0.005) this.v = 0 + this.pos += this.v + this.walk += this.v + this.flash = Math.max(0, this.flash - 1) + this.recoil = Math.max(0, this.recoil - 1) + this.cool-- + this.tick++ + this.sinceShot++ + this.sinceSighted++ + this.footstep() + // His fast gun fallen silent with its volley's clip still running (the target down, a hold, a switch): the + // clip, heard in step with the picture, stops as the first shot he didn't fire would sound (a stop takes + // no start-up), and the next starts afresh. + const due = Math.max(this.tick - this.sinceShot + this.volleyR, this.volleyFrom) + if (this.heardTo > this.tick && this.tick > due) { + hear(this.sounds, { kind: 'cease', v: 1 }) + this.heardTo = this.tick + } + if (this.hold || L <= 0 || this.switchT > 0) { + this.primed = false + return + } + const tg = this.target(this.pos, true) + if (!tg) { + this.primed = false + return + } + this.sinceSighted = 0 + // The shot's sound goes out the player's start-up time ahead of it, so it lands with the flash: a + // target come into sight with his gun ready, he takes aim that long first. + const lead = leadFrames(this.strength, this.tint) + const volley = VOLLEY[this.gun] + if (volley) { + // A fast gun: the next volley goes out as the last runs out (its first shot the one after the last's), or + // he takes aim first, starting afresh. + if (this.tick + lead >= this.heardTo) { + if (this.heardTo <= this.tick) this.cool = Math.max(this.cool, Math.ceil(lead - 1e-9)) + const r = clamp(rateOf(this.gun, L), RATE[this.gun]![1], RATE[this.gun]![0]) + hear(this.sounds, { kind: volley[r]!, v: 1 }) + this.volleyR = r + this.volleyFrom = Math.max(this.heardTo, this.tick + this.cool) + this.heardTo = this.volleyFrom + volleyShots(r) * r + this.quietTo = this.heardTo + } + } else if (!this.primed && this.cool <= lead) { + this.primed = true + if (this.cool <= 0) this.cool = Math.ceil(lead - 1e-9) + hear(this.sounds, { kind: SHOT_SOUND[this.gun]!, v: 1 }) + this.quietTo = this.tick + SQUAD_HEARD + } + if (this.cool > 0) return + this.primed = false + this.cool = rateOf(this.gun, L) + this.flash = this.gun === CHAINGUN || this.gun === PLASMA ? 1 : 2 + this.recoil = this.gun === SHOTGUN ? 5 : 2 + this.sinceShot = 0 + this.shots++ + this.ammo[this.gun]!-- + if (this.ammo[this.gun]! < 5) this.ammo[this.gun]! += [50, 20, 100, 100][this.gun]! + this.shoot(tg, this.gun, 1, this.pos + (this.tall ? 16 : 8)) + } + + /** A footfall each time a foot comes down (the band's legs change every 3 pixels; his view bobs every 2π). */ + private footstep(): void { + const stride = Math.floor(this.walk / (this.tall ? Math.PI * 2 : 3)) + if (stride === this.stride) return + this.stride = stride + // (Only while it's calm and he's not fighting: the guns drown them, and each second of steps is a clip of + // its own, a place in the player the fight needs.) + if (this.v > 0.02 && this.strength < 5 && this.sinceShot > 20) hear(this.sounds, { kind: 'step', v: clamp(this.v / 2) }) + } + + /** A shot from `gun` at `tg`, doing `k` of its damage; a plasma bolt leaves from `muzzle`. */ + private shoot(tg: Foe | Barrel, gun: number, k: number, muzzle: number): void { + if (gun === PLASMA) { + this.missiles.push({ kind: BALL_PLASMA, x: muzzle, u: 0.12, h: 5, v: 7, t: 0 }) + return + } + if (!('kind' in tg)) { + this.explode(tg) + return + } + this.hit(tg, DAMAGE[gun]! * k) + if (gun === SHOTGUN) { + for (let i = 0; i < 2; i++) this.puff(BLOOD, tg.x + (this.rng.f() - 0.5) * 6, tg.u, 2 + this.rng.f() * 6, 5) + for (const f of this.foes) if (f !== tg && f.st < DIE && Math.abs(f.x - tg.x) < 10) this.hit(f, k) + } + } + + private hit(f: Foe, dmg: number): void { + const kind = FOES[f.kind]! + this.puff(BLOOD, f.x + (this.rng.f() - 0.5) * 3, f.u, kind.float + 2 + this.rng.f() * 5, 5) + f.hp -= dmg + if (f.hp <= 0) { + f.st = DIE + f.t = 0 + f.gib = dmg >= 8 + if (this.tick - this.fallHeard >= FALL_HEARD) { + this.fallHeard = this.tick + hear(this.sounds, { kind: 'splat', v: f.gib ? 1 : 0.4 + 0.15 * f.kind }) + } + } else if (f.st === WALK && this.rng.f() < 0.45) { + f.st = PAIN + f.t = 3 + } + } + + private explode(b: Barrel): void { + if (b.fuse < 0) return + b.fuse = -1 + hear(this.sounds, { kind: 'barrel', v: 1 }) + this.puff(BOOM, b.x + 2, b.u, 3, 9) + for (const f of this.foes) if (f.st < DIE && Math.abs(f.x - b.x) < 22) this.hit(f, 12) + for (const o of this.barrels) if (o.fuse === 0 && Math.abs(o.x - b.x) < 20) o.fuse = 3 + } + + private puff(kind: number, x: number, u: number, h: number, life: number): void { + if (this.puffs.length < 80) this.puffs.push({ kind, x, u, h, t: 0, life, seed: this.rng.int() & 0xffff }) + } + + private updateFoes(L: number): void { + const ahead = this.ahead() + let up = 0 + for (const f of this.foes) if (f.st < DIE) up++ + if (!this.hold && L > 0) { + this.spawnT-- + const most = 1 + Math.round(L * 0.75) + if (this.spawnT <= 0 && up < most) { + this.spawn(L, ahead) + this.spawnT = Math.round((230 - 218 * Math.pow(clamp(L / 10), 0.6)) * (0.6 + 0.8 * this.rng.f())) + } + } + const pace = 0.7 + 0.06 * L + for (const f of this.foes) { + const kind = FOES[f.kind]! + f.flash = Math.max(0, f.flash - 1) + if (f.st === DIE) { + if (++f.t >= DIE_T) f.st = DEAD + continue + } + if (f.st === DEAD) continue + if (this.hold) f.st = FLEE + else if (f.st === FLEE) f.st = WALK + const dist = f.x - this.pos + if (f.st === FLEE) { + // Back into the dark, away from him. + f.x += kind.speed * pace + 0.25 + f.walk += kind.speed * pace + 0.25 + continue + } + if (f.st === PAIN) { + if (--f.t <= 0) f.st = WALK + continue + } + if (f.st === ATTACK) { + f.t++ + if (f.t === 4) this.attack(f) + if (f.t >= 9) { + f.st = WALK + f.cool = Math.round((50 + 60 * this.rng.f()) * (1.3 - 0.07 * L)) + } + continue + } + const stand = this.tall ? kind.near : kind.stand + if (dist > stand) { + const step = Math.min(kind.speed * pace, dist - stand) + f.x -= step + f.walk += step + } + if (--f.cool <= 0 && dist < ahead - 4 && !this.blocked(f.x) && (f.kind !== PINKY || dist < stand + 4)) { + f.st = ATTACK + f.t = 0 + } + } + } + + private spawn(L: number, ahead: number): void { + const w = [1, L >= 2 ? 0.9 : 0, L >= 4 ? 0.45 + 0.05 * L : 0, L >= 6 ? 0.25 + 0.05 * L : 0] + let r = this.rng.f() * (w[0]! + w[1]! + w[2]! + w[3]!) + let kind = 0 + while (kind < 3 && r >= w[kind]!) r -= w[kind++]! + const k = FOES[kind]! + this.foes.push({ + kind, + x: this.pos + ahead + 6 + this.rng.f() * 14, + u: (this.rng.f() * 2 - 1) * 0.5, + hp: k.hp, + st: WALK, + t: 0, + walk: this.rng.f() * 8, + cool: 20 + Math.floor(this.rng.f() * 40), + flash: 0, + gib: false, + seed: this.rng.int() & 0xffff, + }) + } + + private attack(f: Foe): void { + const dist = f.x - this.pos + if (f.kind === ZOMBIE) { + f.flash = 2 + // (Down the hall: farther, quieter.) + hear(this.sounds, { kind: 'foeshot', v: clamp(1 - dist / (this.ahead() + 20)) }) + if (this.rng.f() < 0.25) this.hurt(2 + this.rng.f() * 4) + } else if (f.kind === IMP) { + hear(this.sounds, { kind: 'fireball', v: 0.7 }) + this.missiles.push({ kind: BALL_FIRE, x: f.x - 2, u: f.u, h: 6, v: 1.5, t: 0 }) + } else if (f.kind === CACO) { + hear(this.sounds, { kind: 'fireball', v: 1 }) + this.missiles.push({ kind: BALL_CACO, x: f.x - 3, u: f.u, h: FOES[CACO].float + 4, v: 1.15, t: 0 }) + } else if (dist < FOES[PINKY].stand + 6) { + this.hurt(3 + this.rng.f() * 5) + } + } + + private hurt(n: number): void { + this.hp = Math.max(12, this.hp - n) + this.ouch = 8 + } + + private updateMissiles(): void { + for (const m of this.missiles) { + m.t++ + if (m.kind === BALL_PLASMA) { + m.x += m.v + for (const f of this.foes) + if (f.st < DIE && m.x >= f.x - 2 && m.x - m.v <= f.x + 6) { + this.hit(f, DAMAGE[PLASMA]!) + m.t = -1 + break + } + if (m.x - this.pos > this.range() + 20 || this.blocked(m.x)) m.t = -1 + continue + } + if (this.hold) { + // Fizzling out as everything settles. + this.puff(SPARK, m.x, m.u, m.h, 4) + m.t = -1 + continue + } + m.x -= m.v + // Homing in on him (the tall view's camera): toward the middle of the corridor and down to his chest. + const dist = m.x - this.pos + const k = m.v / Math.max(m.v, dist) + m.u += (0 - m.u) * k + m.h += (5 - m.h) * k + if (dist < 4) { + this.puff(SPARK, this.pos + 4, 0, 5, 5) + this.hurt(4 + this.rng.f() * 6) + m.t = -1 + } + } + retain(this.missiles, m => m.t >= 0) + } + + private updateDoors(): void { + for (const dr of this.doors) { + let want = dr.x - this.pos < 3.5 * UNIT ? 1 : 0 + for (const f of this.foes) if (f.st !== DEAD && Math.abs(f.x - dr.x) < 1.2 * UNIT) want = 1 + // (Heard between fights: in one, the guns have the player's places.) + if (want && dr.open === 0 && this.sinceShot > 20) hear(this.sounds, { kind: 'door', v: 1 }) + dr.open = clamp(dr.open + (want ? 0.045 : -0.03)) + } + retain(this.doors, dr => dr.x > this.pos - 2 * UNIT) + } + + /** Where a member of the squad keeps, from him (band pixels; behind him in the band, ahead in the tall view). */ + private squadPlace(m: Mate): number { + const rest = m.busy < 0.5 && !m.leaving + if (this.tall) return [24, 32, 46, 54][m.slot]! * (rest ? 0.7 : 1) + return -(11 + m.slot * 11) - (rest ? 5 : 0) + } + + private updateSquad(L: number, d: Dials): void { + this.crew.room = this.room() + this.crew.update(this.agents, d.boost) + for (const m of this.crew.mates) { + if (Number.isNaN(m.x)) { + // In from behind: past the band's left edge, or past him in the tall view. + m.x = this.tall ? -14 : -(this.mx + 10) + m.y = 10 + Math.floor(m.seed * 20) + } + if (m.leaving && !this.gone.has(m)) { + this.gone.add(m) + hear(this.sounds, { kind: m.ok ? 'teleport' : 'thud', v: m.ok ? 1 : 0.5 }) + } + if (failed(m)) { + // It fell where it stood: the squad walks on. + m.x -= this.v + continue + } + if (finished(m)) continue + m.x = easeTo(m.x, this.squadPlace(m), m.age < ARRIVE * 2 ? 0.07 : 0.05) + m.y-- + if (this.hold || m.busy < 0.5 || m.here < 0.6 || this.switchT > 0) continue + const at = this.pos + m.x + const tg = this.target(at, false) + if (!tg || m.y > 0) continue + m.y = Math.round(rateOf(this.gun, L) * (1.4 + m.seed * 0.6)) + // (Heard only between his: one shot now and then, his own volleys covering theirs.) + if (this.tick >= this.quietTo) { + this.quietTo = this.tick + SQUAD_HEARD + hear(this.sounds, { kind: SHOT_SOUND[this.gun]!, v: 0.45 }) + } + this.shoot(tg, this.gun, 0.6, at + 8) + } + } + + private updateFace(d: Dials): void { + this.ouch = Math.max(0, this.ouch - 1) + this.grin = Math.max(0, this.grin - 1) + if (d.t % 12 === 0) this.hp = Math.min(100, this.hp + 1) + if (--this.lookT <= 0) { + const r = this.rng.f() + this.look = this.hold ? LOOK_AHEAD : r < 0.3 ? LOOK_LEFT : r < 0.6 ? LOOK_RIGHT : LOOK_AHEAD + this.lookT = 18 + Math.floor(this.rng.f() * 30) + } + } + + // ── Drawing ── + + paint(px: Painter, d: Dials): void { + this.crew.clearMarks() + if (this.tall) this.paintView(px, d) + else this.paintBand(px, d) + // The tints over it all (a little less on the figures, so they still read). + const s = Math.round(this.kSmk * 6) / 6 + const b = Math.round(this.kBlu * 6) / 6 + if (s <= 0 && b <= 0) return + for (let i = 0; i < px.px.length; i++) { + const k = px.keep[i] ? 0.5 : 1 + px.px[i] = tone(px.px[i]!, s * k, b * k) + } + for (let i = 0; i < px.lamps.length; i++) if (px.lamps[i]! >= 0) px.lamps[i] = tone(px.lamps[i]!, s * 0.5, b * 0.5) + } + + /** A sprite with its top left at (x0, y0), `scale` pixels a sprite pixel, `rows` of it squashed into its height (a fall). */ + private blit(px: Painter, s: Sprite, pal: readonly number[], x0: number, y0: number, flip: boolean, scale = 1, rows = s.h, fade = 1, seed = 0, z = -1, lamp = -1): void { + const dw = Math.max(1, Math.round(s.w * scale)) + const dh = Math.max(1, Math.round(rows * scale)) + const X0 = Math.round(x0) + const Y0 = Math.round(y0) + const W = px.w + const yMax = z >= 0 ? this.hv : px.h + for (let j = 0; j < dh; j++) { + const y = Y0 + j + if (y < 0 || y >= yMax) continue + const sy = Math.min(s.h - 1, Math.floor(((j + 0.5) / dh) * s.h)) + for (let i = 0; i < dw; i++) { + const x = X0 + i + if (x < 0 || x >= W) continue + let sx = Math.min(s.w - 1, Math.floor(((i + 0.5) / dw) * s.w)) + if (flip) sx = s.w - 1 - sx + const c = s.p[sy * s.w + sx]! + if (!c) continue + if (fade < 1 && hash(i, j, seed) >= fade) continue + if (z >= 0) { + const k = y * W + x + if (this.zb[k]! <= z) continue + this.zb[k] = z + } + if (c === lamp) px.lamp(x, y, pal[c]!) + else px.set(x, y, pal[c]!, true) + } + } + } + + /** `pal` lit at `l`, into one of the two scratch palettes. */ + private shade(pal: readonly number[], l: number, second = false): number[] { + const out = second ? this.sp2 : this.sp + for (let i = 0; i < pal.length; i++) out[i] = lit(pal[i]!, l) + return out + } + + /** Whether he has his gun up: something in his sights lately, and not holding for the person. */ + private ready(): boolean { + return !this.hold && this.sinceSighted < 40 + } + + // The band: side-on. + + private paintBand(px: Painter, d: Dials): void { + const W = px.w + const H = px.h + const camX = this.pos - this.mx + const ceil = H >= 8 ? 1 : 0 + const floorRows = H >= 8 ? 2 : 1 + const wallBot = H - floorRows + const wallRows = Math.max(1, wallBot - ceil) + const fx = this.mx + 9 + const flashing = this.flash > 0 + const smoke = this.kSmk + for (let x = 0; x < W; x++) { + const a = Math.floor(camX + x) + const seg = Math.floor(a / SEG) + const lx = a - seg * SEG + const kind = segKind(seg, this.seed) + const sl = segLight(seg, d.t, this.seed) + const boost = flashing ? 0.35 * Math.max(0, 1 - Math.abs(x - fx) / 14) : 0 + for (let y = 0; y < H; y++) { + let c: number + if (y < ceil) c = ceilTex(a, 0) + else if (y < wallBot) { + const v = 1 - (y - ceil + 0.5) / wallRows + c = wallTex(kind, lx, v, seg, d.t, this.seed) + if (c === SKY) c = skyTex(x + camX * 0.15, (v - 0.25) / 0.56, this.seed) + } else c = floorTex(a, (y - wallBot) * 8 - 4, d.t, this.seed) + const lamp = lampHit + if (!lamp && !glowHit) c = lit(c, q8(sl + boost)) + if (smoke > 0 && y < wallBot) { + // Smoke drifting under the ceiling. + const n = noise2(a * 0.07 - d.t * 0.05, y * 0.45, this.seed + 11) + if (n > 0.45) c = mix(c, 0x707070, Math.round(smoke * (n - 0.45) * 2.2 * 4) / 4) + } + if (lamp) px.lamp(x, y, c) + else px.set(x, y, c) + } + } + // (In a band too short for them, their feet go below the bottom rather than their heads off the top.) + const foot = H - 1 + (H < 10 ? (10 - H) >> 1 : 0) + const sx = (wx: number) => Math.round(wx - camX) + // Barrels by the wall, then the fallen, then those still up (the farthest first). + for (const b of this.barrels) { + const x = sx(b.x) + if (x > -6 && x < W + 2) this.blit(px, BARREL, BARREL_PAL, x, foot + 1 - BARREL.h - 1, false, 1, BARREL.h, 1, 0, -1, 3) + } + for (let round = 0; round < 2; round++) + for (let i = this.foes.length - 1; i >= 0; i--) { + const f = this.foes[i]! + if ((f.st === DEAD || f.st === DIE) !== (round === 0)) continue + this.drawFoeBand(px, f, sx(f.x), foot, d) + } + // The squad, the farthest back first, then him. + for (let s = SQUAD - 1; s >= 0; s--) { + const m = this.crew.inSlot(s) + if (m) this.drawMateBand(px, m, sx(this.pos + m.x), foot, d) + } + this.drawMarineBand(px, 0, this.mx, foot, this.ready() ? 0 : 1, this.walk, this.v > 0.02, this.flash > 0, 1, 0, -1) + for (const m of this.missiles) { + const x = sx(m.x) + const y = foot - Math.round(m.h) + if (m.kind === BALL_PLASMA) { + this.blit(px, BOLT, BOLT_PAL, x, y - 1, false) + px.set(x - 2, y, 0x3a6ac8, true) + } else { + this.blit(px, BALL_SMALL, m.kind === BALL_FIRE ? FIRE_PAL : CACO_PAL, x - 1, y - 1, (m.t & 2) === 0) + px.set(x + 2, y, m.kind === BALL_FIRE ? 0xa02810 : 0x701880, true) + } + } + for (const p of this.puffs) this.drawPuffBand(px, p, sx(p.x), foot) + } + + private drawFoeBand(px: Painter, f: Foe, x: number, foot: number, d: Dials): void { + const kind = FOES[f.kind]! + const W = px.w + if (x < -12 || x > W + 4) return + const frame = f.st === ATTACK && f.t >= 2 && f.t < 7 ? 2 : Math.floor(f.walk / 3) & 1 + const s = kind.frames[frame]! + const bob = kind.float ? Math.round(Math.sin(d.t * 0.12 + f.seed) * 0.8) : 0 + if (f.st === DIE || f.st === DEAD) { + const k = f.st === DEAD ? 1 : f.t / DIE_T + const rows = Math.max(2, Math.round(s.h - (s.h - 2) * k)) + const lift = Math.round(kind.float * (1 - k)) + if (k > 0.5) for (let i = -1; i < s.w + 1; i++) px.set(x + i, foot, f.gib ? 0x9a0c0c : 0x6a0808, true) + const pal = f.gib && k > 0.5 ? GIB_PAL[f.kind]! : kind.pal + this.blit(px, s, pal, x, foot + 1 - rows - lift - (k > 0.5 ? 1 : 0), false, 1, rows) + return + } + const top = foot + 1 - s.h - kind.float + bob + const pal = f.st === PAIN ? this.shade(kind.pal, 1.5) : kind.pal + this.blit(px, s, pal, x, top, f.st === FLEE) + if (f.kind === IMP && f.st === ATTACK && f.t < 4) this.blit(px, BALL_SMALL, FIRE_PAL, x + 2, top - 2, false) + if (f.flash > 0) px.lamp(x - 1, top + 4, 0xffe070) + } + + /** A marine: `i` his colors, his feet's row, `pose` 0 ready or 1 at rest. */ + private drawMarineBand(px: Painter, i: number, x: number, foot: number, pose: number, walk: number, moving: boolean, firing: boolean, fade: number, seed: number, rows: number): void { + const leg = moving ? 1 + (Math.floor(walk / 3) & 1) : 0 + const s = MARINE[pose]![leg]! + const h = rows < 0 ? s.h : rows + const top = foot + 1 - h + this.blit(px, s, MARINE_PAL[i]!, x, top, false, 1, h, fade, seed) + if (rows >= 0) return + if (this.switchT > 2 && this.switchT < SWITCH - 2) return + const g = GUNS[this.gun]! + const [hx, hy] = HAND[pose]! + const gx = x + hx + 1 - g.ax + const gy = top + hy + this.blit(px, g.s, GUN_PAL, gx, gy, false, 1, g.s.h, fade, seed) + if (firing && fade > 0.5) this.blit(px, FLASH_SIDE, this.gun === PLASMA ? PLASMA_FLASH_PAL : FLASH_PAL, gx + g.s.w, gy - 1, false, 1, 3, 1, 0, -1, 3) + } + + private drawMateBand(px: Painter, m: Mate, x: number, foot: number, d: Dials): void { + const i = 1 + m.slot + const goal = this.squadPlace(m) + const running = Math.abs(goal - m.x) > 1.5 && !m.leaving + const pose = m.busy > 0.5 && !this.hold && !m.leaving ? 0 : 1 + const flash = pose === 0 && m.y > Math.round(rateOf(this.gun, d.level) * (1.4 + m.seed * 0.6)) - 2 + if (failed(m)) { + // It falls, then lies there fading. + const k = clamp((1 - m.here) * 2.2) + const rows = Math.max(2, Math.round(9 - 7 * k)) + if (k > 0.6) for (let j = -1; j < 8; j++) px.set(x + j, foot, 0x6a0808, true) + this.drawMarineBand(px, i, x, foot, 1, 0, false, false, clamp(m.here * 2), m.seed * 9999, rows) + } else { + const fade = finished(m) ? m.here : 1 + this.drawMarineBand(px, i, x, foot, pose, this.walk + m.seed * 6 + (running ? m.age * 0.6 : 0), this.v > 0.02 || running, flash, fade, m.seed * 9999 + m.age, -1) + if (finished(m)) this.fog(px, x + 3, foot - 4, 1 - m.here, m.age, 4) + } + if (m.waiting && beckon(m, d.t)) px.lamp(x + 3, foot - 9, 0xffb020) + this.crew.mark(m, x / 2, (foot - 9) / 2, 4, 5, d.columns, d.rows) + } + + /** Teleport fog: green-white sparkles about (x, y), `k` how far it has gone (dots `r` across). */ + private fog(px: Painter, x: number, y: number, k: number, t: number, r: number): void { + const n = Math.round(10 * Math.sin(Math.PI * clamp(k))) + for (let i = 0; i < n; i++) { + const a = hash1(t * 31 + i) * Math.PI * 2 + const rr = r * (0.3 + 0.7 * hash1(t * 17 + i * 3)) + px.dot((x + Math.cos(a) * rr) * 1, (y + Math.sin(a) * rr * 1.6) * 2, i & 1 ? 0xa8ff98 : 0xe8ffe0) + } + } + + private drawPuffBand(px: Painter, p: Puff, x: number, foot: number): void { + const y = foot - Math.round(p.h) + const k = p.t / p.life + if (p.kind === BLOOD) { + px.set(x, y + Math.floor(p.t / 2), 0xc81818, true) + if (p.t > 1) px.set(x + ((p.seed & 1) ? 1 : -1), y + p.t - 1, 0x8a0a0a, true) + } else if (p.kind === SPARK || p.kind === DUST) { + const c = p.kind === SPARK ? (k < 0.5 ? 0xfff0a0 : 0xff7020) : 0xa0a0a0 + px.set(x, y, c, true) + if (p.t < 3) { + px.set(x - 1, y - 1, c, true) + px.set(x + 1, y + 1, c, true) + } + } else { + // A barrel going up: a ball of fire swelling and dying red. + const r = 1.5 + 4.5 * Math.sqrt(k) + for (let dy = -6; dy <= 6; dy++) + for (let dx = -7; dx <= 7; dx++) { + const q = Math.hypot(dx, dy * 1.2) / r + if (q > 1) continue + const heat = (1 - q) * (1.15 - k) + if (heat < 0.08) continue + px.set(x + dx, y + dy, heat > 0.75 ? 0xfff4c0 : heat > 0.5 ? 0xffd040 : heat > 0.28 ? 0xff7a18 : 0xb02a0a, true) + } + } + } + + // The tall view: his own eyes. + + private paintView(px: Painter, d: Dials): void { + const W = px.w + const HV = this.hv + const fy = this.fy + const bobK = Math.min(1, this.v * 1.5) + const hz = HV * 0.5 + Math.sin(this.walk * 0.5) * 0.6 * bobK + this.hz = hz + const camZ = this.pos / UNIT + const HW = 1 + const EYE = 0.62 + const CEIL = 1.38 + const extra = this.flash > 0 ? 0.2 : 0 + const zb = this.zb + const smoke = this.kSmk + const UX = this.ux + const UZ = this.uz + // Each sector's light once a frame, for the stretches in sight (nothing beyond them is lit). + const s0 = Math.floor(this.pos / SEG) + const lights = this.lights + for (let k = 0; k < lights.length; k++) lights[k] = segLight(s0 + k, d.t, this.seed) + // (Each column's ray: `t` along it, `t * ux` across the corridor, `t * uz` ahead.) + for (let y = 0; y < HV; y++) { + const dy = y + 0.5 - hz + const tp = dy > 0 ? (fy * EYE) / dy : dy < 0 ? (fy * CEIL) / -dy : 1e9 + for (let x = 0; x < W; x++) { + const ux = UX[x]! + const uz = UZ[x]! + const tw = Math.abs(ux) > 1e-6 ? HW / Math.abs(ux) : 1e9 + let t = Math.min(tw, tp) + let c = 0 + let surf = tw < tp ? 0 : 1 + // A door across the corridor, raised as far as it's open. + for (const dr of this.doors) { + const td = (dr.x - this.pos) / UNIT / uz + if (td <= 0.05 || td >= t || dr.open >= 1) continue + const h = (-dy * td) / fy + if (h < -EYE + dr.open * (EYE + CEIL)) continue + t = td + surf = 2 + const lx = Math.floor((td * ux + 1) * 16) + const v = (h + EYE) / (EYE + CEIL) - dr.open + c = lx <= 1 || lx >= 30 ? 0x4c4c48 : lx === 15 || lx === 16 ? 0x5a5852 : Math.floor(v * 16) % 3 === 0 ? 0x6a6862 : 0x8c8a82 + lampHit = false + glowHit = false + break + } + if (surf === 0) { + const a = Math.floor((camZ + tw * uz) * UNIT) + const seg = Math.floor(a / SEG) + const side = ux < 0 ? 0 : 1 + const v = clamp(((-dy * tw) / fy + EYE) / (EYE + CEIL), 0, 0.999) + // Door tracks either side of a door's frame. + let track = false + for (const dr of this.doors) if (Math.abs(a - dr.x) <= 1) track = true + if (track) { + c = 0x4c4c48 + lampHit = false + glowHit = false + } else { + c = wallTex(segKind(seg * 2 + side, this.seed), a - seg * SEG, v, seg, d.t, this.seed + side) + if (c === SKY) c = skyTex(x * 2 + side * 60 + d.t * 0.02, (v - 0.25) / 0.56, this.seed) + } + } else if (surf === 1) { + const a = Math.floor((camZ + tp * uz) * UNIT) + const lat = tp * ux * UNIT + c = dy > 0 ? floorTex(a, lat, d.t, this.seed) : ceilTex(a, lat) + } + const fogL = t < 9.75 ? Math.min(1.1, 1.3 - t / 7.5) : 0 + const k = Math.floor(((camZ + t * uz) * UNIT) / SEG) - s0 + const l = q8((glowHit ? Math.max(0.85, fogL * 1.2) : fogL > 0 && k < lights.length ? lights[k]! * fogL : 0) + (t < 3 ? extra : 0)) + if (l <= 0.06) c = 0x0a0806 + else c = lit(c, l) + if (smoke > 0 && dy < 0) { + const n = noise2(x * 0.18 - d.t * 0.03, y * 0.2 + d.t * 0.01, this.seed + 11) + if (n > 0.45) c = mix(c, 0x686868, Math.round(smoke * (n - 0.45) * 2 * 4) / 4) + } + zb[y * W + x] = t + if (lampHit && l > 0.4) px.lamp(x, y, c) + else px.set(x, y, c) + } + } + // Everything in the corridor, as billboards, each hidden where something nearer stands. + for (const b of this.barrels) this.billboard(px, BARREL, BARREL_PAL, b.x, b.u, 0, false, 1, BARREL.h, d, 3) + for (const fo of this.foes) this.drawFoeView(px, fo, d) + for (const m of this.crew.mates) this.drawMateView(px, m, d) + for (const m of this.missiles) + if (m.kind === BALL_PLASMA) this.billboard(px, BOLT, BOLT_PAL, m.x, m.u, m.h, false, 0.9, BOLT.h, d, -1, true) + else this.billboard(px, BALL, m.kind === BALL_FIRE ? FIRE_PAL : CACO_PAL, m.x, m.u, m.h, (m.t & 2) === 0, 1, BALL.h, d, -1, true, 1, 0, 0.7) + for (const p of this.puffs) this.drawPuffView(px, p, d) + this.drawGun(px, d) + if (this.sb > 0) this.drawStatus(px) + else if (this.pano) this.drawHud(px) + } + + /** A wide view too short for the status bar: his ammo in the corner on the left, his face and health on the right. */ + private drawHud(px: Painter): void { + const HV = this.hv + if (HV < 20) return + const W = px.w + const y = HV - 7 + const ammo = this.ammo[this.gun]! + this.number(px, ammo, 2 + String(ammo).length * 4, y) + let right = W - 3 + if (HV >= 24) { + const fx = W - 14 + const fy = HV - 13 + px.rect(fx, fy, 12, 12, 0x2e2a24, true) + this.blit(px, FACES[this.faceNow()]!, FACE_PAL, fx + 1, fy + 1, false) + this.bloody(px, fx + 1, fy + 1) + right = fx - 3 + } + this.number(px, Math.round(this.hp), right, y) + } + + /** The face he makes now. */ + private faceNow(): number { + if (this.hold) return LOOK_AHEAD + if (this.ouch > 0) return OUCH + if (this.grin > 0) return GRIN + return this.sinceShot < 5 && this.gun >= CHAINGUN ? RAGE : this.look + } + + /** Blood on his face (its top left at x, y) when things go badly. */ + private bloody(px: Painter, x: number, y: number): void { + if (this.kSmk <= 0.5 && this.hp >= 40) return + const n = this.kSmk > 0.5 ? BLOODY.length : 3 + for (let i = 0; i < n; i++) px.set(x + BLOODY[i]![0], y + BLOODY[i]![1], 0xb01010, true) + } + + /** The tall view's sectors' lights this frame, from the one he's in (as far as can be seen: 9.75 units). */ + private lights = new Float32Array(Math.ceil((9.75 * UNIT) / SEG) + 2) + /** The tall view's horizon this frame. */ + private hz = 0 + /** Where the last billboard went (screen): its left, top, width, height. */ + private bx = 0 + private by = 0 + private bw = 0 + private bh = 0 + + /** + * A sprite standing in the corridor at `x` along (band pixels), `u` across + * (-1..1), `h` above the floor (sprite pixels), `size` times its own size; + * lit by its sector and the dark. False if it's out of sight. + */ + private billboard(px: Painter, s: Sprite, pal: readonly number[], x: number, u: number, h: number, flip: boolean, size: number, rows: number, d: Dials, lampIdx = -1, glow = false, fade = 1, seed = 0, nearest = 0.3): boolean { + if (!this.onView(x, u, nearest)) return false + const z = this.pt + const scale = this.pk * SPX * size + const footY = this.hz + this.pk * (0.62 - h * SPX) + const dw = s.w * scale + const dh = rows * scale + this.bx = this.ps - dw / 2 + this.by = footY - dh + this.bw = dw + this.bh = dh + const seg = Math.floor(x / SEG) + const l = glow ? 1 : q8(segLight(seg, d.t, this.seed) * clamp(1.3 - z / 7.5, 0, 1.1) + (this.flash > 0 && z < 3 ? 0.2 : 0)) + if (l <= 0.06) return false + this.blit(px, s, l === 1 ? pal : this.shade(pal, l, true), this.bx, this.by, flip, scale, rows, fade, seed, z, lampIdx) + return true + } + + private drawFoeView(px: Painter, fo: Foe, d: Dials): void { + const kind = FOES[fo.kind]! + const frame = fo.st === ATTACK && fo.t >= 2 && fo.t < 7 ? 2 : Math.floor(fo.walk / 3) & 1 + const s = kind.frames[frame]! + const flip = fo.st === FLEE + if (fo.st === DIE || fo.st === DEAD) { + const k = fo.st === DEAD ? 1 : fo.t / DIE_T + const rows = Math.max(2, Math.round(s.h - (s.h - 2) * k)) + const pal = fo.gib && k > 0.5 ? GIB_PAL[fo.kind]! : kind.pal + if (k > 0.5) this.billboard(px, POOL, POOL_PAL, fo.x + 0.5, fo.u, 0, false, 1, 1, d) + this.billboard(px, s, pal, fo.x, fo.u, kind.float * (1 - k), flip, 1, rows, d) + return + } + const bob = kind.float ? Math.sin(d.t * 0.12 + fo.seed) * 0.8 : 0 + const pal = fo.st === PAIN ? this.shade(kind.pal, 1.5) : kind.pal + if (!this.billboard(px, s, pal, fo.x, fo.u, kind.float + bob, flip, 1, s.h, d)) return + if (fo.flash > 0) px.lamp(this.bx + this.bw * 0.1, this.by + this.bh * 0.45, 0xffe070) + if (fo.kind === IMP && fo.st === ATTACK && fo.t < 4) this.billboard(px, BALL, FIRE_PAL, fo.x - 0.5, fo.u, 9, false, 1, BALL.h, d, -1, true) + } + + private drawMateView(px: Painter, m: Mate, d: Dials): void { + const i = 1 + m.slot + const u = [-0.5, 0.5, -0.3, 0.3][m.slot]! + const x = this.pos + m.x + const pose = m.busy > 0.5 && !this.hold && !m.leaving ? 0 : 1 + const running = Math.abs(this.squadPlace(m) - m.x) > 1.5 && !m.leaving + const moving = this.v > 0.02 || running + const leg = moving ? 1 + (Math.floor((this.walk + m.seed * 6 + (running ? m.age * 0.6 : 0)) / 3) & 1) : 0 + // Seen from behind, walking away from him: the visor doesn't show. + const s = MARINE_BACK[pose]![leg]! + let rows = s.h + let fade = 1 + if (failed(m)) { + const k = clamp((1 - m.here) * 2.2) + rows = Math.max(2, Math.round(s.h - 7 * k)) + fade = clamp(m.here * 2) + if (k > 0.6) this.billboard(px, POOL, POOL_PAL, x + 0.5, u, 0, false, 1, 1, d) + } else if (finished(m)) fade = m.here + // (Nearer than this they're running past him, beside or behind: out of his sight.) + if (!this.billboard(px, s, MARINE_PAL[i]!, x, u, 0, false, 1, rows, d, -1, false, fade, m.seed * 9999 + m.age, 0.9)) return + const { bx, by, bw, bh } = this + if (finished(m)) this.fog(px, bx + bw / 2, by + bh / 2, 1 - m.here, m.age, Math.max(3, bw * 0.8)) + if (!failed(m) && pose === 0) { + const flash = m.y > Math.round(rateOf(this.gun, d.level) * (1.4 + m.seed * 0.6)) - 2 + if (flash) px.lamp(bx + bw * 0.5, by - 1, this.gun === PLASMA ? 0x70c0ff : 0xffd040) + } + if (m.waiting && beckon(m, d.t)) px.lamp(bx + bw / 2, by - Math.max(2, bh * 0.15), 0xffb020) + this.crew.mark(m, bx / 2, by / 2, bw / 2, bh / 2, d.columns, d.rows) + } + + private drawPuffView(px: Painter, p: Puff, d: Dials): void { + if (!this.onView(p.x, p.u, 0.3)) return + const z = this.pt + const k = p.t / p.life + if (p.kind === BOOM) { + const r = (1.5 + 4.5 * Math.sqrt(k)) * this.pk * SPX + const cx = this.ps + const cy = this.hz + this.pk * (0.62 - p.h * SPX) + for (let dy = -Math.ceil(r); dy <= Math.ceil(r); dy++) + for (let dx = -Math.ceil(r); dx <= Math.ceil(r); dx++) { + const q = Math.hypot(dx, dy) / r + if (q > 1) continue + const heat = (1 - q) * (1.15 - k) + if (heat < 0.08) continue + const X = Math.round(cx + dx) + const Y = Math.round(cy + dy) + if (X < 0 || Y < 0 || X >= px.w || Y >= this.hv || this.zb[Y * px.w + X]! < z - 0.3) continue + px.set(X, Y, heat > 0.75 ? 0xfff4c0 : heat > 0.5 ? 0xffd040 : heat > 0.28 ? 0xff7a18 : 0xb02a0a, true) + } + return + } + const s = p.kind === BLOOD ? BLOODS : SPARKS + const pal = p.kind === BLOOD ? BLOOD_PAL : p.kind === SPARK ? SPARK_PAL : DUST_PAL + this.billboard(px, s, pal, p.x, p.u, p.h - p.t * (p.kind === BLOOD ? 0.6 : 0), (p.seed & 1) === 1, 1, s.h, d, -1, p.kind === SPARK) + } + + /** His gun, bobbing as he walks, kicking as it fires, lowered while he switches or holds for the person. */ + private drawGun(px: Painter, d: Dials): void { + const W = px.w + const HV = this.hv + // (About a third of his view's height, as the game had it; less in a band too short for it.) + const scale = this.pano ? (HV < 26 ? HV / 26 : Math.max(1, Math.round(HV / 34))) : Math.max(1, Math.round(Math.min(W / 40, HV / 50))) + const s = FP_GUNS[this.gun]! + const bob = Math.min(3, this.v * 2.4) + const ph = this.walk * 0.32 + const sw = this.switchT > 0 ? Math.sin((Math.PI * this.switchT) / SWITCH) * s.h * scale * 1.1 : 0 + const down = (this.hold ? 4 : 0) * scale + this.recoil * 0.5 * scale + sw + const x0 = Math.round(W / 2 - (s.w * scale) / 2 + 1 + Math.cos(ph) * bob) + const y0 = Math.round(HV - s.h * scale + Math.abs(Math.sin(ph)) * bob + down + (this.sb ? 1 : 0)) + const light = q8(segLight(Math.floor(this.pos / SEG), d.t, this.seed) * 0.95 + (this.flash > 0 ? 0.25 : 0)) + const shaded = this.shade(FP_PAL, light) + // (The plasma rifle's glow is its own light.) + shaded[8] = FP_PAL[8]! + shaded[9] = FP_PAL[9]! + this.blit(px, s, shaded, x0, y0, false, scale, s.h, 1, 0, -1, this.gun === PLASMA ? 8 : -1) + if (this.flash > 0 && this.switchT === 0) { + const fl = FP_FLASH + this.blit(px, fl, this.gun === PLASMA ? PLASMA_FLASH_PAL : FLASH_PAL, x0 + (s.w * scale) / 2 - (fl.w * scale) / 2 - 0.5, y0 - fl.h * scale + scale, false, scale, fl.h, 1, 0, -1, 3) + } + // The barrels of the chaingun turning. + if (this.gun === CHAINGUN && this.sinceShot < 6 && (d.t & 1)) for (let i = 0; i < 3; i++) px.set(x0 + (4 + i * 2) * scale, y0, 0x5c5c60, true) + } + + /** The status bar: his ammo, his face, his health. */ + private drawStatus(px: Painter): void { + const W = px.w + const y0 = this.hv + const sb = this.sb + px.rect(0, y0, W, sb, 0x5a5248) + px.rect(0, y0, W, 1, 0x8a8070) + px.rect(0, y0 + sb - 1, W, 1, 0x38322a) + const fx = Math.round(W / 2 - 6) + px.rect(fx, y0 + 1, 12, sb - 2, 0x2e2a24) + const fy = y0 + Math.max(1, Math.round((sb - 10) / 2)) + this.blit(px, FACES[this.faceNow()]!, FACE_PAL, fx + 1, fy, false) + this.bloody(px, fx + 1, fy) + // Ammo on the left, health on the right, where there's room. + const panel = fx + if (panel >= 12) { + const dy = y0 + Math.round((sb - 5) / 2) + this.number(px, this.ammo[this.gun]!, fx - 2, dy) + this.number(px, Math.round(this.hp), W - 2, dy) + } + } + + /** A number in the status bar's red digits, right-aligned at `right`. */ + private number(px: Painter, n: number, right: number, y: number): void { + const s = String(Math.max(0, Math.min(999, Math.round(n)))) + let x = right - s.length * 4 + 1 + for (const ch of s) { + const g = DIGITS[ch.charCodeAt(0) - 48]! + for (let j = 0; j < 5; j++) for (let i = 0; i < 3; i++) if (g[j * 3 + i] === '1') px.set(x + i, y + j, 0xd82818, true) + x += 4 + } + } +} + +/** The squad's marines seen from behind (the tall view): no visor, a pack. */ +const MARINE_BACK = [M_TOP, M_TOP_REST].map(top => M_LEGS.map(legs => sprite([...top.map(r => r.replace(/3/g, '1')), ...legs]))) +/** A pool of blood under the fallen, and the puffs of the tall view. */ +const POOL = sprite(['.1111111.']) +const POOL_PAL = [0, 0x6a0808] +const BLOODS = sprite(['.1.', '121', '.1.']) +const BLOOD_PAL = [0, 0x8a0a0a, 0xd01818] +const SPARKS = sprite(['1.1', '.2.', '1.1']) +const SPARK_PAL = [0, 0xff7020, 0xfff0a0] +const DUST_PAL = [0, 0x808080, 0xc0c0c0] + +export const doomScene = defineScene({ + name: 'doom', + blurb: 'a space marine fighting through a 1993 techbase, faster and harder with the work', + make: seed => new Doom(seed), +}) + +export const doom3dScene = defineScene({ + name: 'doom3d', + blurb: 'the same fight through his own eyes, in a taller band', + make: seed => new Doom(seed, true), +}) diff --git a/hooks/earthrise.ts b/hooks/earthrise.ts new file mode 100644 index 0000000..f3796d3 --- /dev/null +++ b/hooks/earthrise.ts @@ -0,0 +1,1387 @@ +// REVISION: flow-v8-earthrise-tones +// +// Earthrise (the `earthrise` scene): the Earth coming up over a cratered +// lunar horizon in long, low sunlight, after "earthrise" by Shubham on +// ascii.rest (https://ascii.rest/earthrise/; Copyright (c) 2026 bas3line, MIT, +// its notice in THIRD-PARTY-NOTICES). The level is how high the +// Earth stands and how fast it turns: at 1 it is half behind the horizon, +// turning once in about two minutes; at 10 it stands clear of the horizon, +// turning every few seconds, its clouds running a little ahead. It climbs +// and sinks over several seconds as the work picks up or winds down. +// +// The dark sky comes alive with the work: at 1 a few stars hold still and +// breathe; as the level rises more of them come out, the field drifts past +// in three layers (the near ones faster), more of them twinkle, shooting +// stars streak across, and from about 3 up a comet now and then drifts +// through, its tails streaming away from the sun. +// +// Where the terminal draws images (pi in Ghostty, kitty, WezTerm), it is +// also drawn as a picture (`picture`), the original's way: every cell of a +// square grid a halftone dot (none, ·, • or ●) sized by its brightness, +// ordered-dithered, in the nearest of the original's 38 colours. The +// ground is built a few columns a frame for each new size, the sky left +// clear. +// +// The ground is the original's heightfield of craters and boulders, seen +// from a camera standing on it and marched column by column, with real +// shadows marched toward the sun. It is built once a size (`resize`); each +// frame only the Earth (a lit globe with its own map of land, sea, ice and +// cloud, spun) and the stars are drawn again. Everything is laid out in the +// original's own coordinates (its 200 × 100 cells, square) and sampled into +// the pixel layer: the band sees a wide strip across the horizon (squeezed +// to fit a wide terminal), the spine a tall view, the Earth high in it. +// +// Smoke raises a grey pall of moondust over the horizon and greys +// everything; a full context turns the light cold and blue. While Claude +// waits on the person the Earth holds where it stands and stops turning +// (PixelScene breathes the frame in sepia; a picture takes the same tone). +// +// Heard (sound.ts): no air on the moon, so no wind or noise, only steady low +// tones in one key: a deep drone and its fifth, a chord filling in with the +// work; each shooting star a soft low tone, each comet a long low chord. + +import { FRAME_MS } from './activity' +import { Rng } from './cells' +import { CLEAR, PixelScene, type Dials, type Painter } from './pixel-scene' +import { clamp, grey, hash, hash1, luma, mix, rampAt, smoothstep as smooth } from './pixels' +import { defineScene } from './scene-def' +import { hear, type SoundEvent } from './sound' +import { SEPIA_AMOUNT, waitColor, waitGlow, waitLift } from './waiting' + +// --- the world, in the original's units -------------------------------------- + +/** Screen row of eye level in the original's 200 × 100 view; the horizon dips below it. */ +const EYE = 37 +/** Focal length, in the original's cells. */ +const F = 112 +const CAM_H = 20 +/** The moon's radius in ground units, for the falling horizon. */ +const RM = 1500 +const ZMAX = 520 +/** The Earth in the original's view: centre and radius (its lower edge still behind the horizon). */ +const EC_X = 141 +const EC_Y = 32 +const ER = 22 +/** Which face of the globe is turned to us at the start. */ +const LON0 = 3.5 +/** + * How much of the Earth is behind the horizon at level 1 and at 10 (below 0: + * clear of it, by that much of its radius), and how far its height moves + * toward the level each frame (a whole climb in about ten seconds). + */ +const HID_CALM = 0.62 +const HID_BUSY = -0.14 +const CLIMB = 0.007 +/** In the spine: the Earth's centre this many radii below the original's at level 1, and above it at 10. */ +const SPINE_LOW = 0.5 +const SPINE_HIGH = 0.35 +/** Toward the sun: low, from the right and a little behind us (z is forward). */ +const SUN = (() => { + const v = [0.94, 0.14, -0.3] + const l = Math.hypot(...v) + return v.map(c => c / l) as [number, number, number] +})() + +/** Turn rate (radians a second) at level 1 and at 10, eased between on a log scale. */ +const SPIN_CALM = 0.05 +const SPIN_BUSY = 1.6 +/** Clouds run this much ahead of the ground as it turns. */ +const CLOUD_AHEAD = 0.27 + +/** + * The band: BAND_S of the original's cells to a pixel across (twice that + * down), its skyline (where most of it falls) BAND_SKY of the way down, + * and the Earth BAND_R pixels high from its centre, rising where the + * skyline dips lowest between BAND_FROM and BAND_TO of the way across, as + * far behind the horizon as the level puts it (or more, so its top stays in + * the band). + */ +const BAND_S = 2.4 +const BAND_SKY = 0.6 +const BAND_R = 3.4 +const BAND_FROM = 0.62 +const BAND_TO = 0.86 +/** + * A picture: the Earth PIC_R of its height from its centre, PIC_SIZE of the + * original's size against the ground, the skyline PIC_SKY of the way down, + * the ground's brightness in PIC_STEPS steps. + */ +const PIC_R = 0.28 +const PIC_SIZE = 0.85 +const PIC_SKY = 0.55 +const PIC_STEPS = 255 +/** Bump when the ground changes: a picture's ground kept from before (`pictureCache`) is then made afresh. */ +const GROUND_VERSION = 2 +/** What a picture's ground `pw` × `ph` dots is kept under (the same key pi's cache has always used). */ +const groundKey = (pw: number, ph: number) => 'earthrise-ground-v' + GROUND_VERSION + '-' + pw + 'x' + ph +/** + * A picture's halftone: about DOT_ROWS dots down it, each DOT_MIN to + * DOT_MAX of its pixels apart, sized (by radius, of that pitch) as the + * original's glyphs ink: none, ·, •, ●. + */ +const DOT_ROWS = 46 +const DOT_MIN = 4 +const DOT_MAX = 10 +const DOT_R = [0, 0.17, 0.3, 0.47] as const +/** How much of a full dot each step inks, and the 4 × 4 ordered dither between steps (the original's). */ +const COVER = [0, 0.3, 0.6, 1] as const +const BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(v => v / 16 - 0.47) +/** The original's palette: the ground's greys, the night's blues, the Earth's seas, clouds, land. */ +const PALETTE = [ + 0x18181b, 0x232326, 0x303033, 0x414143, 0x555556, 0x6b6a69, 0x83817d, 0x9c9993, 0xb6b2aa, 0xcfcac1, 0xe6e1d8, 0xf7f4ee, + 0x0c1120, 0x131a2e, 0x1b2540, 0x262f4a, 0xdfe9ff, 0x0a2259, 0x0f2f72, 0x15408c, 0x1d53a6, 0x2a69bf, 0x4386d3, 0x6eaeea, + 0xa8d3f6, 0xe8f0fa, 0xc2d0e3, 0x8b9fbc, 0x2f4a26, 0x3b5a2c, 0x5b7238, 0x7a9150, 0x77783f, 0x8f8550, 0xa8955e, 0x6b5634, + 0xb9774a, 0x8a4838, +] as const + +// --- the living sky ---------------------------------------------------------- + +/** + * The star field's layers, far to near: a lattice `cell` wide (the + * original's units) with a star in about `dens` of each unit of sky at level + * 1 and (1 + `more`) times that at 10, drifting `speed` times the sky's + * pace, `lo`..`hi` bright. + */ +const STAR_LAYERS = [ + { cell: 4, dens: 0.0032, more: 2.6, speed: 0.35, lo: 0.17, hi: 0.45, salt: 101 }, + { cell: 6, dens: 0.0016, more: 2.2, speed: 0.7, lo: 0.24, hi: 0.7, salt: 211 }, + { cell: 12, dens: 0.0005, more: 1.4, speed: 1.4, lo: 0.45, hi: 1.05, salt: 307 }, +] as const +/** The sky's drift (the original's units a second) at level 1 and the most added at 10, leftward and a little down. */ +const DRIFT_CALM = 0.03 +const DRIFT_BUSY = 5 +const DRIFT_DOWN = 0.12 +/** Shooting stars a second at level 1 and at 10 (eased between as the square of the level), and with each subagent's boost. */ +const METEOR_CALM = 0.015 +const METEOR_BUSY = 0.9 +/** Comets a second, from about level 3 up to 10, and at most this many in the sky at once (one below about 6). */ +const COMET_BUSY = 0.055 +const COMETS_MAX = 2 +/** Toward the sun on the screen (column right, row down): a comet's tails stream the other way. */ +const SUN_SCREEN = (() => { + const l = Math.hypot(SUN[0], SUN[1]) + return [SUN[0] / l, -SUN[1] / l] as const +})() + +type Meteor = { x: number; y: number; ux: number; uy: number; speed: number; age: number; life: number; len: number; b: number } +type Comet = { x: number; y: number; vx: number; vy: number; age: number; life: number; len: number; b: number; curl: number } + +/** Where sky light is gathered: sample (x, y) of `w` × `h` is centred on (col0 + (x + ½)·fx, row0 + (y + ½)·fy). */ +type View = { col0: number; row0: number; fx: number; fy: number; w: number; h: number } +/** The widest view, in radians: a wider band is squeezed into it rather than looking round behind. */ +const SPAN = 2.8 +/** The spine: the Earth this much of the width, its centre this far down. */ +const EARTH_W = 0.74 +const EARTH_DOWN = 0.42 + +/** The ground's greys, dark to light (the original's palette). */ +const GROUND = [0x0b0b0d, 0x18181b, 0x232326, 0x303033, 0x414143, 0x555556, 0x6b6a69, 0x83817d, 0x9c9993, 0xb6b2aa, 0xcfcac1, 0xe6e1d8, 0xf7f4ee] as const +/** Steps the ground's brightness is held to: Raster paints at most 1024 colour pairs a frame. */ +const GROUND_STEPS = 28 +/** A pall of moondust (smoke). */ +const DUST = 0x77746f +/** At most this many ground samples down each pixel, and the Earth's across and down (fewer where a pixel is small). */ +const GROUND_SUB = 3 +const EARTH_SUB_X = 2 +const EARTH_SUB_Y = 3 +/** Toward the sun, for the Earth alone: a little more behind us than the ground's, so the globe shows a fuller face. */ +const EARTH_SUN = (() => { + const v = [0.82, 0.16, -0.75] + const l = Math.hypot(...v) + return v.map(c => c / l) as [number, number, number] +})() + +// --- noise ------------------------------------------------------------------- + +/** Value noise that can wrap in x every `period` (0: never), as the globe's maps need. */ +function noise(x: number, y: number, period: number): number { + const xi = Math.floor(x) + const yi = Math.floor(y) + const fx = x - xi + const fy = y - yi + const u = fx * fx * (3 - 2 * fx) + const v = fy * fy * (3 - 2 * fy) + let x0 = xi + let x1 = xi + 1 + if (period) { + x0 = ((xi % period) + period) % period + x1 = (x0 + 1) % period + } + const a = hash(x0, yi) + const b = hash(x1, yi) + const c = hash(x0, yi + 1) + const d = hash(x1, yi + 1) + return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v +} + +function fbm(x: number, y: number, octaves: number, period: number): number { + let s = 0 + let n = 0 + let amp = 0.5 + let f = 1 + for (let i = 0; i < octaves; i++) { + s += amp * noise(x * f, y * f, period * f) + n += amp + amp *= 0.5 + f *= 2 + } + return s / n +} + +// --- the ground -------------------------------------------------------------- + +/** A bowl with a raised rim, r in ground units, q its distance in radii. */ +const bowl = (q: number, r: number): number => r * ((q < 1 ? -0.36 * (1 - q * q) : 0) + 0.14 * Math.exp(-(((q - 1) / 0.25) ** 2))) + +/** Craters scattered one to a cell. */ +function craters(X: number, Z: number, cell: number, salt: number, r0: number, r1: number, p: number): number { + const ci = Math.floor(X / cell) + const cj = Math.floor(Z / cell) + let h = 0 + for (let j = cj - 1; j <= cj + 1; j++) { + for (let i = ci - 1; i <= ci + 1; i++) { + if (hash(i + salt, j - salt) > p) continue + const cx = (i + hash(i, j + salt * 3)) * cell + const cz = (j + hash(i + salt * 5, j)) * cell + const e = hash(j + salt, i - salt * 7) + const r = cell * (r0 + (r1 - r0) * e * e) + const dx = X - cx + const dz = Z - cz + const d2 = dx * dx + dz * dz + if (d2 > 4 * r * r) continue + h += bowl(Math.sqrt(d2) / r, r) * 0.95 + } + } + return h +} + +/** A few big boulders in the near ground: [x, z, radius]. */ +const ROCKS = [[30, 50, 2.4], [62, 63, 1.8], [12, 70, 1.3], [-4, 45, 1.2], [90, 55, 1.6], [46, 90, 1.4]] as const + +function height(X: number, Z: number): number { + let h = 6 * fbm(X * 0.006 + 50, Z * 0.006 + 50, 3, 0) + 0.6 * fbm(X * 0.04, Z * 0.04, 2, 0) + // Old, worn highlands at the edge of sight, rising to the left, and a lower ridge in front of them. + if (Z > 150) { + const lift = smooth(150, 300, Z) + const m = 1 - Math.abs(2 * fbm(X * 0.006 + 7, Z * 0.006, 4, 0) - 1) + h += lift * (28 * Math.exp(-(((X + 260) / 230) ** 2)) + 45 * (m * m - 0.35)) + const ridge = Math.exp(-(((Z - 205) / 20) ** 2)) * Math.exp(-(((X + 140) / 130) ** 2)) + h += ridge * 22 * (0.35 + fbm(X * 0.018 + 3, Z * 0.01, 3, 0)) + } + // Big basins only out in the middle distance, so we do not stand in one. + if (Z > 90) h += craters(X, Z, 110, 11, 0.14, 0.34, 0.55) * smooth(90, 150, Z) + if (Z < 300) h += craters(X, Z, 34, 23, 0.12, 0.36, 0.85) + if (Z < 170) h += craters(X, Z, 10, 37, 0.12, 0.34, 0.85) * smooth(170, 110, Z) + // One big crater in the near ground, off to the left. + { + const dx = X + 24 + const dz = Z - 58 + const q = Math.sqrt(dx * dx + dz * dz) / 16 + if (q < 2) h += bowl(q, 16) + } + // Boulders strewn close by, and a few big ones. + if (Z < 90) { + const c = 5 + const ci = Math.floor(X / c) + const cj = Math.floor(Z / c) + if (hash(ci + 91, cj) < 0.14) { + const bx = (ci + 0.2 + 0.6 * hash(ci, cj + 92)) * c + const bz = (cj + 0.2 + 0.6 * hash(ci + 93, cj)) * c + const br = 0.3 + 0.5 * hash(ci + 94, cj + 95) ** 2 + const d2 = ((X - bx) ** 2 + (Z - bz) ** 2) / (br * br) + if (d2 < 4) h += br * 0.9 * Math.exp(-d2 * 1.4) + } + for (const [bx, bz, br] of ROCKS) { + const d2 = ((X - bx) ** 2 + (Z - bz) ** 2) / (br * br) + if (d2 < 1) h += br * (0.85 + 0.3 * noise(X * 1.3, Z * 1.3, 0)) * Math.sqrt(1 - d2) + } + } + return h +} + +/** The camera's height. */ +const camY = (() => { + let y: number | undefined + return () => (y ??= height(0, 7) + CAM_H) +})() + +/** The row (in the original's view) the skyline falls at, looking `ang` radians right of ahead. */ +function skyline(ang: number): number { + const sx = Math.sin(ang) + const cz = Math.cos(ang) + let top = Infinity + for (let D = 20; D < ZMAX; D += 0.03 + D * 0.012) { + const Y = height(sx * D, cz * D) - (D * D) / (2 * RM) + top = Math.min(top, EYE - (F * (Y - camY())) / D) + } + return top +} + +/** How bright the ground is at (X, Z), height Y, from the camera at height camY; `crest` near the far skyline. */ +function groundLight(X: number, Z: number, Y: number, camY: number, crest: number): number { + const e = 0.1 + Z * 0.004 + const hx = (height(X + e, Z) - height(X - e, Z)) / (2 * e) + const hz = (height(X, Z + e) - height(X, Z - e)) / (2 * e) + const nl = Math.hypot(hx, 1, hz) + const lam = (-hx * SUN[0] + SUN[1] - hz * SUN[2]) / nl + // Toward the eye, for the moon's own way of reflecting (Lommel-Seeliger). + const vx = -X + const vy = camY - Y + const vz = -Z + const vl = Math.hypot(vx, vy, vz) + const mu = Math.max(0.02, (-hx * vx + vy - hz * vz) / (nl * vl)) + let lit = 0 + if (lam > 0) { + // March toward the sun; a soft edge for the sun's own width. Each point starts its steps a little + // differently, so the shadows' edges don't step with the march. + lit = 1 + let s = (0.1 + Z * 0.003) * (0.6 + 0.8 * hash(Math.floor(X * 37), Math.floor(Z * 37), 41)) + while (s < 120) { + const d = Y + SUN[1] * s - height(X + SUN[0] * s, Z + SUN[2] * s) + if (d < 0) { + lit = 0 + break + } + lit = Math.min(lit, (d * 30) / s) + s += 0.05 + s * 0.2 + } + lit = smooth(0, 1, lit) + } + // Regolith: patchy, the maria darker. + const albedo = + 0.5 + + 0.9 * fbm(X * 0.025 + 3, Z * 0.025, 3, 0) + + 0.3 * (fbm(X * 0.35, Z * 0.35, 2, 0) - 0.5) - + 0.22 * smooth(0.46, 0.64, fbm(X * 0.0035, Z * 0.0035 + 20, 3, 0)) + const ls = lam > 0 ? ((0.2 * lam) / (lam + mu) + 2.6 * lam) * lit : 0 + let b = (1 - Math.exp(-ls * 1.5)) * albedo + // The far crest catches the sun along its whole length. + if (crest < 2 && lit > 0.2) b = Math.max(b, (crest < 1 ? 0.85 : 0.55) * albedo) + return b +} + +// --- the Earth's maps -------------------------------------------------------- + +/** Equirectangular maps, wrapping in longitude: surface colour, open sea, cloud. */ +const TW = 192 +const TH = 96 +type Maps = { r: Float32Array; g: Float32Array; b: Float32Array; sea: Float32Array; cloud: Float32Array } +let maps: Maps | undefined + +/** The globe's maps: made once, on first use, and shared. */ +function earthMaps(): Maps { + if (maps) return maps + const n = TW * TH + const tr = new Float32Array(n) + const tg = new Float32Array(n) + const tb = new Float32Array(n) + const sea = new Float32Array(n) + const cloud = new Float32Array(n) + const elev = new Float32Array(n) + const storms = [[0.9, 0.8, 1], [3.1, -0.85, -1], [4.6, 0.62, 1], [1.9, 0.25, 1], [5.6, -0.55, -1]] as const + for (let j = 0; j < TH; j++) { + const v = (j + 0.5) / TH + const lat = (0.5 - v) * Math.PI + const al = Math.abs(lat) + for (let i = 0; i < TW; i++) { + const u = i / TW + const lon = u * Math.PI * 2 + const t = j * TW + i + const wx = fbm(u * 6, v * 3 + 9, 3, 6) + elev[t] = fbm(u * 8 + 1.6 * wx, v * 4, 5, 8) - 0.04 * smooth(1.2, 1.5, al) + // Clouds: warped noise, wound into spirals around a few storms. + let cx = u * 14 + let cy = v * 7 + for (const [slon, slat, spin] of storms) { + let dl = lon - slon + dl -= Math.round(dl / (Math.PI * 2)) * Math.PI * 2 + const lx = dl * Math.cos(lat) + const ly = lat - slat + const a = spin * 5 * Math.exp(-Math.hypot(lx, ly) / 0.2) + if (a * spin > 0.02) { + const ca = Math.cos(a) + const sa = Math.sin(a) + cx += ((lx * ca - ly * sa - lx) / (Math.PI * 2)) * 14 + cy -= ((lx * sa + ly * ca - ly) / Math.PI) * 7 + } + } + // Streaked along the winds, folded into filaments the way fronts string out. + const q = fbm(cx * 0.5 + 3, cy * 1.2, 3, 7) + const n0 = fbm(cx + 1.6 * q, cy * 1.3 + 0.5 * q, 5, 14) + const nn = 0.4 * n0 + 0.6 * (1 - Math.abs(2 * fbm(cx * 1.5 + 2.2 * q, cy * 1.6 + 9, 4, 21) - 1)) + // Cloudy at the equator and in the storm belts, clearer in the subtropics. + const belt = 0.05 * Math.exp(-((lat / 0.12) ** 2)) - 0.07 * Math.exp(-(((al - 0.42) / 0.16) ** 2)) + 0.05 * Math.exp(-(((al - 0.95) / 0.25) ** 2)) + cloud[t] = nn + belt + } + } + // Thresholds by share of the globe: about three tenths land, a third cloud. + const quantile = (A: Float32Array, p: number): number => Float32Array.from(A).sort()[Math.floor(p * (A.length - 1))]! + const shore = quantile(elev, 0.7) + const c0 = quantile(cloud, 0.6) + const c1 = quantile(cloud, 0.86) + for (let j = 0; j < TH; j++) { + const v = (j + 0.5) / TH + const al = Math.abs((0.5 - v) * Math.PI) + for (let i = 0; i < TW; i++) { + const u = i / TW + const t = j * TW + i + const e = elev[t]! + const land = smooth(shore - 0.004, shore + 0.006, e) + const ice = smooth(1.22, 1.32, al + 0.12 * fbm(u * 12, v * 6, 2, 12)) + // Desert in the subtropics and on high ground, forest and scrub elsewhere, broken up finely. + const grain = fbm(u * 36 + 2, v * 18, 3, 36) - 0.5 + const arid = clamp(Math.exp(-(((al - 0.4) / 0.22) ** 2)) * (0.1 + 1.2 * fbm(u * 10 + 4, v * 5, 3, 10)) + (e - shore - 0.04) * 4 + 0.8 * grain) + const relief = 1 + 1.2 * grain - 2.5 * Math.max(0, e - shore - 0.08) + let r = lerp(0.13, 0.4, arid) * relief + let g = lerp(0.25, 0.34, arid) * relief + let b = lerp(0.08, 0.19, arid) * relief + // Ocean, lighter over the shelves near the coasts. + const shelf = smooth(shore - 0.06, shore, e) + r = lerp(lerp(0.025, 0.07, shelf), r, land) + g = lerp(lerp(0.11, 0.3, shelf), g, land) + b = lerp(lerp(0.38, 0.62, shelf), b, land) + tr[t] = lerp(r, 0.92, ice) + tg[t] = lerp(g, 0.95, ice) + tb[t] = lerp(b, 0.99, ice) + sea[t] = (1 - land) * (1 - ice) + cloud[t] = smooth(c0, c1, cloud[t]!) + } + } + maps = { r: tr, g: tg, b: tb, sea, cloud } + return maps +} + +const lerp = (a: number, b: number, k: number): number => a + (b - a) * k + +/** A map at longitude `lon` and map row `vy`, bilinear, wrapping round. */ +function sample(A: Float32Array, lon: number, vy: number): number { + const fx = ((((lon / (Math.PI * 2)) % 1) + 1) % 1) * TW + const x0 = Math.floor(fx) % TW + const ax = fx - Math.floor(fx) + const x1 = (x0 + 1) % TW + const y0 = Math.max(0, Math.min(TH - 2, Math.floor(vy))) + const ay = clamp(vy - y0) + const a = A[y0 * TW + x0]! + const b = A[y0 * TW + x1]! + const c = A[y0 * TW + TW + x0]! + const d = A[y0 * TW + TW + x1]! + return a + (b - a) * ax + (c - a) * ay + (a - b - c + d) * ax * ay +} + +/** Toward the sun in screen space (z toward us), and halfway to the eye, for the sea's glint. */ +const L = [EARTH_SUN[0], EARTH_SUN[1], -EARTH_SUN[2]] as const +const HV = (() => { + const v = [L[0], L[1], L[2] + 1] + const l = Math.hypot(...v) + return v.map(c => c / l) as [number, number, number] +})() +const TILT = 0.4 +const NOD = 0.22 +const CT = Math.cos(TILT) +const ST = Math.sin(TILT) +const CN = Math.cos(NOD) +const SN = Math.sin(NOD) + +/** + * One point of the sky round the Earth, (dx, dy) from its centre over its + * radius `er` (the original's cells, its radius ER), over the sky's light + * (br, bg, bb): [r, g, b, alpha, cap, floor] into `out`, the last two for a + * halftone dot (how bright its colour may go, how big it must be at least). + */ +function earthAt(dx: number, dy: number, er: number, spin: number, drift: number, out: Float32Array, br = 0, bg = 0, bb = 0): void { + const m = earthMaps() + // In the original's measure, whatever the size it's drawn at. + dx *= ER / er + dy *= ER / er + let cr = br + let cg = bg + let cb = bb + let a = 0 + let cap = 1 + let floor = 0 + const d = Math.hypot(dx, dy) + // The Earth's air: a thin blue rim on its sunlit side. + if (d >= ER - 1 && d < ER + 12) { + const side = smooth(-0.3, 0.75, (dx * L[0] - dy * L[1]) / d) + let g = Math.exp(-Math.max(0, d - ER) / 1.2) * 0.55 * side + if (g < 0.05) g = 0 + cr += 0.25 * g + cg += 0.52 * g + cb += 1.0 * g + a = Math.max(a, clamp(g * 3)) + } + if (d < ER) { + const nx = dx / ER + const ny = -dy / ER + const q2 = nx * nx + ny * ny + const nz = Math.sqrt(1 - q2) + // Into the globe's own frame: tip the pole toward us, then lean it. + const ax = nx * CT + ny * ST + const ay0 = -nx * ST + ny * CT + const ay = ay0 * CN - nz * SN + const az = ay0 * SN + nz * CN + const lat = Math.asin(clamp(ay, -1, 1)) + const lon = Math.atan2(ax, az) + LON0 + spin + const vy = (0.5 - lat / Math.PI) * TH - 0.5 + const ndl = nx * L[0] + ny * L[1] + nz * L[2] + // Full sun at the right limb, dimming toward the terminator, so the disc reads as a ball. + const day = smooth(-0.005, 0.06, ndl) * (0.45 + 0.8 * Math.sqrt(clamp(ndl))) + const dusk = Math.exp(-(((ndl - 0.02) / 0.035) ** 2)) + const cl = sample(m.cloud, lon + drift, vy) + // The cloud's own shadow, offset away from the sun. + const sh = sample(m.cloud, lon + drift - 0.03, vy + 0.4) + const sw = sample(m.sea, lon, vy) + const shade = 1 - 0.45 * sh * (1 - cl) + let er = lerp(sample(m.r, lon, vy) * shade, 0.95, cl) + let eg = lerp(sample(m.g, lon, vy) * shade, 0.97, cl) + let eb = lerp(sample(m.b, lon, vy) * shade, 1.0, cl) + er *= day * (1 + 0.06 * dusk) + eg *= day * (1 - 0.03 * dusk) + eb *= day * (1 - 0.1 * dusk) + const glint = Math.pow(Math.max(0, nx * HV[0] + ny * HV[1] + nz * HV[2]), 70) * 0.8 * sw * (1 - cl) + const rim = Math.pow(1 - nz, 2.2) * smooth(0, 0.3, ndl) + er += glint * 0.95 + rim * 0.2 + eg += glint * 0.92 + rim * 0.42 + eb += glint * 0.85 + rim * 0.85 + // A crisp edge where the disc meets space; the night side is a void. + const edge = smooth(1, 0.95, Math.sqrt(q2)) + cr = lerp(cr, er, edge) + cg = lerp(cg, eg, edge) + cb = lerp(cb, eb, edge) + a = lerp(a, clamp(Math.max(er, eg, eb) * 12), edge) + // Land keeps its earth tones rather than washing out to cream; the day side, the brightest thing in the sky, full round dots. + cap = lerp(1, 0.66, (1 - sw) * (1 - cl) * smooth(0.95, 0.85, Math.abs(ay))) + floor = 0.15 * Math.min(1, day) * edge + } + out[0] = cr + out[1] = cg + out[2] = cb + out[3] = a + out[4] = cap + out[5] = floor +} + +/** A colour (0..1 a channel) as the palette's nearest, by a 32³ table filled as it's asked. */ +const nearestLut = new Int16Array(32768).fill(-1) +function nearest(r: number, g: number, b: number): number { + const k = (Math.min(31, (clamp(r) * 31.99) | 0) << 10) | (Math.min(31, (clamp(g) * 31.99) | 0) << 5) | Math.min(31, (clamp(b) * 31.99) | 0) + const hit = nearestLut[k]! + if (hit >= 0) return PALETTE[hit]! + let best = 0 + let bd = Infinity + for (let i = 0; i < PALETTE.length; i++) { + const c = PALETTE[i]! + const dr = ((c >> 16) & 255) / 255 - r + const dg = ((c >> 8) & 255) / 255 - g + const db = (c & 255) / 255 - b + const d = 0.3 * dr * dr + 0.5 * dg * dg + 0.2 * db * db + if (d < bd) { + bd = d + best = i + } + } + nearestLut[k] = best + return PALETTE[best]! +} + +/** The ink of each dot step at `p` pixels a dot: how much of each of its p × p pixels it covers (4 × 4 samples a pixel). */ +function dotStamps(p: number): Float32Array[] { + return DOT_R.map(rf => { + const st = new Float32Array(p * p) + const r = rf * p + const c = p / 2 + for (let y = 0; y < p; y++) { + for (let x = 0; x < p; x++) { + let n = 0 + for (let j = 0; j < 4; j++) for (let i = 0; i < 4; i++) if (Math.hypot(x + (i + 0.5) / 4 - c, y + (j + 0.5) / 4 - c) < r) n++ + st[y * p + x] = n / 16 + } + } + return st + }) +} + +/** A picture's dot pitch for `h` pixels down it. */ +const dotPitch = (h: number): number => Math.max(DOT_MIN, Math.min(DOT_MAX, Math.round(h / DOT_ROWS))) + +/** Light (0..~1.2 a channel) as a colour, a little lifted, held to 5 bits a channel (`bits`: Raster's colour pairs are few). */ +function lightColor(r: number, g: number, b: number, bits = 0xf8): number { + const c = (v: number) => Math.min(255, Math.round(Math.pow(clamp(v, 0, 1.2) / 1.2, 0.72) * 255 * 1.22)) & bits + return (c(r) << 16) | (c(g) << 8) | c(b) +} + +// --- the scene --------------------------------------------------------------- + +/** + * One way of seeing the world at one size: the original's (col, row) under + * pixel (x, y) is (col0 + x·s, row0 + y·sy), its columns turned by `k`; the + * Earth's centre `ex` and radius `er` in the same units, its centre `eyLow` + * at level 1 and `eyHigh` at 10. The ground under each pixel (its + * brightness, or -1 for sky) is built `done` columns so far; `near` marks + * the sky the Earth and its air can reach, once it's all built. + */ +type Layout = { + pw: number + ph: number + s: number + sy: number + col0: number + row0: number + k: number + ex: number + eyLow: number + eyHigh: number + er: number + ground: Float32Array + near: Uint8Array + done: number + /** Ground samples down each pixel, and their rows. */ + sub: number + at: Float32Array + /** How many steps the ground's brightness is held to. */ + steps: number + /** The row (the original's) the skyline falls at on average, once the ground is in: where shooting stars and comets keep above. */ + horizon: number +} + +/** The ground's sample buffers, shared (one column at a time). */ +let gx = new Float32Array(0) +let gz = new Float32Array(0) +let gy = new Float32Array(0) + +/** A picture: its pixels, its dots' grid (`gw` × `gh`, `p` pixels apart, inset `ox`, `oy`), each dot's light, and the dots' ink. */ +type Canvas = { + w: number + h: number + p: number + gw: number + gh: number + ox: number + oy: number + r: Float32Array + g: Float32Array + b: Float32Array + rgba: Uint8Array + stamps: Float32Array[] +} + +/** Sky light gathered for the cells' specks. */ +type Specks = { r: Float32Array; g: Float32Array; b: Float32Array } + +export class Earthrise extends PixelScene { + /** The view the cells see, and a picture's. */ + private cells: Layout = layout(0, 0, 2, false) + private pic: Layout | undefined + private canvas: Canvas | undefined + private specks: Specks = { r: new Float32Array(0), g: new Float32Array(0), b: new Float32Array(0) } + /** How far the Earth has turned, and its clouds (radians): each one made starts on its own face. */ + private spin = hash1(this.seed) * Math.PI * 2 + private drift = 0 + /** How high the Earth stands, 0 (level 1) .. 1 (level 10), eased; NaN until the first frame. */ + private alt = Number.NaN + /** Smoke and blue, eased (0..1). */ + private kSmoke = 0 + private kBlue = 0 + private readonly rgba = new Float32Array(6) + /** How far the sky has drifted (the original's units, at the pace of a layer of speed 1), and what's crossing it. */ + private skyOff = hash1(this.seed + 7) * 1000 + private readonly meteors: Meteor[] = [] + private readonly comets: Comet[] = [] + private readonly rng = new Rng(Math.floor(hash1(this.seed + 13) * 0x7fffffff)) + /** The frame a picture was last asked for: the sky's visitors cross the view that's on show. */ + private picT = -1e9 + /** What happened on screen, to be heard (taken by the adapter each frame). */ + sounds: SoundEvent[] = [] + + protected resize(d: Dials): void { + this.cells = layout(d.columns * 2, d.rows * 2, 2, d.tall) + build(this.cells, Infinity) + const n = d.columns * 2 * d.rows * 4 + this.specks = { r: new Float32Array(n), g: new Float32Array(n), b: new Float32Array(n) } + } + + protected update(d: Dials): void { + // The level is how high it stands and how fast it turns; waiting on the person, it holds and stops. + const busy = clamp((d.level - 1) / 9) + if (Number.isNaN(this.alt)) this.alt = busy + else if (d.wait < 0.5) this.alt += clamp(busy - this.alt, -CLIMB, CLIMB) + const still = 1 - d.wait + const rate = SPIN_CALM * Math.pow(SPIN_BUSY / SPIN_CALM, busy) * still + const dt = FRAME_MS / 1000 + this.spin += rate * dt + this.drift += rate * CLOUD_AHEAD * dt + this.kSmoke = clamp(this.kSmoke + clamp((d.tint === 'smoke' ? 1 : 0) - this.kSmoke, -0.05, 0.05)) + this.kBlue = clamp(this.kBlue + clamp((d.tint === 'blue' ? 1 : 0) - this.kBlue, -0.04, 0.04)) + this.moveSky(d, dt, still) + } + + /** The sky drifts with the work, and shooting stars and comets come and go across the view on show. */ + private moveSky(d: Dials, dt: number, still: number): void { + const busy = this.alt + this.skyOff += (DRIFT_CALM + DRIFT_BUSY * Math.pow(busy, 1.7)) * still * dt + const L = this.pic && d.t - this.picT < 30 ? this.pic : this.cells + if (L.pw <= 0) return + const c0 = L.col0 + const c1 = L.col0 + L.pw * L.s + const r0 = L.row0 + const r1 = Math.max(r0 + 4, L.horizon) + const rng = this.rng + + for (let i = this.meteors.length - 1; i >= 0; i--) { + const m = this.meteors[i]! + m.age += dt + m.x += m.ux * m.speed * dt + m.y += m.uy * m.speed * dt + if (m.age >= m.life) this.meteors.splice(i, 1) + } + const meteors = (METEOR_CALM + METEOR_BUSY * busy * busy) * (1 + d.boost / 30) * still + if (this.meteors.length < 6 && rng.f() < meteors * dt) { + const a = 0.2 + 0.55 * rng.f() + const dir = rng.f() < 0.65 ? -1 : 1 + this.meteors.push({ + x: c0 + (c1 - c0) * rng.f(), + y: r0 + (r1 - r0) * 0.6 * rng.f(), + ux: dir * Math.cos(a), + uy: Math.sin(a), + speed: 45 + 60 * rng.f(), + age: 0, + life: 0.3 + 0.5 * rng.f(), + len: 6 + 9 * rng.f(), + b: 0.7 + 0.6 * rng.f(), + }) + // (Brighter, louder; drawn from what it already drew, so the sky goes as it did.) + hear(this.sounds, { kind: 'streak', v: clamp((this.meteors[this.meteors.length - 1]!.b - 0.6) / 0.7) }) + } + + for (let i = this.comets.length - 1; i >= 0; i--) { + const c = this.comets[i]! + c.age += dt * still + c.x += c.vx * dt * still + c.y += c.vy * dt * still + if (c.age >= c.life) this.comets.splice(i, 1) + } + const comets = COMET_BUSY * smooth(0.2, 1, busy) * still + const room = busy > 0.55 ? COMETS_MAX : 1 + if (this.comets.length < room && rng.f() < comets * dt) { + const a = (rng.f() - 0.5) * 0.7 + (rng.f() < 0.5 ? 0 : Math.PI) + const v = 1.5 + 2.5 * rng.f() + hear(this.sounds, { kind: 'comet', v: 1 }) + this.comets.push({ + x: c0 + (c1 - c0) * (0.12 + 0.76 * rng.f()), + y: r0 + (r1 - r0) * (0.12 + 0.4 * rng.f()), + vx: Math.cos(a) * v, + vy: Math.sin(a) * v * 0.4, + age: 0, + life: 18 + 16 * rng.f(), + len: 16 + 20 * rng.f(), + b: 0.75 + 0.35 * rng.f(), + curl: (rng.f() < 0.5 ? -1 : 1) * (0.002 + 0.003 * rng.f()), + }) + } + } + + /** + * This frame's sky into r, g, b (one value per sample of `v`, added to): + * the drifting stars (spread over the four samples round each when `soft`, + * else in the nearest), the shooting stars and the comets. + */ + private sky(v: View, R: Float32Array, G: Float32Array, B: Float32Array, soft: boolean, tSec: number): void { + const { col0, row0, fx, fy, w, h } = v + const busy = this.alt + const dim = 1 - 0.6 * this.kSmoke + const add = (col: number, row: number, k: number, tr: number, tg: number, tb: number) => { + const px = (col - col0) / fx - 0.5 + const py = (row - row0) / fy - 0.5 + if (soft) { + const x0 = Math.floor(px) + const y0 = Math.floor(py) + const ax = px - x0 + const ay = py - y0 + for (let j = 0; j < 2; j++) { + const y = y0 + j + if (y < 0 || y >= h) continue + const wy = j ? ay : 1 - ay + for (let i = 0; i < 2; i++) { + const x = x0 + i + if (x < 0 || x >= w) continue + const q = k * wy * (i ? ax : 1 - ax) + const o = y * w + x + R[o]! += q * tr + G[o]! += q * tg + B[o]! += q * tb + } + } + return + } + const x = Math.round(px) + const y = Math.round(py) + if (x < 0 || y < 0 || x >= w || y >= h) return + const o = y * w + x + R[o]! += k * tr + G[o]! += k * tg + B[o]! += k * tb + } + + // Stars, layer by layer: a lattice in the sky's own coordinates, drifting by. + for (const S of STAR_LAYERS) { + const ox = this.skyOff * S.speed + const oy = ox * DRIFT_DOWN + const u0 = col0 + ox + const v0 = row0 + oy + const C = S.cell + const p0 = S.dens * C * C * (1 + S.more * busy) + const twinkling = 0.12 + 0.35 * busy + for (let j = Math.floor(v0 / C); j <= Math.floor((v0 + h * fy) / C); j++) { + for (let i = Math.floor(u0 / C); i <= Math.floor((u0 + w * fx) / C); i++) { + const h0 = hash(i, j, S.salt) + let p = p0 + if (S.salt === STAR_LAYERS[0].salt) { + // The galaxy: bands across the far layer, thick with faint stars. + let q = j * C - 0.32 * i * C - 4 + q -= 170 * Math.round(q / 170) + p *= 1 + 3 * Math.exp(-((q / 10.5) ** 2)) * smooth(0.35, 0.65, fbm(i * C * 0.05, j * C * 0.08, 3, 0)) + } + if (h0 >= p) continue + // Coming out as the level climbs, not popping. + const fade = clamp((p - h0) / (0.3 * p0)) + const col = (i + hash(i, j, S.salt + 1)) * C - ox + const row = (j + hash(i, j, S.salt + 2)) * C - oy + let k = (S.lo + (S.hi - S.lo) * Math.pow(hash(i, j, S.salt + 3), 3)) * fade * dim * (1 + 0.35 * busy) + const tw = hash(i, j, S.salt + 4) + if (tw > 1 - twinkling) k *= 0.5 + 0.5 * Math.sin(((tSec * (1 + 1.5 * busy)) / (2 + 3 * hash(i, j, S.salt + 5))) * Math.PI * 2 + tw * 50) + const tint = hash(i, j, S.salt + 6) + const [tr, tg, tb] = tint < 0.3 ? [0.84, 0.9, 1] : tint > 0.88 ? [1, 0.93, 0.84] : [0.96, 0.96, 0.98] + add(col, row, k, tr, tg, tb) + // The brightest carry a faint cross, as the original's do. + if (soft && k > 0.7) { + add(col - fx, row, k * 0.2, tr, tg, tb) + add(col + fx, row, k * 0.2, tr, tg, tb) + add(col, row - fy, k * 0.2, tr, tg, tb) + add(col, row + fy, k * 0.2, tr, tg, tb) + } + } + } + } + + // What crosses the sky: lit where it falls on a sample, within its reach. + const field = (x0: number, x1: number, y0: number, y1: number, at: (col: number, row: number) => void) => { + const xa = Math.max(0, Math.floor((x0 - col0) / fx)) + const xb = Math.min(w - 1, Math.ceil((x1 - col0) / fx)) + const ya = Math.max(0, Math.floor((y0 - row0) / fy)) + const yb = Math.min(h - 1, Math.ceil((y1 - row0) / fy)) + for (let y = ya; y <= yb; y++) for (let x = xa; x <= xb; x++) at(col0 + (x + 0.5) * fx, row0 + (y + 0.5) * fy) + } + for (const m of this.meteors) { + // A streak: the head, and a tail as long as it's flown, fading back; in and out quickly. + const tl = Math.max(0.5, Math.min(m.len, m.speed * m.age)) + const wd = Math.max(0.45, 0.6 * Math.min(fx, fy)) + const env = Math.min(1, m.age / 0.08) * (1 - smooth(0.55 * m.life, m.life, m.age)) * m.b * dim + const tx = m.x - m.ux * tl + const ty = m.y - m.uy * tl + field(Math.min(m.x, tx) - 2, Math.max(m.x, tx) + 2, Math.min(m.y, ty) - 2, Math.max(m.y, ty) + 2, (col, row) => { + const back = -((col - m.x) * m.ux + (row - m.y) * m.uy) + if (back < -wd * 2 || back > tl + wd) return + const perp = (col - m.x) * m.uy - (row - m.y) * m.ux + const along = back < 0 ? Math.exp(-((back / wd) ** 2)) : Math.pow(1 - clamp(back / tl), 1.6) + const k = env * along * Math.exp(-((perp / wd) ** 2)) + if (k < 0.01) return + add(col, row, k, 0.92, 0.96, 1) + }) + } + const [sx, sy] = SUN_SCREEN + for (const c of this.comets) { + // A comet: a bright head in its coma, a straight blue ion tail away from the sun, a wider dust tail curving off it. + const env = smooth(0, 3, c.age) * (1 - smooth(c.life - 4, c.life, c.age)) * c.b * dim + if (env <= 0) continue + const reach = c.len * 1.1 + 4 + const ux = -sx + const uy = -sy + field(c.x - reach, c.x + reach, c.y - reach * 0.5, c.y + reach * 0.5, (col, row) => { + const dx = col - c.x + const dy = row - c.y + const r2 = dx * dx + dy * dy + let kr = 1.3 * Math.exp(-r2 / 0.35) + 0.6 * Math.exp(-r2 / 5) + let kg = kr + let kb = kr + const a = dx * ux + dy * uy + if (a > 0) { + const p = dx * uy - dy * ux + const wi = 0.5 + 0.022 * a + const ion = 0.85 * Math.exp(-a / (0.6 * c.len)) * Math.exp(-((p / wi) ** 2)) * smooth(0, 2, a) + const pd = p - c.curl * a * a + const wd = 0.8 + 0.1 * a + const dust = 0.8 * Math.exp(-a / (0.4 * c.len)) * Math.exp(-((pd / wd) ** 2)) + kr += ion * 0.62 + dust * 1 + kg += ion * 0.82 + dust * 0.95 + kb += ion * 1.05 + dust * 0.82 + } + const m = Math.max(kr, kg, kb) * env + if (m < 0.015) return + add(col, row, env, kr, kg, kb) + }) + } + } + + /** A colour as the tints show it now. */ + private tinted(c: number): number { + if (this.kSmoke > 0) c = mix(grey(c, 0.9), 0x8a8682, this.kSmoke * 0.5 * (1 - luma(c) / 255)) + if (this.kBlue > 0) { + const v = luma(c) / 255 + const cold = ((Math.round(v * 40) & 255) << 16) | ((Math.round(14 + v * 100) & 255) << 8) | Math.round(60 + v * 195) + c = mix(c, cold, this.kBlue * 0.85) + } + return c + } + + /** The ground, the moondust and the Earth into the cells' pixels (CLEAR left for the open sky). */ + private paintWorld(L: Layout, px: Int32Array, d: Dials): void { + const { pw, ph, s, sy, col0, row0, ex, er, steps } = L + const tSec = (d.t * FRAME_MS) / 1000 + const ey = lerp(L.eyLow, L.eyHigh, Number.isNaN(this.alt) ? clamp((d.level - 1) / 9) : this.alt) + + // The ground, as built. + for (let i = 0; i < pw * ph; i++) { + const b = L.ground[i]! + if (b < 0) continue + const q = Math.round(Math.pow(clamp(b), 0.85) * steps) / steps + px[i] = this.tinted(rampAt(GROUND, q)) + } + + // Moondust (smoke): a grey pall over the horizon, drifting. + if (this.kSmoke > 0) { + for (let x = 0; x < pw; x++) { + let sky = 0 + while (sky < ph && L.ground[sky * pw + x]! < 0) sky++ + for (let y = 0; y < sky; y++) { + const k = this.dust(L, x, y, sky, tSec) + if (k <= 0.12) continue + px[y * pw + x] = mix(0x1c1b1a, DUST, Math.round(k * 6) / 6) + } + } + } + + // The Earth and its air. + const out = this.rgba + const nx = Math.max(1, Math.min(EARTH_SUB_X, Math.round(s))) + const ny = Math.max(1, Math.min(EARTH_SUB_Y, Math.round(sy))) + const sw = s / nx + const shh = sy / ny + const pall = this.kSmoke + for (let y = 0; y < ph; y++) { + for (let x = 0; x < pw; x++) { + const i = y * pw + x + if (!L.near[i]) continue + let r = 0 + let g = 0 + let b = 0 + let a = 0 + for (let jy = 0; jy < ny; jy++) { + const dy = row0 + y * sy + (jy + 0.5) * shh - ey + for (let jx = 0; jx < nx; jx++) { + const dx = col0 + x * s + (jx + 0.5) * sw - ex + earthAt(dx, dy, er, this.spin, this.drift, out) + r += out[0]! + g += out[1]! + b += out[2]! + a += out[3]! + } + } + const m = nx * ny + a /= m + if (a < 0.3) continue + const c = this.tinted(lightColor(r / m / a, g / m / a, b / m / a)) + const under = px[i]! + // Through the pall the Earth shows dimmer. + px[i] = under === CLEAR ? c : mix(under, c, 1 - pall * 0.5) + } + } + } + + /** How thick the moondust (smoke) is at pixel (x, y) of `L`, `sky` pixels of open sky above the ground in its column. */ + private dust(L: Layout, x: number, y: number, sky: number, tSec: number): number { + const up = (sky - y) / Math.max(4, L.ph * 0.5) + const n = fbm((L.col0 + x * L.s) * 0.033 + tSec * 0.4, (L.row0 + y * L.sy) * 0.0625, 2, 0) + return this.kSmoke * clamp(1.3 - up) * (0.55 + 0.45 * n) + } + + paint(px: Painter, d: Dials): void { + const L = this.cells + const { pw, s, sy, col0, row0 } = L + this.paintWorld(L, px.px, d) + const tSec = (d.t * FRAME_MS) / 1000 + + // The sky, as braille specks in the open: the stars drifting by, shooting stars, comets. + const dw = px.dw + const dh = px.dh + const { r: R, g: G, b: B } = this.specks + if (R.length !== dw * dh) return + R.fill(0) + G.fill(0) + B.fill(0) + this.sky({ col0, row0, fx: s, fy: sy / 2, w: dw, h: dh }, R, G, B, false, tSec) + for (let y = 0; y < dh; y++) { + const pyy = y >> 1 + for (let x = 0; x < dw; x++) { + const o = y * dw + x + const v = Math.max(R[o]!, G[o]!, B[o]!) + if (v < 0.16) continue + const i = pyy * pw + x + if (px.px[i] !== CLEAR) continue + const cell = (pyy >> 1) * (pw >> 1) + (x >> 1) + // A cell any of whose pixels are painted keeps clear of specks. + const c0 = (pyy & ~1) * pw + (x & ~1) + if (px.px[c0] !== CLEAR || px.px[c0 + 1] !== CLEAR || px.px[c0 + pw] !== CLEAR || px.px[c0 + pw + 1] !== CLEAR) continue + const star = this.tinted(lightColor(R[o]!, G[o]!, B[o]!) | 0x101010) + // A cell's specks share one colour: the brightest of them. + const prev = px.dots[cell] ? px.dotColor[cell]! : -1 + px.dot(x, y, star) + if (prev >= 0 && luma(prev) > luma(star)) px.dotColor[cell] = prev + } + } + } + + /** + * This frame as a picture `w` × `h` pixels (square), RGBA, the open sky + * transparent: for a terminal that draws images. A halftone, as the + * original draws: a grid of dots, each sized by its light (Bayer-dithered + * between the steps), in the palette's nearest colour, the colour making + * up what the size could not. A new size's ground is built at most + * `budget` dots a call; until it's all built (and at level 0) there is no + * picture: draw `grid()`. + */ + picture(w: number, h: number, budget = Infinity): Uint8Array | undefined { + if (this.strength <= 0 || w < 2 || h < 2) return undefined + const p = dotPitch(h) + const gw = Math.floor(w / p) + const gh = Math.floor(h / p) + if (gw < 2 || gh < 2) return undefined + let L = this.pic + if (!L || L.pw !== gw || L.ph !== gh) L = this.pic = layout(gw, gh, 1, h > w) + if (L.done < gw) { + build(L, budget) + if (L.done < gw) return undefined + } + let cv = this.canvas + if (!cv || cv.w !== w || cv.h !== h) { + const n = gw * gh + cv = this.canvas = { + w, + h, + p, + gw, + gh, + ox: Math.floor((w - gw * p) / 2), + oy: Math.floor((h - gh * p) / 2), + r: new Float32Array(n), + g: new Float32Array(n), + b: new Float32Array(n), + rgba: new Uint8Array(w * h * 4), + stamps: dotStamps(p), + } + } + const d = this.dials() + this.picT = d.t + const tSec = (d.t * FRAME_MS) / 1000 + const { r: R, g: G, b: B, rgba, stamps } = cv + R.fill(0) + G.fill(0) + B.fill(0) + rgba.fill(0) + const { s, sy, col0, row0, ex, er, ground } = L + this.sky({ col0, row0, fx: s, fy: sy, w: gw, h: gh }, R, G, B, true, tSec) + + // Moondust (smoke) over the horizon, as grey light in the sky. + if (this.kSmoke > 0) { + for (let x = 0; x < gw; x++) { + let sky = 0 + while (sky < gh && ground[sky * gw + x]! < 0) sky++ + for (let y = 0; y < sky; y++) { + const k = this.dust(L, x, y, sky, tSec) * 0.5 + const o = y * gw + x + R[o] = R[o]! * (1 - k) + k * 0.47 + G[o] = G[o]! * (1 - k) + k * 0.455 + B[o] = B[o]! * (1 - k) + k * 0.435 + } + } + } + + const ey = lerp(L.eyLow, L.eyHigh, Number.isNaN(this.alt) ? clamp((d.level - 1) / 9) : this.alt) + const out = this.rgba + const lift = d.wait > 0 ? waitLift(d.wait, d.t) : 1 + const glow = d.wait > 0 ? waitGlow(d.wait, d.t) : 0 + for (let y = 0; y < gh; y++) { + for (let x = 0; x < gw; x++) { + const i = y * gw + x + let cr: number + let cg: number + let cb: number + let cap = 1 + let floor = 0 + const gb = ground[i]! + if (gb >= 0) { + // The ground: grey, a touch warm; dim ground in darker ink, so it stays grey. + cr = gb + cg = gb * 0.95 + cb = gb * 0.86 + cap = 0.42 + 0.62 * gb + } else { + cr = R[i]! + cg = G[i]! + cb = B[i]! + } + if (L.near[i]) { + // The Earth and its air over the sky, 2 × 2 samples a dot. + let ar = 0 + let ag = 0 + let ab = 0 + let ac = 0 + let af = 0 + for (let jy = 0; jy < 2; jy++) { + const dy = row0 + (y + (jy + 0.5) / 2) * sy - ey + for (let jx = 0; jx < 2; jx++) { + earthAt(col0 + (x + (jx + 0.5) / 2) * s - ex, dy, er, this.spin, this.drift, out, cr, cg, cb) + ar += out[0]! + ag += out[1]! + ab += out[2]! + ac += out[4]! + af += out[5]! + } + } + const pall = 1 - this.kSmoke * 0.5 + cr = lerp(cr, ar / 4, pall) + cg = lerp(cg, ag / 4, pall) + cb = lerp(cb, ab / 4, pall) + cap = ac / 4 + floor = af / 4 + } + // The dot: its size from the light, dithered; its colour from the hue, making up what the size could not. + const peak = Math.max(cr, cg, cb, 1e-4) + const level = clamp(floor + (1 - floor) * Math.pow(peak, 0.85) * 0.95) + const step = Math.max(0, Math.min(3, Math.round(level * 3 + BAYER[(y & 3) * 4 + (x & 3)]!))) + if (!step) continue + const want = Math.min(1, (level + 0.06) / COVER[step]!) + const k = Math.min(cap, 0.3 + 0.7 * want) / peak + let c = this.tinted(nearest(cr * k, cg * k, cb * k)) + if (d.wait > 0) c = waitColor(c, d.wait, lift, SEPIA_AMOUNT, glow) + const st = stamps[step]! + const r8 = (c >> 16) & 255 + const g8 = (c >> 8) & 255 + const b8 = c & 255 + const px0 = cv.ox + x * p + const py0 = cv.oy + y * p + for (let yy = 0; yy < p; yy++) { + let o = ((py0 + yy) * w + px0) * 4 + for (let xx = 0; xx < p; xx++, o += 4) { + const a = st[yy * p + xx]! + if (a <= 0) continue + rgba[o] = r8 + rgba[o + 1] = g8 + rgba[o + 2] = b8 + rgba[o + 3] = Math.round(a * 255) + } + } + } + } + return rgba + } + + pictureKey(w: number, h: number): string { + const p = dotPitch(h) + return groundKey(Math.floor(w / p), Math.floor(h / p)) + } + + pictureCache(): { key: string; data: Float32Array } | undefined { + const L = this.pic + return L && L.done >= L.pw ? { key: groundKey(L.pw, L.ph), data: L.ground } : undefined + } + + restorePicture(w: number, h: number, data: Float32Array): boolean { + const p = dotPitch(h) + const gw = Math.floor(w / p) + const gh = Math.floor(h / p) + if (gw < 2 || gh < 2 || data.length !== gw * gh) return false + const L = layout(gw, gh, 1, h > w) + L.ground.set(data) + L.done = gw + markNear(L) + this.pic = L + return true + } +} + +/** The view for `pw` × `ph` pixels, each `ry` times as tall as it is wide; its ground not yet built. */ +function layout(pw: number, ph: number, ry: number, tall: boolean): Layout { + let s: number + let col0: number + let row0: number + let ex: number + let er: number + let eyLow: number + let eyHigh: number + if (pw <= 0 || ph <= 0) { + s = 1 + col0 = row0 = 0 + ex = EC_X + er = ER + eyLow = eyHigh = EC_Y + } else if (!tall) { + // A picture's pixels are square and many: the Earth a share of its height. The cells' are BAND_S across. + const pic = ry === 1 + const rpx = pic ? PIC_R * ph : BAND_R + s = pic ? (ER * PIC_SIZE) / (rpx * ry) : BAND_S + const sy = ry * s + er = rpx * sy + col0 = 100 - 0.5 * pw * s + const k = Math.min(1, (SPAN * F) / (pw * s)) + const skyAt = (col: number) => skyline(((col - 100) / F) * k) + // Where the skyline falls, across the band: its middle sets how much ground shows. + const sky: number[] = [] + for (let i = 0; i < 24; i++) sky.push(skyAt(col0 + ((i + 0.5) / 24) * pw * s)) + sky.sort((p, q) => p - q) + row0 = sky[12]! - (pic ? PIC_SKY : BAND_SKY) * ph * sy + // The Earth: where the skyline under it dips lowest. + ex = col0 + BAND_TO * pw * s + let low = -Infinity + for (let i = 0; i <= 12; i++) { + const col = col0 + (BAND_FROM + ((BAND_TO - BAND_FROM) * i) / 12) * pw * s + const under = Math.min(skyAt(col - er * 0.6), skyAt(col), skyAt(col + er * 0.6)) + if (under > low) { + low = under + ex = col + } + } + // Its top kept in the band, a little clear of the edge. + const top = row0 + er + Math.max(0.4 * sy, 0.08 * er) + eyLow = Math.max(top, low + er * (2 * HID_CALM - 1)) + eyHigh = Math.max(top, low + er * (2 * HID_BUSY - 1)) + } else { + // As wide as the Earth wants of the width, or no more than four tenths of the height. + s = Math.max((2 * ER) / (EARTH_W * pw), (2 * ER) / (0.4 * ry * ph)) + col0 = EC_X - 0.5 * pw * s + row0 = EC_Y - EARTH_DOWN * ph * ry * s + ex = EC_X + er = ER + eyLow = EC_Y + SPINE_LOW * ER + eyHigh = EC_Y - SPINE_HIGH * ER + } + const sy = ry * s + const sub = Math.max(1, Math.min(GROUND_SUB, Math.round(sy))) + const rows = ph * sub + const at = new Float32Array(rows) + for (let i = 0; i < rows; i++) at[i] = row0 + ((i + 0.5) / sub) * sy + return { + pw, + ph, + s, + sy, + col0, + row0, + k: Math.min(1, (SPAN * F) / (Math.max(1, pw) * s)), + ex, + eyLow, + eyHigh, + er, + ground: new Float32Array(pw * ph).fill(-1), + near: new Uint8Array(pw * ph), + done: 0, + sub, + at, + steps: ry === 1 ? PIC_STEPS : GROUND_STEPS, + horizon: row0 + ph * sy * 0.6, + } +} + +/** + * Build `L`'s ground on from where it got to, about `budget` pixels' worth: + * march each pixel column from near to far, then light what each pixel sees. + * Once the last column is in, mark where the Earth can reach. + */ +function build(L: Layout, budget: number): void { + const { pw, ph, s, sy, col0, row0, k, ex, er, sub, at, ground } = L + if (L.done >= pw) return + const rows = ph * sub + if (gx.length < rows) { + gx = new Float32Array(rows) + gz = new Float32Array(rows) + gy = new Float32Array(rows) + } + const cam = camY() + let spent = 0 + while (L.done < pw && spent < budget) { + const x = L.done++ + const col = col0 + (x + 0.5) * s + const ang = ((col - 100) / F) * k + const sx = Math.sin(ang) + const cz = Math.cos(ang) + // The sample rows still sky above the ground found so far: [0, top). + let top = rows + let D = 3 + let prevY = 0 + let prevD = 0 + let prevF = 1e9 + let skyline = Infinity + while (D < ZMAX && top > 0) { + const X = sx * D + const Z = cz * D + const hy = height(X, Z) + const Y = hy - (D * D) / (2 * RM) + const yf = EYE - (F * (Y - cam)) / D + while (top > 0 && at[top - 1]! >= yf) { + const i = --top + // Between this sample and the last, by where its row falls. + const a = prevF > yf + 1e-6 ? clamp((prevF - at[i]!) / (prevF - yf)) : 1 + const dd = prevD ? lerp(prevD, D, a) : D + gx[i] = sx * dd + gz[i] = cz * dd + gy[i] = prevD ? lerp(prevY, hy, a) : hy + } + skyline = Math.min(skyline, yf) + prevF = yf + prevY = hy + prevD = D + D += 0.03 + D * 0.012 + } + // Each pixel: the ground if most of its samples are, lit as their mean. + for (let y = 0; y < ph; y++) { + let sum = 0 + let m = 0 + for (let j = 0; j < sub; j++) { + const i = y * sub + j + if (i < top) continue + sum += groundLight(gx[i]!, gz[i]!, gy[i]!, cam, (at[i]! - skyline) / sy) + m++ + } + if (m * 2 > sub) ground[y * pw + x] = sum / m + } + spent += Math.max(1, rows - top) + 16 + } + if (L.done >= pw) markNear(L) +} + +/** Where the Earth and its air can reach, as it climbs and sinks: the sky round it, once the ground is in. */ +function markNear(L: Layout): void { + const { pw, ph, s, sy, col0, row0, ex, er, ground } = L + let sum = 0 + for (let x = 0; x < pw; x++) { + let y = 0 + while (y < ph && ground[y * pw + x]! < 0) y++ + sum += y + } + if (pw > 0) L.horizon = row0 + (sum / pw) * sy + const air = (13 * er) / ER + for (let y = 0; y < ph; y++) { + const row = row0 + (y + 0.5) * sy + if (row <= L.eyHigh - er - air - sy || row >= L.eyLow + er + air) continue + for (let x = 0; x < pw; x++) { + const col = col0 + (x + 0.5) * s + const i = y * pw + x + if (ground[i]! < 0 && Math.abs(col - ex) < er + air) L.near[i] = 1 + } + } +} + +export const earthriseScene = defineScene({ + name: 'earthrise', + blurb: 'the Earth over the moon, rising and turning faster with the work', + make: seed => new Earthrise(seed), +}) diff --git a/hooks/register.tsx b/hooks/register.tsx index d4220fa..9d219af 100644 --- a/hooks/register.tsx +++ b/hooks/register.tsx @@ -1,4 +1,4 @@ -// REVISION: flow-v177-config-hook +// REVISION: flow-v179-doom-volleys // // Flow for Claude Code, by Rob Macrae: ambient scenes (a fire, the surf, a ski run, // rockets, a hot-air balloon and more) drawn as one terminal `Raster` in the @@ -125,6 +125,8 @@ const DEFAULTS_POLL_MS = 30_000 /** A blit unanswered for this many ticks is presumed lost, not in flight. */ const BLIT_STALE_TICKS = 15 const MAX_ROWS = 5 +/** ...and for a scene with a taller view of its own (`Scene.taller`: doom3d), as many as the prompt leaves up to this. */ +const TALL_ROWS = 14 /** A desktop site redraws at most 10 times a second (`$.ui.invalidate`'s limit there). */ const DESKTOP_MS = 100 /** @@ -751,6 +753,8 @@ export const register: Register = (on, options) => { chime: newChimeState(), /** The event clips playing, oldest first (the first to give way when the player is full). */ events: [] as AbortController[], + /** The last volley's clip (a fast gun's run of shots), stopped early when its gun falls silent (`cease`). */ + volley: undefined as AbortController | undefined, seed: 1, scene: '', /** @@ -776,6 +780,7 @@ export const register: Register = (on, options) => { sound.playing.clear() sound.takes.clear() sound.events.length = 0 + sound.volley = undefined sound.bed = [] sound.queue.length = 0 sound.seen = 0 @@ -988,11 +993,12 @@ export const register: Register = (on, options) => { // (`take` undefined) first (a tail cut beats a strike unheard, or the bed dropping out); a refused // one tries again a moment on (a bed's take stopping an event clip each time: busy events never // keep the bed out). - const play = (clip: { asset: string } | { base64: string; mime: string }, gain = 1, take?: number, tries = 0): void => { + const play = (clip: { asset: string } | { base64: string; mime: string }, gain = 1, take?: number, tries = 0, volley = false): void => { const event = take === undefined if ((tries === 0 || !event) && sound.playing.size >= MAX_PLAYS) sound.events.shift()?.abort() const stop = new AbortController() sound.playing.add(stop) + if (volley) sound.volley = stop if (event) sound.events.push(stop) else sound.takes.set(take, stop) // No player (a Linux or Windows terminal), or refused for good: just silence. Refused because the @@ -1013,7 +1019,7 @@ export const register: Register = (on, options) => { retrying = true $.clock.after(50, () => { if (!current(gen) || (take !== undefined && sound.takes.get(take) !== stop)) return - play(clip, gain, take, tries + 1) + play(clip, gain, take, tries + 1, volley) }) }) .finally(() => { @@ -1059,6 +1065,12 @@ export const register: Register = (on, options) => { } // Events with a clip of their own play now; the rest gather into a burst. for (const e of events) { + // A gun fallen silent: the rest of its volley's clip, shots never fired, isn't heard. + if (e.kind === 'cease') { + sound.volley?.abort() + sound.volley = undefined + continue + } const p = eventPlay(e, sound.seed++, cfg.style, shownScene.strength) if (!p) { // (Somewhere since the last frame, at random: a frame's pops all at once would buzz at the frame rate.) @@ -1071,10 +1083,10 @@ export const register: Register = (on, options) => { const at = Math.max(sound.clock, sound.nextOf.get(e.kind) ?? 0) if (at - sound.clock > 4 * SOUND_STAGGER_MS) continue sound.nextOf.set(e.kind, at + SOUND_STAGGER_MS) - if (at === sound.clock) play({ asset: p.asset }, p.gain) + if (at === sound.clock) play({ asset: p.asset }, p.gain, undefined, 0, p.volley) else { const gen = sound.gen - $.clock.after(at - sound.clock, () => current(gen) && play({ asset: p.asset }, p.gain)) + $.clock.after(at - sound.clock, () => current(gen) && play({ asset: p.asset }, p.gain, undefined, 0, p.volley)) } } mood.scene = cfg.style @@ -1544,7 +1556,8 @@ export const register: Register = (on, options) => { }) on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => { - const rows = Math.min(MAX_ROWS, e.props.maxRows - 1) + const most = cfg.layout !== 'spine' && driver.isShown() && driver.dial().taller ? TALL_ROWS : MAX_ROWS + const rows = Math.min(most, e.props.maxRows - 1) const hidden = cfg.layout === 'spine' || e.props.hasSurvey || !driver.isShown() || rows < 2 if (e.surface === 'desktop') { // No Raster here: the frame is one Svg, redrawn by the frame loop. diff --git a/hooks/sound-files.ts b/hooks/sound-files.ts index 932dba9..e15579d 100644 --- a/hooks/sound-files.ts +++ b/hooks/sound-files.ts @@ -46,6 +46,30 @@ export const SOUND_FILES: readonly string[] = [ 'sounds/bubbles/bed-l31.m4a', 'sounds/bubbles/bed-l32.m4a', 'sounds/bubbles/bed-l33.m4a', + 'sounds/doom/bed-l01.m4a', + 'sounds/doom/bed-l02.m4a', + 'sounds/doom/bed-l03.m4a', + 'sounds/doom/bed-l11.m4a', + 'sounds/doom/bed-l12.m4a', + 'sounds/doom/bed-l13.m4a', + 'sounds/doom/bed-l21.m4a', + 'sounds/doom/bed-l22.m4a', + 'sounds/doom/bed-l23.m4a', + 'sounds/doom/bed-l31.m4a', + 'sounds/doom/bed-l32.m4a', + 'sounds/doom/bed-l33.m4a', + 'sounds/earthrise/bed-l01.m4a', + 'sounds/earthrise/bed-l02.m4a', + 'sounds/earthrise/bed-l03.m4a', + 'sounds/earthrise/bed-l11.m4a', + 'sounds/earthrise/bed-l12.m4a', + 'sounds/earthrise/bed-l13.m4a', + 'sounds/earthrise/bed-l21.m4a', + 'sounds/earthrise/bed-l22.m4a', + 'sounds/earthrise/bed-l23.m4a', + 'sounds/earthrise/bed-l31.m4a', + 'sounds/earthrise/bed-l32.m4a', + 'sounds/earthrise/bed-l33.m4a', 'sounds/engine/bed-l01.m4a', 'sounds/engine/bed-l02.m4a', 'sounds/engine/bed-l03.m4a', @@ -61,7 +85,14 @@ export const SOUND_FILES: readonly string[] = [ 'sounds/events/blast1.m4a', 'sounds/events/blast2.m4a', 'sounds/events/blast3.m4a', + 'sounds/events/bolts2-1.m4a', + 'sounds/events/bolts2-2.m4a', + 'sounds/events/bolts3-1.m4a', + 'sounds/events/bolts3-2.m4a', 'sounds/events/boom.m4a', + 'sounds/events/chaingun1.m4a', + 'sounds/events/chaingun2.m4a', + 'sounds/events/chaingun3.m4a', 'sounds/events/chime1.m4a', 'sounds/events/chime2.m4a', 'sounds/events/chime3.m4a', @@ -73,6 +104,11 @@ export const SOUND_FILES: readonly string[] = [ 'sounds/events/clang2.m4a', 'sounds/events/clang3.m4a', 'sounds/events/clank.m4a', + 'sounds/events/comet.m4a', + 'sounds/events/door.m4a', + 'sounds/events/fireball1.m4a', + 'sounds/events/fireball2.m4a', + 'sounds/events/fireball3.m4a', 'sounds/events/horn1.m4a', 'sounds/events/horn2.m4a', 'sounds/events/horn3.m4a', @@ -80,12 +116,35 @@ export const SOUND_FILES: readonly string[] = [ 'sounds/events/lap1.m4a', 'sounds/events/lap2.m4a', 'sounds/events/lap3.m4a', + 'sounds/events/pistol1.m4a', + 'sounds/events/pistol2.m4a', + 'sounds/events/pistol3.m4a', + 'sounds/events/plasma1.m4a', + 'sounds/events/plasma2.m4a', + 'sounds/events/plasma3.m4a', + 'sounds/events/rattle3-1.m4a', + 'sounds/events/rattle3-2.m4a', + 'sounds/events/rattle4-1.m4a', + 'sounds/events/rattle4-2.m4a', + 'sounds/events/rattle5-1.m4a', + 'sounds/events/rattle5-2.m4a', + 'sounds/events/reload.m4a', 'sounds/events/sep.m4a', + 'sounds/events/shotgun1.m4a', + 'sounds/events/shotgun2.m4a', + 'sounds/events/shotgun3.m4a', 'sounds/events/sonic.m4a', 'sounds/events/splash.m4a', + 'sounds/events/splat1.m4a', + 'sounds/events/splat2.m4a', + 'sounds/events/splat3.m4a', + 'sounds/events/streak1.m4a', + 'sounds/events/streak2.m4a', + 'sounds/events/streak3.m4a', 'sounds/events/swish1.m4a', 'sounds/events/swish2.m4a', 'sounds/events/swish3.m4a', + 'sounds/events/teleport.m4a', 'sounds/events/thud.m4a', 'sounds/events/wave1.m4a', 'sounds/events/wave2.m4a', @@ -248,6 +307,14 @@ export const BED_GAINS: Readonly> = { 'bubbles/l1': 0.2888, 'bubbles/l2': 0.4998, 'bubbles/l3': 1.0403, + 'doom/l0': 0.4851, + 'doom/l1': 0.4438, + 'doom/l2': 0.4825, + 'doom/l3': 0.6178, + 'earthrise/l0': 0.4696, + 'earthrise/l1': 0.4942, + 'earthrise/l2': 0.5459, + 'earthrise/l3': 0.6153, 'engine/l0': 0.2455, 'engine/l1': 0.4133, 'engine/l2': 0.5747, diff --git a/hooks/sound.ts b/hooks/sound.ts index eecdc5d..68ea539 100644 --- a/hooks/sound.ts +++ b/hooks/sound.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v172-cleanup-followups +// REVISION: flow-v181-doom-volleys // // Soundscapes. Claude Code's `$.audio.play` plays a clip (macOS `afplay`) at a // gain set when it starts; it can't loop smoothly or change a clip as it @@ -92,6 +92,26 @@ export type SoundKind = | 'lap' // a small wave on a calm sea | 'swish' // a ski turn | 'horn' // a train's horn: pulling away, or before a level crossing + | 'pistol' // the marine's guns firing, each its own (a squad marine's too, quieter) + | 'shotgun' + | 'chaingun' + | 'plasma' + | 'foeshot' // a zombie firing back, down the hall + | 'splat' // a monster going down + | 'barrel' // a barrel going up + | 'fireball' // a fireball thrown + | 'door' // a door rising (through his eyes) + | 'reload' // a gun changed + | 'teleport' // a squad marine teleporting out, its agent done + | 'step' // a footfall + | 'rattle3' // a chaingun's volley, a shot every 3 frames (rattle4, rattle5: every 4, 5) + | 'rattle4' + | 'rattle5' + | 'bolts2' // a plasma rifle's volley, a bolt every 2 frames (bolts3: every 3) + | 'bolts3' + | 'cease' // a gun fallen silent before its volley's clip ran out: that clip stops + | 'streak' // a shooting star + | 'comet' // a comet coming into the sky export type SoundEvent = { kind: SoundKind; v: number } /** What a scene is doing now, for its bed: each 0..1 (a scene reports the ones it has). */ @@ -178,7 +198,8 @@ class Mix { const decay = (dt: number, tau: number) => Math.exp(-dt / tau) /** A clip to play and how loud (linear gain, capped at MAX_GAIN). */ -export type Play = { asset: string; gain: number } +/** A clip to play; a `volley`'s stops early on a `cease`. */ +export type Play = { asset: string; gain: number; volley?: boolean } /** One layer of a scene's bed: a clip with `variants` takes, at a gain from the mood (`s`: the level, 0..1). */ type Layer = { clip: string; variants: number; gain: (s: number, m: SoundMood) => number } @@ -211,11 +232,17 @@ const MASTER: Record number> = { ski: () => 1.4, bubbles: s => 1 - 1.1 * Math.max(0, s - 0.5), train: s => 0.2 + 0.38 * Math.sqrt(s), + earthrise: s => 0.8 + 1.5 * s - 0.6 * s * s, + doom: s => 0.5 + 1.35 * s - 0.75 * s * s, } +/** A scene that sounds as another does (doom3d is doom through his eyes): its bed and its master. */ +const SOUNDS_AS: Readonly> = { doom3d: 'doom' } +export const soundsAs = (scene: string): string => SOUNDS_AS[scene] ?? scene + /** A scene's master volume at a level (0..10). */ export function master(scene: string, level: number): number { - return (MASTER[scene] ?? (() => 1))(Math.max(0, Math.min(1, level / 10))) + return (MASTER[soundsAs(scene)] ?? (() => 1))(Math.max(0, Math.min(1, level / 10))) } /** The most a play's gain may be: clips peak at -2 to -3 dBFS and afplay's gain multiplies, so past this it clips. */ @@ -325,10 +352,26 @@ export const LAYERS: Record = { { clip: 'train/drone2', variants: BED_TAKES, gain: (s, m) => 0.35 * Math.max(0, 1 - Math.abs((m.amb.wind ?? s) - 0.42) / 0.32) }, { clip: 'train/drone3', variants: BED_TAKES, gain: (s, m) => 0.35 * Math.max(0, 1 - Math.abs((m.amb.wind ?? s) - 0.88) / 0.3) }, ], + earthrise: [ + // No air on the moon: no noise or wind, only steady low tones in one key: a deep drone, its fifth, and a + // chord above them filling in with the work (each shooting star and comet is an event). + { clip: 'earthrise/drone', variants: BED_TAKES, gain: s => 0.5 - 0.15 * s }, + { clip: 'earthrise/fifth', variants: BED_TAKES, gain: s => 0.15 + 0.1 * s }, + { clip: 'earthrise/chord', variants: BED_TAKES, gain: s => 0.04 + 0.45 * s }, + ], + doom: [ + // The base: its machinery's hum and the air handlers, computer banks chirping, nukage bubbling in the + // floor; the fighting farther off rumbling louder with the level (every shot, fall and blast is an event). + { clip: 'doom/hum', variants: BED_TAKES, gain: s => 0.4 - 0.15 * s }, + { clip: 'doom/air', variants: BED_TAKES, gain: s => 0.14 + 0.08 * s }, + { clip: 'doom/computers', variants: BED_TAKES, gain: s => 0.22 }, + { clip: 'doom/nukage', variants: BED_TAKES, gain: s => 0.2 * (1 - 0.6 * s) }, + { clip: 'doom/rumble', variants: BED_TAKES, gain: s => 0.55 * s * s }, + ], } /** Events with a clip of their own (`sounds/.m4a`), and how loud by their size. */ -export const EVENTS: Partial number }>> = { +export const EVENTS: Partial number; volley?: boolean }>> = { ignite: { clip: 'events/ignite', variants: 1, gain: () => 1 }, sep: { clip: 'events/sep', variants: 1, gain: () => 0.9 }, sonic: { clip: 'events/sonic', variants: 1, gain: () => 1 }, @@ -344,6 +387,24 @@ export const EVENTS: Partial 0.25 + 0.35 * v }, lap: { clip: 'events/lap', variants: 3, gain: v => 0.35 + 0.6 * v }, horn: { clip: 'events/horn', variants: 3, gain: v => 0.35 + 0.6 * v }, + pistol: { clip: 'events/pistol', variants: 3, gain: v => 0.8 * v }, + shotgun: { clip: 'events/shotgun', variants: 3, gain: v => 0.95 * v }, + chaingun: { clip: 'events/chaingun', variants: 3, gain: v => 0.45 * v }, + plasma: { clip: 'events/plasma', variants: 3, gain: v => 0.28 * v }, + rattle3: { clip: 'events/rattle3-', variants: 2, gain: v => 0.45 * v, volley: true }, + rattle4: { clip: 'events/rattle4-', variants: 2, gain: v => 0.45 * v, volley: true }, + rattle5: { clip: 'events/rattle5-', variants: 2, gain: v => 0.45 * v, volley: true }, + bolts2: { clip: 'events/bolts2-', variants: 2, gain: v => 0.28 * v, volley: true }, + bolts3: { clip: 'events/bolts3-', variants: 2, gain: v => 0.28 * v, volley: true }, + foeshot: { clip: 'events/pistol', variants: 3, gain: v => 0.12 + 0.3 * v }, + splat: { clip: 'events/splat', variants: 3, gain: v => 0.35 + 0.5 * v }, + barrel: { clip: 'events/blast', variants: 3, gain: () => 1 }, + fireball: { clip: 'events/fireball', variants: 3, gain: v => 0.35 * v }, + door: { clip: 'events/door', variants: 1, gain: () => 0.6 }, + reload: { clip: 'events/reload', variants: 1, gain: () => 0.6 }, + teleport: { clip: 'events/teleport', variants: 1, gain: () => 0.7 }, + streak: { clip: 'events/streak', variants: 3, gain: v => 0.2 + 0.45 * v }, + comet: { clip: 'events/comet', variants: 1, gain: () => 0.6 }, } /** A clip's file: one of its variants, picked by a hash of the moment and the clip (no fixed rotation to fall in step with). */ @@ -411,6 +472,8 @@ export const MOODS: Record BedMood> = { }, falcon: rocketMood, starship: rocketMood, + earthrise: byLevel, + doom: byLevel, } /** A layer's gain in a mood's mix (the mix is made at these, before the scene's master volume). */ @@ -448,17 +511,18 @@ const NEAREST = new Map() /** The scene's bed's next take for this mood (undefined when it's silent now), never one of those in `not`. */ export function bedPlay(mood: SoundMood, seed: number, not: readonly string[] = []): Play | undefined { - const spec = MOODS[mood.scene] + const scene = soundsAs(mood.scene) + const spec = MOODS[scene] if (!spec) return undefined - const want = `${mood.scene}/${spec(mood).key}` + const want = `${scene}/${spec(mood).key}` let bed = NEAREST.get(want) if (bed === undefined && !NEAREST.has(want)) { - const key = nearest(mood.scene, want.slice(mood.scene.length + 1)) - bed = key === undefined ? undefined : { clip: `${mood.scene}/bed-${key}`, gain: BED_GAINS[`${mood.scene}/${key}`] ?? 0 } + const key = nearest(scene, want.slice(scene.length + 1)) + bed = key === undefined ? undefined : { clip: `${scene}/bed-${key}`, gain: BED_GAINS[`${scene}/${key}`] ?? 0 } NEAREST.set(want, bed) } if (!bed) return undefined - const gain = master(mood.scene, mood.level) * bed.gain + const gain = master(scene, mood.level) * bed.gain if (gain <= 0.01) return undefined let a = asset(bed.clip, BED_TAKES, seed) for (let k = 1; not.includes(a) && k < 64; k++) a = asset(bed.clip, BED_TAKES, seed + k * 7919) @@ -520,7 +584,7 @@ export function bedStep( ): { play: (Play & { id: number })[]; stop: number[] } { const play: (Play & { id: number })[] = [] const stop: number[] = [] - if (!MIXED.has(mood.scene)) return { play, stop } + if (!MIXED.has(soundsAs(mood.scene))) return { play, stop } const volume = mood.volume ?? DEFAULT_VOLUME const slot = takes[0] // (A bed fallen silent keeps its slot, quiet, till the takes it stopped have left the player.) @@ -637,7 +701,7 @@ export function chimePlay(seed: number): Play { export function eventPlay(e: SoundEvent, seed: number, scene: string, level: number): Play | undefined { const c = EVENTS[e.kind] // (Clips peak near full scale and afplay's gain multiplies: past MAX_GAIN it clips.) - return c && { asset: asset(c.clip, c.variants, seed), gain: Math.min(MAX_GAIN, c.gain(e.v) * master(scene, level)) } + return c && { asset: asset(c.clip, c.variants, seed), gain: Math.min(MAX_GAIN, c.gain(e.v) * master(scene, level)), volley: c.volley } } /** @@ -655,6 +719,11 @@ const VOICES: Partial m.white() * decay(dt, 0.0025) * (0.12 + 0.12 * v) }, + // A boot on a steel deck: a dull knock (pitched where a laptop plays it) with a scuff of grit, heavier running. + step: { + len: 0.09, + voice: (m, dt, v, x) => (Math.sin(TAU * (150 + 60 * x) * dt) * decay(dt, 0.02) * 0.7 + m.white() * decay(dt, 0.005) * 0.4) * (0.06 + 0.1 * v), + }, } /** A number in [0, 1) from a seed, scattered (neighbouring seeds land far apart). */ diff --git a/hooks/styles.ts b/hooks/styles.ts index 38126df..aa441f4 100644 --- a/hooks/styles.ts +++ b/hooks/styles.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v170-dry-scenes +// REVISION: flow-v175-doom3d // // The scenes, all driven by the same dials (strength 0..10, coverage boost, // the agents one by one, tint, night, waiting): SCENES, the one list of them (each scene file exports its @@ -32,6 +32,8 @@ import { falconScene, starshipScene } from './rocket' import { warpScene } from './starfield' import { bubblesScene } from './bubbles' import { trainScene } from './train' +import { earthriseScene } from './earthrise' +import { doom3dScene, doomScene } from './doom' /** What shows over a scene: smoke after a failure or a compaction, blue when the context is nearly full. */ @@ -66,6 +68,24 @@ export interface Scene { sounds?: SoundEvent[] /** What it's doing now, for its soundscape's background. */ ambience?(): Ambience + /** + * This frame at real pixels, `w` × `h` (square), RGBA with the open sky + * transparent, for a terminal that draws images (pi's, earthrise's alone + * so far). A new size may take a while to make, `budget` a call: until + * then (and at level 0) undefined, and the adapter draws `grid()`. + */ + picture?(w: number, h: number, budget?: number): Uint8Array | undefined + /** What a picture's size takes long to make, to keep across runs (`key` names it): once it's made, else undefined. */ + pictureCache?(): { key: string; data: Float32Array } | undefined + /** The key `pictureCache` would give a picture `w` × `h`, and taking back what was kept under it (false: not usable). */ + pictureKey?(w: number, h: number): string + restorePicture?(w: number, h: number, data: Float32Array): boolean + /** + * It draws a view taller than the band's 5 rows (doom3d: his own eyes), so + * above the prompt the adapter gives it more where it has them (pi: + * flow.json's `rows`; Claude Code: as many as the prompt leaves). + */ + readonly taller?: boolean } function defOf(style: SceneName): SceneDef { @@ -557,6 +577,9 @@ export const SCENES = [ skiScene, bubblesScene, trainScene, + earthriseScene, + doomScene, + doom3dScene, ] as const export type SceneName = (typeof SCENES)[number]['name'] export const STYLES: readonly SceneName[] = SCENES.map(d => d.name) diff --git a/pi/index.ts b/pi/index.ts index 14f9def..01e6b59 100644 --- a/pi/index.ts +++ b/pi/index.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v175-plain-names // // Flow for pi (badlogic/pi-mono), by Rob Macrae: the same ambient // scenes as the Claude Code mod, in a widget above pi's editor. pi's events @@ -26,8 +26,17 @@ // and no side panes, so there is no spine, and `/flow pick` is a plain list (pi's own select) // rather than thumbnails, its choice going the way `/flow ` goes. Nor has it a player, so // there is no soundscape: `/flow sound` says so, and the help and the status leave it out. +// +// In a terminal that draws kitty images (Ghostty, kitty, WezTerm, Warp), a +// scene that can draw itself at real pixels (earthrise) shows as a picture +// instead of its cells, taller than the band (picture.ts): flow.json's +// `"rows"` sets how tall (14 unless it says; never more than half the +// terminal), `"pictures": false` keeps the cells. A new size's picture is made +// a little each frame, the cells showing meanwhile, and what took long is +// kept in ~/.cache/flow for the next time. A scene with a taller view of its +// own (doom3d, through the marine's eyes) gets as many rows in cells. -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' @@ -35,8 +44,9 @@ import { Activity } from '../hooks/activity' import { FRAME_MS, SceneDriver } from '../hooks/scene' import { pickBlurb } from '../hooks/picker' import { type Host, parseFlowArgs, readConfig, replyTo, storedValue, type FlowConfig } from '../hooks/settings' -import { SCENES } from '../hooks/styles' +import { SCENES, type Scene } from '../hooks/styles' import { gridToAnsi } from './ansi' +import { cellPixels, kittyImages, kittyLines, pictureId, pictureSize } from './picture' import { COMMAND_TOOLS, effortOf, FLOW_ENTRY, piLinesWritten, READ_TOOLS } from './mapping' import { PiSettings, type SessionEntries } from './session' import type { PiApi, PiComponent, PiContext, PiTui } from './types' @@ -51,6 +61,42 @@ const SETTINGS = join(homedir(), '.pi', 'agent', 'flow.json') const PI: Host = { panes: false, sound: false } /** Where the settings lived before, newest first: as vista, then as ascii-fire. */ const OLD_SETTINGS = [join(homedir(), '.pi', 'agent', 'vista.json'), join(homedir(), '.pi', 'agent', 'ascii-fire.json')] +/** A picture's rows (and a taller scene's, `Scene.taller`) unless flow.json says (`rows`), and at most this share of the terminal's. */ +const PICTURE_ROWS = 14 +const PICTURE_SHARE = 0.5 +/** + * At most this many pixels a picture (the terminal scales it up to its cells), and at most one this often: + * enough for earthrise's halftone dots at the terminal's own pixels in most windows, so they stay crisp + * (a frame of dots on a clear sky packs small, well under 100 KB). + */ +const PICTURE_PIXELS = 600_000 +const PICTURE_MS = 100 +/** A new size's picture is made between frames, this long at a time, with this long between for everything else. */ +const PICTURE_BUILD_MS = 12 +const PICTURE_YIELD_MS = 4 +/** Where what a picture took long to make is kept: the most recent few sizes. */ +const CACHE = join(homedir(), '.cache', 'flow') +const CACHE_KEPT = 8 + +/** Drop all but the CACHE_KEPT newest kept pictures' parts. */ +async function pruneCache(): Promise { + const names = (await readdir(CACHE)).filter(n => n.endsWith('.f32')) + const dated = await Promise.all(names.map(async n => ({ n, t: (await stat(join(CACHE, n))).mtimeMs }))) + dated.sort((a, b) => b.t - a.t) + await Promise.all(dated.slice(CACHE_KEPT).map(({ n }) => unlink(join(CACHE, n)))) +} + +/** flow.json's own keys for pi's pictures: whether to draw them, and how many rows. */ +async function loadPicturePrefs(): Promise<{ pictures: boolean; rows: number }> { + let raw: Record = {} + try { + raw = JSON.parse(await readFile(SETTINGS, 'utf8')) as Record + } catch { + // None yet: the defaults. + } + const rows = typeof raw.rows === 'number' && Number.isFinite(raw.rows) ? Math.round(raw.rows) : PICTURE_ROWS + return { pictures: raw.pictures !== false, rows: Math.max(3, Math.min(60, rows)) } +} async function loadSettings(): Promise { for (const file of [SETTINGS, ...OLD_SETTINGS]) { @@ -111,6 +157,117 @@ export default function flow(pi: PiApi) { let sinceContext = 0 let sinceDefaults = 0 + /** Pictures: whether the terminal draws them, its cells' pixels, flow.json's say, and the one image id this pi uses. */ + let kitty = false + let cell = { width: 9, height: 18 } + let prefs = { pictures: true, rows: PICTURE_ROWS } + const picId = pictureId() + /** Whether the widget shows a picture now, and how long since the last was sent. */ + let showingPicture = false + let sincePicture = 0 + /** How much of a new size's picture one slice makes (the scene's own units), learned from how long it takes. */ + let budget = 300 + /** The size being made between frames (`w`x`h`), if any. */ + let making: string | undefined + /** What a picture of each size took long to make, kept in CACHE: being read back, read back, not there, or written. */ + const kept = new Map() + /** The rows a picture, or a taller scene's view, takes now. */ + const tallRows = () => { + const terminal = process.stdout.rows || 40 + return Math.max(3, Math.min(prefs.rows, Math.floor(terminal * PICTURE_SHARE))) + } + + /** Whether this scene shows as a picture here. */ + const pictured = (f: Scene) => kitty && prefs.pictures && typeof f.picture === 'function' + + /** Read back what a picture of this size took long to make, if it was kept: false until that's known (make nothing yet). */ + const readKept = (f: Scene, w: number, h: number): boolean => { + const key = f.pictureKey?.(w, h) + if (!key) return true + const state = kept.get(key) + if (state) return state !== 'reading' + kept.set(key, 'reading') + void readFile(join(CACHE, key + '.f32')) + .then(buf => { + const data = new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)) + kept.set(key, f.restorePicture?.(w, h, data) ? 'restored' : 'absent') + }) + .catch(() => kept.set(key, 'absent')) + return false + } + + /** Keep what a picture made here took long to make, once (written aside, then moved in, so no reader sees half). */ + const writeKept = (f: Scene) => { + const made = f.pictureCache?.() + if (!made || kept.get(made.key) !== 'absent') return + kept.set(made.key, 'written') + const file = join(CACHE, made.key + '.f32') + const bytes = new Uint8Array(made.data.buffer, made.data.byteOffset, made.data.byteLength).slice() + const tmp = file + '.' + process.pid + '.tmp' + void mkdir(CACHE, { recursive: true }) + .then(() => writeFile(tmp, bytes)) + .then(() => rename(tmp, file)) + .then(pruneCache) + .catch(() => { + // Not kept: made afresh next time. + }) + } + + /** This frame as a picture's lines, or undefined while it's still being made (draw the cells). */ + const drawPicture = (f: Scene): string[] | undefined => { + if (!f.picture || width <= 0) return undefined + const rows = tallRows() + const { w, h } = pictureSize(width, rows, cell, PICTURE_PIXELS) + if (!readKept(f, w, h)) return undefined + const rgba = f.picture(w, h, 0) + if (!rgba) { + makeBetweenFrames(f, w, h) + return undefined + } + writeKept(f) + return kittyLines(rgba, w, h, width, rows, picId) + } + + /** Make a new size's picture a slice at a time, off the frame loop, until it's made (or the size or scene moves on). */ + const makeBetweenFrames = (f: Scene, w: number, h: number) => { + const key = w + 'x' + h + if (making === key) return + making = key + const slice = () => { + if (making !== key) return + if (driver.built(driver.cfg.style) !== f || !f.picture) { + making = undefined + return + } + const t0 = performance.now() + const made = f.picture(w, h, budget) !== undefined + if (made) { + making = undefined + writeKept(f) + return + } + const ms = performance.now() - t0 + budget = Math.max(20, Math.min(1e6, (budget * PICTURE_BUILD_MS) / Math.max(1, ms))) + setTimeout(slice, PICTURE_YIELD_MS) + } + setTimeout(slice, 0) + } + + /** This frame's lines: a picture when it's time for one, else the cells; undefined to keep the last picture. */ + const nextLines = (f: Scene, force = false): string[] | undefined => { + if (pictured(f)) { + if (showingPicture && !force && sincePicture < PICTURE_MS) return undefined + const p = drawPicture(f) + if (p) { + showingPicture = true + sincePicture = 0 + return p + } + } + showingPicture = false + return gridToAnsi(f.grid()) + } + /** Read the local clock (pi runs on this machine, so its time is the person's). */ const readClock = () => { const d = new Date() @@ -120,7 +277,7 @@ export default function flow(pi: PiApi) { /** Point the current scene at this frame's dials and size. */ const dial = () => { const f = driver.dial() - if (width > 0) f.ensure(width, ROWS) + if (width > 0) f.ensure(width, f.taller ? tallRows() : ROWS) return f } @@ -130,7 +287,7 @@ export default function flow(pi: PiApi) { render(w: number) { if (w !== width) { width = w - lines = gridToAnsi(dial().grid()) + lines = nextLines(dial(), true) ?? lines } return lines }, @@ -161,6 +318,7 @@ export default function flow(pi: PiApi) { const usage = ctx.getContextUsage() if (usage?.percent != null) activity.contextPercent = usage.percent readClock() + void cellPixels().then(c => (cell = c)) } sinceDefaults += elapsed if (sinceDefaults >= DEFAULTS_EVERY_MS && !refreshing) { @@ -177,8 +335,12 @@ export default function flow(pi: PiApi) { f.step() // pi has no player: a scene's events (for Claude Code's soundscape) are taken and dropped each frame. if (f.sounds) f.sounds.length = 0 - lines = gridToAnsi(f.grid()) - tui.requestRender() + sincePicture += elapsed + const next = nextLines(f) + if (next) { + lines = next + tui.requestRender() + } } const pace = driver.pace() timer = setTimeout(() => frame(pace), pace) @@ -197,6 +359,9 @@ export default function flow(pi: PiApi) { if (ctx.mode !== 'tui' || !ctx.hasUI) return ctxRef = ctx width = 0 + kitty = await kittyImages() + cell = await cellPixels() + prefs = await loadPicturePrefs() readClock() stop() isMounted = false @@ -261,6 +426,7 @@ export default function flow(pi: PiApi) { const entries = entriesOf(ctx) // flow.json as it is now: what follows compares with it. await settings.refresh(entries) + prefs = await loadPicturePrefs() if (cmd.kind === 'pick') { // No panes for thumbnails here: pi's own list, each scene with its blurb; the one chosen is // `/flow ` from here on. diff --git a/pi/picture.ts b/pi/picture.ts new file mode 100644 index 0000000..ac6e71c --- /dev/null +++ b/pi/picture.ts @@ -0,0 +1,91 @@ +// REVISION: flow-v1-pictures +// +// Pictures for pi: a scene that can draw itself at real pixels (`picture`, +// earthrise so far) is sent to a terminal that speaks the kitty graphics +// protocol (Ghostty, kitty, WezTerm, Warp) as one image a frame, in place of +// its cells. The frame goes as raw RGBA, zlib-compressed, under one image id +// that each frame replaces; pi's own renderer reserves the image's rows +// (from its `r=`) and deletes it when the line changes or goes. Node only. + +import { deflateSync } from 'node:zlib' + +/** Whether this terminal draws kitty images: pi's own answer when it's there, else the same reading of the environment. */ +export async function kittyImages(): Promise { + try { + const tui = (await import('@earendil-works/pi-tui' as string)) as { getCapabilities?: () => { images: string | null } } + if (tui.getCapabilities) return tui.getCapabilities().images === 'kitty' + } catch { + // Not inside pi (a test, a script): read the environment. + } + return kittyFromEnv(process.env) +} + +/** As pi-tui reads it: PI_IMAGE_PROTOCOL first, never under tmux or screen, then the terminals known to draw them. */ +export function kittyFromEnv(env: NodeJS.ProcessEnv): boolean { + const forced = env.PI_IMAGE_PROTOCOL?.toLowerCase() + if (forced) return forced === 'kitty' + const term = env.TERM?.toLowerCase() ?? '' + const program = env.TERM_PROGRAM?.toLowerCase() ?? '' + if (env.TMUX || term.startsWith('tmux') || term.startsWith('screen')) return false + return Boolean( + env.KITTY_WINDOW_ID || + program === 'kitty' || + program === 'ghostty' || + term.includes('ghostty') || + env.GHOSTTY_RESOURCES_DIR || + env.WEZTERM_PANE || + program === 'wezterm' || + program === 'warpterminal' || + env.WARP_SESSION_ID, + ) +} + +/** A cell's size in the terminal's pixels, as pi measured it (or a common one). */ +export async function cellPixels(): Promise<{ width: number; height: number }> { + try { + const tui = (await import('@earendil-works/pi-tui' as string)) as { getCellDimensions?: () => { widthPx: number; heightPx: number } } + const d = tui.getCellDimensions?.() + if (d && d.widthPx > 0 && d.heightPx > 0) return { width: d.widthPx, height: d.heightPx } + } catch { + // Not inside pi. + } + return { width: 9, height: 18 } +} + +/** + * How many pixels to draw a picture `columns` × `rows` cells: the cells' + * own shape, at the terminal's own pixels or fewer, at most `budget` of + * them (the terminal scales it up to fill the cells). + */ +export function pictureSize(columns: number, rows: number, cell: { width: number; height: number }, budget: number): { w: number; h: number } { + const fw = columns * cell.width + const fh = rows * cell.height + const k = Math.min(1, Math.sqrt(budget / Math.max(1, fw * fh))) + return { w: Math.max(2, Math.round(fw * k)), h: Math.max(2, Math.round(fh * k)) } +} + +const CHUNK = 4096 + +/** + * The picture as a pi widget's lines: the image's escape sequence on the + * first (placed over `columns` × `rows` cells, the cursor left where it + * was), then empty lines for the rest of its rows. + */ +export function kittyLines(rgba: Uint8Array, w: number, h: number, columns: number, rows: number, id: number): string[] { + const data = deflateSync(rgba, { level: 1 }).toString('base64') + const head = `a=T,f=32,o=z,s=${w},v=${h},c=${columns},r=${rows},i=${id},C=1,q=2` + let seq = '' + for (let at = 0; at < data.length; at += CHUNK) { + const more = at + CHUNK < data.length ? 1 : 0 + const part = data.slice(at, at + CHUNK) + seq += at === 0 ? `\x1b_G${head},m=${more};${part}\x1b\\` : `\x1b_Gm=${more};${part}\x1b\\` + } + const lines = [seq] + for (let i = 1; i < rows; i++) lines.push('') + return lines +} + +/** A fresh image id for this pi: random, so two extensions (or two sessions' pi) never share one. */ +export function pictureId(): number { + return 1 + Math.floor(Math.random() * 0xfffffffe) +} diff --git a/scripts/check.ts b/scripts/check.ts index eb14953..a17c6a1 100644 --- a/scripts/check.ts +++ b/scripts/check.ts @@ -1,9 +1,10 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v174-taller-scenes // // What every scene must hold to (AGENTS.md), measured: plugin.json lists // the scenes in SCENES, hooks/sound-files.ts lists the clips in sounds/, // level 0 draws nothing, a frame has at most 1024 colour pairs, and // step() + grid() takes under ~2 ms at the band's and the spine's sizes +// (a scene with a taller view of its own, doom3d's, at its taller band too) // (with subagents' companions too, working and waiting on the person: the // slower is shown). Each over every backdrop a scene passes through (avalon's // suns and nebulae). Exits 1 on any failure. Not part of the mod (Node). @@ -15,6 +16,7 @@ import { readdirSync } from 'node:fs' import { join } from 'node:path' import { SOUND_FILES } from '../hooks/sound-files' +import { makeScene } from '../hooks/styles' import { syncManifest } from './sync-manifest' import { backdropsOf, build, LOOKS, nightsOf, pairs, scenesFrom } from './scene-lab' @@ -32,6 +34,11 @@ const TIMED = [ [250, 5], [22, 60], ] as const +/** A taller scene's band (`Scene.taller`: as many rows as pi's flow.json gives it, 14 unless it says), timed in place of the 5-row one. */ +const TALLER = [ + [250, 14], + [80, 14], +] as const let failed = 0 const bad = (why: string) => { @@ -58,7 +65,8 @@ for (const scene of scenesFrom(process.argv.slice(2))) { let most = 0 let where = '' const backdrops = backdropsOf(scene) - for (const [columns, rows] of SIZES) { + const taller = makeScene(scene, 1).taller === true + for (const [columns, rows] of taller ? [...SIZES, ...TALLER] : SIZES) { for (const backdrop of backdrops) { for (const level of [1, 5, 10]) { for (const night of nightsOf(scene)) { @@ -89,7 +97,8 @@ for (const scene of scenesFrom(process.argv.slice(2))) { } } // (Working at 10, and waiting on the person, its sepia over every cell, over each backdrop: the slowest.) - const ms = TIMED.map(([columns, rows]) => { + const timed = taller ? [TALLER[0], TIMED[1]] : TIMED + const ms = timed.map(([columns, rows]) => { let worst = 0 for (const backdrop of backdrops) { for (const waiting of [false, true]) { @@ -105,10 +114,10 @@ for (const scene of scenesFrom(process.argv.slice(2))) { } return worst }) - console.log(`${scene.padEnd(10)} ${String(most).padStart(10)} ${ms.map(m => m.toFixed(2).padStart(11)).join(' ')}`) + console.log(`${scene.padEnd(10)} ${String(most).padStart(10)} ${ms.map(m => m.toFixed(2).padStart(11)).join(' ')}${taller ? ` (its band at ${TALLER[0].join('×')})` : ''}`) if (most > MAX_PAIRS) bad(`${scene}: ${most} colour pairs (at ${where}); Raster paints ${MAX_PAIRS}, quantize the gradients`) ms.forEach((m, i) => { - if (m > MAX_MS) bad(`${scene}: ${m.toFixed(2)} ms a frame at ${TIMED[i]!.join('×')} (budget ${MAX_MS} ms)`) + if (m > MAX_MS) bad(`${scene}: ${m.toFixed(2)} ms a frame at ${timed[i]!.join('×')} (budget ${MAX_MS} ms)`) }) } console.log(failed ? `\n${failed} problem${failed > 1 ? 's' : ''}` : '\nall good') diff --git a/scripts/make-sounds.ts b/scripts/make-sounds.ts index 0c2dfb9..b34ff98 100644 --- a/scripts/make-sounds.ts +++ b/scripts/make-sounds.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v181-doom-volleys // // Builds the soundscapes' clips into sounds/ (AAC, mono 22.05 kHz) and the // manifest hooks/sound-files.ts. Each recipe makes a WAV with sox (and Node, @@ -416,7 +416,87 @@ function nwave(x: Float32Array, at: number, ms: number, amp: number): void { for (let i = 0; i < n && i0 + i < x.length; i++) x[i0 + i]! += amp * (1 - (2 * i) / n) } +/** + * Computer banks chirping: about `per` a second, each a run of 1-4 short soft + * blips (a square-ish tone, 25-70 ms) on a few stepped pitches between `lo` + * and `hi` Hz, at random (never a rhythm). + */ +function chirps(path: string, seed: number, seconds: number, per: number, lo: number, hi: number): void { + const r = rng(seed) + const x = new Float32Array(Math.ceil(seconds * RATE)) + const runs = Math.max(1, Math.round(per * seconds)) + for (let j = 0; j < runs; j++) { + let at = r() * (seconds - 0.5) + const n = 1 + Math.floor(r() * 4) + const amp = 0.3 + 0.7 * r() + for (let k = 0; k < n; k++) { + const f = lo * Math.pow(hi / lo, Math.floor(r() * 6) / 5) + const len = 0.025 + 0.045 * r() + const i0 = Math.floor(at * RATE) + for (let i = 0; i < len * RATE && i0 + i < x.length; i++) { + const t = i / RATE + // Odd harmonics, a little: a soft square, eased in and out (no click). + const w = Math.sin(2 * Math.PI * f * t) + Math.sin(6 * Math.PI * f * t) / 4 + Math.sin(10 * Math.PI * f * t) / 9 + x[i0 + i]! += w * Math.sin((Math.PI * i) / (len * RATE)) * amp * 0.3 + } + at += len + 0.02 + 0.06 * r() + } + } + wav(path, x) +} + +/** + * A gunshot, all noise: a crack blooming in under a millisecond, its + * low-pass closing from `open` Hz as it dies (`tau` s), with a body of lower + * noise under it; the room is added after. + */ +function gunshot(path: string, seed: number, seconds: number, open: number, tau: number, body: number): void { + const r = rng(seed) + const x = new Float32Array(Math.ceil(seconds * RATE)) + const f = [0, 0] + const g = [0, 0] + for (let i = 0; i < x.length; i++) { + const t = i / RATE + const env = (1 - Math.exp(-t / 0.0006)) * Math.exp(-t / tau) + const hz = 300 + (open - 300) * Math.exp(-t / (tau * 0.6)) + const k = 1 - Math.exp((-2 * Math.PI * hz) / RATE) + const kb = 1 - Math.exp((-2 * Math.PI * 380) / RATE) + const n = r() * 2 - 1 + f[0]! += k * (n - f[0]!) + f[1]! += k * (f[0]! - f[1]!) + g[0]! += kb * (n - g[0]!) + g[1]! += kb * (g[0]! - g[1]!) + x[i] = (f[1]! + g[1]! * body * Math.exp(-t / (tau * 1.6))) * env + } + let max = 1e-6 + for (const v of x) max = Math.max(max, Math.abs(v)) + for (let i = 0; i < x.length; i++) x[i]! /= max + wav(path, x) +} + +/** + * A zap (a plasma bolt): a buzzing tone sweeping down from `f0` to `f1` Hz + * over `len` s, its edge from a fast wobble, with a fizz of noise. + */ +function zap(path: string, seed: number, len: number, f0: number, f1: number): void { + const r = rng(seed) + const x = new Float32Array(Math.ceil((len + 0.05) * RATE)) + let ph = 0 + for (let i = 0; i < x.length; i++) { + const t = i / RATE + const k = Math.min(1, t / len) + const hz = f0 * Math.pow(f1 / f0, k) + ph += (2 * Math.PI * hz * (1 + 0.08 * Math.sin(2 * Math.PI * 61 * t))) / RATE + const env = Math.min(1, t / 0.002) * Math.exp(-t / (len * 0.45)) + x[i] = (Math.tanh(2.5 * Math.sin(ph)) * 0.7 + (r() * 2 - 1) * 0.35) * env + } + wav(path, x) +} + type Tools = { v: number; tmp: (name: string) => string } + +/** How many shots a volley clip holds at a shot every `r` frames: about 1.4 s of fire (doom.ts says the same). */ +const volleyShots = (r: number) => Math.round(1400 / (r * 70)) type Recipe = { variants?: number; make: (out: string, t: Tools) => void } /** @@ -710,7 +790,205 @@ const RECIPES: Record> = { ]), ), }, + earthrise: { + // Designed, as the warp drive and the colony ship are (no real counterpart). + // There's no air on the moon, so no noise, wind or swells: only steady tones, + // all low (nothing whistling or glinting), in one key (A): a deep drone (its + // pitch carried by harmonics a laptop plays), its fifth, and a major chord + // above them filling in with the work. + drone: { + variants: 3, + make: (out, t) => { + hum(t.tmp('h.wav'), seedOf(out), SLEN, [[55, 0.25], [110, 0.35], [165, 0.3], [220, 0.22], [330, 0.1]], 220) + fx([t.tmp('h.wav')], out, 'reverb', 70, 50, 100, 'gain', '-n', -3, ...bedEnd(t, false)) + }, + }, + fifth: { + variants: 3, + make: (out, t) => { + hum(t.tmp('h.wav'), seedOf(out), SLEN, [[82.5, 0.2], [165, 0.3], [247.5, 0.25], [330, 0.12]], 300) + fx([t.tmp('h.wav')], out, 'reverb', 70, 50, 100, 'gain', '-n', -3, ...bedEnd(t, false)) + }, + }, + chord: { + variants: 3, + make: (out, t) => { + hum(t.tmp('h.wav'), seedOf(out), SLEN, [[220, 0.3], [277.5, 0.25], [330, 0.25], [440, 0.1]], 400) + fx([t.tmp('h.wav')], out, 'lowpass', 1000, 'reverb', 80, 50, 100, 'gain', '-n', -3, ...bedEnd(t, false)) + }, + }, + }, + doom: { + // A techbase, designed (generic, none of the game's own sounds): the machinery's + // hum and the air handlers (after a plant room: strongest at 63-250 Hz), computer + // banks chirping, nukage bubbling in the floor, and the fighting farther off. + hum: { + variants: 3, + make: (out, t) => { + hum(t.tmp('h.wav'), seedOf(out), SLEN, [[60, 0.3], [120, 0.4], [180, 0.3], [240, 0.18], [360, 0.08], [480, 0.04]], 180) + fx([t.tmp('h.wav')], out, 'overdrive', 4, 'reverb', 45, 50, 80, 'gain', '-n', -3, ...bedEnd(t, false)) + }, + }, + air: { variants: 3, make: (out, t) => noiseBed(out, t, ['pinknoise', 'lowpass', 1400, 'highpass', 80, 'bass', '+3', 140], [[0.27, 0.12], [1.3, 0.06]], ['reverb', 40]) }, + computers: { + variants: 3, + make: (out, t) => { + chirps(t.tmp('c.wav'), t.v + 170, SLEN, 0.7, 700, 2400) + fx([t.tmp('c.wav')], out, 'reverb', 50, 50, 70, 'gain', '-n', -4, ...bedEnd(t, false)) + }, + }, + nukage: { + variants: 3, + make: (out, t) => { + blips(t.tmp('b.wav'), t.v + 180, SLEN, 3, 140, 420, 5) + fx([t.tmp('b.wav')], out, 'lowpass', 1100, 'reverb', 40, 'gain', '-n', -4, ...bedEnd(t, false)) + }, + }, + rumble: { + variants: 3, + make: (out, t) => { + // Distant fighting: deep thumps at random under a low roar, all through the walls. + impulses(t.tmp('i.wav'), t.v + 190, SLEN, 3, [600, 2400], 0.6) + synth(t.tmp('n.wav'), 'synth', SLEN, 'brownnoise', 'lowpass', 300, 'gain', -10) + turbulence(t.tmp('n.wav'), seedOf(out), [[0.4, 0.4], [1.7, 0.2]]) + fx([t.tmp('i.wav'), t.tmp('n.wav')], out, 'lowpass', 500, 'bass', '+4', 110, 'reverb', 60, 50, 100, 'gain', '-n', -3, ...bedEnd(t, false)) + }, + }, + }, events: { + pistol: { + // A sharp crack with a little body, in a hard steel corridor. + variants: 3, + make: (out, t) => { + gunshot(t.tmp('g.wav'), 300 + t.v * 13, 0.5, 6000 + 600 * t.v, 0.035, 0.6) + fx([t.tmp('g.wav')], out, 'highpass', 160, 'reverb', 35, 60, 60, 'gain', '-n', -2) + }, + }, + chaingun: { + // Shorter and punchier than the pistol, its tail short (the next is right behind). + variants: 3, + make: (out, t) => { + gunshot(t.tmp('g.wav'), 310 + t.v * 17, 0.3, 5000 + 700 * t.v, 0.022, 0.9) + fx([t.tmp('g.wav')], out, 'highpass', 140, 'overdrive', 6, 'reverb', 20, 60, 40, 'gain', '-n', -2) + }, + }, + shotgun: { + // A heavy boom, then the pump racked: back, forward. + variants: 3, + make: (out, t) => { + explosion(t.tmp('x.wav'), 320 + t.v * 19, 0.7, 4200 + 400 * t.v) + const rack = (name: string, at: number, hz: number) => { + synth(t.tmp(name), 'synth', 0.05, 'whitenoise', 'bandpass', hz, hz * 0.6, 'fade', 'q', 0.001, 0.05, 0.04, 'gain', -10, 'pad', at, 0) + return t.tmp(name) + } + const a = rack('a.wav', 0.42 + 0.03 * t.v, 2600) + const b = rack('b.wav', 0.56 + 0.03 * t.v, 1900) + fx([t.tmp('x.wav'), a, b], out, 'highpass', 90, 'overdrive', 8, 'reverb', 40, 60, 70, 'gain', '-n', -2) + }, + }, + plasma: { + // A bolt leaving the rifle: a buzzing zap falling in pitch (generic sci-fi). + variants: 3, + make: (out, t) => { + zap(t.tmp('z.wav'), 330 + t.v, 0.16 + 0.02 * t.v, 2400 + 300 * t.v, 380) + fx([t.tmp('z.wav')], out, 'highpass', 200, 'lowpass', 7000, 'reverb', 30, 50, 50, 'gain', '-n', -3) + }, + }, + // A fast gun heard a volley at a time, one clip for VOLLEY_SHOTS of its shots at its rate (frames of 70 ms): + // a clip a shot would fill the player's four places (each held for its length and afplay's drain) and cut + // each other off unheard. Each shot its own take. + ...Object.fromEntries( + ([['rattle', 3], ['rattle', 4], ['rattle', 5], ['bolts', 2], ['bolts', 3]] as const).map(([name, r]) => [ + `${name}${r}-`, + { + variants: 2, + make: (out: string, t: Tools) => { + const n = volleyShots(r) + const parts = Array.from({ length: n }, (_, i) => { + const seed = 340 + 31 * t.v + 7 * i + 100 * r + if (name === 'rattle') { + gunshot(t.tmp('g.wav'), seed, 0.3, 5000 + 700 * ((i + t.v) % 3), 0.022, 0.9) + fx([t.tmp('g.wav')], t.tmp(`s${i}.wav`), 'highpass', 140, 'overdrive', 6, 'gain', -1 + 0.6 * ((i * 7) % 3) / 2, 'pad', ((i * r * 70) / 1000).toFixed(3), 0) + } else { + zap(t.tmp('z.wav'), seed, 0.16 + 0.02 * ((i + t.v) % 3), 2400 + 300 * ((i + t.v) % 3), 380) + fx([t.tmp('z.wav')], t.tmp(`s${i}.wav`), 'highpass', 200, 'lowpass', 7000, 'pad', ((i * r * 70) / 1000).toFixed(3), 0) + } + return t.tmp(`s${i}.wav`) + }) + fx(parts, out, ...(name === 'rattle' ? ['reverb', 20, 60, 40] : ['reverb', 30, 50, 50]), 'gain', '-n', name === 'rattle' ? -2 : -3) + }, + }, + ]), + ), + splat: { + // Something going down: a wet thump and a squelch. + variants: 3, + make: (out, t) => { + synth(t.tmp('k.wav'), 'synth', 0.35, 'sine', `${120 + 10 * t.v}-60`, 'fade', 'q', 0.003, 0.35, 0.3) + synth(t.tmp('n.wav'), 'synth', 0.3, 'pinknoise', 'bandpass', 700, 900) + turbulence(t.tmp('n.wav'), seedOf(out), [[38, 0.7], [12, 0.4]]) + fx([t.tmp('n.wav')], t.tmp('n2.wav'), 'fade', 'q', 0.005, 0.3, 0.25, 'gain', -4) + fx([t.tmp('k.wav'), t.tmp('n2.wav')], out, 'reverb', 30, 60, 50, 'gain', '-n', -3) + }, + }, + fireball: { + // A fireball thrown: a roaring whoosh, crackling. + variants: 3, + make: (out, t) => { + synth(t.tmp('n.wav'), 'synth', 0.8, 'brownnoise', 'synth', 0.8, 'pinknoise', 'mix', 'bandpass', 600 + 100 * t.v, 700) + turbulence(t.tmp('n.wav'), seedOf(out), [[14, 0.35], [4, 0.25]]) + impulses(t.tmp('i.wav'), t.v + 200, 0.8, 30, [20, 80], 0.8) + fx([t.tmp('i.wav')], t.tmp('i2.wav'), 'highpass', 1500, 'gain', -8) + fx([t.tmp('n.wav'), t.tmp('i2.wav')], out, 'fade', 'q', 0.12, 0.8, 0.5, 'reverb', 40, 'gain', '-n', -3) + }, + }, + door: { + // A heavy door rising: a motor's grind and the slab scraping up, a clunk as it stops (short: in the tall + // view a door comes every few seconds, and a clip holds a place in the player for its length). + make: (out, t) => { + hum(t.tmp('m.wav'), 210, 0.9, [[90, 0.4], [180, 0.35], [270, 0.2], [360, 0.1]], 40) + synth(t.tmp('s.wav'), 'synth', 0.9, 'brownnoise', 'synth', 0.9, 'pinknoise', 'mix', 'lowpass', 1200, 'highpass', 90, 'gain', -6) + turbulence(t.tmp('s.wav'), 211, [[7, 0.3], [2, 0.2]]) + fx([t.tmp('m.wav'), t.tmp('s.wav')], t.tmp('ms.wav'), 'fade', 'q', 0.1, 0.9, 0.15, 'gain', -4) + clunk(t.tmp('c.wav'), 212) + fx([t.tmp('c.wav')], t.tmp('c2.wav'), 'gain', -6, 'pad', 0.78, 0) + fx([t.tmp('ms.wav'), t.tmp('c2.wav')], out, 'reverb', 35, 60, 60, 'gain', '-n', -3) + }, + }, + reload: { + // A gun changed: two clacks of steel. + make: (out, t) => { + clunk(t.tmp('a.wav'), 220) + clunk(t.tmp('b.wav'), 221) + fx([t.tmp('a.wav')], t.tmp('a2.wav'), 'highpass', 900, 'trim', 0, 0.25) + fx([t.tmp('b.wav')], t.tmp('b2.wav'), 'highpass', 1200, 'trim', 0, 0.25, 'pad', 0.3, 0) + fx([t.tmp('a2.wav'), t.tmp('b2.wav')], out, 'reverb', 25, 60, 40, 'gain', '-n', -3) + }, + }, + teleport: { + // A marine teleporting out: a shimmering sweep rising, glittering, fading (generic sci-fi). + make: (out, t) => { + wander(t.tmp('w.wav'), 230, 1.6, 600, 3800, 6, 25) + synth(t.tmp('r.wav'), 'synth', 1.6, 'sine', '300-1800', 'gain', -16) + glints(t.tmp('g.wav'), 231, 1.6, 30) + fx([t.tmp('w.wav'), t.tmp('r.wav'), t.tmp('g.wav')], out, 'fade', 'q', 0.25, 1.6, 0.9, 'reverb', 70, 50, 100, 'gain', '-n', -3) + }, + }, + streak: { + // A shooting star: a soft low tone, struck and fading (no air on the moon to whoosh through). + variants: 3, + make: (out, t) => { + hum(t.tmp('h.wav'), 240 + t.v, 1.2, [[220, 0.3], [330, 0.25], [440, 0.1]], 300) + fx([t.tmp('h.wav')], out, 'lowpass', 1000, 'fade', 'q', 0.03, 1.2, 1, 'reverb', 60, 50, 100, 'gain', '-n', -4) + }, + }, + comet: { + // A comet: a long, low chord swelling in as it comes into the sky. + make: (out, t) => { + hum(t.tmp('h.wav'), 250, 6.5, [[110, 0.3], [165, 0.28], [220, 0.22], [277.5, 0.15]], 300) + fx([t.tmp('h.wav')], out, 'fade', 'q', 2.5, 6.5, 3.5, 'reverb', 80, 50, 100, 'gain', '-n', -4) + }, + }, horn: { // A diesel's two-tone horn, high then low: each reed a steady pitch that's // never pure (narrow resonances on noise, see hum) with its overtones, diff --git a/sounds/doom/bed-l01.m4a b/sounds/doom/bed-l01.m4a new file mode 100644 index 0000000..7ab11b0 Binary files /dev/null and b/sounds/doom/bed-l01.m4a differ diff --git a/sounds/doom/bed-l02.m4a b/sounds/doom/bed-l02.m4a new file mode 100644 index 0000000..c3351e9 Binary files /dev/null and b/sounds/doom/bed-l02.m4a differ diff --git a/sounds/doom/bed-l03.m4a b/sounds/doom/bed-l03.m4a new file mode 100644 index 0000000..3b27861 Binary files /dev/null and b/sounds/doom/bed-l03.m4a differ diff --git a/sounds/doom/bed-l11.m4a b/sounds/doom/bed-l11.m4a new file mode 100644 index 0000000..e81126c Binary files /dev/null and b/sounds/doom/bed-l11.m4a differ diff --git a/sounds/doom/bed-l12.m4a b/sounds/doom/bed-l12.m4a new file mode 100644 index 0000000..b7d1fbd Binary files /dev/null and b/sounds/doom/bed-l12.m4a differ diff --git a/sounds/doom/bed-l13.m4a b/sounds/doom/bed-l13.m4a new file mode 100644 index 0000000..100237e Binary files /dev/null and b/sounds/doom/bed-l13.m4a differ diff --git a/sounds/doom/bed-l21.m4a b/sounds/doom/bed-l21.m4a new file mode 100644 index 0000000..94c4244 Binary files /dev/null and b/sounds/doom/bed-l21.m4a differ diff --git a/sounds/doom/bed-l22.m4a b/sounds/doom/bed-l22.m4a new file mode 100644 index 0000000..3a0d91a Binary files /dev/null and b/sounds/doom/bed-l22.m4a differ diff --git a/sounds/doom/bed-l23.m4a b/sounds/doom/bed-l23.m4a new file mode 100644 index 0000000..9e42a9d Binary files /dev/null and b/sounds/doom/bed-l23.m4a differ diff --git a/sounds/doom/bed-l31.m4a b/sounds/doom/bed-l31.m4a new file mode 100644 index 0000000..c753b89 Binary files /dev/null and b/sounds/doom/bed-l31.m4a differ diff --git a/sounds/doom/bed-l32.m4a b/sounds/doom/bed-l32.m4a new file mode 100644 index 0000000..ad7c09d Binary files /dev/null and b/sounds/doom/bed-l32.m4a differ diff --git a/sounds/doom/bed-l33.m4a b/sounds/doom/bed-l33.m4a new file mode 100644 index 0000000..3439646 Binary files /dev/null and b/sounds/doom/bed-l33.m4a differ diff --git a/sounds/earthrise/bed-l01.m4a b/sounds/earthrise/bed-l01.m4a new file mode 100644 index 0000000..00dc355 Binary files /dev/null and b/sounds/earthrise/bed-l01.m4a differ diff --git a/sounds/earthrise/bed-l02.m4a b/sounds/earthrise/bed-l02.m4a new file mode 100644 index 0000000..65c2602 Binary files /dev/null and b/sounds/earthrise/bed-l02.m4a differ diff --git a/sounds/earthrise/bed-l03.m4a b/sounds/earthrise/bed-l03.m4a new file mode 100644 index 0000000..efa6113 Binary files /dev/null and b/sounds/earthrise/bed-l03.m4a differ diff --git a/sounds/earthrise/bed-l11.m4a b/sounds/earthrise/bed-l11.m4a new file mode 100644 index 0000000..0fed36d Binary files /dev/null and b/sounds/earthrise/bed-l11.m4a differ diff --git a/sounds/earthrise/bed-l12.m4a b/sounds/earthrise/bed-l12.m4a new file mode 100644 index 0000000..3f83e98 Binary files /dev/null and b/sounds/earthrise/bed-l12.m4a differ diff --git a/sounds/earthrise/bed-l13.m4a b/sounds/earthrise/bed-l13.m4a new file mode 100644 index 0000000..2dba2a9 Binary files /dev/null and b/sounds/earthrise/bed-l13.m4a differ diff --git a/sounds/earthrise/bed-l21.m4a b/sounds/earthrise/bed-l21.m4a new file mode 100644 index 0000000..44fc8ff Binary files /dev/null and b/sounds/earthrise/bed-l21.m4a differ diff --git a/sounds/earthrise/bed-l22.m4a b/sounds/earthrise/bed-l22.m4a new file mode 100644 index 0000000..d49b24a Binary files /dev/null and b/sounds/earthrise/bed-l22.m4a differ diff --git a/sounds/earthrise/bed-l23.m4a b/sounds/earthrise/bed-l23.m4a new file mode 100644 index 0000000..1043721 Binary files /dev/null and b/sounds/earthrise/bed-l23.m4a differ diff --git a/sounds/earthrise/bed-l31.m4a b/sounds/earthrise/bed-l31.m4a new file mode 100644 index 0000000..7355943 Binary files /dev/null and b/sounds/earthrise/bed-l31.m4a differ diff --git a/sounds/earthrise/bed-l32.m4a b/sounds/earthrise/bed-l32.m4a new file mode 100644 index 0000000..cd8dd25 Binary files /dev/null and b/sounds/earthrise/bed-l32.m4a differ diff --git a/sounds/earthrise/bed-l33.m4a b/sounds/earthrise/bed-l33.m4a new file mode 100644 index 0000000..c851f80 Binary files /dev/null and b/sounds/earthrise/bed-l33.m4a differ diff --git a/sounds/events/bolts2-1.m4a b/sounds/events/bolts2-1.m4a new file mode 100644 index 0000000..5b47c89 Binary files /dev/null and b/sounds/events/bolts2-1.m4a differ diff --git a/sounds/events/bolts2-2.m4a b/sounds/events/bolts2-2.m4a new file mode 100644 index 0000000..18e8ed5 Binary files /dev/null and b/sounds/events/bolts2-2.m4a differ diff --git a/sounds/events/bolts3-1.m4a b/sounds/events/bolts3-1.m4a new file mode 100644 index 0000000..d8f6374 Binary files /dev/null and b/sounds/events/bolts3-1.m4a differ diff --git a/sounds/events/bolts3-2.m4a b/sounds/events/bolts3-2.m4a new file mode 100644 index 0000000..f0c33ab Binary files /dev/null and b/sounds/events/bolts3-2.m4a differ diff --git a/sounds/events/chaingun1.m4a b/sounds/events/chaingun1.m4a new file mode 100644 index 0000000..0224b45 Binary files /dev/null and b/sounds/events/chaingun1.m4a differ diff --git a/sounds/events/chaingun2.m4a b/sounds/events/chaingun2.m4a new file mode 100644 index 0000000..56d0ac2 Binary files /dev/null and b/sounds/events/chaingun2.m4a differ diff --git a/sounds/events/chaingun3.m4a b/sounds/events/chaingun3.m4a new file mode 100644 index 0000000..18fb6b4 Binary files /dev/null and b/sounds/events/chaingun3.m4a differ diff --git a/sounds/events/comet.m4a b/sounds/events/comet.m4a new file mode 100644 index 0000000..c355bb0 Binary files /dev/null and b/sounds/events/comet.m4a differ diff --git a/sounds/events/door.m4a b/sounds/events/door.m4a new file mode 100644 index 0000000..57b547b Binary files /dev/null and b/sounds/events/door.m4a differ diff --git a/sounds/events/fireball1.m4a b/sounds/events/fireball1.m4a new file mode 100644 index 0000000..6c5ece5 Binary files /dev/null and b/sounds/events/fireball1.m4a differ diff --git a/sounds/events/fireball2.m4a b/sounds/events/fireball2.m4a new file mode 100644 index 0000000..52ca759 Binary files /dev/null and b/sounds/events/fireball2.m4a differ diff --git a/sounds/events/fireball3.m4a b/sounds/events/fireball3.m4a new file mode 100644 index 0000000..1097494 Binary files /dev/null and b/sounds/events/fireball3.m4a differ diff --git a/sounds/events/pistol1.m4a b/sounds/events/pistol1.m4a new file mode 100644 index 0000000..9f6a08b Binary files /dev/null and b/sounds/events/pistol1.m4a differ diff --git a/sounds/events/pistol2.m4a b/sounds/events/pistol2.m4a new file mode 100644 index 0000000..42912f6 Binary files /dev/null and b/sounds/events/pistol2.m4a differ diff --git a/sounds/events/pistol3.m4a b/sounds/events/pistol3.m4a new file mode 100644 index 0000000..e14b007 Binary files /dev/null and b/sounds/events/pistol3.m4a differ diff --git a/sounds/events/plasma1.m4a b/sounds/events/plasma1.m4a new file mode 100644 index 0000000..c3fb792 Binary files /dev/null and b/sounds/events/plasma1.m4a differ diff --git a/sounds/events/plasma2.m4a b/sounds/events/plasma2.m4a new file mode 100644 index 0000000..058f086 Binary files /dev/null and b/sounds/events/plasma2.m4a differ diff --git a/sounds/events/plasma3.m4a b/sounds/events/plasma3.m4a new file mode 100644 index 0000000..49f6566 Binary files /dev/null and b/sounds/events/plasma3.m4a differ diff --git a/sounds/events/rattle3-1.m4a b/sounds/events/rattle3-1.m4a new file mode 100644 index 0000000..6bbf94e Binary files /dev/null and b/sounds/events/rattle3-1.m4a differ diff --git a/sounds/events/rattle3-2.m4a b/sounds/events/rattle3-2.m4a new file mode 100644 index 0000000..7e685a3 Binary files /dev/null and b/sounds/events/rattle3-2.m4a differ diff --git a/sounds/events/rattle4-1.m4a b/sounds/events/rattle4-1.m4a new file mode 100644 index 0000000..2056309 Binary files /dev/null and b/sounds/events/rattle4-1.m4a differ diff --git a/sounds/events/rattle4-2.m4a b/sounds/events/rattle4-2.m4a new file mode 100644 index 0000000..c3f3ddf Binary files /dev/null and b/sounds/events/rattle4-2.m4a differ diff --git a/sounds/events/rattle5-1.m4a b/sounds/events/rattle5-1.m4a new file mode 100644 index 0000000..b5f5879 Binary files /dev/null and b/sounds/events/rattle5-1.m4a differ diff --git a/sounds/events/rattle5-2.m4a b/sounds/events/rattle5-2.m4a new file mode 100644 index 0000000..e786d06 Binary files /dev/null and b/sounds/events/rattle5-2.m4a differ diff --git a/sounds/events/reload.m4a b/sounds/events/reload.m4a new file mode 100644 index 0000000..79aac9d Binary files /dev/null and b/sounds/events/reload.m4a differ diff --git a/sounds/events/shotgun1.m4a b/sounds/events/shotgun1.m4a new file mode 100644 index 0000000..fe5e71a Binary files /dev/null and b/sounds/events/shotgun1.m4a differ diff --git a/sounds/events/shotgun2.m4a b/sounds/events/shotgun2.m4a new file mode 100644 index 0000000..0880476 Binary files /dev/null and b/sounds/events/shotgun2.m4a differ diff --git a/sounds/events/shotgun3.m4a b/sounds/events/shotgun3.m4a new file mode 100644 index 0000000..bcaa26a Binary files /dev/null and b/sounds/events/shotgun3.m4a differ diff --git a/sounds/events/splat1.m4a b/sounds/events/splat1.m4a new file mode 100644 index 0000000..2759281 Binary files /dev/null and b/sounds/events/splat1.m4a differ diff --git a/sounds/events/splat2.m4a b/sounds/events/splat2.m4a new file mode 100644 index 0000000..c353ffc Binary files /dev/null and b/sounds/events/splat2.m4a differ diff --git a/sounds/events/splat3.m4a b/sounds/events/splat3.m4a new file mode 100644 index 0000000..9a6ccc3 Binary files /dev/null and b/sounds/events/splat3.m4a differ diff --git a/sounds/events/streak1.m4a b/sounds/events/streak1.m4a new file mode 100644 index 0000000..bbb7601 Binary files /dev/null and b/sounds/events/streak1.m4a differ diff --git a/sounds/events/streak2.m4a b/sounds/events/streak2.m4a new file mode 100644 index 0000000..ec585d3 Binary files /dev/null and b/sounds/events/streak2.m4a differ diff --git a/sounds/events/streak3.m4a b/sounds/events/streak3.m4a new file mode 100644 index 0000000..e078777 Binary files /dev/null and b/sounds/events/streak3.m4a differ diff --git a/sounds/events/teleport.m4a b/sounds/events/teleport.m4a new file mode 100644 index 0000000..0ab4e33 Binary files /dev/null and b/sounds/events/teleport.m4a differ diff --git a/tests/flow.test.ts b/tests/flow.test.ts index 996431a..60bfb91 100644 --- a/tests/flow.test.ts +++ b/tests/flow.test.ts @@ -1,4 +1,4 @@ -// REVISION: flow-v171-dry-adapter +// REVISION: flow-v179-doom-volleys import type { EngineInterface, On } from 'claude-code' import { expect, mock, test } from 'claude-code/testing' @@ -16,13 +16,14 @@ import { Balloon, skyColor } from '../hooks/balloon' import { Falcon } from '../hooks/rocket' import { Colony } from '../hooks/colony' import { Train } from '../hooks/train' +import { Doom } from '../hooks/doom' import { makeScene, nextStyle, SCENES, STYLES, styleNamed, type SceneName } from '../hooks/styles' import { finishSave, openSession, planSave, runScene, type SceneCtx } from '../hooks/register' import { coverage, frameSvg, gridPixels, SVG_LIMIT } from '../hooks/svg' import { Cells, isTall } from '../hooks/cells' import { SceneDriver } from '../hooks/scene' import { BED_EVERY_MS, BED_FADE_MS, BED_MIN_MS, BED_MS, BURST_MAX, CHIME_DELAY_MS, CHIME_QUIET_MS, MAX_PLAYS, MOODS, PLAYER_DRAIN_MS, PLAYER_LEAD_MS, type BedTake, bedGap, bedPlay, bedStep, burst, chimePlay, chimeStep, EVENTS, eventPlay, gather, LAYERS, newChimeState } from '../hooks/sound' -import { DEFAULT_VOLUME, MAX_GAIN, master, VOLUME_DB, volumeGain } from '../hooks/sound' +import { DEFAULT_VOLUME, leadFrames, MAX_GAIN, master, soundsAs, VOLUME_DB, volumeGain } from '../hooks/sound' import { BREATH_FRAMES, breath, easeWait, waitTone } from '../hooks/waiting' import { SOUND_FILES } from '../hooks/sound-files' import { PixelScene, type Dials, type Painter } from '../hooks/pixel-scene' @@ -985,12 +986,12 @@ test('soundscapes: every scene has a bed for every mood, every clip it and the e const files = new Set(SOUND_FILES) const ambs = [{}, { roar: 1, vent: 1, wind: 1, space: 0, burner: 1, swell: 1, lip: 1 }, { roar: 1, space: 1 }, { wind: 0.5 }, { sea: 1 }] for (const scene of STYLES) { - // A scene without layers is silent (a new one, till it's given a soundscape): no moods either. - if (!LAYERS[scene]) { - expect(MOODS[scene]).toBeUndefined() + // A scene without layers is silent (a new one, till it's given a soundscape): no moods either. (doom3d sounds as doom.) + if (!LAYERS[soundsAs(scene)]) { + expect(MOODS[soundsAs(scene)]).toBeUndefined() continue } - expect(MOODS[scene]).toBeDefined() + expect(MOODS[soundsAs(scene)]).toBeDefined() let heard = 0 for (let level = 0; level <= 10; level++) for (const amb of ambs) @@ -1379,6 +1380,23 @@ test('the band draws its 5 rows on the terminal', async ($, on) => { await ui.unmount() }) +test('a scene with a taller view of its own (doom3d) gets a taller band, as far as the prompt leaves; back to 5 for the rest', async ($, on) => { + mock.clock(on) + mock.store(on) + engine(on) + await start($) + await flow($, 'doom3d') + const rowsWith = async (maxRows: number) => { + const ui = await $.ui.mount({ plugin: 'flow-scenes', surface: 'terminal', ...BAND, props: { ...BAND.props, maxRows } }) + const rows = (await ui.find({ type: 'Raster', key: 'flow' }))?.props.rows + await ui.unmount() + return rows + } + expect([await rowsWith(40), await rowsWith(12)]).toEqual([14, 11]) + await flow($, 'doom') + expect(await rowsWith(40)).toBe(5) +}) + test('the band yields to other surfaces, surveys, and a one-row squeeze', async ($, on) => { mock.clock(on) mock.store(on) @@ -2929,6 +2947,168 @@ test("sessions: a wait is its own session's: the chime follows that session's so await ui.unmount() }) +test('doom: the level is how hard he goes at it: a pistol at a plod at 1, faster and with heavier guns up to a plasma rifle at a run at 10', () => { + for (const [columns, rows] of [[120, 5], [22, 40]] as const) { + const run = (level: number) => { + const d = makeScene('doom', 3) as Doom + d.strength = level + d.ensure(columns, rows) + for (let i = 0; i < 900; i++) d.step() + return d + } + const [calm, mid, busy] = [run(1), run(5), run(10)] + const at = `${columns}×${rows}` + expect([at, calm.weapon, mid.weapon, busy.weapon]).toEqual([at, 0, 1, 3]) + expect([at, calm.distance < mid.distance && mid.distance < busy.distance]).toEqual([at, true]) + expect([at, calm.shots > 0 && calm.shots < mid.shots && mid.shots < busy.shots]).toEqual([at, true]) + // The level easing up, he changes guns on the way. + const d = run(1) + const guns = new Set() + d.strength = 10 + for (let i = 0; i < 400; i++) { + d.step() + guns.add(d.weapon) + } + expect([at, [...guns].sort()]).toEqual([at, [0, 1, 2, 3]]) + } +}) + +test('doom: waiting on the person, he stops and holds his fire and the monsters slink away; answered, the fight goes on', () => { + for (const [columns, rows] of [[120, 5], [22, 40]] as const) { + const d = makeScene('doom', 4) as Doom + d.strength = 8 + d.ensure(columns, rows) + for (let i = 0; i < 300; i++) d.step() + d.waiting = true + d.strength = WAIT_LEVEL + for (let i = 0; i < 120; i++) d.step() + const shots = d.shots + const where = d.distance + for (let i = 0; i < 200; i++) d.step() + const at = `${columns}×${rows}` + expect([at, d.speed, d.coming, d.shots - shots, d.distance - where]).toEqual([at, 0, 0, 0, 0]) + d.waiting = false + d.strength = 6 + for (let i = 0; i < 600; i++) d.step() + expect([at, d.shots > shots, d.distance > where]).toEqual([at, true, true]) + } +}) + +/** His single shots' sounds, and each volley's (a fast gun's run of shots) with the frames between its shots. */ +const SHOTS = ['pistol', 'shotgun', 'chaingun', 'plasma'] +const VOLLEY_RATE: Record = { rattle3: 3, rattle4: 4, rattle5: 5, bolts2: 2, bolts3: 3 } + +test('doom: heard as it happens: each gun its own shot (his sent ahead of its flash, so it lands with it; a fast gun a volley at a time, stopped when it falls silent), the fallen, his footsteps; none while he holds for the person', () => { + for (const [scene, columns, rows] of [['doom', 120, 5], ['doom', 22, 40], ['doom3d', 160, 14]] as const) { + const heard = (level: number) => { + const d = makeScene(scene, 3) as Doom + d.strength = level + d.ensure(columns, rows) + const kinds = new Set() + // Each of his shots: the frames between its sound going out and its flash. + const early: number[] = [] + let sent = -1 + let last = -99 + // The frames his shots are heard at (each volley's, as its clip plays them, to a cease), and fired at. + const heardAt: number[] = [] + const firedAt: number[] = [] + let ceases = 0 + for (let i = 0; i < 900; i++) { + const shots = d.shots + d.step() + for (const e of d.sounds) { + kinds.add(e.kind) + if (e.v === 1 && (SHOTS.includes(e.kind) || VOLLEY_RATE[e.kind])) sent = i + const r = VOLLEY_RATE[e.kind] + const lead = leadFrames(d.strength, 'normal') + if (r) for (let k = 0; k < Math.round(1400 / (r * 70)); k++) heardAt.push(i + lead + k * r) + if (e.kind === 'cease') { + ceases++ + while (heardAt.length && heardAt[heardAt.length - 1]! > i) heardAt.pop() + } + } + d.sounds.length = 0 + if (d.shots > shots) { + // (The first shot of a run: a volley going on was sent as the last ran out, a run's length ahead.) + if (sent >= 0 && i - last > 6) early.push(i - sent) + sent = -1 + last = i + firedAt.push(i) + } + } + return { kinds, early, heardAt, firedAt, ceases, d } + } + const at = `${scene} ${columns}×${rows}` + const [calm, mid, fast, busy] = [heard(1), heard(5), heard(7), heard(10)] + expect([at, calm.kinds.has('pistol'), mid.kinds.has('shotgun'), [...fast.kinds].some(k => k.startsWith('rattle')), [...busy.kinds].some(k => k.startsWith('bolts'))]).toEqual([at, true, true, true, true]) + // (Footsteps while it's calm; in a busy fight the guns have the player's places.) + expect([at, calm.kinds.has('step'), busy.kinds.has('step'), busy.kinds.has('splat')]).toEqual([at, true, false, true]) + // A pistol's or a shotgun's shot is heard the player's start-up time ahead (to the frame), the first of a fight + // too (he takes aim that long); a faster gun's as soon as the last has fired. + for (const { early, d } of [calm, mid]) { + const lead = Math.ceil(leadFrames(d.strength, 'normal') - 1e-9) + expect([at, early.length > 0, early.every(f => Math.abs(f - lead) <= 1)]).toEqual([at, true, true]) + } + // A fast gun's volley lands with the first shot of a run too (nearly always: a run broken off and taken up + // again within a frame or two may not). Its shots are heard with his, to the frame: nearly every shot fired + // is heard, and a shot heard that he never fired is the one a cease stops (just begun) or very rare. + for (const { early, heardAt, firedAt, ceases, d } of [fast, busy]) { + const lead = Math.ceil(leadFrames(d.strength, 'normal') - 1e-9) + expect([at, early.length > 0, early.filter(f => Math.abs(f - lead) <= 1).length >= 0.8 * early.length]).toEqual([at, true, true]) + const near = (xs: number[], f: number) => xs.some(x => Math.abs(x - f) <= 1) + const heard = firedAt.filter(f => f < 880 && near(heardAt, f)).length / firedAt.filter(f => f < 880).length + const phantoms = heardAt.filter(f => f < 880 && !near(firedAt, f)).length + expect([at, heard >= 0.9, phantoms <= ceases + 0.05 * heardAt.length]).toEqual([at, true, true]) + } + // Holding for the person: nothing fired, so nothing of his heard. + const d = busy.d + d.waiting = true + d.strength = WAIT_LEVEL + for (let i = 0; i < 120; i++) d.step() + d.sounds.length = 0 + for (let i = 0; i < 200; i++) d.step() + expect([at, d.sounds.filter(e => e.v === 1 && (SHOTS.includes(e.kind) || VOLLEY_RATE[e.kind])).length]).toEqual([at, 0]) + } +}) + +test('earthrise: heard as it happens: a streak for each shooting star (more with the work), a swell for a comet; none while it holds', () => { + for (const [columns, rows] of [[120, 5], [22, 40]] as const) { + const count = (level: number, waiting = false) => { + const e = makeScene('earthrise', 5) + e.strength = level + e.waiting = waiting + e.ensure(columns, rows) + const kinds: Record = {} + for (let i = 0; i < 1500; i++) { + e.step() + for (const s of e.sounds!) kinds[s.kind] = (kinds[s.kind] ?? 0) + 1 + e.sounds!.length = 0 + } + return kinds + } + const at = `${columns}×${rows}` + const [calm, busy, held] = [count(1), count(10), count(10, true)] + expect([at, (calm.streak ?? 0) < (busy.streak ?? 0), (busy.comet ?? 0) > 0]).toEqual([at, true, true]) + expect([at, (held.streak ?? 0) + (held.comet ?? 0) <= 2]).toEqual([at, true]) + } +}) + +test('doom3d: the same fight through his own eyes, in a band of any height, and it asks for a taller one', () => { + expect([(makeScene('doom3d', 1) as Doom).taller, (makeScene('doom', 1) as Doom).taller]).toEqual([true, false]) + for (const [columns, rows] of [[160, 14], [120, 5], [22, 40]] as const) { + const run = (level: number) => { + const d = makeScene('doom3d', 3) as Doom + d.strength = level + d.ensure(columns, rows) + for (let i = 0; i < 900; i++) d.step() + return d + } + const [calm, busy] = [run(1), run(10)] + const at = `${columns}×${rows}` + expect([at, calm.weapon, busy.weapon, calm.distance < busy.distance, calm.shots < busy.shots]).toEqual([at, 0, 3, true, true]) + } +}) + test('train: waiting on the person, it draws up at a red signal or a platform and stands, its lamps shining through the sepia; answered, the horn and away', () => { for (const [columns, rows] of [[120, 5], [22, 60]] as const) for (const seed of [3, 8]) { @@ -3503,7 +3683,7 @@ test('crew: only places the layout can show are given; less room sends the rest }) /** The scenes that give each subagent a companion of its own. */ -const CREW_SCENES = ['fire', 'surf', 'ski', 'balloon', 'falcon', 'starship', 'engine', 'train', 'avalon'] as const +const CREW_SCENES = ['fire', 'surf', 'ski', 'balloon', 'falcon', 'starship', 'engine', 'train', 'avalon', 'doom', 'doom3d'] as const test('companion scenes: each agent gets one that arrives, is marked where it is, and leaves when done, in the band and the spine', () => { for (const style of CREW_SCENES) {