SLOPSHOPPER

brainrot-break

A TikTok-style feed of pixel art mini-videos to scroll while Claude is answering: like, scroll, and watch Claude finish.

newpanebandcommandtimer
★ 1v1.5.2MITupdated 2026-10-25BordyV/brainrot-break/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brainrot-break
│ ┃ brainrot-break ✕ › fix the failing auth test and add an audit log call │ ┃ Loading the feed... │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /brainrot │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · brainrot-break
Loading the feed...
README

brainrot-break

Release Check Claude Code 2.1.305+ Terminal and desktop app 6 languages License: MIT reach validates

A feed of pixel art mini-videos to scroll while Claude thinks.

When an answer from Claude takes more than a couple of seconds, a TikTok-style feed opens next to it: scroll, like, and the feed tells you when Claude is done. It plays in the Claude Code terminal and in the Claude desktop app, in English, French, Spanish, German, Portuguese or Italian. Everything is drawn locally: it uses no tokens and calls no model.

Six of the feed's videos playing

Try the feed in your browser: every video, playing as in the mod.

Install

At the prompt of a Claude Code session:

/plugin marketplace add BordyV/brainrot-break
/plugin install brainrot-break@brainrot-break

From a shell:

claude plugin marketplace add BordyV/brainrot-break
claude plugin install brainrot-break@brainrot-break

In the Claude desktop app, where /plugin doesn't run: click + next to the prompt box, then Plugins, Add marketplace with BordyV/brainrot-break, then Add plugin.

To update later: claude plugin update brainrot-break@brainrot-break, then /reload-plugins or a new session.

How it works

  • Ask Claude something. If the answer takes more than ~2.5 s, the For you feed opens.
  • When Claude has answered, the video dims and a card says ✓ Claude has answered, with what you watched while waiting. Keep scrolling or close it; it closes by itself after 15 s.
  • A video can belong to several themes (Jenga is crafts and destruction). It shows as soon as one of its themes is ticked: Hide all then ticking robots brings every robot video.
  • Kinds you like come back sooner, and kinds you skip in the first 2 s a little later (scroll back within 10 s and that skip is forgotten). Scroll back and a video picks up where you left it.

Controls

ActionMouseKeys*
Next / previous videowheel, ▼ / ▲j / k
Pauseclick the videospace
Likedouble-click the video, ♥l
Comments≡m
Not interested (fewer videos like this one, Undo for 5 s)⊘n, then u to undo
Only this video's theme for now (click again or ✕ to stop)the #hashtags at the end of the caption
Menu: themes, my likes, settings··· at the top of the side columnt
Shortcuts? under ···h, or ? once the video has been clicked
Keep scrolling / close the end cardbuttonsc / x

\* Keys work once the feed holds the keyboard: ctrl+x tab gives it to the feed, Esc gives it back to the prompt. Once the video has been clicked, it takes the keys too.

Choose what you see

  • ⊘ Not interested (n): fewer videos like this one, and a little fewer of its theme. It never hides anything: a kind you mark comes back every few rounds instead of every round. ⊘ Fewer "…" · Undo shows for 5 s under the caption (u).
  • ··· menu (t), at the top of the side column, on the playing video:
  • Not interested, as ⊘;
  • Hide #theme: unticks this video's theme, so its videos stop coming unless another of their themes is ticked;
  • Choose my themes…: every theme with its number of videos, to switch on or off (one always stays on), Show all and Hide all (all but the playing video's, then switch on the ones you want);
  • My likes: the videos you liked, kept between sessions, to watch again;
  • Settings…: language, opening while Claude works, next video by itself;
  • Forget my "not interested": back to an even feed, likes kept.

Commands

CommandWhat it does
/brainrotOpen the feed now
/brainrot statsVideos watched, likes, time and your favorite kinds, this session and all time
/brainrot off / onTurn the automatic feed off or on
`/brainrot lang en\fr\es\de\pt\it`Switch the feed's language (also in ··· › Settings)
/brainrot fps 0-15Desktop app only: how many times a second the video redraws (0 freezes it)

The language can also be set in the plugin's config (language: en, fr, es, de, pt or it). English is the default.

The videos

454 of them, in 33 themes:

The 454 videos of the feed

  • Satisfying & ASMR (24): hourglass, bubbles, paint mixing, dominoes, kinetic sand, soap cubes, slime stretch, pressure washing, carpet cleaning, perfect fit, laser rust removal, ball sort, paint bucket, screen film peel, wood planer, bubble wrap, fridge restock, lawn stripes, snow plough, window squeegee, floor tiling, zen garden, folding laundry, dishwasher Tetris.
  • Ball & marble physics (25): marbles, double pendulum, growing ball, escape the rings, marble race, ball splits, string art bounce, which colour wins, spinning hexagon, marble funnel, balls vs saws, bounce flashes, marble machine, gravity maze, 100 balls one hole, snooker, pinball, peg breaker, colour gates, sky track, will it make it, rainbow ball, sled on a drawn line, Plinko*, pendulum wave*.
  • Retro arcade (33): runner, Tetris, brick breaker, racing, Snake, Chrome dino, Pac-Man, Geometry Dash, space invaders, asteroids, frog crossing, pong, galaxy dive, missile defence, barrel climber, lunar lander, centipede, bomber maze, minesweeper, light cycles, lemmings, platformer, bubble shooter, falling jewels, sokoban, solitaire win, speedrun, rage climb, closing hexagon, horde survivor*, air hockey*, arcade hoops*, shooting gallery*.
  • Mobile games (30): flappy bird, 2048, stack tower, fruit slicing, road crossing, helix jump, colour switch, hole in the city, territory, cell eater, match-3, block blast, zigzag, knife throw, crowd runner, pull the pin, parking jam, water sort, doodle jump, slingshot birds, merge watermelon, screw jam, hexa sort, colour block jam, runner*, sky track*, flow connect*, unblock the arrows*, triple tile match*, merge army*.
  • Versus & battles (21): rock paper scissors, flag battle royale, pong wars, ant colony war, red vs blue armies, colour spirals, snake vs snake, stick duel, big fish, zombie outbreak, tank duel, space dogfight, immune system, tower defence, colour tournament, sumo bots, fire vs water, foxes and rabbits, last ball standing, fire water grass, battling tops*.
  • Generative art & maths (16): Game of Life, Langton’s ant, boids, spirograph, fractal tree, maze solver, A* pathfinding, Voronoi growth, dragon curve, Hilbert curve, spinning cube, spinning donut, wave function collapse, water Pythagoras, pendulum wave, travelling salesman.
  • Animals & pets (27): sleeping cat, cat vs glass, dog zoomies, capybara spa, ducklings, frog and flies, hamster wheel, penguin slide, corgi walk, cat vs cucumber, baby hippo, frisbee dog, bird feeder, otters, raccoon raid, leafcutter ants, octopus camouflage, bees, squirrel course, cat vs laser pointer, salmon run, cat on the keyboard*, whale breach*, turtle hatchlings*, shell swap*, sheepdog*, seagull heist*.
  • Food & cooking (17): pancake flip, ramen pull, pizza toppings, popcorn, sushi conveyor, burger stack, cake frosting, frying an egg, bubble tea, fruit slices, donut glazing, smash burger, cotton candy, ice cream tower, watermelon bands, mochi pounding, tanghulu.
  • Nature & weather (16): northern lights, sunset at sea, thunderstorm, tornado, surfer, volcano, waterfall, four seasons tree, flower bloom, forest fire, geyser, falling leaves, mushrooms after rain, coral reef, caterpillar to butterfly*, salmon run*.
  • Space & sci-fi (14): rocket landing, solar system, black hole, warp speed, UFO abduction, planet formation, Mars rover, space docking, meteor shower, test flight, Kessler cascade, terraforming, wormhole, three-body problem.
  • Cozy & lo-fi (24): lava lamp, fireworks, campfire, snowy cabin, rainy window, lo-fi study desk, aquarium, windowsill plant, café window, train window, bookshelf tidy, night city windows, knitting, hot air balloons, lantern festival, fishing dock, farm crops, toasting marshmallows, jellyfish, ferris wheel, sailing, lighthouse, laundromat at night, snow globe*.
  • Sports (28): trick shots, penalty shootout, bowling strike, hole in one, ski slalom, skate halfpipe, pit stop, table tennis, curling, archery, beach volleyball, ski jump, horse race, cup stacking, speed cube, keepy-uppy, parkour, bungee jump, juggling, wall of death, speed bag, drag race*, monster truck*, snooker*, pinball*, stadium wave*, surfer*, arcade hoops*.
  • Vehicles & city (18): cyberpunk city, traffic lights, roundabout, parallel parking, level crossing, runway, car wash, tower crane, city builder, metro map, drag race, monster truck, container port, drawbridge, night highway, rollercoaster, building demolition, scramble crossing.
  • Music & dance (13): audio visualiser, piano tiles, trend dance, guitar highway, bass drop, DJ decks, rhythm circles, disco floor, stadium wave, conga line, dancing fountain, marble xylophone, dancing robot.
  • Machines & how it's made (22): claw machine, vending machine, assembly line, bottling line, Rube Goldberg machine, coin pusher, gumball machine, laser cutter, 3D printer, colour sorter, crayon factory, wood chipper, paper shredder, gacha machine, robot arm, pin art, warehouse robots, metal casting, recycling plant, factory automation*, combine harvester*, canal lock*.
  • Crafts & makers (14): brick build, pixel art drawing, pottery wheel, cross-stitch, fuse beads, mosaic, weaving loom, mural, sandcastle, block house, card tower, Jenga collapse, flip-book, rug tufting.
  • Science experiments (17): elephant toothpaste, Mentos and cola, Newton’s cradle, Tesla coil, mousetrap chain reaction, baking soda volcano, crystal growth, water balloon slow-mo, Chladni plate, gas in a box, water rocket, egg drop, coil train, ant farm, pendulum wave*, water Pythagoras*, drinking bird*.
  • Developer humour (26): sorting, Matrix rain, stuck at 99%, git branches, merge conflict, CI pipeline, stack overflow, whack-a-bug, server rack, loading spinners, network packets, garbage collector, memory leak, rubber duck debugging, pipes screensaver, flying toasters, disk defragmenter, test suite, robot coder, neural network, node_modules, error cascade, calendar Tetris, bar chart race, cat on the keyboard*, touch grass*.
  • Internet memes (17): DVD logo, Nyan Cat, absurd creature parade, split-screen runner, spinning cat, rat dance, bongo cat, the backrooms, tier list, cat on the keyboard, touch grass, the feed in the feed, and four Italian brainrot heroes: Coccodrillo Bombardiere, Tronchetto Tum Tum, Ballerina Cappuccina and Elefantino Cactusino; and speedrun*.
  • Seasons, holidays & spooky (15): pumpkin patch, haunted house, Christmas lights, sleigh across the moon, unwrapping, New Year’s ball drop, dragon dance, snowman, cherry blossoms, witch’s cauldron, bats at dusk, spider web, piñata, leaf pile, snow globe*.
  • Puzzles & logic (18): Tower of Hanoi, sudoku solver, sliding 15-puzzle, flow connect, pipe rotation puzzle, jigsaw, ice sliding puzzle, laser mirrors, unblock the arrows, triple tile match, sokoban*, minesweeper*, 2048*, water sort*, ball sort*, screw jam*, hexa sort*, colour block jam*.
  • Simulations & emergence (13): falling sand world, phantom traffic jam, particle life, genetic cars, cars learn to drive, rope bridge, soft bodies, ragdoll stairs, Game of Life*, Langton’s ant*, boids*, foxes and rabbits*, zombie outbreak*.
  • Destruction (11): hydraulic press, wrecking ball, voxel tower collapse, slow-motion bullet, wood splitting, barrel chain, building demolition*, Jenga collapse*, card tower*, wood chipper*, dam break*.
  • Sci-fi, robots & mecha (9): robot vacuum, laser grid heist, robot learns to walk, robot arm*, warehouse robots*, robot coder*, sumo bots*, dancing robot*, drone light show*.
  • Fluids, sand & particles (13): dam break, tilting glass, sand art bottle, water filling a cave, sand funnel maze, snow globe, canal lock, fizzy pour, hourglass*, kinetic sand*, falling sand world*, lava lamp*, elephant toothpaste*.
  • Idle, strategy & tycoon games (7): factory automation, idle mine, auto battler, merge army, horde survivor, city builder*, tower defence*.
  • Ocean & underwater (11): bait ball, whale breach, deep-sea dive, turtle hatchlings, shell swap, surfer*, jellyfish*, coral reef*, octopus camouflage*, aquarium*, sailing*.
  • Insects & tiny worlds (10): caterpillar to butterfly, dung beetle, mosquito vs swatter, fly at the window, ant mill, bees*, leafcutter ants*, ant farm*, ant colony war*, spider web*.
  • Birds, sky & flight (7): paper plane, drone light show, seagull heist, hot air balloons*, bird feeder*, bats at dusk*, lantern festival*.
  • Farm, garden & plants (5): combine harvester, sheepdog, Venus flytrap, farm crops*, pumpkin patch*.
  • Toys & desk toys (7): slinky on the stairs, drinking bird, water ring toss, battling tops, fishing game, Newton’s cradle*, coil train*.
  • Fantasy & RPG (5): slime merge, boss fight, forging a sword, inventory packing, witch’s cauldron*.
  • Party, board & fair games (13): Plinko, wheel of prizes, four in a row, air hockey, arcade hoops, cup shuffle, bottle flip, shooting gallery, claw machine*, coin pusher*, gumball machine*, gacha machine*, piñata*.

\* Also in this theme, though its home is another one: a video can have several themes.

Each one is a small simulation of a few hundred lines, drawn at 32 × 56 pixels and about 15 frames a second. They live in plugin/hooks/themes/, one file per theme.

Notes

  • Terminal and desktop app: the terminal draws the video with half-block characters at ~15 frames a second, the desktop app as an SVG at up to 10. VS Code and mobile show a note instead.
  • In the terminal, the feed opens as a pane beside the conversation from 144 columns (110 once you have opened it with /brainrot); on a narrower terminal a line by the prompt says so, with a button to move the feed to the side from 110 columns. Below 110 columns there is no room beside the conversation: widen the window and the feed comes back where it was.
  • In the terminal, clicks need Claude Code's fullscreen layout; the wheel and keys work everywhere.

Development

npx tsx tools/check.ts                 # type-check, validate, tests, offline render
npx tsx tools/render.ts [kind|theme]   # look at the videos without a session
npx tsx tools/gif.ts                   # rewrite assets/feed.gif
npx tsx tools/social.ts                # rewrite assets/social.png, the link preview
npx tsx tools/new-video.ts <theme> <kind> "<English name>" "<French name>"

claude --plugin-dir plugin starts a session with the mod loaded from its folder, plugin/. AGENTS.md explains how the mod works and how to add a video.

License

MIT. What changed in each version: CHANGELOG.md.

Source 35 files
hooks/register.tsx 1435 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { EndCard, FeedMode, FeedStats, Kind, Video } from '../types'
5import { encodeCells, svgFrame } from './cells'
6import { drawHeart, drawProgress, mix, rect, rngFrom } from './draw'
7import type { Sim } from './draw'
8import { MAKERS, THEMES, THEME_OF, themesOf } from './themes'
9import type { Theme } from './themes'
10import { COMMENTS, LABELS, LANGS, META, STRINGS, THEME_NAMES } from './i18n'
11import type { Lang } from './i18n'
12
13export { META }
14
15const PANE = 'brainrot-break'
16const OPEN_DELAY_MS = 2500
17const END_CARD_MS = 15000
18const FRAME_MS = 66
19const SWIPE_FRAMES = 6
20const LOOP_FRAMES = 300
21const SWIPE_COOLDOWN_MS = 350
22const SIDE = 7
23const LESS_OF_MS = 5000
24// A swipe this soon after a clip starts counts as a quick skip.
25const QUICK_SKIP_MS = 2000
26const LIKED_KEPT = 50
27
28const EMPTY_STATS: FeedStats = { views: 0, likes: 0, ms: 0, byKind: {} }
29
30const feedAtom = atom({ plugin: 'brainrot-break', key: 'feed' } as const, [] as Video[])
31const indexAtom = atom({ plugin: 'brainrot-break', key: 'index' } as const, 0)
32const likedAtom = atom({ plugin: 'brainrot-break', key: 'liked' } as const, [] as string[])
33const modeAtom = atom({ plugin: 'brainrot-break', key: 'mode' } as const, 'off' as FeedMode)
34const endCardAtom = atom({ plugin: 'brainrot-break', key: 'endCard' } as const, null as EndCard | null)
35const sessionAtom = atom({ plugin: 'brainrot-break', key: 'session' } as const, EMPTY_STATS)
36
37const KINDS = Object.keys(MAKERS) as Kind[]
38const THEME_LIST = Object.keys(THEMES) as Theme[]
39const themeOf = (kind: Kind) => THEME_OF[kind] as Theme
40const THEME_COUNTS = Object.fromEntries(THEME_LIST.map(t => [t, KINDS.filter(k => themesOf(k).includes(t)).length])) as Record<Theme, number>
41
42// The playing video lives in module variables: it is redrawn 15 times a
43// second by blits, and a reload simply starts the clip over.
44type Player = {
45  video?: Video
46  sim?: Sim
47  W: number
48  H: number
49  cur: Uint32Array
50  shown: Uint32Array
51  prev?: Uint32Array
52  swipe: number
53  dir: 1 | -1
54  t: number
55  hearts: { x: number; y: number; life: number }[]
56  big: number
57  bigAt?: { x: number; y: number }
58  shownAt: number
59  watchFrom: number
60  isPaused: boolean
61  isDim: boolean
62}
63
64const player: Player = {
65  W: 0,
66  H: 0,
67  cur: new Uint32Array(0),
68  shown: new Uint32Array(0),
69  swipe: 0,
70  dir: 1,
71  t: 0,
72  hearts: [],
73  big: 0,
74  shownAt: 0,
75  watchFrom: 0,
76  isPaused: false,
77  isDim: false,
78}
79let mounted: { requestId: string; columns: number; rows: number } | undefined
80// The desktop pane, when one draws the feed: it is redrawn rather than blitted.
81let desktopSite: string | undefined
82// How often the desktop redraws the video. Every redraw rebuilds the pane,
83// so a lower rate leaves the mouse more time to land a click.
84let desktopFps = 10
85let desktopCredit = 0
86// The desktop site last scrolled to the feed's row, past the spare one above.
87let desktopPlaced: string | undefined
88// The feed plays in the band while its pane, opened unasked on a narrow
89// terminal, waits undrawn: the ticker moves it there once the engine seats it.
90let paneWaits = false
91let ticker: Timer | undefined
92let openTimer: Timer | undefined
93let closeTimer: Timer | undefined
94let manual = false
95let enabled = true
96let scores: Partial<Record<Kind, number>> = {}
97// Lowered by ⊘ on the theme's videos; multiplies each kind's score.
98let themeScores: Partial<Record<Theme, number>> = {}
99let hidden: Theme[] = []
100// A click on a caption's hashtags: only this theme for now, until it is closed.
101let focus: Theme | undefined
102// The ··· menu, drawn in place of the video while it is open: the action
103// sheet on the playing clip, or the list of every theme one step further.
104type MenuPage = 'sheet' | 'themes' | 'likes' | 'settings' | 'help'
105const menu = { isOpen: false, page: 'sheet' as MenuPage, offset: 0, max: 0 }
106// The videos liked, newest first, kept between sessions so they can be watched again.
107let likedVideos: Video[] = []
108let autoScroll = false
109// The clip whose comments are open, if any.
110let commentsFor: string | undefined
111// The last ⊘, undoable for a few seconds: the weights it changed, as they were.
112let lessOf: { kind: Kind; kindScore?: number; themeScores: Partial<Record<Theme, number>>; timer: Timer } | undefined
113let lastSwipe = 0
114let bag: Kind[] = []
115let turn = { views: 0, likes: 0, startedAt: 0 }
116let frames = 0
117let defaultLang: Lang = 'en'
118let lang: Lang = 'en'
119const S = () => STRINGS[lang]
120
121function pick<T>(list: T[]): T {
122  return list[Math.floor(Math.random() * list.length)] as T
123}
124
125function fmt(n: number): string {
126  if (n >= 1e6) return `${(n / 1e6).toFixed(1).replace('.', S().decimal)}M`
127  if (n >= 1e3) return `${(n / 1e3).toFixed(1).replace('.', S().decimal)}k`
128  return String(n)
129}
130
131function duration(ms: number): string {
132  const s = Math.round(ms / 1000)
133  if (s < 60) return `${s} s`
134  if (s < 3600) return `${Math.floor(s / 60)} min ${s % 60 ? `${s % 60} s` : ''}`.trim()
135  return `${Math.floor(s / 3600)} h ${Math.floor((s % 3600) / 60)}`
136}
137
138const clampWeight = (w: number) => Math.max(0.2, Math.min(6, w))
139// A kind's themes count by their mean, so one with three themes is not lowered three times over.
140const themeWeight = (kind: Kind) => {
141  const ts = themesOf(kind)
142  return ts.reduce((sum, t) => sum + (themeScores[t] ?? 1), 0) / ts.length
143}
144const weight = (kind: Kind) => clampWeight((scores[kind] ?? 1) * themeWeight(kind))
145// A video is shown as soon as one of its themes is ticked; under a hashtag
146// focus, as soon as one of them is the one.
147const isVisible = (kind: Kind) => {
148  const ts = themesOf(kind)
149  return ts.some(t => !hidden.includes(t)) && (!focus || ts.includes(focus))
150}
151
152// Scales the score of every theme of a kind, and hands back their scores as they were, to undo it.
153function scaleThemes(kind: Kind, factor: number): Partial<Record<Theme, number>> {
154  const before: Partial<Record<Theme, number>> = {}
155  const next = { ...themeScores }
156  for (const t of themesOf(kind)) {
157    before[t] = themeScores[t]
158    next[t] = clampWeight((themeScores[t] ?? 1) * factor)
159  }
160  themeScores = next
161  return before
162}
163
164function restoreThemes(before: Partial<Record<Theme, number>>) {
165  let next = themeScores
166  for (const t of Object.keys(before) as Theme[]) next = withScore(next, t, before[t])
167  themeScores = next
168}
169
170// A shuffle bag: a round holds each kind at most once, and the kinds you
171// like come out earlier in it. Never twice in a row. A kind joins a round
172// with a chance its weight gives (1 and up: always, 0.2: one round in five),
173// so ⊘ makes a kind rarer without hiding it. Hidden themes stay out.
174function newRound(): Kind[] {
175  const kinds = KINDS.filter(isVisible)
176  for (;;) {
177    const round = kinds.filter(k => Math.random() < weight(k))
178    if (round.length > 0) return round
179  }
180}
181
182function drawKind(prev?: Kind): Kind {
183  bag = bag.filter(isVisible)
184  if (bag.length === 0) bag = newRound()
185  const candidates = bag.filter(k => k !== prev)
186  const pool = candidates.length > 0 ? candidates : bag
187  const weights = pool.map(weight)
188  let r = Math.random() * weights.reduce((a, b) => a + b, 0)
189  let kind = pool[0] as Kind
190  for (let i = 0; i < pool.length; i++) {
191    r -= weights[i] as number
192    if (r <= 0) {
193      kind = pool[i] as Kind
194      break
195    }
196  }
197  bag = bag.filter(k => k !== kind)
198  return kind
199}
200
201function makeVideo(prev?: Kind): Video {
202  const kind = drawKind(prev)
203  const m = META[kind]
204  const likes = Math.floor(Math.pow(10, 2.5 + Math.random() * 3.8))
205  return {
206    id: Math.random().toString(36).slice(2, 10),
207    kind,
208    seed: Math.floor(Math.random() * 2 ** 31),
209    author: pick(m.authors),
210    caption: Math.floor(Math.random() * m.captions.en.length),
211    tags: Math.floor(Math.random() * m.tags.length),
212    sound: Math.floor(Math.random() * m.sounds.en.length),
213    likes,
214    comments: Math.floor(likes * (0.005 + Math.random() * 0.03)),
215  }
216}
217
218function bump($: EngineInterface, kind: Kind, delta: number) {
219  scores = { ...scores, [kind]: clampWeight((scores[kind] ?? 1) + delta) }
220  void $.store.set('scores', scores)
221}
222
223function withScore<K extends string>(table: Partial<Record<K, number>>, key: K, value: number | undefined) {
224  const next = { ...table }
225  if (value === undefined) delete next[key]
226  else next[key] = value
227  return next
228}
229
230function saveWeights($: EngineInterface) {
231  void $.store.set('scores', scores)
232  void $.store.set('themeScores', themeScores)
233}
234
235// ⊘: much less of this kind, a little less of its theme, and on to the next clip.
236async function notInterested($: EngineInterface) {
237  if (menu.isOpen) await closeMenu($)
238  const kind = player.video?.kind
239  if (!kind) return
240  lessOf?.timer.cancel()
241  const timer = $.clock.after(LESS_OF_MS, () => {
242    lessOf = undefined
243    $.ui.invalidate('ui.render')
244  })
245  const kindScore = scores[kind]
246  scores = { ...scores, [kind]: clampWeight((scores[kind] ?? 1) * 0.5) }
247  lessOf = { kind, kindScore, themeScores: scaleThemes(kind, 0.85), timer }
248  saveWeights($)
249  bag = bag.filter(k => k !== kind)
250  await swipe($, 1, false)
251}
252
253async function undoNotInterested($: EngineInterface) {
254  if (!lessOf) return
255  const { kind, kindScore, themeScores: before, timer } = lessOf
256  timer.cancel()
257  lessOf = undefined
258  scores = withScore(scores, kind, kindScore)
259  restoreThemes(before)
260  saveWeights($)
261  $.ui.invalidate('ui.render')
262}
263
264// Forgets every ⊘ (and quick skip): what was lowered goes back to normal, likes stay.
265async function forgetNotInterested($: EngineInterface) {
266  themeScores = {}
267  scores = Object.fromEntries(Object.entries(scores).filter(([, v]) => (v ?? 1) >= 1))
268  saveWeights($)
269  $.ui.toast(S().forgotten)
270  $.ui.invalidate('ui.render')
271}
272
273async function setHidden($: EngineInterface, next: Theme[]) {
274  hidden = next
275  void $.store.set('hiddenThemes', hidden)
276  if (focus && hidden.includes(focus)) focus = undefined
277  await redrawAhead($)
278}
279
280async function setFocus($: EngineInterface, theme: Theme | undefined) {
281  focus = theme
282  // a new round, so the feed follows the change at once
283  bag = []
284  await redrawAhead($)
285}
286
287const toggleFocus = ($: EngineInterface) => setFocus($, focus || !player.video ? undefined : themeOf(player.video.kind))
288
289// The clips queued ahead may no longer belong in the feed: draw them again.
290async function redrawAhead($: EngineInterface) {
291  const index = await read($, indexAtom)
292  await update($, feedAtom, list => {
293    const out = list.slice(0, index + 1)
294    for (const v of list.slice(index + 1)) out.push(isVisible(v.kind) ? v : makeVideo(out[out.length - 1]?.kind))
295    return out
296  })
297  $.ui.invalidate('ui.render')
298}
299
300async function toggleTheme($: EngineInterface, theme: Theme) {
301  if (hidden.includes(theme)) return setHidden($, hidden.filter(t => t !== theme))
302  if (hidden.length >= THEME_LIST.length - 1) {
303    $.ui.toast(S().lastTheme)
304    return
305  }
306  await setHidden($, [...hidden, theme])
307}
308
309// The clip pauses while the menu covers it, and its watch time with it.
310async function openMenu($: EngineInterface, page: MenuPage = 'sheet') {
311  if (!menu.isOpen) {
312    noteWatch()
313    player.watchFrom = 0
314  }
315  menu.isOpen = true
316  menu.page = page
317  menu.offset = 0
318  $.ui.invalidate('ui.render')
319}
320
321async function closeMenu($: EngineInterface) {
322  if (!menu.isOpen) return
323  menu.isOpen = false
324  player.watchFrom = ticker ? Date.now() : 0
325  if (player.video && !isVisible(player.video.kind)) await swipe($, 1, false)
326  $.ui.invalidate('ui.render')
327}
328
329const toggleMenu = ($: EngineInterface) => (menu.isOpen ? closeMenu($) : openMenu($))
330const toggleHelp = ($: EngineInterface) => (menu.isOpen && menu.page === 'help' ? closeMenu($) : openMenu($, 'help'))
331
332// A liked video played again: it comes next in the feed.
333async function playLiked($: EngineInterface, id: string) {
334  const video = likedVideos.find(v => v.id === id)
335  if (!video) return
336  // the clip on screen already: back to it
337  if (player.video?.id === id) return closeMenu($)
338  const index = await read($, indexAtom)
339  await update($, feedAtom, list => [...list.slice(0, index + 1), video, ...list.slice(index + 1).filter(v => v.id !== id)])
340  menu.isOpen = false
341  player.watchFrom = ticker ? Date.now() : 0
342  await swipe($, 1, false)
343}
344
345// /brainrot, described in the feed's language: registered again when it changes.
346async function registerCommand($: EngineInterface) {
347  await $.command.register({ name: 'brainrot', description: S().commandDescription, argumentHint: '[on|off|stats|lang en|fr|es|de|pt|it]' })
348}
349
350async function setLanguage($: EngineInterface, next: Lang) {
351  lang = next
352  await $.store.set('language', next)
353  // a description left in the old language is no reason to fail the switch
354  await registerCommand($).catch(() => undefined)
355  $.ui.invalidate('ui.render')
356}
357
358async function changeSetting($: EngineInterface, act: string) {
359  if (act === 'lang') {
360    await setLanguage($, LANGS[(LANGS.indexOf(lang) + 1) % LANGS.length] as Lang)
361  } else if (act === 'autoopen') {
362    enabled = !enabled
363    await $.store.set('enabled', enabled)
364  } else if (act === 'autoscroll') {
365    autoScroll = !autoScroll
366    await $.store.set('autoScroll', autoScroll)
367  }
368  $.ui.invalidate('ui.render')
369}
370
371function toggleComments($: EngineInterface) {
372  const id = player.video?.id
373  commentsFor = commentsFor === id ? undefined : id
374  $.ui.invalidate('ui.render')
375}
376
377// The comments under a clip: two of its theme's and three generic ones,
378// picked from its seed, so a clip shows the same ones in either language.
379function commentsOf(video: Video): { handle: string; text: string; likes: number }[] {
380  const rng = rngFrom(video.seed ^ 0x2c6e)
381  const order = (n: number) => {
382    const a = [...Array(n).keys()]
383    for (let i = n - 1; i > 0; i--) {
384      const j = Math.floor(rng() * (i + 1))
385      ;[a[i], a[j]] = [a[j] as number, a[i] as number]
386    }
387    return a
388  }
389  const theme = COMMENTS.themes[themeOf(video.kind)][lang]
390  const generic = COMMENTS.generic[lang]
391  const texts = [...order(theme.length).slice(0, 2).map(i => theme[i] as string), ...order(generic.length).slice(0, 3).map(i => generic[i] as string)]
392  const handles = order(COMMENTS.handles.length)
393  return texts
394    .map((text, i) => ({ handle: COMMENTS.handles[handles[i] as number] as string, text, likes: Math.floor(video.likes * 0.08 * rng()) }))
395    .sort((a, b) => b.likes - a.likes)
396}
397
398// Hides every theme but the playing clip's: one has to stay, and from there
399// the ones wanted can be switched back on.
400async function hideAllThemes($: EngineInterface) {
401  const keep = player.video ? themeOf(player.video.kind) : THEME_LIST[0]
402  await setHidden($, THEME_LIST.filter(t => t !== keep))
403  if (keep) $.ui.toast(S().keptTheme(`#${THEME_NAMES[lang][keep]}`))
404}
405
406// The sheet's "Hide #theme": the playing clip's whole theme, then back to the feed.
407async function hidePlayingTheme($: EngineInterface) {
408  const kind = player.video?.kind
409  if (!kind) return
410  if (hidden.length >= THEME_LIST.length - 1) {
411    $.ui.toast(S().lastTheme)
412    return
413  }
414  const theme = themeOf(kind)
415  await setHidden($, [...hidden, theme])
416  $.ui.toast(S().themeHidden(`#${THEME_NAMES[lang][theme]}`))
417  await closeMenu($)
418}
419
420// ▲ ▼, the wheel and j/k: a swipe, or a scroll of the menu while it is open.
421async function move($: EngineInterface, dir: 1 | -1, rows = 3) {
422  if (!menu.isOpen) return swipe($, dir)
423  menu.offset = Math.max(0, Math.min(menu.max, menu.offset + dir * rows))
424  $.ui.invalidate('ui.render')
425}
426
427type Part = { text: string; act?: string; color?: string }
428type Row = { text: string; act?: string; color?: string; bold?: boolean; dim?: boolean; parts?: Part[] }
429
430// The caption with its hashtags at the end, as on TikTok, wrapped to rows of
431// parts at the width: the hashtags in cyan, and only they take a click.
432function captionRows(caption: string, tags: string, width: number, maxRows: number): Row[] {
433  const lines: Part[][] = [[]]
434  let used = 0
435  const add = (text: string, isTag: boolean) => {
436    const line = lines[lines.length - 1] as Part[]
437    const last = line[line.length - 1]
438    if (last && (last.act === 'focus') === isTag) last.text += text
439    else line.push(isTag ? { text, act: 'focus', color: 'cyan' } : { text })
440    used += [...text].length
441  }
442  const words = [...caption.split(' ').map(w => [w, false] as const), ...tags.split(' ').map(w => [w, true] as const)]
443  for (const [word, isTag] of words) {
444    if (!word) continue
445    const len = Math.min(width, [...word].length)
446    if (used > 0 && used + 1 + len > width) {
447      lines.push([])
448      used = 0
449    }
450    // a space goes with the word before it: between two hashtags it takes the click too
451    if (used > 0) add(' ', (lines[lines.length - 1]?.at(-1)?.act === 'focus'))
452    add([...word].slice(0, width).join(''), isTag)
453  }
454  const shown = lines.slice(0, maxRows)
455  if (lines.length > maxRows) {
456    const last = shown[shown.length - 1]?.at(-1)
457    if (last) last.text = [...last.text].slice(0, Math.max(0, [...last.text].length - 1)).join('') + '…'
458  }
459  return shown.map(parts => ({ text: parts.map(p => p.text).join(''), parts }))
460}
461
462function sheetRows(): Row[] {
463  const kind = player.video?.kind
464  const tag = kind ? `#${THEME_NAMES[lang][themeOf(kind)]}` : ''
465  return [
466    { text: `${S().thisVideo} ${tag}`, dim: true },
467    { text: ' ' },
468    { text: `⊘  ${S().nopeAction}`, act: 'nope' },
469    { text: `✕  ${S().hideTheme(tag)}`, act: 'hide' },
470    { text: `#  ${S().chooseThemes}`, act: 'themes' },
471    { text: `♥  ${S().myLikes(likedVideos.length)}`, act: 'likes' },
472    { text: `*  ${S().settings}`, act: 'settings' },
473    { text: `?  ${S().shortcuts}`, act: 'help' },
474    { text: `↺  ${S().forget}`, act: 'forget' },
475    { text: ' ' },
476    { text: `[ ${S().close} ]`, act: 'menu', bold: true },
477  ]
478}
479
480function likesRows(): Row[] {
481  const back: Row = { text: S().menuBack, act: 'sheet', dim: true }
482  if (likedVideos.length === 0) return [back, { text: ' ' }, { text: S().noLikes, dim: true }]
483  return [back, ...likedVideos.map(v => ({ text: `♥ ${LABELS[lang][v.kind]}  ${v.author}`, act: `play:${v.id}` }))]
484}
485
486// The ? page: every key, then what it does.
487function helpRows(): Row[] {
488  const keys: [string, string][] = [
489    ['j k ▲ ▼', S().helpNext],
490    ['l ♥', S().helpLike],
491    ['click', S().helpPause],
492    ['m ≡', S().helpComments],
493    ['n ⊘', S().helpNope],
494    ['u', S().helpUndo],
495    ['t ···', S().helpMenu],
496    ['#tags', S().helpTags],
497    ['c x', S().helpEndCard],
498    ['? h', S().helpHelp],
499  ]
500  return [
501    { text: S().shortcuts, bold: true },
502    { text: ' ' },
503    ...keys.map(([key, what]) => ({ text: `${key.padEnd(8)}${what}` })),
504    { text: ' ' },
505    { text: `[ ${S().close} ]`, act: 'menu', bold: true },
506  ]
507}
508
509function settingsRows(): Row[] {
510  return [
511    { text: S().menuBack, act: 'sheet', dim: true },
512    { text: ' ' },
513    { text: S().settingLanguage, act: 'lang' },
514    { text: S().settingAutoOpen(enabled), act: 'autoopen' },
515    { text: S().settingAutoScroll(autoScroll), act: 'autoscroll' },
516  ]
517}
518
519// The menu's rows, as many as fit in the height; the rest scrolls.
520// Themes come in the alphabetical order of their names in the current language.
521function themeRows(width: number): Row[] {
522  const names = THEME_NAMES[lang]
523  const sorted = [...THEME_LIST].sort((a, b) => names[a].localeCompare(names[b], lang, { sensitivity: 'base' }))
524  return [
525    { text: S().menuBack, act: 'sheet', dim: true },
526    { text: `${hidden.length === 0 ? '✓' : ' '} ${S().showAll}`, act: 'all', bold: true },
527    { text: `  ${S().hideAll}`, act: 'none', bold: true },
528    ...sorted.map(t => {
529      const isOn = !hidden.includes(t)
530      const name = THEME_NAMES[lang][t]
531      const count = String(THEME_COUNTS[t])
532      const room = Math.max(1, width - 2 - [...name].length - count.length)
533      const row: Row = { text: `${isOn ? '✓' : '✗'} ${name}${' '.repeat(room)}${count}`, act: `theme:${t}` }
534      return isOn ? row : { ...row, dim: true }
535    }),
536  ]
537}
538
539function menuRows(width: number, height: number): Row[] {
540  const rows =
541    menu.page === 'sheet'
542      ? sheetRows()
543      : menu.page === 'likes'
544        ? likesRows()
545        : menu.page === 'settings'
546          ? settingsRows()
547          : menu.page === 'help'
548            ? helpRows()
549            : themeRows(width)
550  const h = Math.max(1, Math.min(height, rows.length))
551  menu.max = rows.length - h
552  menu.offset = Math.max(0, Math.min(menu.max, menu.offset))
553  return rows.slice(menu.offset, menu.offset + h)
554}
555
556function addStats(s: FeedStats, kind: Kind | undefined, d: { views?: number; likes?: number; ms?: number }): FeedStats {
557  const views = d.views ?? 0
558  const likes = d.likes ?? 0
559  const ms = d.ms ?? 0
560  const byKind = { ...s.byKind }
561  if (kind) {
562    const k = byKind[kind] ?? { views: 0, likes: 0, ms: 0 }
563    byKind[kind] = { views: k.views + views, likes: Math.max(0, k.likes + likes), ms: k.ms + ms }
564  }
565  return { views: s.views + views, likes: Math.max(0, s.likes + likes), ms: s.ms + ms, byKind }
566}
567
568function favorite(s: FeedStats): Kind | undefined {
569  let best: Kind | undefined
570  let score = 0
571  for (const [k, v] of Object.entries(s.byKind) as [Kind, { views: number; likes: number; ms: number }][]) {
572    const value = v.likes * 60000 + v.ms
573    if (value > score) {
574      score = value
575      best = k
576    }
577  }
578  return best
579}
580
581function statsLine(s: FeedStats): string {
582  const fav = favorite(s)
583  return S().statsLine(s.views, s.likes, duration(s.ms), fav && LABELS[lang][fav])
584}
585
586// Views and watch time are noted while drawing, where state cannot be
587// written; they wait here until a timer or a handler writes them.
588let pending: { kind: Kind; views?: number; ms?: number }[] = []
589
590async function flushStats($: EngineInterface) {
591  if (pending.length === 0) return
592  const batch = pending
593  pending = []
594  await update($, sessionAtom, s => batch.reduce((acc, d) => addStats(acc, d.kind, d), s))
595}
596
597// Notes the time spent on the playing video since it was last counted.
598function noteWatch() {
599  const kind = player.video?.kind
600  const now = Date.now()
601  const ms = player.watchFrom ? now - player.watchFrom : 0
602  player.watchFrom = ticker ? now : 0
603  if (kind && ms > 0) pending.push({ kind, ms })
604}
605
606// The last few videos keep their simulation, so scrolling back resumes
607// a clip where it was left instead of starting it over.
608const KEPT_VIDEOS = 8
609const kept = new Map<string, { sim: Sim; t: number; W: number; H: number }>()
610let lastSkip: { id: string; kind: Kind; kindScore?: number; themeScores: Partial<Record<Theme, number>>; at: number } | undefined
611
612function load(video: Video, W: number, H: number) {
613  if (player.video?.id === video.id && player.W === W && player.H === H) return
614  const isSameVideo = player.video?.id === video.id
615  if (player.W !== W || player.H !== H) {
616    player.shown = new Uint32Array(W * H)
617    player.prev = undefined
618    player.swipe = 0
619  }
620  if (!isSameVideo) noteWatch()
621  if (player.video && player.sim && !isSameVideo) {
622    kept.delete(player.video.id)
623    kept.set(player.video.id, { sim: player.sim, t: player.t, W: player.W, H: player.H })
624    for (const id of kept.keys()) if (kept.size > KEPT_VIDEOS) kept.delete(id)
625  }
626  const saved = isSameVideo ? undefined : kept.get(video.id)
627  player.video = video
628  player.W = W
629  player.H = H
630  player.cur = new Uint32Array(W * H)
631  const isResumed = saved !== undefined && saved.W === W && saved.H === H
632  player.sim = isResumed ? saved.sim : MAKERS[video.kind](W, H, rngFrom(video.seed))
633  if (!isSameVideo) {
634    player.t = isResumed ? saved.t : 0
635    player.shownAt = Date.now()
636    player.watchFrom = player.shownAt
637    player.hearts = []
638    player.big = 0
639    player.isPaused = false
640    if (!saved) {
641      turn.views++
642      pending.push({ kind: video.kind, views: 1 })
643    }
644  }
645}
646
647function advance() {
648  if (player.swipe > 0) player.swipe--
649  if (player.isPaused) return
650  player.sim?.step()
651  player.t++
652  if (player.big > 0) player.big--
653  for (const h of player.hearts) {
654    h.y -= 0.7
655    h.x += Math.sin(h.life * 0.5) * 0.3
656    h.life--
657  }
658  player.hearts = player.hearts.filter(h => h.life > 0)
659}
660
661function compose(): Uint32Array {
662  const { W, H, cur, shown, prev } = player
663  player.sim?.draw(cur)
664  for (const h of player.hearts) drawHeart(cur, W, H, h.x, h.y, Math.min(1, h.life / 8), false)
665  if (player.big > 0) {
666    const at = player.bigAt ?? { x: W / 2, y: H / 2 }
667    drawHeart(cur, W, H, at.x - 7, at.y - 6, Math.min(1, player.big / 5), true)
668  }
669  drawProgress(cur, W, H, (player.t % LOOP_FRAMES) / LOOP_FRAMES)
670  if (player.isDim) for (let i = 0; i < cur.length; i++) cur[i] = mix(cur[i] as number, 0x000000, 0.55)
671  if (player.isPaused) {
672    rect(cur, W, H, W / 2 - 4, H / 2 - 5, 3, 10, 0xf2f2f2)
673    rect(cur, W, H, W / 2 + 1, H / 2 - 5, 3, 10, 0xf2f2f2)
674  }
675  if (player.swipe > 0 && prev) {
676    const o = Math.round(H * (1 - player.swipe / SWIPE_FRAMES))
677    for (let y = 0; y < H; y++) {
678      const fromNew = player.dir === 1 ? y >= H - o : y < o
679      const src = fromNew ? cur : prev
680      const sy = player.dir === 1 ? (fromNew ? y - (H - o) : y + o) : fromNew ? y + H - o : y - o
681      shown.set(src.subarray(sy * W, sy * W + W), y * W)
682    }
683  } else {
684    shown.set(cur)
685  }
686  return shown
687}
688
689function tick($: EngineInterface) {
690  if (!player.sim || menu.isOpen) return
691  const term = mounted && mounted.columns === player.W && mounted.rows * 2 === player.H ? mounted : undefined
692  if (!term && !desktopSite) return
693  advance()
694  if (++frames % 15 === 0) {
695    void flushStats($)
696    if (paneWaits) void seatPane($)
697  }
698  // Next video by itself: once the clip has played its loop through.
699  if (autoScroll && player.t === LOOP_FRAMES) void swipe($, 1, false)
700  if (term) {
701    const cells = encodeCells(compose(), term.columns, term.rows)
702    void $.ui.blit({ requestId: term.requestId, key: 'video', cells }).catch(() => undefined)
703  }
704  // The desktop redraws ten times a second at most; the clip keeps its speed.
705  // Each tick earns desktopFps fifteenths of a redraw, so 10 fps is two ticks in
706  // three, not one in two (a rounded divisor gave 7.5).
707  if (desktopSite && desktopPlaced !== desktopSite) {
708    desktopPlaced = desktopSite
709    void $.ui.scroll({ to: { key: 'feed' }, in: desktopSite, block: 'start' }).catch(() => undefined)
710  }
711  if (desktopSite && desktopFps > 0) {
712    desktopCredit = Math.min(15, desktopCredit) + Math.min(15, desktopFps)
713    if (desktopCredit >= 15) {
714      desktopCredit -= 15
715      $.ui.invalidate('ui.render')
716    }
717  }
718}
719
720function startTicker($: EngineInterface) {
721  if (ticker) return
722  ticker = $.clock.every(FRAME_MS, () => tick($))
723  player.watchFrom = Date.now()
724}
725
726function stopTicker($: EngineInterface) {
727  noteWatch()
728  void flushStats($)
729  ticker?.cancel()
730  ticker = undefined
731  player.watchFrom = 0
732  mounted = undefined
733  desktopSite = undefined
734  desktopPlaced = undefined
735}
736
737async function ensureFeed($: EngineInterface) {
738  const feed = await read($, feedAtom)
739  if (feed.length > 0) return
740  const first = makeVideo()
741  await update($, feedAtom, () => [first, makeVideo(first.kind)])
742  await update($, indexAtom, () => 0)
743}
744
745async function clearEndCard($: EngineInterface) {
746  closeTimer?.cancel()
747  closeTimer = undefined
748  player.isDim = false
749  await update($, endCardAtom, () => null)
750}
751
752async function openFeed($: EngineInterface) {
753  await ensureFeed($)
754  turn = { views: 0, likes: 0, startedAt: Date.now() }
755  const opened = await $.ui.open({ id: PANE, title: S().paneTitle, rows: 30 })
756  if (opened.isPlaced) {
757    paneWaits = false
758    await update($, modeAtom, () => 'pane' as FeedMode)
759  } else {
760    // Too narrow for a pane opened unasked: play in the band above the prompt.
761    // The pane stays open, undrawn, so the engine seats it if the terminal widens.
762    paneWaits = true
763    await update($, modeAtom, () => 'band' as FeedMode)
764  }
765  startTicker($)
766}
767
768// From the band to the pane: a press is the person asking, so the engine places
769// it at any width, and from then on opens it unasked from 110 columns.
770async function moveToPane($: EngineInterface) {
771  if ((await read($, modeAtom)) !== 'band') return
772  const opened = await $.ui.open({ id: PANE, title: S().paneTitle, rows: 30 })
773  if (!opened.isPlaced) return
774  paneWaits = false
775  await update($, modeAtom, () => 'pane' as FeedMode)
776}
777
778// The waiting pane got seated (the terminal widened): leave the band for it.
779async function seatPane($: EngineInterface) {
780  const pane = (await $.ui.panes()).find(p => p.id === PANE)
781  if (!paneWaits || !pane?.isPlaced || (await read($, modeAtom)) !== 'band') return
782  paneWaits = false
783  await update($, modeAtom, () => 'pane' as FeedMode)
784}
785
786async function closeFeed($: EngineInterface) {
787  stopTicker($)
788  manual = false
789  menu.isOpen = false
790  await clearEndCard($)
791  const mode = await read($, modeAtom)
792  await update($, modeAtom, () => 'off' as FeedMode)
793  // In the band, the pane may still wait undrawn.
794  paneWaits = false
795  if (mode !== 'off') await $.ui.close({ id: PANE })
796}
797
798async function showEndCard($: EngineInterface) {
799  noteWatch()
800  await flushStats($)
801  const card: EndCard = { views: turn.views, likes: turn.likes, seconds: Math.round((Date.now() - turn.startedAt) / 1000) }
802  player.isDim = true
803  await update($, endCardAtom, () => card)
804  void $.store.set('lastSession', await read($, sessionAtom))
805  closeTimer?.cancel()
806  closeTimer = $.clock.after(END_CARD_MS, () => {
807    closeTimer = undefined
808    void closeFeed($)
809  })
810}
811
812async function swipe($: EngineInterface, dir: 1 | -1, isSkip = true) {
813  const now = Date.now()
814  const index = await read($, indexAtom)
815  const target = index + dir
816  if (target < 0) return
817  // A quick skip: a little less of this kind, and a touch less of its theme.
818  if (isSkip && dir === 1 && player.video && now - player.shownAt < QUICK_SKIP_MS) {
819    const { id, kind } = player.video
820    const kindScore = scores[kind]
821    scores = { ...scores, [kind]: clampWeight((scores[kind] ?? 1) * 0.75) }
822    lastSkip = { id, kind, kindScore, themeScores: scaleThemes(kind, 0.97), at: now }
823    saveWeights($)
824  }
825  let feed = await read($, feedAtom)
826  if (target >= feed.length - 1) {
827    const last = feed[feed.length - 1]?.kind
828    const fresh = makeVideo(last)
829    await update($, feedAtom, list => [...list, fresh])
830    feed = await read($, feedAtom)
831  }
832  const video = feed[target]
833  if (!video) return
834  // Back on a clip skipped a moment ago: that skip was a mistake, not a verdict.
835  if (dir === -1 && lastSkip?.id === video.id && now - lastSkip.at < 10000) {
836    scores = withScore(scores, lastSkip.kind, lastSkip.kindScore)
837    restoreThemes(lastSkip.themeScores)
838    saveWeights($)
839    lastSkip = undefined
840  }
841  commentsFor = undefined
842  if (player.W > 0) {
843    player.prev = player.shown.slice()
844    player.swipe = SWIPE_FRAMES
845    player.dir = dir
846    load(video, player.W, player.H)
847  }
848  await update($, indexAtom, () => target)
849}
850
851async function like($: EngineInterface, at?: { x: number; y: number }) {
852  const video = player.video
853  if (!video) return
854  const liked = await read($, likedAtom)
855  const wasLiked = liked.includes(video.id)
856  player.big = 12
857  player.bigAt = at
858  // A double tap only ever likes; the button toggles.
859  if (at && wasLiked) return
860  const isLiked = !wasLiked
861  await update($, likedAtom, list => (isLiked ? [...new Set([...list, video.id])] : list.filter(id => id !== video.id)))
862  likedVideos = isLiked ? [video, ...likedVideos.filter(v => v.id !== video.id)].slice(0, LIKED_KEPT) : likedVideos.filter(v => v.id !== video.id)
863  void $.store.set('likedVideos', likedVideos)
864  const delta = isLiked ? 1 : -1
865  turn.likes = Math.max(0, turn.likes + delta)
866  await update($, sessionAtom, s => addStats(s, video.kind, { likes: delta }))
867  // A like raises the kind and its themes; taking it back lowers them again.
868  bump($, video.kind, 2 * delta)
869  scaleThemes(video.kind, isLiked ? 1.05 : 1 / 1.05)
870  void $.store.set('themeScores', themeScores)
871  if (!isLiked) {
872    player.big = 0
873    return
874  }
875  for (let i = 0; i < 5; i++)
876    player.hearts.push({ x: player.W * (0.5 + Math.random() * 0.35), y: player.H * (0.65 + Math.random() * 0.2), life: 16 + i * 3 })
877}
878
879type Common = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Client'>
880
881async function feedData($: EngineInterface) {
882  const feed = await read($, feedAtom)
883  const index = await read($, indexAtom)
884  const liked = await read($, likedAtom)
885  const card = await read($, endCardAtom)
886  const video = feed[index]
887  return { video, index, isLiked: video ? liked.includes(video.id) : false, card }
888}
889
890// The like and scroll column and the caption (or the end card): the same on
891// every surface, only the video beside them differs. The caption always goes
892// under the video, two lines at most.
893async function feedParts(
894  $: EngineInterface,
895  els: Common,
896  d: { video: Video; index: number; isLiked: boolean; card: EndCard | null },
897  isDesktop: boolean,
898  height = 0,
899  captionWidth = 44,
900) {
901  const { Box, Text, Button, Client } = els
902  const { video, index, isLiked, card } = d
903  const meta = META[video.kind]
904  // The menu's ··· sits at the top, level with the clip's top; the rest at the bottom.
905  const actions = (
906    <Box flexDirection="column" width={SIDE} height={height} alignItems="center" justifyContent="space-between">
907      <Box flexDirection="column" alignItems="center">
908        <Button key="themes" plain hotkey="t" onPress={() => toggleMenu($)}>
909          ···
910        </Button>
911        <Button key="help" plain hotkey="h" onPress={() => toggleHelp($)}>
912          ?
913        </Button>
914      </Box>
915      <Box flexDirection="column" alignItems="center">
916        <Button key="nope" plain hotkey="n" onPress={() => notInterested($)}>
917          ⊘
918        </Button>
919        <Text> </Text>
920        <Button key="like" plain hotkey="l" onPress={() => like($)}>
921          {isLiked ? <Text color="#ff2d55" bold>♥</Text> : <Text bold>♡</Text>}
922        </Button>
923        <Text dimColor>{fmt(video.likes + (isLiked ? 1 : 0))}</Text>
924        <Text> </Text>
925        <Button key="comments" plain hotkey="m" onPress={() => toggleComments($)}>
926          ≡
927        </Button>
928        <Text dimColor>{fmt(video.comments)}</Text>
929        <Text> </Text>
930        <Button key="prev" plain hotkey="k" onPress={() => move($, -1)}>
931          ▲
932        </Button>
933        <Text dimColor>{`${index + 1}`}</Text>
934        <Button key="next" plain hotkey="j" onPress={() => move($, 1)}>
935          ▼
936        </Button>
937      </Box>
938    </Box>
939  )
940  // The hashtags take a click: only this video's theme for now. Its own line, so the click lands on them.
941  const capRows = captionRows(meta.captions[lang][video.caption] as string, meta.tags[video.tags] as string, captionWidth, 2)
942  const focusText = focus ? `◉ ${S().focusOn(`#${THEME_NAMES[lang][focus]}`)}` : ''
943  const focusLine = isDesktop ? (
944    <Client key="unfocus" module="./controls.tsx" props={{ rows: [{ text: focusText, act: 'focus', color: 'cyan', bold: true }], width: Math.min(44, [...focusText].length), isLeft: true }} width={Math.min(44, [...focusText].length)} height={1} />
945  ) : (
946    <Button key="unfocus" plain onPress={() => setFocus($, undefined)}>
947      <Text color="cyan" bold>{focusText}</Text>
948    </Button>
949  )
950  const info = card ? (
951    <Box flexDirection="column">
952      <Text bold color="green">
953        {S().answered}
954      </Text>
955      <Text dimColor>{S().waiting}</Text>
956      <Text> </Text>
957      <Text>{S().thisWait(card.views, card.likes, duration(card.seconds * 1000))}</Text>
958      <Text dimColor wrap="truncate-end">
959        {S().session}: {statsLine(await read($, sessionAtom))}
960      </Text>
961      <Box flexDirection="row" gap={1}>
962        <Button key="continue" hotkey="c" onPress={() => keepWatching($)}>
963          {S().keepScrolling}
964        </Button>
965        <Button key="close" hotkey="x" variant="primary" onPress={() => closeFeed($)}>
966          {S().close}
967        </Button>
968      </Box>
969    </Box>
970  ) : (
971    <Box flexDirection="column">
972      {commentsFor === video.id && (
973        <Box flexDirection="column" marginBottom={1}>
974          <Text bold>≡ {S().commentsTitle(fmt(video.comments))}</Text>
975          {commentsOf(video).map(c => (
976            <Text wrap="truncate-end">
977              <Text bold>{c.handle}</Text> {c.text} <Text dimColor>♥ {fmt(c.likes)}</Text>
978            </Text>
979          ))}
980        </Box>
981      )}
982      {focus && focusLine}
983      <Text bold>{video.author}</Text>
984      <Client key="caption" module="./controls.tsx" props={{ rows: capRows, width: captionWidth, isLeft: true }} width={captionWidth} height={capRows.length} />
985      <Text dimColor wrap="truncate-end">
986        ♫ {meta.sounds[lang][video.sound]}
987      </Text>
988      {lessOf && !isDesktop && (
989        <Box flexDirection="row" gap={1}>
990          <Text dimColor>⊘ {S().notInterested(LABELS[lang][lessOf.kind])} ·</Text>
991          <Button key="undo" plain hotkey="u" onPress={() => undoNotInterested($)}>
992            {S().undo}
993          </Button>
994        </Box>
995      )}
996    </Box>
997  )
998  return { actions, info }
999}
1000
1001async function feedTree($: EngineInterface, els: Elements['terminal'], requestId: string, columns: number, rows: number) {
1002  const { Box, Text, Button, Raster, Client } = els
1003  if (columns < SIDE + 12 || rows < 7) return <Text dimColor>{S().tooSmall}</Text>
1004
1005  const d = await feedData($)
1006  const { video, card } = d
1007  if (!video) return <Text dimColor>{S().loading}</Text>
1008
1009  // A vertical 9:16 clip: each cell is two square-ish pixels, so C = R * 9 / 8.
1010  const fit = (r: number): [number, number] => {
1011    const R = Math.max(6, Math.min(36, r))
1012    return [Math.round((R * 9) / 8), R]
1013  }
1014  // Under the video: the caption (author, two lines, sound) or the end card.
1015  let [C, R] = fit(rows - (card ? 7 : 4))
1016  if (C > columns - SIDE - 1) {
1017    C = Math.max(10, columns - SIDE - 1)
1018    R = Math.max(6, Math.min(R, Math.round((C * 8) / 9)))
1019  }
1020
1021  load(video, C, R * 2)
1022  mounted = { requestId, columns: C, rows: R }
1023  const { actions, info } = await feedParts($, els, { ...d, video }, false, R, columns)
1024  // The open menu covers the caption too: it takes every row.
1025  const menuH = Math.max(R, rows)
1026  const menuList = menu.isOpen ? menuRows(C, menuH) : []
1027  const screen = menu.isOpen ? (
1028    <Box width={C} height={menuH}>
1029      <Client key="menu" module="./controls.tsx" props={{ rows: menuList, width: C, isLeft: true }} width={C} height={menuList.length} />
1030    </Box>
1031  ) : (
1032    <Box width={C} height={R}>
1033      <Raster key="video" columns={C} rows={R} cells={encodeCells(compose(), C, R)} />
1034      <Box position="absolute" top={0} left={0}>
1035        <Client key="tap" module="./tap.tsx" props={{ columns: C, rows: R }} width={C} height={R} />
1036      </Box>
1037    </Box>
1038  )
1039
1040  return (
1041    <Box flexDirection="column">
1042      <Box flexDirection="row" gap={1}>
1043        {screen}
1044        {actions}
1045      </Box>
1046      {!menu.isOpen && info}
1047    </Box>
1048  )
1049}
1050
1051// Docked beside the transcript from 110 terminal columns; below that the
1052// engine seats the pane by the prompt, where the feed would push the
1053// conversation away. A body narrower than this (the frame takes two) is that.
1054const DOCK_COLUMNS = 108
1055
1056// Too narrow for the side: no feed, a line that says so. In the band, once
1057// wide enough, a press moves it to the side (the person asking).
1058function narrowNotice($: EngineInterface, els: Elements['terminal'], columns: number) {
1059  const { Box, Text, Button } = els
1060  mounted = undefined
1061  return (
1062    <Box flexDirection="row" gap={1}>
1063      <Text dimColor wrap="truncate-end">
1064        {S().tooNarrow(columns + 2)}
1065      </Text>
1066      {columns >= DOCK_COLUMNS && (
1067        <Button key="side" plain hotkey="p" onPress={() => moveToPane($)}>
1068          <Text color="cyan">⇥ {S().toSide} (p)</Text>
1069        </Button>
1070      )}
1071    </Box>
1072  )
1073}
1074
1075// The desktop draws the same clip at a fixed pixel size, scaled to the pane.
1076const DESKTOP_W = 32
1077const DESKTOP_H = 56
1078
1079async function desktopTree($: EngineInterface, els: Elements['desktop'], requestId: string, columns: number, bodyRows: number) {
1080  const { Box, Text, Svg } = els
1081  const d = await feedData($)
1082  const { video, card } = d
1083  if (!video) return <Text dimColor>{S().loading}</Text>
1084
1085  load(video, DESKTOP_W, DESKTOP_H)
1086  desktopSite = requestId
1087  // As big as the pane allows (about 8 x 16 pixels a cell): beside the side
1088  // column across, above the caption (author, two lines, sound, a gap) down,
1089  // in half steps so the pixels stay even.
1090  const byWidth = ((columns - SIDE - 2) * 8) / DESKTOP_W
1091  const byHeight = ((bodyRows - 7) * 16) / DESKTOP_H
1092  const scale = Math.max(2, Math.floor(Math.min(byWidth, byHeight) * 2) / 2)
1093  const { info: caption } = await feedParts($, els, { ...d, video, card: null }, true, 0, Math.min(44, columns))
1094  const { Client } = els
1095  // The Svg's size in cells (about 8 x 16 a cell): the tap layer's, and the side column's height.
1096  const tapCols = Math.ceil((DESKTOP_W * scale) / 8)
1097  const tapRows = Math.ceil((DESKTOP_H * scale) / 16)
1098  const sideRows = [
1099    { text: '⊘', act: 'nope' },
1100    { text: ' ' },
1101    d.isLiked ? { text: '♥', act: 'like', color: '#ff2d55', bold: true } : { text: '♡', act: 'like', bold: true },
1102    { text: fmt(video.likes + (d.isLiked ? 1 : 0)), act: 'like', dim: true },
1103    { text: ' ' },
1104    { text: '≡', act: 'comments' },
1105    { text: fmt(video.comments), act: 'comments', dim: true },
1106    { text: ' ' },
1107    { text: '▲', act: 'prev' },
1108    { text: `${d.index + 1}`, dim: true },
1109    { text: '▼', act: 'next' },
1110  ]
1111  const moreRows = [{ text: '···', act: 'menu' }, { text: '?', act: 'help' }]
1112  // No height of its own: the row stretches it to the video's, ··· at the top, ▼ at the bottom.
1113  const actions = (
1114    <Box flexDirection="column" justifyContent="space-between">
1115      <Client key="more" module="./controls.tsx" props={{ rows: moreRows, width: SIDE }} width={SIDE} height={moreRows.length} />
1116      <Client key="controls" module="./controls.tsx" props={{ rows: sideRows, width: SIDE }} width={SIDE} height={sideRows.length} />
1117    </Box>
1118  )
1119  const cardRows = [
1120    { text: `[ ${S().keepScrolling} ]`, act: 'continue' },
1121    { text: ' ' },
1122    { text: `[ ${S().close} ]`, act: 'close', bold: true },
1123  ]
1124  const undoRows = lessOf ? [{ text: `⊘ ${S().notInterested(LABELS[lang][lessOf.kind])} · ${S().undo}`, act: 'undo', dim: true }] : []
1125  const info = card ? (
1126    <Box flexDirection="column">
1127      <Text bold color="green">
1128        {S().answered}
1129      </Text>
1130      <Text dimColor>{S().waiting}</Text>
1131      <Text> </Text>
1132      <Text>{S().thisWait(card.views, card.likes, duration(card.seconds * 1000))}</Text>
1133      <Text dimColor wrap="truncate-end">
1134        {S().session}: {statsLine(await read($, sessionAtom))}
1135      </Text>
1136      <Text> </Text>
1137      <Client key="card" module="./controls.tsx" props={{ rows: cardRows, width: 28 }} width={28} height={cardRows.length} />
1138    </Box>
1139  ) : lessOf ? (
1140    <Box flexDirection="column">
1141      {caption}
1142      <Client key="undo" module="./controls.tsx" props={{ rows: undoRows, width: Math.min(44, columns), isLeft: true }} width={Math.min(44, columns)} height={1} />
1143    </Box>
1144  ) : (
1145    caption
1146  )
1147  // The Svg takes no clicks: a transparent tap layer over it does, as in the terminal.
1148  // Its size in cells comes from the Svg's pixels (about 8 x 16 a cell): sized
1149  // in percent, the region came out empty on the desktop and took no clicks.
1150  const menuList = menu.isOpen ? menuRows(tapCols, tapRows) : []
1151  const screen = menu.isOpen ? (
1152    <Box width={tapCols} height={tapRows}>
1153      <Client key="menu" module="./controls.tsx" props={{ rows: menuList, width: tapCols, isLeft: true }} width={tapCols} height={menuList.length} />
1154    </Box>
1155  ) : (
1156    <Box>
1157      <Svg
1158        source={svgFrame(compose(), DESKTOP_W, DESKTOP_H)}
1159        alt={`${video.author}: ${META[video.kind].captions[lang][video.caption]}`}
1160        width={DESKTOP_W * scale}
1161        height={DESKTOP_H * scale}
1162      />
1163      <Box position="absolute" top={0} left={0}>
1164        <Client key="tap" module="./tap.tsx" props={{ columns: tapCols, rows: tapRows }} width={tapCols} height={tapRows} />
1165      </Box>
1166    </Box>
1167  )
1168  // The caption always goes under the video.
1169  const body = (
1170    <Box flexDirection="column" gap={1}>
1171      <Box flexDirection="row" gap={1}>
1172        {screen}
1173        {actions}
1174      </Box>
1175      {info}
1176    </Box>
1177  )
1178  // The pane only hands the wheel on while it has something to scroll, in
1179  // that direction: a spare row above the feed and one below give it room both
1180  // ways, and ui.scroll keeps the window on the feed, one row down.
1181  return (
1182    <Box flexDirection="column" minHeight={bodyRows + 2}>
1183      <Box height={1} />
1184      <Box key="feed" flexDirection="column">
1185        {body}
1186      </Box>
1187    </Box>
1188  )
1189}
1190
1191async function togglePause($: EngineInterface) {
1192  player.isPaused = !player.isPaused
1193  $.ui.invalidate('ui.render')
1194}
1195
1196async function keepWatching($: EngineInterface) {
1197  manual = true
1198  await clearEndCard($)
1199}
1200
hooks/cells.ts 57 lines
1// Packs a W x 2R pixel buffer into Raster cells: one ▀ per cell, its
2// foreground the upper pixel and its background the lower one.
3
4const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
5
6function base64(bytes: Uint8Array): string {
7  const out: string[] = []
8  let i = 0
9  for (; i + 2 < bytes.length; i += 3) {
10    const v = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
11    out.push(B64[(v >> 18) & 63]! + B64[(v >> 12) & 63]! + B64[(v >> 6) & 63]! + B64[v & 63]!)
12  }
13  const rest = bytes.length - i
14  if (rest === 1) {
15    const v = bytes[i]! << 16
16    out.push(B64[(v >> 18) & 63]! + B64[(v >> 12) & 63]! + '==')
17  } else if (rest === 2) {
18    const v = (bytes[i]! << 16) | (bytes[i + 1]! << 8)
19    out.push(B64[(v >> 18) & 63]! + B64[(v >> 12) & 63]! + B64[(v >> 6) & 63]! + '=')
20  }
21  return out.join('')
22}
23
24export function encodeCells(px: Uint32Array, columns: number, rows: number): string {
25  const words = new Uint32Array(columns * rows * 3)
26  const view = new DataView(words.buffer)
27  for (let cy = 0; cy < rows; cy++)
28    for (let x = 0; x < columns; x++) {
29      const o = (cy * columns + x) * 12
30      view.setUint32(o, 0x2580, true)
31      view.setUint32(o + 4, px[2 * cy * columns + x]! & 0xffffff, true)
32      view.setUint32(o + 8, px[(2 * cy + 1) * columns + x]! & 0xffffff, true)
33    }
34  return base64(new Uint8Array(words.buffer))
35}
36
37// The desktop has no Raster: a frame is an SVG instead, one path per colour,
38// each horizontal run of a colour one rectangle of it.
39export function svgFrame(px: Uint32Array, width: number, height: number): string {
40  const paths = new Map<number, string[]>()
41  for (let y = 0; y < height; y++) {
42    let x = 0
43    while (x < width) {
44      const c = px[y * width + x]! & 0xffffff
45      let end = x + 1
46      while (end < width && (px[y * width + end]! & 0xffffff) === c) end++
47      let runs = paths.get(c)
48      if (!runs) paths.set(c, (runs = []))
49      runs.push(`M${x} ${y}h${end - x}v1h-${end - x}z`)
50      x = end
51    }
52  }
53  let out = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges">`
54  for (const [c, runs] of paths) out += `<path fill="#${c.toString(16).padStart(6, '0')}" d="${runs.join('')}"/>`
55  return out + '</svg>'
56}
57
hooks/draw.ts 163 lines
1// Each "video" is a little simulation drawn into a W x H pixel buffer
2// (0x00RRGGBB per pixel). Two pixel rows make one terminal row (▀).
3
4export type Rng = () => number
5
6export interface Sim {
7  step(): void
8  draw(px: Uint32Array): void
9}
10
11export type Maker = (W: number, H: number, rng: Rng) => Sim
12
13export function rngFrom(seed: number): Rng {
14  let a = seed >>> 0
15  return () => {
16    a = (a + 0x6d2b79f5) >>> 0
17    let t = a
18    t = Math.imul(t ^ (t >>> 15), t | 1)
19    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
20    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
21  }
22}
23
24// --- colour and drawing helpers ---------------------------------------------
25
26const c255 = (v: number) => (v < 0 ? 0 : v > 255 ? 255 : v | 0)
27
28export const rgb = (r: number, g: number, b: number) => (c255(r) << 16) | (c255(g) << 8) | c255(b)
29
30export function mix(a: number, b: number, t: number): number {
31  const u = 1 - t
32  return rgb(
33    ((a >> 16) & 255) * u + ((b >> 16) & 255) * t,
34    ((a >> 8) & 255) * u + ((b >> 8) & 255) * t,
35    (a & 255) * u + (b & 255) * t,
36  )
37}
38
39export function add(a: number, b: number): number {
40  return rgb(((a >> 16) & 255) + ((b >> 16) & 255), ((a >> 8) & 255) + ((b >> 8) & 255), (a & 255) + (b & 255))
41}
42
43export function scale(a: number, k: number): number {
44  return rgb(((a >> 16) & 255) * k, ((a >> 8) & 255) * k, (a & 255) * k)
45}
46
47export function hsv(h: number, s: number, v: number): number {
48  h = ((h % 1) + 1) % 1
49  const i = Math.floor(h * 6)
50  const f = h * 6 - i
51  const p = v * (1 - s)
52  const q = v * (1 - f * s)
53  const t = v * (1 - (1 - f) * s)
54  const [r, g, b] = [
55    [v, t, p],
56    [q, v, p],
57    [p, v, t],
58    [p, q, v],
59    [t, p, v],
60    [v, p, q],
61  ][i % 6] as [number, number, number]
62  return rgb(r * 255, g * 255, b * 255)
63}
64
65export function rect(px: Uint32Array, W: number, H: number, x0: number, y0: number, w: number, h: number, c: number) {
66  const xa = Math.max(0, Math.round(x0))
67  const ya = Math.max(0, Math.round(y0))
68  const xb = Math.min(W, Math.round(x0 + w))
69  const yb = Math.min(H, Math.round(y0 + h))
70  for (let y = ya; y < yb; y++) for (let x = xa; x < xb; x++) px[y * W + x] = c
71}
72
73export function dot(px: Uint32Array, W: number, H: number, x: number, y: number, c: number) {
74  x = Math.round(x)
75  y = Math.round(y)
76  if (x >= 0 && x < W && y >= 0 && y < H) px[y * W + x] = c
77}
78
79export function disc(px: Uint32Array, W: number, H: number, cx: number, cy: number, r: number, c: number) {
80  const n = Math.ceil(r)
81  for (let dy = -n; dy <= n; dy++)
82    for (let dx = -n; dx <= n; dx++) if (dx * dx + dy * dy <= r * r + 0.3) dot(px, W, H, cx + dx, cy + dy, c)
83}
84
85export function sprite(px: Uint32Array, W: number, H: number, x: number, y: number, rows: string[], colors: Record<string, number>) {
86  rows.forEach((row, j) => {
87    for (let i = 0; i < row.length; i++) {
88      const c = colors[row[i] as string]
89      if (c !== undefined) dot(px, W, H, x + i, y + j, c)
90    }
91  })
92}
93
94const FONT: Record<string, string> = {
95  '0': '111101101101111',
96  '1': '010110010010111',
97  '2': '111001111100111',
98  '3': '111001111001111',
99  '4': '101101111001001',
100  '5': '111100111001111',
101  '6': '111100111101111',
102  '7': '111001001001001',
103  '8': '111101111101111',
104  '9': '111101111001111',
105  A: '010101111101101',
106  B: '110101110101110',
107  C: '011100100100011',
108  D: '110101101101110',
109  S: '011100010001110',
110  V: '101101101101010',
111  Z: '111001010100111',
112  '+': '000010111010000',
113  '-': '000000111000000',
114  x: '000101010101000',
115  '%': '101001010100101',
116  ':': '000010000010000',
117  '!': '010010010000010',
118  '?': '111001011000010',
119  '.': '000000000000010',
120  '/': '001001010100100',
121}
122
123export function text(px: Uint32Array, W: number, H: number, x: number, y: number, s: string, c: number) {
124  for (let k = 0; k < s.length; k++) {
125    const g = FONT[s[k] as string]
126    if (!g) continue
127    for (let j = 0; j < 15; j++) if (g[j] === '1') dot(px, W, H, x + k * 4 + (j % 3), y + Math.floor(j / 3), c)
128  }
129}
130
131export function sky(px: Uint32Array, W: number, H: number, top: number, bottom: number, to = H) {
132  for (let y = 0; y < to; y++) {
133    const c = mix(top, bottom, y / Math.max(1, to - 1))
134    for (let x = 0; x < W; x++) px[y * W + x] = c
135  }
136}
137
138// --- overlays: hearts, progress bar ---------------------------------------------
139
140const HEART = ['.x.x.', 'xxxxx', '.xxx.', '..x..']
141const BIG_HEART = ['.xx.xx.', 'xxxxxxx', 'xxxxxxx', '.xxxxx.', '..xxx..', '...x...']
142
143export function drawHeart(px: Uint32Array, W: number, H: number, x: number, y: number, k: number, big: boolean) {
144  const rows = big ? BIG_HEART : HEART
145  const n = big ? 2 : 1
146  rows.forEach((row, j) => {
147    for (let i = 0; i < row.length; i++) {
148      if (row[i] !== 'x') continue
149      for (let a = 0; a < n; a++)
150        for (let b = 0; b < n; b++) {
151          const xx = Math.round(x + i * n + a)
152          const yy = Math.round(y + j * n + b)
153          if (xx >= 0 && xx < W && yy >= 0 && yy < H) px[yy * W + xx] = mix(px[yy * W + xx] as number, 0xff2d55, k)
154        }
155    }
156  })
157}
158
159export function drawProgress(px: Uint32Array, W: number, H: number, t: number) {
160  const n = Math.round(W * t)
161  for (let x = 0; x < W; x++) px[(H - 1) * W + x] = x < n ? 0xf2f2f2 : mix(px[(H - 1) * W + x] as number, 0x333333, 0.8)
162}
163
hooks/themes/index.ts 222 lines
1import type { Kind } from '../../types'
2import type { Maker } from '../draw'
3import { ANIMALS } from './animals'
4import { ARCADE } from './arcade'
5import { CITY } from './city'
6import { COZY } from './cozy'
7import { CRAFTS } from './crafts'
8import { DESTRUCTION } from './destruction'
9import { DEV } from './dev'
10import { FOOD } from './food'
11import { GENERATIVE } from './generative'
12import { MACHINES } from './machines'
13import { MEMES } from './memes'
14import { MOBILE } from './mobile'
15import { MUSIC } from './music'
16import { NATURE } from './nature'
17import { PHYSICS } from './physics'
18import { PUZZLES } from './puzzles'
19import { ROBOTS } from './robots'
20import { SATISFYING } from './satisfying'
21import { SCIENCE } from './science'
22import { SEASONS } from './seasons'
23import { SIMULATIONS } from './simulations'
24import { SPACE } from './space'
25import { SPORTS } from './sports'
26import { VERSUS } from './versus'
27import { FLUIDS } from './fluids'
28import { IDLE } from './idle'
29import { OCEAN } from './ocean'
30import { INSECTS } from './insects'
31import { SKY } from './sky'
32import { FARM } from './farm'
33import { TOYS } from './toys'
34import { FANTASY } from './fantasy'
35import { PARTY } from './party'
36
37// The videos added by theme, one file per theme. Their texts live in
38// hooks/texts/, under the same theme and kind names.
39
40export const THEMES = {
41  satisfying: SATISFYING,
42  physics: PHYSICS,
43  arcade: ARCADE,
44  mobile: MOBILE,
45  versus: VERSUS,
46  generative: GENERATIVE,
47  animals: ANIMALS,
48  food: FOOD,
49  nature: NATURE,
50  space: SPACE,
51  cozy: COZY,
52  sports: SPORTS,
53  city: CITY,
54  music: MUSIC,
55  machines: MACHINES,
56  crafts: CRAFTS,
57  science: SCIENCE,
58  dev: DEV,
59  memes: MEMES,
60  seasons: SEASONS,
61  puzzles: PUZZLES,
62  simulations: SIMULATIONS,
63  destruction: DESTRUCTION,
64  robots: ROBOTS,
65  fluids: FLUIDS,
66  idle: IDLE,
67  ocean: OCEAN,
68  insects: INSECTS,
69  sky: SKY,
70  farm: FARM,
71  toys: TOYS,
72  fantasy: FANTASY,
73  party: PARTY,
74}
75
76export const THEME_MAKERS = {
77  ...SATISFYING,
78  ...PHYSICS,
79  ...ARCADE,
80  ...MOBILE,
81  ...VERSUS,
82  ...GENERATIVE,
83  ...ANIMALS,
84  ...FOOD,
85  ...NATURE,
86  ...SPACE,
87  ...COZY,
88  ...SPORTS,
89  ...CITY,
90  ...MUSIC,
91  ...MACHINES,
92  ...CRAFTS,
93  ...SCIENCE,
94  ...DEV,
95  ...MEMES,
96  ...SEASONS,
97  ...PUZZLES,
98  ...SIMULATIONS,
99  ...DESTRUCTION,
100  ...ROBOTS,
101  ...FLUIDS,
102  ...IDLE,
103  ...OCEAN,
104  ...INSECTS,
105  ...SKY,
106  ...FARM,
107  ...TOYS,
108  ...FANTASY,
109  ...PARTY,
110}
111
112/** Every video of the feed, by kind. */
113export const MAKERS: Record<Kind, Maker> = THEME_MAKERS
114
115export type Theme = keyof typeof THEMES
116
117/** Every video's theme: what the theme menu switches off. */
118export const THEME_OF: Record<string, Theme> = {
119  ...Object.fromEntries(Object.entries(THEMES).flatMap(([theme, makers]) => Object.keys(makers).map(kind => [kind, theme as Theme]))),
120}
121
122// Videos that belong to more than one theme: their home theme (THEME_OF,
123// where their code and texts live) and these. Hiding any of a video's themes
124// hides it; a hashtag focus on any of them shows it.
125export const EXTRA_THEMES: Record<string, Theme[]> = {
126  catkeys: ['dev', 'animals'],
127  touchgrass: ['dev'],
128  sokoban: ['puzzles'],
129  minesweeper: ['puzzles'],
130  game2048: ['puzzles'],
131  watersort: ['puzzles'],
132  ballsort: ['puzzles'],
133  robotarm: ['robots'],
134  warehouse: ['robots'],
135  robotcoder: ['robots'],
136  sumo: ['robots'],
137  robot: ['robots'],
138  life: ['simulations'],
139  langton: ['simulations'],
140  boids: ['simulations'],
141  foxrabbit: ['simulations'],
142  zombies: ['simulations'],
143  demolition: ['destruction'],
144  jenga: ['destruction'],
145  cardtower: ['destruction'],
146  chipper: ['destruction'],
147  runner: ['mobile'],
148  skytrack: ['mobile'],
149  dragrace: ['sports'],
150  monstertruck: ['sports'],
151  snooker: ['sports'],
152  pinball: ['sports'],
153  stadiumwave: ['sports'],
154  surfer: ['sports', 'ocean'],
155  // the themes of the second list, and the older videos that belong in them too
156  sable: ['fluids'],
157  kinetic: ['fluids'],
158  powder: ['fluids'],
159  lava: ['fluids'],
160  toothpaste: ['fluids'],
161  citybuilder: ['idle'],
162  towerdef: ['idle'],
163  jellyfish: ['ocean'],
164  reef: ['ocean'],
165  octopus: ['ocean'],
166  aquarium: ['ocean'],
167  sailing: ['ocean'],
168  bees: ['insects'],
169  leafcutter: ['insects'],
170  antfarm: ['insects'],
171  antwar: ['insects'],
172  spiderweb: ['insects'],
173  balloons: ['sky'],
174  feeder: ['sky'],
175  bats: ['sky'],
176  lanterns: ['sky'],
177  farm: ['farm'],
178  pumpkins: ['farm'],
179  cradle: ['toys'],
180  coiltrain: ['toys'],
181  cauldron: ['fantasy'],
182  claw: ['party'],
183  coinpusher: ['party'],
184  gumball: ['party'],
185  gacha: ['party'],
186  pinata: ['party'],
187  factory: ['machines'],
188  combine: ['machines'],
189  canallock: ['machines'],
190  flow: ['mobile'],
191  arrows: ['mobile'],
192  triplematch: ['mobile'],
193  mergearmy: ['mobile'],
194  horde: ['arcade'],
195  airhockey: ['arcade'],
196  hoops: ['arcade', 'sports'],
197  plinko: ['physics'],
198  pendulumwave: ['physics', 'science'],
199  pythagoras: ['science'],
200  drinkingbird: ['science'],
201  whale: ['animals'],
202  hatchlings: ['animals'],
203  shellswap: ['animals'],
204  sheepdog: ['animals'],
205  seagull: ['animals'],
206  butterfly: ['nature'],
207  snowglobe: ['cozy', 'seasons'],
208  dambreak: ['destruction'],
209  droneshow: ['robots'],
210  speedrun: ['memes'],
211  // the third list
212  battletops: ['versus'],
213  screwjam: ['puzzles'],
214  hexasort: ['puzzles'],
215  blockjam: ['puzzles'],
216  salmon: ['nature'],
217  shootgallery: ['arcade'],
218}
219
220/** A video's themes: its home theme first, then any others. */
221export const themesOf = (kind: string): Theme[] => [THEME_OF[kind] as Theme, ...(EXTRA_THEMES[kind] ?? [])]
222
hooks/i18n.ts 348 lines
1import type { Kind } from '../types'
2import { LOCALES } from './locales'
3import type { Theme } from './themes'
4import { THEME_TEXTS } from './texts'
5
6export type Lang = 'en' | 'fr' | 'es' | 'de' | 'pt' | 'it'
7
8export const LANGS: Lang[] = ['en', 'fr', 'es', 'de', 'pt', 'it']
9
10/** The languages written next to each video, in hooks/texts/ and below. */
11export type SourceLang = 'en' | 'fr'
12
13/** The other languages: each one's texts live in hooks/locales/<lang>/. */
14export type LocaleLang = Exclude<Lang, SourceLang>
15
16// Each video keeps the index of its caption and sound, so switching the
17// language retranslates the clips already in the feed. Every language
18// lists the same number of lines, in the same order.
19export type Meta<L extends string = Lang> = {
20  authors: string[]
21  tags: string[]
22  captions: Record<L, string[]>
23  sounds: Record<L, string[]>
24}
25
26/** A video's texts in hooks/texts/: its name in each language, then its META. */
27export type Entry = Meta<SourceLang> & { name: Record<SourceLang, string> }
28
29/** One video's texts in a language of hooks/locales/: the same lines as English, in the same order. */
30export type VideoText = { name: string; captions: string[]; sounds: string[] }
31
32/** A language of hooks/locales/: the interface, the theme names, the comments, and each video's texts. */
33export type Locale = {
34  strings: Strings
35  themes: Record<Theme, string>
36  comments: { generic: string[]; themes: Record<Theme, string[]> }
37  videos: Partial<Record<Kind, VideoText>>
38}
39
40function metaOf<K extends string>(texts: Record<K, Entry>): Record<K, Meta<SourceLang>> {
41  return Object.fromEntries(Object.entries<Entry>(texts).map(([k, { name: _, ...meta }]) => [k, meta])) as Record<K, Meta<SourceLang>>
42}
43
44function labelsOf<K extends string>(texts: Record<K, Entry>, lang: SourceLang): Record<K, string> {
45  return Object.fromEntries(Object.entries<Entry>(texts).map(([k, e]) => [k, e.name[lang]])) as Record<K, string>
46}
47
48const SOURCE_META: Record<Kind, Meta<SourceLang>> = metaOf(THEME_TEXTS)
49
50const SOURCE_LABELS: Record<SourceLang, Record<Kind, string>> = {
51  en: labelsOf(THEME_TEXTS, 'en'),
52  fr: labelsOf(THEME_TEXTS, 'fr'),
53}
54
55// The comments under a video: a few generic ones and one or two of its
56// theme's, picked from its seed. Both languages list the same number.
57const SOURCE_COMMENTS: { handles: string[]; generic: Record<SourceLang, string[]>; themes: Record<Theme, Record<SourceLang, string[]>> } = {
58  handles: ['@byte.size', '@lofi.lena', '@pixel.pete', '@nap.queen', '@sudo.sandwich', '@404.vibes', '@mochi.dev', '@the.real.kev', '@cafe.au.code', '@tiny.hands', '@rubber.duck', '@night.owl.42', '@kernel.kate', '@merge.conflict', '@just.vibing', '@cache.miss'],
59  generic: {
60    en: ['the way I watched this 9 times', 'why is this so satisfying', 'pov: you were supposed to be working', 'who else is here while their code compiles', 'the ending got me', 'I can hear this video', 'my attention span thanks you', 'this is my roman empire now', 'wait for it… WAIT FOR IT', 'ok but the colours', 'replaying until Claude answers', 'the algorithm knows me too well', 'instructions unclear, watched again', 'this deserves more likes', 'first', 'the pixels are pixeling'],
61    fr: ['je l’ai regardé 9 fois', 'pourquoi c’est si satisfaisant', 'pov : t’étais censé bosser', 'qui d’autre est là pendant que son code compile', 'la fin m’a eu', 'j’entends cette vidéo', 'ma capacité d’attention te remercie', 'c’est mon empire romain maintenant', 'attends… ATTENDS', 'ok mais les couleurs', 'je rejoue jusqu’à ce que Claude réponde', 'l’algo me connaît trop bien', 'instructions pas claires, j’ai regardé encore', 'ça mérite plus de likes', 'premier', 'les pixels pixellisent'],
62  },
63  themes: {
64    satisfying: { en: ['the click at the end', 'I could watch this for hours', 'my brain just relaxed'], fr: ['le clic à la fin', 'je pourrais regarder ça des heures', 'mon cerveau vient de se détendre'] },
65    physics: { en: ['Newton would be proud', 'the bounce physics are elite', 'this is basically homework'], fr: ['Newton serait fier', 'la physique des rebonds est parfaite', 'c’est mes devoirs en fait'] },
66    arcade: { en: ['the bot is better than me', 'insert coin to continue', 'high score or no score'], fr: ['le bot joue mieux que moi', 'insérez une pièce pour continuer', 'high score ou rien'] },
67    mobile: { en: ['this ad is better than the game', 'level 9999 energy', 'one more try, I promise'], fr: ['cette pub est mieux que le jeu', 'énergie niveau 9999', 'encore un essai, promis'] },
68    versus: { en: ['team blue all the way', 'red was robbed', 'best of three?'], fr: ['team bleu à fond', 'on a volé le rouge', 'en trois manches ?'] },
69    generative: { en: ['maths but make it pretty', 'the screensaver of my dreams', 'the pattern never repeats'], fr: ['les maths mais en joli', 'l’économiseur d’écran de mes rêves', 'le motif ne se répète jamais'] },
70    animals: { en: ['protect him at all costs', 'the little paws', 'he has a job and he does it'], fr: ['à protéger à tout prix', 'les petites pattes', 'il a un travail et il le fait'] },
71    food: { en: ['I’m hungry now, thanks', 'recipe please', 'chef’s kiss'], fr: ['j’ai faim maintenant, merci', 'la recette svp', 'le baiser du chef'] },
72    nature: { en: ['I can smell the rain', 'touch grass, pixel edition', 'nature is healing'], fr: ['je sens la pluie', 'touche de l’herbe, version pixel', 'la nature guérit'] },
73    space: { en: ['we are so small', 'houston, we have a vibe', 'the stars are aligned'], fr: ['on est tout petits', 'houston, on a une ambiance', 'les étoiles sont alignées'] },
74    cozy: { en: ['this is my comfort video', 'blanket and tea time', 'lo-fi beats to wait to'], fr: ['c’est ma vidéo doudou', 'plaid et thé', 'lo-fi pour attendre'] },
75    sports: { en: ['what a shot', 'the replay is worth it', 'ref, are you watching?'], fr: ['quel tir', 'le replay en vaut la peine', 'l’arbitre, tu regardes ?'] },
76    city: { en: ['city pop is playing in my head', 'I’d live there', 'the traffic is too real'], fr: ['j’ai de la city pop dans la tête', 'j’habiterais là', 'les bouchons sont trop réels'] },
77    music: { en: ['the beat drop', 'it’s stuck in my head', 'on repeat'], fr: ['le drop', 'c’est coincé dans ma tête', 'en boucle'] },
78    machines: { en: ['how it’s made at 2 a.m.', 'the precision', 'factory ASMR'], fr: ['comment c’est fait à 2 h du mat', 'la précision', 'ASMR d’usine'] },
79    crafts: { en: ['the patience', 'I’m buying the kit', 'handmade is better'], fr: ['la patience', 'j’achète le kit', 'le fait main c’est mieux'] },
80    science: { en: ['do not try this at home', 'my teacher never showed us this', 'for science'], fr: ['ne faites pas ça chez vous', 'mon prof ne nous a jamais montré ça', 'pour la science'] },
81    dev: { en: ['works on my machine', 'this is my code in prod', 'git push --force energy'], fr: ['ça marche sur ma machine', 'c’est mon code en prod', 'énergie git push --force'] },
82    memes: { en: ['classic', 'I sent this to everyone', 'the meme lives on'], fr: ['un classique', 'je l’ai envoyé à tout le monde', 'le mème est éternel'] },
83    seasons: { en: ['it’s that time of year', 'already feeling festive', 'the seasonal vibes'], fr: ['c’est la saison', 'déjà l’esprit de fête', 'l’ambiance de saison'] },
84    puzzles: { en: ['I solved it before the end', 'my brain hurts (good)', 'one more move'], fr: ['je l’ai résolu avant la fin', 'mon cerveau chauffe (en bien)', 'encore un coup'] },
85    simulations: { en: ['I could stare at this forever', 'sim city but smaller', 'watching the system work'], fr: ['je pourrais fixer ça pour toujours', 'sim city en petit', 'regarder le système tourner'] },
86    destruction: { en: ['so much destruction, so little time', 'it had it coming', 'again but slower'], fr: ['tant de destruction, si peu de temps', 'il l’avait cherché', 'encore mais au ralenti'] },
87    robots: { en: ['the robot uprising is cute', 'beep boop', 'he’s trying his best'], fr: ['la révolte des robots est mignonne', 'bip boup', 'il fait de son mieux'] },
88    fluids: { en: ['the way it finds its level', 'I can hear it pouring', 'liquid ASMR'], fr: ['la façon dont ça trouve son niveau', 'je l’entends couler', 'ASMR liquide'] },
89    idle: { en: ['number go up', 'I’d play this for 400 hours', 'the economy is booming'], fr: ['le nombre monte', 'j’y jouerais 400 heures', 'l’économie est en plein boom'] },
90    ocean: { en: ['the ocean is scary and I love it', 'I can hear the waves', 'we know more about space than this'], fr: ['l’océan fait peur et j’adore', 'j’entends les vagues', 'on connaît mieux l’espace que ça'] },
91    insects: { en: ['tiny but mighty', 'the little legs!!', 'nature documentary at 2 a.m.'], fr: ['petit mais costaud', 'les petites pattes !!', 'documentaire animalier à 2 h du mat'] },
92    sky: { en: ['the sky is the limit', 'I want to fly', 'up up and away'], fr: ['le ciel est la limite', 'je veux voler', 'et hop, dans les airs'] },
93    farm: { en: ['farm life is the best life', 'I can smell the hay', 'cottagecore unlocked'], fr: ['la vie à la ferme, la meilleure', 'je sens le foin', 'cottagecore débloqué'] },
94    toys: { en: ['I had this as a kid!', 'where can I buy one', 'my desk needs this'], fr: ['j’avais ça petit !', 'où est-ce qu’on achète ça', 'il faut ça sur mon bureau'] },
95    fantasy: { en: ['+10 XP for watching', 'the side quest I needed', 'roll for initiative'], fr: ['+10 XP pour avoir regardé', 'la quête secondaire dont j’avais besoin', 'lance l’initiative'] },
96    party: { en: ['one more round!', 'this is rigged', 'game night energy'], fr: ['encore une partie !', 'c’est truqué', 'ambiance soirée jeux'] },
97  },
98}
99
100// Each theme's name in the theme menu.
101const SOURCE_THEME_NAMES: Record<SourceLang, Record<Theme, string>> = {
102  en: {
103    satisfying: 'satisfying',
104    physics: 'physics',
105    arcade: 'arcade',
106    mobile: 'mobile games',
107    versus: 'versus',
108    generative: 'generative art',
109    animals: 'animals',
110    food: 'food',
111    nature: 'nature',
112    space: 'space',
113    cozy: 'cozy',
114    sports: 'sports',
115    city: 'city',
116    music: 'music',
117    machines: 'machines',
118    crafts: 'crafts',
119    science: 'science',
120    dev: 'dev',
121    memes: 'memes',
122    seasons: 'seasons',
123    puzzles: 'puzzles',
124    simulations: 'simulations',
125    destruction: 'destruction',
126    robots: 'robots',
127    fluids: 'fluids & sand',
128    idle: 'idle & strategy',
129    ocean: 'ocean',
130    insects: 'insects',
131    sky: 'sky & flight',
132    farm: 'farm & garden',
133    toys: 'toys',
134    fantasy: 'fantasy',
135    party: 'party games',
136  },
137  fr: {
138    satisfying: 'satisfaisant',
139    physics: 'physique',
140    arcade: 'arcade',
141    mobile: 'jeux mobiles',
142    versus: 'duels',
143    generative: 'art génératif',
144    animals: 'animaux',
145    food: 'cuisine',
146    nature: 'nature',
147    space: 'espace',
148    cozy: 'cosy',
149    sports: 'sport',
150    city: 'ville',
151    music: 'musique',
152    machines: 'machines',
153    crafts: 'artisanat',
154    science: 'science',
155    dev: 'dev',
156    memes: 'mèmes',
157    seasons: 'saisons',
158    puzzles: 'casse-têtes',
159    simulations: 'simulations',
160    destruction: 'destruction',
161    robots: 'robots',
162    fluids: 'fluides et sable',
163    idle: 'idle et stratégie',
164    ocean: 'océan',
165    insects: 'insectes',
166    sky: 'ciel et vol',
167    farm: 'ferme et jardin',
168    toys: 'jouets',
169    fantasy: 'fantasy',
170    party: 'jeux de fête',
171  },
172}
173
174const en = {
175  paneTitle: 'For you',
176  commandDescription: 'Pixel art mini-videos to scroll while Claude answers (on | off | stats | lang en|fr|es|de|pt|it | fps 0-15 | no argument: open)',
177  tooSmall: 'Make this a bit bigger to see the feed.',
178  tooNarrow: (columns: number) => `▶ Brainrot break: the window is too narrow for the feed (${columns} columns, 110 needed).`,
179  loading: 'Loading the feed...',
180  terminalOnly: 'The pixel art feed plays in the terminal and the desktop app.',
181  pause: 'Pause',
182  play: 'Play',
183  toSide: 'Move to the side',
184  answered: '✓ Claude has answered',
185  waiting: 'your answer is waiting in the transcript',
186  thisWait: (views: number, likes: number, time: string) => `This wait: ${views} video${views === 1 ? '' : 's'} · ${likes} ♥ · ${time}`,
187  session: 'Session',
188  keepScrolling: 'Keep scrolling',
189  close: 'Close',
190  statsLine: (views: number, likes: number, time: string, fav?: string) =>
191    `${views} videos · ${likes} ♥ · ${time}${fav ? ` · favorite: ${fav}` : ''}`,
192  lastSession: (line: string) => `Brainrot break, last session: ${line}`,
193  off: 'Brainrot break is off. /brainrot on turns it back on.',
194  on: 'Brainrot break is on: the feed opens when an answer from Claude takes more than 2 s.',
195  opened: 'Feed open. Wheel or j/k to scroll, double-click or l to like, click to pause.',
196  statsTitle: 'Brainrot break, this session:',
197  top: 'top',
198  nothingYet: 'nothing watched yet',
199  allTime: 'All time',
200  language: 'Language: English.',
201  languageUsage: 'Usage: /brainrot lang en|fr|es|de|pt|it',
202  answeredToast: 'Claude has answered ✓',
203  fps: (n: number) => (n === 0 ? 'Desktop video frozen (/brainrot fps 10 plays it again).' : `Desktop video: ${n} frames a second.`),
204  fpsUsage: 'Usage: /brainrot fps 0-15 (desktop app; 0 freezes the video)',
205  decimal: '.',
206  notInterested: (name: string) => `Fewer “${name}”`,
207  undo: 'Undo',
208  menuBack: '‹ Back',
209  thisVideo: 'This video:',
210  nopeAction: 'Not interested',
211  hideTheme: (tag: string) => `Hide ${tag}`,
212  chooseThemes: 'Choose my themes…',
213  themeHidden: (tag: string) => `${tag} hidden. ··· › Choose my themes brings it back.`,
214  showAll: 'Show all',
215  hideAll: 'Hide all',
216  keptTheme: (tag: string) => `${tag} stays on: keep at least one, then pick the ones you want.`,
217  forget: "Forget my 'not interested'",
218  forgotten: "Your 'not interested' marks are forgotten.",
219  lastTheme: 'Keep at least one theme.',
220  myLikes: (n: number) => `My likes (${n})`,
221  noLikes: 'No likes yet: double-click a video you love.',
222  settings: 'Settings…',
223  settingLanguage: 'Language: English',
224  settingAutoOpen: (on: boolean) => `Opens while Claude works: ${on ? 'on' : 'off'}`,
225  settingAutoScroll: (on: boolean) => `Next video by itself: ${on ? 'on' : 'off'}`,
226  commentsTitle: (n: string) => `${n} comments`,
227  focusOn: (tag: string) => `Only ${tag} videos · ✕`,
228  shortcuts: 'Shortcuts',
229  helpNext: 'next / previous video',
230  helpLike: 'like',
231  helpPause: 'pause',
232  helpComments: 'comments',
233  helpNope: 'not interested',
234  helpUndo: 'undo it',
235  helpMenu: 'menu',
236  helpTags: 'only that theme',
237  helpEndCard: 'end card: keep scrolling / close',
238  helpHelp: 'this help',
239}
240
241const fr: typeof en = {
242  paneTitle: 'Pour toi',
243  commandDescription: 'Mini-vidéos pixel art à scroller pendant que Claude répond (on | off | stats | lang en|fr|es|de|pt|it | fps 0-15 | sans argument : ouvrir)',
244  tooSmall: 'Agrandis un peu pour voir le feed.',
245  tooNarrow: (columns: number) => `▶ Brainrot break : la fenêtre est trop étroite pour le feed (${columns} colonnes, il en faut 110).`,
246  loading: 'Chargement du feed...',
247  terminalOnly: 'Le feed pixel art se regarde dans le terminal et l’app desktop.',
248  pause: 'Pause',
249  play: 'Lecture',
250  toSide: 'Mettre sur le côté',
251  answered: '✓ Claude a répondu',
252  waiting: "la réponse t'attend dans le transcript",
253  thisWait: (views, likes, time) => `Cette attente : ${views} vidéo${views > 1 ? 's' : ''} · ${likes} ♥ · ${time}`,
254  session: 'Session',
255  keepScrolling: 'Continuer à scroller',
256  close: 'Fermer',
257  statsLine: (views, likes, time, fav) => `${views} vidéos · ${likes} ♥ · ${time}${fav ? ` · préféré : ${fav}` : ''}`,
258  lastSession: line => `Brainrot break, session précédente : ${line}`,
259  off: 'Brainrot break désactivé. /brainrot on pour le réactiver.',
260  on: 'Brainrot break activé : le feed s’ouvre quand une réponse de Claude dure plus de 2 s.',
261  opened: 'Feed ouvert. Molette ou j/k pour scroller, double-clic ou l pour liker, clic pour mettre en pause.',
262  statsTitle: 'Brainrot break, cette session :',
263  top: 'top',
264  nothingYet: 'rien regardé pour l’instant',
265  allTime: 'Depuis toujours',
266  language: 'Langue : français.',
267  languageUsage: 'Utilisation : /brainrot lang en|fr|es|de|pt|it',
268  answeredToast: 'Claude a répondu ✓',
269  fps: n => (n === 0 ? 'Vidéo desktop figée (/brainrot fps 10 pour la relancer).' : `Vidéo desktop : ${n} images par seconde.`),
270  fpsUsage: 'Utilisation : /brainrot fps 0-15 (app desktop ; 0 fige la vidéo)',
271  decimal: ',',
272  notInterested: name => `Moins de « ${name} »`,
273  undo: 'Annuler',
274  menuBack: '‹ Retour',
275  thisVideo: 'Cette vidéo :',
276  nopeAction: 'Pas intéressé',
277  hideTheme: tag => `Masquer ${tag}`,
278  chooseThemes: 'Choisir mes thèmes…',
279  themeHidden: tag => `${tag} masqué. ··· › Choisir mes thèmes pour le remettre.`,
280  showAll: 'Tout afficher',
281  hideAll: 'Tout masquer',
282  keptTheme: tag => `${tag} reste affiché : il en faut un, coche ensuite ceux que tu veux.`,
283  forget: "Oublier mes « pas intéressé »",
284  forgotten: "Tes « pas intéressé » sont oubliés.",
285  lastTheme: 'Garde au moins un thème.',
286  myLikes: n => `Mes likes (${n})`,
287  noLikes: 'Pas encore de like : double-clique une vidéo que tu adores.',
288  settings: 'Réglages…',
289  settingLanguage: 'Langue : français',
290  settingAutoOpen: on => `S’ouvre quand Claude bosse : ${on ? 'oui' : 'non'}`,
291  settingAutoScroll: on => `Vidéo suivante toute seule : ${on ? 'oui' : 'non'}`,
292  commentsTitle: n => `${n} commentaires`,
293  focusOn: tag => `Que des vidéos ${tag} · ✕`,
294  shortcuts: 'Raccourcis',
295  helpNext: 'vidéo suivante / précédente',
296  helpLike: 'liker',
297  helpPause: 'pause',
298  helpComments: 'commentaires',
299  helpNope: 'pas intéressé',
300  helpUndo: 'annuler',
301  helpMenu: 'menu',
302  helpTags: 'que ce thème',
303  helpEndCard: 'écran de fin : continuer / fermer',
304  helpHelp: 'cette aide',
305}
306
307export type Strings = typeof en
308
309const LOCALE_LANGS = Object.keys(LOCALES) as LocaleLang[]
310const KINDS = Object.keys(SOURCE_META) as Kind[]
311const fromLocales = <T>(pick: (l: Locale) => T) => Object.fromEntries(LOCALE_LANGS.map(l => [l, pick(LOCALES[l])])) as Record<LocaleLang, T>
312
313export const STRINGS: Record<Lang, Strings> = { en, fr, ...fromLocales(l => l.strings) }
314
315export const THEME_NAMES: Record<Lang, Record<Theme, string>> = { ...SOURCE_THEME_NAMES, ...fromLocales(l => l.themes) }
316
317export const COMMENTS: { handles: string[]; generic: Record<Lang, string[]>; themes: Record<Theme, Record<Lang, string[]>> } = {
318  handles: SOURCE_COMMENTS.handles,
319  generic: { ...SOURCE_COMMENTS.generic, ...fromLocales(l => l.comments.generic) },
320  themes: Object.fromEntries(
321    (Object.keys(SOURCE_COMMENTS.themes) as Theme[]).map(t => [t, { ...SOURCE_COMMENTS.themes[t], ...fromLocales(l => l.comments.themes[t]) }]),
322  ) as Record<Theme, Record<Lang, string[]>>,
323}
324
325// A video not yet translated in a language falls back to English there; a test
326// makes sure none is left.
327export const META: Record<Kind, Meta> = Object.fromEntries(
328  KINDS.map(k => {
329    const m = SOURCE_META[k]
330    return [
331      k,
332      {
333        ...m,
334        captions: { ...m.captions, ...fromLocales(l => l.videos[k]?.captions ?? m.captions.en) },
335        sounds: { ...m.sounds, ...fromLocales(l => l.videos[k]?.sounds ?? m.sounds.en) },
336      },
337    ]
338  }),
339) as Record<Kind, Meta>
340
341export const LABELS: Record<Lang, Record<Kind, string>> = {
342  ...SOURCE_LABELS,
343  ...fromLocales(l => Object.fromEntries(KINDS.map(k => [k, l.videos[k]?.name ?? SOURCE_LABELS.en[k]])) as Record<Kind, string>),
344}
345
346/** Each language's untranslated videos, for the tests. */
347export const MISSING: Record<LocaleLang, Kind[]> = fromLocales(l => KINDS.filter(k => !l.videos[k]))
348
hooks/controls.tsx 76 lines
1import type { ClientModule } from 'claude-code'
2
3import { KEYS } from './keys'
4
5// The desktop app's controls: rows of text that turn a click, or a key once
6// clicked, into an action posted to the hooks module. The desktop drops a
7// Button's mouse press when the pane redraws under it; this region keeps
8// its listeners across redraws, so the press always lands.
9//
10// A row can be made of parts, each with its own colour and action, laid out
11// left to right: a click lands on the part under it (the caption's hashtags).
12
13type Part = { text: string; act?: string; color?: string }
14type Row = { text: string; act?: string; color?: string; bold?: boolean; dim?: boolean; parts?: Part[] }
15type Props = { rows: Row[]; width: number; isLeft?: boolean }
16type State = { rows: Row[] }
17
18
19// The action under column x of a row of parts (drawn from the left).
20function partAt(parts: Part[], x: number): string | undefined {
21  let from = 0
22  for (const p of parts) {
23    const to = from + [...p.text].length
24    if (x >= from && x < to) return p.act
25    from = to
26  }
27  return undefined
28}
29
30const Controls: ClientModule<Props, State> = (props, surface) => {
31  if (surface.state === undefined) {
32    surface.onPointer(e => {
33      if (e.type !== 'down' || e.button !== 'left') return
34      const row = surface.state?.rows[e.y]
35      const act = row?.parts ? partAt(row.parts, e.x) : row?.act
36      if (act) surface.post({ act })
37    })
38    surface.onKey(e => {
39      const act = KEYS[e.key]
40      if (act) surface.post({ act })
41    })
42  }
43  // The listeners read the rows drawn last, so a redraw with new props keeps them right.
44  if (surface.state?.rows !== props.rows) surface.setState({ rows: props.rows })
45
46  const { Box, Text } = surface.elements
47  const pad = (s: string) => {
48    const chars = [...s].slice(0, props.width)
49    const room = props.width - chars.length
50    const before = props.isLeft ? 0 : Math.floor(room / 2)
51    return ' '.repeat(before) + chars.join('') + ' '.repeat(room - before)
52  }
53  return (
54    <Box flexDirection="column" width={props.width}>
55      {props.rows.map(row =>
56        row.parts ? (
57          <Text>
58            {row.parts.map(p => (p.color ? <Text color={p.color}>{p.text}</Text> : <Text>{p.text}</Text>))}
59            {' '.repeat(Math.max(0, props.width - [...row.text].length))}
60          </Text>
61        ) : row.color ? (
62          <Text color={row.color} bold={row.bold ?? false} dimColor={row.dim ?? false}>
63            {pad(row.text)}
64          </Text>
65        ) : (
66          <Text bold={row.bold ?? false} dimColor={row.dim ?? false}>
67            {pad(row.text)}
68          </Text>
69        ),
70      )}
71    </Box>
72  )
73}
74
75export default Controls
76
hooks/tap.tsx 37 lines
1import type { ClientModule } from 'claude-code'
2
3import { KEYS } from './keys'
4
5// An invisible layer over the video that turns clicks into taps:
6// one tap pauses, two quick taps like, as on TikTok. A click gives it the
7// keyboard, so it takes the feed's keys (and ?, which no Button hotkey can be).
8
9type Props = { columns: number; rows: number }
10type State = { last: number }
11
12const DOUBLE_TAP_MS = 350
13
14const Tap: ClientModule<Props, State> = (props, surface) => {
15  if (surface.state === undefined) {
16    surface.setState({ last: 0 })
17    surface.onPointer(e => {
18      if (e.type !== 'down' || e.button !== 'left') return
19      const now = Date.now()
20      const isDouble = now - (surface.state?.last ?? 0) < DOUBLE_TAP_MS
21      // Where the tap landed, as a fraction of the region, for the heart.
22      const fx = (e.x + 0.5) / Math.max(1, surface.columns)
23      const fy = (e.y + 0.5) / Math.max(1, surface.rows)
24      surface.post({ tap: isDouble ? 'double' : 'single', x: e.x, y: e.y, fx, fy })
25      surface.setState({ last: isDouble ? 0 : now })
26    })
27    surface.onKey(e => {
28      const act = KEYS[e.key]
29      if (act) surface.post({ act })
30    })
31  }
32  const { Box } = surface.elements
33  return <Box width={props.columns} height={props.rows} />
34}
35
36export default Tap
37
hooks/themes/animals.ts 1595 lines
1import { disc, dot, hsv, mix, rect, scale, sky, text } from '../draw'
2import type { Maker } from '../draw'
3import { burst, clamp, ellipse, irand, line, lit, litSprite, pick, ring, slab, sparks, textWidth, tint } from '../kit'
4import type { Spark } from '../kit'
5
6// Animals & pets: one animal, one funny thing, a loop.
7
8const ease = (t: number) => {
9  const u = clamp(t, 0, 1)
10  return u * u * (3 - 2 * u)
11}
12/** 0 to 1 across [a, b] of a timeline. */
13const span = (t: number, a: number, b: number) => clamp((t - a) / (b - a), 0, 1)
14
15// --- cat pushes the glass ---------------------------------------------------------
16
17const catglass: Maker = (W, H, rng) => {
18  const table = Math.round(H * 0.58)
19  const edge = Math.round(W * 0.8)
20  let t = 0
21  let fur = 0xf0a040
22  let shards: Spark[] = []
23  const CAT = [
24    '.o...o..',
25    '.oo.oo..',
26    '.ooooo..',
27    '.oeoeo..',
28    '.ooqoo..',
29    '..ooo...',
30    '.ooooo..',
31    'oowwwoo.',
32    'ooooooo.',
33    'ooooooot',
34    '.oo.oo.t',
35  ]
36
37  return {
38    step() {
39      t++
40      shards = sparks(shards, 0.12).filter(s => s.y < H - 3)
41      if (t === 128) {
42        burst(shards, rng, edge + 3, H - 6, 0xc8f0ff, 12, 1.2)
43        burst(shards, rng, edge + 3, H - 6, 0x6ab0ff, 8, 0.8)
44      }
45      if (t > 210) {
46        t = 0
47        fur = pick(rng, [0xf0a040, 0x6a6a72, 0x2a2a2e, 0xe8e0d0, 0xc8783a])
48      }
49    },
50    draw(px) {
51      sky(px, W, H, 0xf6e6d0, 0xe8cfae, table)
52      for (let y = 4; y < table - 2; y += 9) rect(px, W, H, 0, y, W, 1, 0xead8be)
53      rect(px, W, H, 0, table, edge + 2, 2, 0x8a5a32)
54      rect(px, W, H, 0, table + 2, edge + 2, H - table - 2, 0x6a4226)
55      rect(px, W, H, edge + 2, table, W - edge - 2, H - table, 0xd8c0a0)
56      rect(px, W, H, 0, H - 3, W, 3, 0xb89a78)
57      const cx = Math.round(W * 0.25)
58      const blink = t % 50 > 46 || (t > 160 && t < 175)
59      const look = t > 140
60      const rows = CAT.map((r, i) => (i === 3 && blink ? r.replace(/e/g, 'o') : r))
61      litSprite(px, W, H, cx, table - 11, rows, { o: fur, e: 0x3ad06a, q: 0xff8aa0, w: mix(fur, 0xffffff, 0.5), t: fur })
62      if (look) dot(px, W, H, cx + 2, table - 8, 0x000000)
63      const push = span(t, 30, 110)
64      const gx = Math.round(W * 0.55) + (edge - Math.round(W * 0.55)) * ease(push)
65      if (t > 25 && t < 120) line(px, W, H, cx + 6, table - 4, gx - 1, table - 4, fur)
66      if (t < 112) {
67        rect(px, W, H, gx, table - 6, 4, 6, 0xc8f0ff)
68        rect(px, W, H, gx + 1, table - 4, 2, 4, 0x6ab0ff)
69        rect(px, W, H, gx, table - 6, 1, 6, 0xe8faff)
70      } else if (t < 128) {
71        const f = (t - 112) / 16
72        const y = table - 6 + f * f * (H - table - 2)
73        const a = f * 2
74        line(px, W, H, edge + 2, y, edge + 2 + Math.cos(a) * 4, y + Math.sin(a) * 4, 0xc8f0ff)
75        line(px, W, H, edge + 3, y, edge + 3 + Math.cos(a) * 4, y + Math.sin(a) * 4, 0x6ab0ff)
76      } else if (t < 200) rect(px, W, H, edge - 1, H - 4, 8, 1, 0x6ab0ff)
77      for (const s of shards) dot(px, W, H, s.x, s.y, s.c)
78      if (t > 135 && t < 160) text(px, W, H, cx + 8, table - 18, '!', 0xff3a3a)
79    },
80  }
81}
82
83// --- dog zoomies ------------------------------------------------------------------
84
85const zoomies: Maker = (W, H, rng) => {
86  const cx = W / 2
87  const cy = H * 0.52
88  const rx = W * 0.36
89  const ry = H * 0.32
90  let a = 0
91  let speed = 0
92  let dir = 1
93  let t = 0
94  let rest = 0
95  let dust: Spark[] = []
96  const flowers = Array.from({ length: 8 }, () => [rng() * W, rng() * H, hsv(rng(), 0.6, 1)] as [number, number, number])
97
98  return {
99    step() {
100      t++
101      dust = sparks(dust, 0)
102      if (rest > 0) {
103        rest--
104        speed *= 0.9
105        if (rest === 0) dir = rng() < 0.5 ? -1 : 1
106      } else {
107        speed = Math.min(0.14, speed + 0.004)
108        if (t % 260 === 200) rest = 70
109        if (rng() < 0.004) dir = -dir
110      }
111      a += speed * dir
112      if (speed > 0.08 && rng() < 0.6) {
113        const x = cx + Math.cos(a) * rx
114        const y = cy + Math.sin(a) * ry
115        dust.push({ x, y, vx: (rng() - 0.5) * 0.3, vy: (rng() - 0.5) * 0.3, l: 10, c: 0xc8a870 })
116      }
117    },
118    draw(px) {
119      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x + y * 2) % 9 === 0 ? 0x5aa03a : 0x6ab84a
120      for (let k = 0; k < 120; k++) {
121        const q = (k / 120) * Math.PI * 2
122        dot(px, W, H, cx + Math.cos(q) * rx, cy + Math.sin(q) * ry, 0x8ac060)
123      }
124      for (const [x, y, c] of flowers) dot(px, W, H, x, y, c)
125      ellipse(px, W, H, cx, cy, 4, 3, 0x6ac8ff)
126      ellipse(px, W, H, cx, cy, 3, 2, 0x9ae0ff)
127      litSprite(px, W, H, 2, 2, ['.s.', 'sss', '.b.', 'b.b'], { s: 0xffd0a0, b: 0x3a5aaa })
128      for (const d of dust) dot(px, W, H, d.x, d.y, d.c)
129      const x = cx + Math.cos(a) * rx
130      const y = cy + Math.sin(a) * ry
131      // the way it runs: the ellipse's own tangent, not a circle's
132      const h = Math.atan2(ry * Math.cos(a) * dir, -rx * Math.sin(a) * dir)
133      if (rest > 0 && speed < 0.03) {
134        lit(px, W, H, x, y, 2.5, 1.5, 0xb8783a)
135        dot(px, W, H, x + 2, y - 1, 0x2a1a0a)
136        if (t % 20 < 10) dot(px, W, H, x + 3, y, 0xff6a8a)
137      } else {
138        for (let k = -2; k <= 2; k++) disc(px, W, H, x + Math.cos(h) * k * 0.9, y + Math.sin(h) * k * 0.9, 1.2, 0xb8783a)
139        dot(px, W, H, x + Math.cos(h) * 3, y + Math.sin(h) * 3, 0x2a1a0a)
140        dot(px, W, H, x - Math.cos(h) * 3.2, y - Math.sin(h) * 3.2, 0xffffff)
141        if (speed > 0.1) for (let k = 4; k < 8; k += 2) dot(px, W, H, x - Math.cos(h) * k, y - Math.sin(h) * k, 0xffffff)
142      }
143    },
144  }
145}
146
147// --- capybara spa -----------------------------------------------------------------
148
149const capybara: Maker = (W, H, rng) => {
150  const water = Math.round(H * 0.52)
151  let t = 0
152  const steam = Array.from({ length: 10 }, () => ({ x: rng() * W, y: water - rng() * 20, s: 0.15 + rng() * 0.15 }))
153  const snow = Array.from({ length: 12 }, () => ({ x: rng() * W, y: rng() * H }))
154  const CAPY = ['..bbbbbbbb..', '.bbbbbbbbbbe', 'bbbbbbbbbbbb', 'bbbbbbbbbbbn', 'bbbbbbbbbbbb']
155
156  return {
157    step() {
158      t++
159      for (const s of steam) {
160        s.y -= s.s
161        s.x += Math.sin((s.y + t) * 0.1) * 0.1
162        if (s.y < water - 26) {
163          s.y = water
164          s.x = rng() * W
165        }
166      }
167      for (const s of snow) {
168        s.y += 0.25
169        if (s.y > water) {
170          s.y = 0
171          s.x = rng() * W
172        }
173      }
174    },
175    draw(px) {
176      sky(px, W, H, 0x5a6a8a, 0xb0b8c8, water)
177      for (let x = 0; x < W; x++) {
178        const h = 6 + Math.sin(x * 0.4) * 2 + Math.sin(x * 1.3) * 1
179        rect(px, W, H, x, water - h, 1, h, (x % 5) < 2 ? 0x7a7a82 : 0x6a6a72)
180        dot(px, W, H, x, water - h, 0xf0f4f8)
181      }
182      for (let y = water; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x + y + (t >> 3)) % 7 === 0 ? 0x8ad8d0 : mix(0x5ac0b8, 0x2a8a90, (y - water) / (H - water))
183      const bob = Math.sin(t * 0.05) * 0.6
184      const cx = Math.round(W / 2 - 6)
185      const cy = water - 3 + bob
186      litSprite(px, W, H, cx, cy, CAPY, { b: 0x9a6a3a, e: 0x2a1a0a, n: 0x5a3a1a })
187      if (t % 80 > 74) litSprite(px, W, H, cx + 11, cy + 1, ['b'], { b: 0x9a6a3a })
188      rect(px, W, H, cx, water, 12, 1, 0x8ad8d0)
189      disc(px, W, H, cx + 6, cy - 2, 1.6, 0xff9a1a)
190      dot(px, W, H, cx + 6, cy - 4, 0x3a9a2a)
191      for (let k = 0; k < 3; k++) {
192        const rr = ((t * 0.08 + k * 2) % 6) + 1
193        for (let q = 0; q < 16; q++) {
194          const ang = (q / 16) * Math.PI * 2
195          dot(px, W, H, cx + 6 + Math.cos(ang) * rr * 1.6, water + 1 + Math.sin(ang) * rr * 0.4, 0xa8e8e0)
196        }
197      }
198      for (const s of steam) dot(px, W, H, s.x, s.y, 0xe8eef4)
199      for (const s of snow) dot(px, W, H, s.x, s.y, 0xffffff)
200      const dx = (t * 0.15) % (W + 10) - 5
201      litSprite(px, W, H, dx, water + 8, ['.y.', 'yyy'], { y: 0xffe040 })
202      dot(px, W, H, dx + 3, water + 8, 0xff8a20)
203    },
204  }
205}
206
207// --- ducklings in a row -----------------------------------------------------------
208
209const ducklings: Maker = (W, H, rng) => {
210  const path = Math.round(H * 0.6)
211  let t = 0
212  let lost = 2
213  const n = 5
214
215  return {
216    step() {
217      t++
218      if (t > 420) {
219        t = 0
220        lost = irand(rng, n)
221      }
222    },
223    draw(px) {
224      sky(px, W, H, 0x9ad8ff, 0xd8f0ff, Math.round(H * 0.35))
225      for (let y = Math.round(H * 0.35); y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x * 3 + y) % 11 === 0 ? 0x5aa83a : 0x6ac04a
226      rect(px, W, H, 0, path - 2, W, 6, 0xd8c098)
227      ellipse(px, W, H, W * 0.75, H * 0.85, W * 0.3, 4, 0x5ab0e8)
228      const flowerX = W * 0.35
229      dot(px, W, H, flowerX, path - 6, 0xff6a9a)
230      dot(px, W, H, flowerX, path - 5, 0x3a8a2a)
231      const mx = -8 + t * 0.12
232      const step = Math.floor(t / 5) % 2
233      litSprite(px, W, H, mx, path - 6, ['..ww.', '.wwko', 'wwww.', 'wwww.', '.o.o.'], { w: 0xf4f4f0, k: 0x000000, o: 0xff9a2a })
234      for (let i = 0; i < n; i++) {
235        let x = mx - 4 - i * 4
236        let y = path - 3
237        if (i === lost) {
238          const away = span(t, 140, 180) - span(t, 230, 270)
239          const panic = t > 230 && t < 300
240          x += (flowerX - x) * away
241          y -= away * 3
242          if (panic) x += Math.sin(t * 0.8) * 0.5
243        }
244        litSprite(px, W, H, x, y + ((step + i) % 2 ? 0 : -0.5), ['.yy', 'yyk', 'yyy'], { y: 0xffe040, k: 0x000000 })
245        dot(px, W, H, x + 3, y + 1, 0xff9a2a)
246      }
247      if (t > 225 && t < 250) text(px, W, H, mx - 4 - lost * 4, path - 12, '!', 0xff3a3a)
248    },
249  }
250}
251
252// --- frog and flies ---------------------------------------------------------------
253
254const frog: Maker = (W, H, rng) => {
255  const water = Math.round(H * 0.45)
256  const pads = [
257    { x: W * 0.3, y: H * 0.75 },
258    { x: W * 0.7, y: H * 0.62 },
259    { x: W * 0.4, y: H * 0.88 },
260  ]
261  let at = 0
262  let jump = -1
263  let to = 0
264  let flies = Array.from({ length: 3 }, () => ({ x: rng() * W, y: 6 + rng() * (water - 4), a: rng() * 6 }))
265  let tongue: { x: number; y: number; t: number } | undefined
266  let caught = 0
267  let gulp = 0
268  let t = 0
269
270  return {
271    step() {
272      t++
273      if (gulp > 0) gulp--
274      for (const f of flies) {
275        f.a += (rng() - 0.5) * 0.8
276        f.x = clamp(f.x + Math.cos(f.a) * 0.6, 1, W - 2)
277        f.y = clamp(f.y + Math.sin(f.a) * 0.6, 4, H - 4)
278      }
279      const p = pads[at] as { x: number; y: number }
280      if (jump >= 0) {
281        jump += 0.04
282        if (jump >= 1) {
283          at = to
284          jump = -1
285        }
286        return
287      }
288      if (tongue) {
289        tongue.t++
290        if (tongue.t > 8) tongue = undefined
291        return
292      }
293      const fx = p.x
294      const fy = p.y - 3
295      const near = flies.find(f => Math.hypot(f.x - fx, f.y - fy) < 14)
296      if (near && rng() < 0.15) {
297        tongue = { x: near.x, y: near.y, t: 0 }
298        flies = flies.filter(f => f !== near)
299        flies.push({ x: rng() < 0.5 ? 0 : W - 1, y: 6 + rng() * 20, a: rng() * 6 })
300        caught++
301        gulp = 20
302      } else if (rng() < 0.004) {
303        to = (at + 1 + irand(rng, 2)) % pads.length
304        jump = 0
305      }
306    },
307    draw(px) {
308      sky(px, W, H, 0xb8e0a0, 0x7ab870, water)
309      for (let x = 0; x < W; x += 3) rect(px, W, H, x, water - 6 - (x % 4), 1, 6 + (x % 4), 0x3a7a2a)
310      for (let y = water; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x + y * 3 + (t >> 2)) % 13 === 0 ? 0x4a8ab0 : mix(0x2a6a8a, 0x1a4a6a, (y - water) / (H - water))
311      for (const p of pads) {
312        lit(px, W, H, p.x, p.y, 4, 1.6, 0x4aa03a)
313        dot(px, W, H, p.x + 2, p.y, 0x2a6a8a)
314      }
315      const p = pads[at] as { x: number; y: number }
316      let fx = p.x
317      let fy = p.y - 2
318      if (jump >= 0) {
319        const q = pads[to] as { x: number; y: number }
320        fx = p.x + (q.x - p.x) * jump
321        fy = p.y - 2 + (q.y - p.y) * jump - Math.sin(jump * Math.PI) * 10
322      }
323      litSprite(px, W, H, fx - 3, fy - 3, ['.e.e.', 'ggggg', 'ggggg', 'g...g'], { e: 0xffffff, g: 0x5ad03a })
324      dot(px, W, H, fx - 2, fy - 3, 0x000000)
325      dot(px, W, H, fx, fy - 3, 0x000000)
326      if (gulp > 10) disc(px, W, H, fx, fy - 1, 1.2, 0xc8f0a0)
327      if (tongue) line(px, W, H, fx - 1, fy - 1, tongue.x, tongue.y, 0xff6a8a)
328      for (const f of flies) {
329        dot(px, W, H, f.x, f.y, 0x1a1a1a)
330        if (t % 2) dot(px, W, H, f.x, f.y - 1, 0xe0e8ff)
331      }
332      text(px, W, H, 1, 1, String(caught), 0xffffff)
333    },
334  }
335}
336
337// --- hamster wheel ----------------------------------------------------------------
338
339const hamster: Maker = (W, H, rng) => {
340  const cx = W / 2
341  const cy = H * 0.48
342  const R = Math.min(W * 0.42, H * 0.3)
343  let rot = 0
344  let speed = 0
345  let pos = Math.PI / 2
346  let fling = -1
347  let fx = 0
348  let fy = 0
349  let fvx = 0
350  let fvy = 0
351  let dizzy = 0
352  let t = 0
353
354  return {
355    step() {
356      t++
357      if (fling >= 0) {
358        fling++
359        fvy += 0.12
360        fx += fvx
361        fy += fvy
362        if (fy > H - 6) {
363          fy = H - 6
364          fvy = 0
365          fvx *= 0.8
366        }
367        if (fling > 30) dizzy = 60
368        if (fling > 30) fling = -1
369        speed *= 0.97
370        rot += speed
371        return
372      }
373      if (dizzy > 0) {
374        dizzy--
375        speed *= 0.97
376        rot += speed
377        if (dizzy === 0) pos = Math.PI / 2
378        return
379      }
380      speed = Math.min(0.32, speed + 0.0015 + (t % 400 > 300 ? 0.003 : 0))
381      rot += speed
382      if (speed > 0.16) pos -= (speed - 0.16) * 0.8
383      else pos += (Math.PI / 2 - pos) * 0.1
384      if (pos < -Math.PI * 1.4) {
385        fling = 0
386        fx = cx + Math.cos(pos) * (R - 3)
387        fy = cy + Math.sin(pos) * (R - 3)
388        fvx = 1.2 + rng() * 0.5
389        fvy = -1
390        speed = 0.2
391        t = 0
392      }
393    },
394    draw(px) {
395      px.fill(0xf0e0c8)
396      for (let x = 2; x < W; x += 4) rect(px, W, H, x, 0, 1, H, 0xc8b8a0)
397      rect(px, W, H, 0, H - 4, W, 4, 0xe8d090)
398      for (let x = 0; x < W; x += 2) dot(px, W, H, x, H - 4 + (x % 3), 0xd0b070)
399      line(px, W, H, cx, cy, cx - 4, H - 4, 0x8a8a96)
400      line(px, W, H, cx, cy, cx + 4, H - 4, 0x8a8a96)
401      ring(px, W, H, cx, cy, R, 0x3a8ae0)
402      ring(px, W, H, cx, cy, R - 1, 0x2a6ac0)
403      for (let k = 0; k < 8; k++) {
404        const a = rot + (k / 8) * Math.PI * 2
405        line(px, W, H, cx, cy, cx + Math.cos(a) * (R - 1), cy + Math.sin(a) * (R - 1), 0x9ac0f0)
406      }
407      disc(px, W, H, cx, cy, 1.2, 0x2a6ac0)
408      let hx = cx + Math.cos(pos) * (R - 3)
409      let hy = cy + Math.sin(pos) * (R - 3)
410      if (fling >= 0 || dizzy > 0) {
411        hx = fx
412        hy = fy
413      }
414      const legs = Math.floor(t * (0.2 + speed)) % 2
415      lit(px, W, H, hx, hy, 2.2, 1.6, 0xf0b060)
416      dot(px, W, H, hx + 1, hy - 1, 0x1a1a1a)
417      dot(px, W, H, hx - 1, hy + 1, 0xffffff)
418      if (!dizzy && fling < 0) dot(px, W, H, hx + (legs ? 1 : -1), hy + 2, 0xd0905a)
419      if (dizzy > 0) for (let k = 0; k < 3; k++) dot(px, W, H, hx + Math.cos(t * 0.3 + k * 2) * 3, hy - 3 + Math.sin(t * 0.3 + k * 2), 0xffe040)
420      if (speed > 0.16 && fling < 0 && !dizzy) for (let k = 2; k < 6; k++) dot(px, W, H, hx - k, hy + (k % 2), 0xffffff)
421    },
422  }
423}
424
425// --- penguin slide ----------------------------------------------------------------
426
427const penguins: Maker = (W, H, rng) => {
428  const top = Math.round(H * 0.3)
429  const sea = Math.round(H * 0.82)
430  type P = { u: number; v: number; phase: number; delay: number }
431  let ps: P[] = []
432  let splash: Spark[] = []
433  let t = 0
434  const slopeY = (x: number) => top + 4 + (x / W) * (sea - top - 6)
435
436  function reset() {
437    ps = Array.from({ length: 5 }, (_, i) => ({ u: -4 - i * 5, v: 0, phase: 0, delay: i * 12 }))
438    t = 0
439  }
440  reset()
441
442  return {
443    step() {
444      t++
445      splash = sparks(splash, 0.12)
446      for (const p of ps) {
447        if (p.phase === 0) {
448          p.u += 0.18
449          if (p.u >= 3 && t > p.delay + 40) p.phase = 1
450        } else if (p.phase === 1) {
451          p.v = Math.min(1.6, p.v + 0.05)
452          p.u += p.v
453          if (p.u > W + 2) {
454            p.phase = 2
455            burst(splash, rng, W - 3, sea, 0xd8f0ff, 10, 1)
456          }
457        }
458      }
459      if (ps.every(p => p.phase === 2) && t > 60) reset()
460    },
461    draw(px) {
462      sky(px, W, H, 0x6ab0e8, 0xd8f0ff, sea)
463      for (let y = top; y < sea; y++) {
464        const x0 = ((y - top - 4) / (sea - top - 6)) * W
465        for (let x = 0; x < W; x++) if (x <= x0 + 2) px[y * W + x] = x > x0 - 1 ? 0xffffff : mix(0xe8f0f8, 0xc8d8e8, x / W)
466      }
467      for (let y = sea; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x + y + (t >> 2)) % 8 === 0 ? 0x6ab8f0 : 0x2a6ab0
468      for (const p of ps) {
469        if (p.phase === 2) continue
470        if (p.phase === 0) {
471          const w = Math.sin(p.u * 3) > 0 ? 0 : 1
472          litSprite(px, W, H, p.u, top - 4 + w * 0, ['.kk.', 'kwwk', 'kwwk', '.oo.'], { k: 0x1a1a2a, w: 0xffffff, o: 0xff9a1a })
473          dot(px, W, H, p.u + 2, top - 3, 0xff9a1a)
474        } else {
475          const y = slopeY(p.u) - 2
476          litSprite(px, W, H, p.u - 2, y, ['.kkkko', 'kwwwwk'], { k: 0x1a1a2a, w: 0xffffff, o: 0xff9a1a })
477          if (p.v > 1) dot(px, W, H, p.u - 4, y + 1, 0xffffff)
478        }
479      }
480      for (const s of splash) dot(px, W, H, s.x, s.y, s.c)
481    },
482  }
483}
484
485// --- corgi walk -------------------------------------------------------------------
486
487const corgi: Maker = (W, H, rng) => {
488  const horizon = Math.round(H * 0.32)
489  let t = 0
490  let look = 0
491  void rng
492
493  return {
494    step() {
495      t++
496      if (t % 240 === 180) look = 40
497      if (look > 0) look--
498    },
499    draw(px) {
500      sky(px, W, H, 0x8ac8ff, 0xd8f0ff, horizon)
501      for (let y = horizon; y < H; y++) {
502        const s = (y - horizon) / (H - horizon)
503        const hw = 2 + s * W * 0.32
504        for (let x = 0; x < W; x++) {
505          const onPath = Math.abs(x - W / 2) < hw
506          const stripe = Math.floor(Math.log(s + 0.05) * 10 - t * 0.08) % 2 === 0
507          px[y * W + x] = onPath ? (stripe ? 0xd8c098 : 0xccb48a) : stripe ? 0x5ab040 : 0x52a63a
508        }
509      }
510      for (let k = 0; k < 4; k++) {
511        const s = ((k * 0.25 + t * 0.004) % 1) ** 2
512        const y = horizon + s * (H - horizon)
513        const side = k % 2 ? -1 : 1
514        const x = W / 2 + side * (4 + s * W * 0.45)
515        const sz = 1 + s * 4
516        lit(px, W, H, x, y - sz, sz, sz, 0x2a8a3a)
517        rect(px, W, H, x, y - sz * 0.2, 1, sz * 0.6, 0x6a4a2a)
518      }
519      const cx = W / 2
520      const cy = H * 0.72
521      const wig = Math.sin(t * 0.4) * 1.2
522      const step = Math.floor(t / 4) % 2
523      lit(px, W, H, cx - 2 + wig * 0.3, cy, 3.3, 3.3, 0xe8903a)
524      lit(px, W, H, cx + 2 + wig * 0.3, cy, 3.3, 3.3, 0xe8903a)
525      disc(px, W, H, cx - 2 + wig * 0.3, cy + 1, 2, 0xfff0dc)
526      disc(px, W, H, cx + 2 + wig * 0.3, cy + 1, 2, 0xfff0dc)
527      dot(px, W, H, cx + wig * 0.3, cy + 1, 0xc8703a)
528      rect(px, W, H, cx - 4, cy + 3 + step, 2, 2, 0xfff0dc)
529      rect(px, W, H, cx + 3, cy + 3 + (1 - step), 2, 2, 0xfff0dc)
530      const hx = cx + (look > 0 ? (look > 20 ? 2 : 1) : 0)
531      litSprite(px, W, H, hx - 3, cy - 9, ['.e...e.', 'eee.eee', 'ooooooo', '.ooooo.'], { e: 0xe8903a, o: 0xe8903a })
532      if (look > 0) {
533        dot(px, W, H, hx + 1, cy - 7, 0x1a1a1a)
534        dot(px, W, H, hx + 3, cy - 6, 0x1a1a1a)
535      }
536    },
537  }
538}
539
540// --- cat vs cucumber --------------------------------------------------------------
541
542const cucumber: Maker = (W, H, rng) => {
543  const floor = Math.round(H * 0.72)
544  let t = 0
545  let fur = 0x6a6a72
546  const SIT = ['o...o...', 'oo.oo...', 'ooooo...', 'oeoeo...', '.ooo....', 'oooooo..', 'oooooooo', 'ooooooo.', '.o..o...']
547  const JUMP = ['o.o..', 'ooo..', 'EoE..', 'ooo..', '.ooo.', '.oooo', 'o.o.o', 'o...o']
548
549  return {
550    step() {
551      t++
552      if (t > 230) {
553        t = 0
554        fur = pick(rng, [0x6a6a72, 0xf0a040, 0x2a2a2e, 0xe8e0d0])
555      }
556    },
557    draw(px) {
558      sky(px, W, H, 0xf0e6d8, 0xe0d0b8, floor)
559      rect(px, W, H, 0, floor, W, H - floor, 0xa87a4a)
560      for (let x = 0; x < W; x += 6) rect(px, W, H, x, floor, 1, H - floor, 0x8a5a32)
561      const bx = Math.round(W * 0.68)
562      slab(px, W, H, bx, floor - 2, 6, 2, 0xe03a5a)
563      rect(px, W, H, bx + 1, floor - 3, 4, 1, 0xa86a3a)
564      const placed = span(t, 30, 60)
565      const ux = Math.round(W * 0.2)
566      if (t > 20 && t < 70) {
567        const hx = ux + 3
568        line(px, W, H, hx, -2, hx, floor - 6 + (1 - placed) * -8, 0xffd0a0)
569      }
570      if (t > 40) {
571        rect(px, W, H, ux, floor - 2, 7, 2, 0x3a9a3a)
572        rect(px, W, H, ux, floor - 2, 7, 1, 0x5ac05a)
573      }
574      const turn = t > 95
575      const leap = span(t, 105, 150)
576      if (t < 105) {
577        const rows = turn ? SIT.map(r => [...r].reverse().join('')) : SIT
578        litSprite(px, W, H, bx - 7, floor - 9, rows, { o: fur, e: 0x3ad06a })
579        if (!turn && t % 10 < 5) dot(px, W, H, bx - 1, floor - 4, fur)
580      } else if (t < 150) {
581        const x = bx - 6 + leap * (W - bx + 2)
582        const y = floor - 9 - Math.sin(leap * Math.PI) * H * 0.4
583        litSprite(px, W, H, x, y, JUMP, { o: fur, E: 0xffffff })
584        text(px, W, H, x - 2, y - 7, '!', 0xff3a3a)
585      } else {
586        litSprite(px, W, H, W - 7, floor - 9, SIT.map(r => [...r].reverse().join('')), { o: fur, e: 0x3ad06a })
587        if (t > 170 && t % 30 < 15) text(px, W, H, W - 10, floor - 17, '?', 0x3a3a3a)
588      }
589    },
590  }
591}
592
593// --- baby hippo -------------------------------------------------------------------
594
595const hippo: Maker = (W, H, rng) => {
596  const ground = Math.round(H * 0.66)
597  let t = 0
598  let splashes: Spark[] = []
599
600  return {
601    step() {
602      t++
603      splashes = sparks(splashes, 0.12)
604      if (t === 150) burst(splashes, rng, W * 0.62, ground + 4, 0x9ad0ff, 14, 1.3)
605      if (t > 240) t = 0
606    },
607    draw(px) {
608      sky(px, W, H, 0xe8f0d8, 0xc8d8b0, ground)
609      rect(px, W, H, 0, ground, W, H - ground, 0x8a7050)
610      ellipse(px, W, H, W * 0.62, ground + 6, W * 0.32, 4, 0x5a9ab8)
611      for (let x = 0; x < W; x += 5) rect(px, W, H, x, 4, 1, ground - 4, 0x8a8a72)
612      rect(px, W, H, 0, 6, W, 1, 0x8a8a72)
613      const bite = t > 40 && t < 110 ? Math.abs(Math.sin((t - 40) * 0.25)) : 0
614      const inPool = span(t, 130, 150) - span(t, 210, 235)
615      const hx = W * 0.3 + inPool * W * 0.32
616      const hy = ground - 3 + inPool * 6 - Math.sin(inPool * Math.PI) * 6
617      lit(px, W, H, hx, hy, 6, 3.5, 0x8a7a8a, 0x4a3a4a)
618      const mouth = bite * 3
619      lit(px, W, H, hx + 6, hy - 2 - mouth * 0.5, 3, 2, 0x9a8a9a)
620      ellipse(px, W, H, hx + 6, hy + mouth * 0.5, 3, 1.5, 0x9a8a9a)
621      if (mouth > 1) rect(px, W, H, hx + 4, hy - 1 - mouth * 0.3, 4, mouth * 0.8, 0xff7a9a)
622      dot(px, W, H, hx + 4, hy - 4 - mouth * 0.5, 0x1a1a1a)
623      dot(px, W, H, hx + 2, hy - 5, 0xff9ab0)
624      if (inPool < 0.5) {
625        rect(px, W, H, hx - 4, hy + 2, 2, 2, 0x7a6a7a)
626        rect(px, W, H, hx + 2, hy + 2, 2, 2, 0x7a6a7a)
627      }
628      if (t > 35 && t < 115) {
629        const bx = W - 3 - bite * 2
630        rect(px, W, H, bx - 2, ground - 3, 5, 3, 0x3a3a2a)
631        rect(px, W, H, bx - 1, ground - 12, 3, 9, 0x4a5a3a)
632      }
633      for (const s of splashes) dot(px, W, H, s.x, s.y, s.c)
634    },
635  }
636}
637
638// --- frisbee dog ------------------------------------------------------------------
639
640const frisbee: Maker = (W, H, rng) => {
641  const ground = Math.round(H * 0.74)
642  let t = 0
643  let fx = 0
644  let fy = 0
645  let fvx = 0
646  let fvy = 0
647  let dx = 3
648  let dy = 0
649  let dvy = 0
650  let has = false
651  let miss = false
652  let caught = 0
653  // the way the dog faces: where it runs, and back to the thrower with the frisbee
654  let face = 1
655
656  function throwIt() {
657    fx = 4
658    fy = ground - 8
659    fvx = 0.3 + rng() * 0.15
660    fvy = -0.6 - rng() * 0.2
661    has = false
662    miss = rng() < 0.2
663    dx = 6
664    t = 0
665  }
666  throwIt()
667
668  return {
669    step() {
670      t++
671      if (!has) {
672        fvy += 0.02
673        fx += fvx
674        fy += fvy
675        if (fx > W - 3) fvx = -Math.abs(fvx)
676        if (fy > ground - 1) {
677          fy = ground - 1
678          fvx *= 0.8
679          fvy = 0
680        }
681        const target = fx + fvx * 6
682        const before = dx
683        dx = clamp(dx + clamp(target - 4 - dx, -0.9, 0.9), 0, W - 8)
684        if (Math.abs(dx - before) > 0.05) face = dx > before ? 1 : -1
685        if (dy === 0 && !miss && Math.abs(fx - dx - 6) < 4 && fy < ground - 6 && fvy > 0) dvy = -1.5
686        if (dvy !== 0 || dy < 0) {
687          dvy += 0.1
688          dy += dvy
689          if (dy >= 0) {
690            dy = 0
691            dvy = 0
692          }
693        }
694        const onGround = fy >= ground - 1.5 && Math.abs(fx - dx - 6) < 3
695        if (onGround || (Math.abs(fx - dx - 6) < 2.5 && Math.abs(fy - (ground - 5 + dy)) < 3)) {
696          has = true
697          if (fy < ground - 2) caught++
698        }
699      } else {
700        dx -= 0.6
701        face = -1
702        if (dy < 0) {
703          dvy += 0.1
704          dy = Math.min(0, dy + dvy)
705        }
706        fx = dx
707        fy = ground - 5 + dy
708        if (dx < 6) throwIt()
709      }
710    },
711    draw(px) {
712      sky(px, W, H, 0x6ac0ff, 0xc8ecff, ground)
713      rect(px, W, H, 0, ground, W, H - ground, 0x5ab040)
714      for (let x = 0; x < W; x += 3) dot(px, W, H, x, ground, 0x7ad050)
715      for (let k = 0; k < 2; k++) {
716        const tx = W * (0.3 + k * 0.5)
717        lit(px, W, H, tx, ground - 14, 5, 5, 0x3a8a3a)
718        rect(px, W, H, tx, ground - 10, 1, 10, 0x6a4a2a)
719      }
720      litSprite(px, W, H, 1, ground - 8, ['.s.', 'sss', 'rrr', 'rrr', 'b.b', 'b.b', 'b.b', 'k.k'], { s: 0xffd0a0, r: 0xe04a4a, b: 0x3a4aaa, k: 0x2a2a2a })
721      const run = Math.floor(t / 3) % 2
722      const DOG = ['.....bb.', '.....bwk', 'bbbbbbb.', 'bbbbbb..', run ? 'b.b.b...' : '.b.b.b..']
723      litSprite(px, W, H, dx, ground - 6 + dy, face > 0 ? DOG : DOG.map(r => r.split('').reverse().join('')), { b: 0x2a2a2a, w: 0xffffff, k: 0x1a1a1a })
724      // the white tip of its tail, at the back
725      dot(px, W, H, face > 0 ? dx + 1 : dx + 6, ground - 5 + dy, 0xffffff)
726      rect(px, W, H, fx - 1.5, fy, 3, 1, 0xff3aa0)
727      text(px, W, H, W - 1 - 3, 1, String(caught % 10), 0xffffff)
728    },
729  }
730}
731
732// --- bird feeder ------------------------------------------------------------------
733
734const feeder: Maker = (W, H, rng) => {
735  const fx = W / 2
736  const fy = H * 0.42
737  type Bird = { x: number; y: number; tx: number; ty: number; c: number; state: number; t: number }
738  let birds: Bird[] = []
739  let seeds: Spark[] = []
740  let sq = { y: -10, phase: 0, t: 0 }
741  let t = 0
742
743  const perch = (i: number): [number, number] => [fx + (i % 2 ? 4 : -4), fy + 5 + Math.floor(i / 2) * 3]
744
745  return {
746    step() {
747      t++
748      seeds = sparks(seeds, 0.08).filter(s => s.y < H - 4)
749      if (sq.phase === 0 && t % 500 === 300) sq = { y: -10, phase: 1, t: 0 }
750      if (sq.phase) {
751        sq.t++
752        if (sq.phase === 1) {
753          sq.y += 0.5
754          if (sq.y > fy - 4) sq.phase = 2
755        } else if (sq.phase === 2) {
756          if (rng() < 0.2) seeds.push({ x: fx, y: fy + 6, vx: (rng() - 0.5) * 0.4, vy: 0, l: 40, c: 0xd8b060 })
757          if (sq.t > 160) sq.phase = 3
758        } else {
759          sq.y += 2
760          if (sq.y > H + 6) sq.phase = 0
761        }
762      }
763      if (birds.length < 4 && rng() < 0.02 && sq.phase !== 2) {
764        const i = [0, 1, 2, 3].find(k => !birds.some(b => b.state < 3 && Math.abs(b.tx - perch(k)[0]) < 1 && Math.abs(b.ty - perch(k)[1]) < 1))
765        if (i !== undefined) {
766          const [tx, ty] = perch(i)
767          birds.push({ x: rng() < 0.5 ? -3 : W + 3, y: rng() * fy, tx, ty, c: pick(rng, [0xe03a3a, 0x3a8ae0, 0xffd040, 0x8a6a4a]), state: 0, t: 0 })
768        }
769      }
770      for (const b of birds) {
771        b.t++
772        if (sq.phase === 2 && b.state < 2) {
773          b.state = 2
774          b.tx = b.x < fx ? -6 : W + 6
775          b.ty = rng() * fy
776        }
777        if (b.state === 0) {
778          b.x += (b.tx - b.x) * 0.06
779          b.y += (b.ty - b.y) * 0.06 + Math.sin(b.t * 0.6) * 0.3
780          if (Math.hypot(b.tx - b.x, b.ty - b.y) < 0.6) {
781            b.state = 1
782            b.t = 0
783          }
784        } else if (b.state === 1) {
785          if (b.t % 12 === 0) seeds.push({ x: b.x, y: b.y + 1, vx: (rng() - 0.5) * 0.3, vy: 0, l: 30, c: 0xd8b060 })
786          if (b.t > 90 + irand(rng, 60)) {
787            b.state = 2
788            b.tx = b.x < fx ? -6 : W + 6
789            b.ty = rng() * fy
790          }
791        } else {
792          b.x += (b.tx - b.x) * 0.05
793          b.y += (b.ty - b.y) * 0.05 - 0.2
794          if (b.x < -4 || b.x > W + 4) b.state = 3
795        }
796      }
797      birds = birds.filter(b => b.state < 3)
798    },
799    draw(px) {
800      sky(px, W, H, 0xa8d8f0, 0xe8f4e0)
801      rect(px, W, H, 0, H - 4, W, 4, 0x6aa84a)
802      line(px, W, H, 0, 6, W, 4, 0x6a4a2a)
803      line(px, W, H, 0, 7, W, 5, 0x5a3a1a)
804      for (let k = 0; k < 6; k++) lit(px, W, H, k * 6 + 2, 4 + (k % 2), 2, 2, 0x4a9a3a)
805      line(px, W, H, fx, 5, fx, fy - 2, 0x8a8a8a)
806      litSprite(px, W, H, fx - 4, fy - 3, ['..rrrrr..', '.rrrrrrr.', 'rrrrrrrrr', '.wsssssw.', '.wsssssw.', '.wsssssw.', '.wwwwwww.', 'k.......k'], { r: 0xc84a3a, w: 0xf0e0c0, s: 0xd8b060, k: 0x6a4a2a })
807      for (const s of seeds) dot(px, W, H, s.x, s.y, s.c)
808      for (const b of birds) {
809        const flap = b.state !== 1 && b.t % 6 < 3
810        litSprite(px, W, H, b.x - 1, b.y - 1, flap ? ['x.x', '.x.', '.x.'] : ['.x.', 'xxx', '.x.'], { x: b.c })
811        dot(px, W, H, b.x + (b.x < fx ? 1 : -1), b.y - 1, 0x1a1a1a)
812      }
813      if (sq.phase) {
814        const sy = sq.y
815        litSprite(px, W, H, fx - 2, sy - 2, ['.tt..', 'tt.bb', '.bbbb', '.bbbe', '..bb.'], { t: 0x9a6a3a, b: 0x8a5a2a, e: 0x1a1a1a })
816      }
817    },
818  }
819}
820
821// --- otters holding hands ---------------------------------------------------------
822
823const otters: Maker = (W, H, rng) => {
824  const sea = Math.round(H * 0.4)
825  let t = 0
826  let tap = 0
827  const kelp = Array.from({ length: 5 }, () => rng() * W)
828
829  return {
830    step() {
831      t++
832      if (t % 160 === 100) tap = 30
833      if (tap > 0) tap--
834    },
835    draw(px) {
836      sky(px, W, H, 0xffb87a, 0xffe0b0, sea)
837      disc(px, W, H, W * 0.75, sea - 6, 4, 0xfff0c0)
838      for (let y = sea; y < H; y++)
839        for (let x = 0; x < W; x++) {
840          const w = Math.sin(x * 0.4 + t * 0.06 + y * 0.3)
841          px[y * W + x] = w > 0.85 ? 0x8ac8e8 : mix(0x3a8ab0, 0x1a4a7a, (y - sea) / (H - sea))
842        }
843      for (const k of kelp) for (let y = H - 1; y > sea + 10; y -= 1) dot(px, W, H, k + Math.sin(y * 0.3 + t * 0.03) * 1.2, y, 0x2a6a3a)
844      const cx = W / 2 + Math.sin(t * 0.01) * W * 0.15
845      const cy = sea + 5 + Math.sin(t * 0.06) * 0.8
846      const a = Math.sin(t * 0.015) * 0.3
847      const otter = (ox: number, oy: number, flip: number) => {
848        for (let k = -4; k <= 4; k++) disc(px, W, H, ox + Math.cos(a) * k, oy + Math.sin(a) * k * flip, 1.6, 0x7a4a2a)
849        for (let k = -3; k <= 3; k++) dot(px, W, H, ox + Math.cos(a) * k, oy - 1 + Math.sin(a) * k * flip, 0xa0704a)
850        const hx = ox + Math.cos(a) * 5
851        const hy = oy + Math.sin(a) * 5 * flip
852        lit(px, W, H, hx, hy - 1, 1.6, 1.6, 0x9a6a3a)
853        dot(px, W, H, hx + 0.5, hy - 1.5, 0x1a1a1a)
854        dot(px, W, H, hx + 1.5, hy - 1, 0xd8b090)
855      }
856      otter(cx - 3, cy - 2, 1)
857      otter(cx - 3, cy + 3, -1)
858      line(px, W, H, cx - 2, cy - 1, cx - 2, cy + 2, 0x6a3a1a)
859      if (tap > 0) {
860        const up = tap % 8 < 4
861        disc(px, W, H, cx - 1, cy - 3 - (up ? 2 : 0), 1, 0x9a9aa2)
862        rect(px, W, H, cx - 2, cy - 2, 3, 1, 0xd0a0b0)
863      }
864    },
865  }
866}
867
868// --- raccoon raid -----------------------------------------------------------------
869
870const raccoon: Maker = (W, H, rng) => {
871  const ground = Math.round(H * 0.78)
872  let t = 0
873  let trash: { x: number; y: number; vx: number; vy: number; c: number }[] = []
874
875  return {
876    step() {
877      t++
878      for (const p of trash) {
879        if (p.y < ground - 1) {
880          p.vy += 0.12
881          p.x += p.vx
882          p.y += p.vy
883        } else {
884          p.y = ground - 1
885          p.vx = 0
886        }
887      }
888      if (t === 90)
889        for (let k = 0; k < 10; k++) trash.push({ x: W * 0.62, y: ground - 6, vx: (rng() - 0.3) * 1.2, vy: -rng() * 1.5, c: pick(rng, [0xe0e0e0, 0x8ac04a, 0xd04a3a, 0x8a6a4a, 0xf0d060]) })
890      if (t > 260) {
891        t = 0
892        trash = []
893      }
894    },
895    draw(px) {
896      const lit = t > 170 && t < 260
897      sky(px, W, H, 0x0a0a1e, 0x1a1a32, ground)
898      rect(px, W, H, 0, 0, W * 0.45, ground, 0x2a2230)
899      for (let y = 4; y < ground; y += 3) for (let x = (y % 6) ? 0 : 2; x < W * 0.45; x += 4) dot(px, W, H, x, y, 0x342a3c)
900      rect(px, W, H, W * 0.1, H * 0.25, 6, 7, lit ? 0xffe08a : 0x1a1a2a)
901      rect(px, W, H, W * 0.1 + 2, H * 0.25, 1, 7, 0x2a2230)
902      if (lit) for (let y = ground; y > H * 0.32; y--) for (let x = W * 0.05; x < W * 0.05 + (ground - y) * 0.5 + 6; x++) {
903        const k = Math.round(y) * W + Math.round(x)
904        if (k >= 0 && k < W * H) px[k] = mix(px[k] as number, 0xffe08a, 0.15)
905      }
906      rect(px, W, H, 0, ground, W, H - ground, 0x3a3a44)
907      for (let x = 0; x < W; x += 5) dot(px, W, H, x, ground + 2, 0x4a4a54)
908      const bx = Math.round(W * 0.58)
909      const tipped = t >= 90
910      if (!tipped) {
911        slab(px, W, H, bx, ground - 9, 7, 9, 0x5a6a5a)
912        rect(px, W, H, bx - 1, ground - 10, 9, 2, 0x6a7a6a)
913        for (let y = ground - 8; y < ground; y += 2) rect(px, W, H, bx, y, 7, 1, 0x4a5a4a)
914      } else {
915        rect(px, W, H, bx, ground - 7, 9, 7, 0x5a6a5a)
916        rect(px, W, H, bx - 1, ground - 7, 1, 7, 0x2a2a2a)
917        rect(px, W, H, bx + 11, ground - 2, 3, 2, 0x6a7a6a)
918      }
919      for (const p of trash) dot(px, W, H, p.x, p.y, p.c)
920      let rx = -8 + span(t, 10, 70) * (bx - 6)
921      if (t > 175) rx = bx - 6 - span(t, 175, 210) * (bx + 4) * -1 + span(t, 175, 210) * (W + 10)
922      const peek = t > 70 && t < 175
923      const ry = ground - 5
924      // it always heads right, in to the bin and out past it: masked face in front, striped tail behind
925      litSprite(px, W, H, rx, ry, ['...g.g.', '...gggg', '.gwkkw.', '.gggggg', 'sgggggg', '.sg.g.g'], { g: 0x8a8a92, k: 0x1a1a1a, w: 0xffffff, s: 0x5a5a62 })
926      if (peek && t > 120) {
927        rect(px, W, H, rx + 3, ry + 1, 3, 2, 0xf0c040)
928        dot(px, W, H, rx + 4, ry + 1, 0xd04a3a)
929      }
930      if (t > 175) {
931        rect(px, W, H, rx + 5, ry + 1, 3, 2, 0xf0c040)
932        dot(px, W, H, rx + 6, ry + 1, 0xd04a3a)
933      }
934      if (!lit && (t >> 3) % 6 === 0) {
935        dot(px, W, H, rx + 4, ry + 2, 0xfff0a0)
936        dot(px, W, H, rx + 5, ry + 2, 0xfff0a0)
937      }
938      if (lit && t < 190) text(px, W, H, rx + 1, ry - 8, '!', 0xff3a3a)
939    },
940  }
941}
942
943// --- leafcutter ants --------------------------------------------------------------
944
945const leafcutter: Maker = (W, H, rng) => {
946  const nest = { x: 4, y: H * 0.78 }
947  const tree = { x: W - 5, y: H * 0.25 }
948  type Ant = { u: number; back: boolean; leaf: number; c: number; drop: boolean }
949  const ants: Ant[] = Array.from({ length: 22 }, (_, i) => ({ u: i / 22, back: i % 2 === 0, leaf: 1 + irand(rng, 3), c: hsv(0.28 + rng() * 0.08, 0.7, 0.5 + rng() * 0.4), drop: false }))
950  const at = (u: number): [number, number] => {
951    const x = tree.x + (nest.x - tree.x) * u
952    const y = tree.y + (nest.y - tree.y) * u + Math.sin(u * Math.PI * 3) * 5
953    return [x, y]
954  }
955  let dropped: [number, number, number][] = []
956  let t = 0
957
958  return {
959    step() {
960      t++
961      for (const a of ants) {
962        a.u += a.back ? -0.0035 : 0.0035
963        if (a.u > 1) {
964          a.u = 1
965          a.back = true
966        }
967        if (a.u < 0) {
968          a.u = 0
969          a.back = false
970          a.leaf = 1 + irand(rng, 3)
971          a.c = hsv(0.28 + rng() * 0.08, 0.7, 0.5 + rng() * 0.4)
972        }
973        if (!a.back && rng() < 0.0008) {
974          const [x, y] = at(a.u)
975          dropped.push([x, y - 2, a.c])
976          a.back = true
977        }
978      }
979      if (dropped.length > 6) dropped.shift()
980    },
981    draw(px) {
982      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x * 7 + y * 3) % 13 === 0 ? 0x5a3a20 : (x + y * 5) % 17 === 0 ? 0x7a5432 : 0x6a4628
983      for (let k = 0; k <= 60; k++) {
984        const [x, y] = at(k / 60)
985        dot(px, W, H, x, y, 0x5a3c22)
986      }
987      lit(px, W, H, nest.x, nest.y, 3, 3, 0x8a6a42)
988      disc(px, W, H, nest.x, nest.y, 1.5, 0x1a0e06)
989      rect(px, W, H, tree.x - 1, 0, 4, tree.y + 2, 0x4a3018)
990      for (let k = 0; k < 6; k++) lit(px, W, H, tree.x - 4 + k * 2, tree.y - 4 + (k % 2) * 2, 2, 2, 0x3a8a2a)
991      for (const [x, y, c] of dropped) rect(px, W, H, x, y, 2, 2, c)
992      for (const a of ants) {
993        const [x, y] = at(a.u)
994        dot(px, W, H, x, y, 0x8a2a10)
995        dot(px, W, H, x + (a.back ? 1 : -1), y, 0x6a1a08)
996        if (!a.back) rect(px, W, H, x - 1, y - 1 - a.leaf, a.leaf + 1, a.leaf, a.c)
997      }
998      void t
999    },
1000  }
1001}
1002
1003// --- octopus camouflage -----------------------------------------------------------
1004
1005const octopus: Maker = (W, H, rng) => {
1006  const zones = [
1007    { x0: 0, c: 0xd8c088 },
1008    { x0: W * 0.33, c: 0x8a5a7a },
1009    { x0: W * 0.66, c: 0x4a8a5a },
1010  ]
1011  let x = W * 0.15
1012  let dir = 1
1013  let col = 0xff6a3a
1014  let ink: Spark[] = []
1015  let fish = { x: -6, on: false }
1016  let alarm = 0
1017  let t = 0
1018
1019  const bgAt = (xx: number) => {
1020    let c = (zones[0] as { c: number }).c
1021    for (const z of zones) if (xx >= z.x0) c = z.c
1022    return c
1023  }
1024
1025  return {
1026    step() {
1027      t++
1028      ink = sparks(ink, -0.01)
1029      if (alarm > 0) {
1030        alarm--
1031        x += dir * 1.2
1032        if (alarm === 0) dir = -dir
1033      } else {
1034        x += dir * 0.15
1035        col = mix(col, bgAt(x), 0.03)
1036      }
1037      if (x < 4 || x > W - 5) {
1038        dir = -dir
1039        x = clamp(x, 4, W - 5)
1040      }
1041      if (!fish.on && t % 360 === 200) fish = { x: dir > 0 ? W + 5 : -5, on: true }
1042      if (fish.on) {
1043        fish.x += fish.x > x ? -0.5 : 0.5
1044        if (Math.abs(fish.x - x) < 8 && !alarm) {
1045          alarm = 25
1046          burst(ink, rng, x, H * 0.7, 0x1a1a24, 20, 0.6)
1047          dir = fish.x > x ? -1 : 1
1048        }
1049        if (alarm && (fish.x < -6 || fish.x > W + 6)) fish.on = false
1050        if (alarm) fish.x += fish.x > x ? 0.8 : -0.8
1051      }
1052    },
1053    draw(px) {
1054      sky(px, W, H, 0x2a6a9a, 0x0a2a4a)
1055      for (const [i, z] of zones.entries()) {
1056        const x1 = (zones[i + 1]?.x0 ?? W) as number
1057        for (let y = Math.round(H * 0.7); y < H; y++) for (let xx = Math.floor(z.x0); xx < x1; xx++) px[y * W + xx] = (xx + y) % 5 === 0 ? scale(z.c, 0.85) : z.c
1058      }
1059      for (let k = 0; k < 6; k++) dot(px, W, H, (k * 11 + t * 0.1) % W, (k * 7 + t * 0.2) % (H * 0.6), 0x6aa8d0)
1060      const y = H * 0.7 - 2
1061      const c = alarm > 0 ? (alarm % 4 < 2 ? 0xff3a3a : 0xffffff) : col
1062      lit(px, W, H, x, y - 3, 3, 3, c)
1063      for (let k = -2; k <= 2; k++) {
1064        const wav = Math.sin(t * 0.2 + k) * 1
1065        line(px, W, H, x + k * 1.2, y - 1, x + k * 2.2 + wav, y + 2, scale(c, 0.85))
1066      }
1067      dot(px, W, H, x - 1, y - 3, 0xffffff)
1068      dot(px, W, H, x + 1, y - 3, 0xffffff)
1069      dot(px, W, H, x - 1, y - 2, 0x1a1a1a)
1070      dot(px, W, H, x + 1, y - 2, 0x1a1a1a)
1071      for (const s of ink) disc(px, W, H, s.x, s.y, 1.3, s.c)
1072      if (fish.on) {
1073        // it swims toward the octopus, then away once it is spotted: face that way
1074        const FISH = ['y.yy.', 'yyyky', 'y.yy.']
1075        const right = alarm ? fish.x > x : fish.x < x
1076        litSprite(px, W, H, fish.x - 2, H * 0.4, right ? FISH : FISH.map(r => r.split('').reverse().join('')), { y: 0xffd040, k: 0x1a1a1a })
1077      }
1078    },
1079  }
1080}
1081
1082// --- bees in the garden -----------------------------------------------------------
1083
1084const bees: Maker = (W, H, rng) => {
1085  const flowers = Array.from({ length: 6 }, (_, i) => ({ x: 3 + (i / 5) * (W - 6), y: H * 0.55 + rng() * H * 0.3, c: hsv(rng(), 0.6, 1), h: 4 + rng() * 6 }))
1086  const hive = { x: W - 5, y: 8 }
1087  type Bee = { x: number; y: number; tx: number; ty: number; t: number; at: number; pollen: number }
1088  const bs: Bee[] = Array.from({ length: 6 }, () => ({ x: hive.x, y: hive.y, tx: hive.x, ty: hive.y, t: irand(rng, 60), at: -1, pollen: 0 }))
1089  let t = 0
1090
1091  return {
1092    step() {
1093      t++
1094      for (const b of bs) {
1095        b.t--
1096        const d = Math.hypot(b.tx - b.x, b.ty - b.y)
1097        if (d > 0.5) {
1098          b.x += ((b.tx - b.x) / d) * 0.5 + Math.sin(t * 0.5 + b.ty) * 0.2
1099          b.y += ((b.ty - b.y) / d) * 0.5 + Math.cos(t * 0.4 + b.tx) * 0.2
1100        } else if (b.t <= 0) {
1101          if (b.pollen >= 3) {
1102            b.tx = hive.x
1103            b.ty = hive.y + 2
1104            b.pollen = 0
1105          } else {
1106            const f = pick(rng, flowers)
1107            b.tx = f.x
1108            b.ty = f.y - f.h - 1
1109            b.pollen++
1110          }
1111          b.t = 30 + irand(rng, 40)
1112        }
1113      }
1114    },
1115    draw(px) {
1116      sky(px, W, H, 0x9ad8ff, 0xe8f8e0)
1117      for (let y = Math.round(H * 0.6); y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x + y * 3) % 7 === 0 ? 0x5aa83a : 0x6ac048
1118      for (const f of flowers) {
1119        const sway = Math.sin(t * 0.04 + f.x) * 0.8
1120        line(px, W, H, f.x, f.y, f.x + sway, f.y - f.h, 0x3a8a2a)
1121        const hx = f.x + sway
1122        const hy = f.y - f.h
1123        for (const [dx, dy] of [
1124          [1, 0],
1125          [-1, 0],
1126          [0, 1],
1127          [0, -1],
1128        ] as [number, number][])
1129          dot(px, W, H, hx + dx, hy + dy, f.c)
1130        dot(px, W, H, hx, hy, 0xffd040)
1131      }
1132      line(px, W, H, hive.x, 0, hive.x, 3, 0x6a4a2a)
1133      lit(px, W, H, hive.x, hive.y, 3, 4, 0xe0a83a)
1134      for (let k = -3; k <= 3; k += 2) rect(px, W, H, hive.x - 3, hive.y + k, 7, 1, 0xb8802a)
1135      dot(px, W, H, hive.x, hive.y + 2, 0x2a1a0a)
1136      for (const b of bs) {
1137        dot(px, W, H, b.x, b.y, 0xffd020)
1138        dot(px, W, H, b.x - 1, b.y, 0x1a1a1a)
1139        if (t % 2) dot(px, W, H, b.x, b.y - 1, 0xe8f4ff)
1140        if (b.pollen) dot(px, W, H, b.x, b.y + 1, 0xffa020)
1141      }
1142    },
1143  }
1144}
1145
1146// --- squirrel obstacle course -----------------------------------------------------
1147
1148const squirrel: Maker = (W, H, rng) => {
1149  const ground = H - 5
1150  const pts: [number, number, number][] = [
1151    [2, H * 0.3, 0],
1152    [W * 0.45, H * 0.3, 0],
1153    [W * 0.6, H * 0.45, 1],
1154    [W * 0.85, H * 0.45, 0],
1155    [W * 0.3, H * 0.62, 1],
1156    [W * 0.6, H * 0.62, 0],
1157    [W * 0.85, H * 0.8, 1],
1158    [W * 0.2, ground - 1, 1],
1159  ]
1160  let seg = 0
1161  let u = 0
1162  let t = 0
1163  let hold = 0
1164  let wheel = 0
1165  void rng
1166
1167  return {
1168    step() {
1169      t++
1170      wheel += 0.1
1171      if (hold > 0) {
1172        if (--hold === 0) {
1173          seg = 0
1174          u = 0
1175        }
1176        return
1177      }
1178      const a = pts[seg] as [number, number, number]
1179      const b = pts[seg + 1] as [number, number, number] | undefined
1180      if (!b) {
1181        hold = 70
1182        return
1183      }
1184      const len = Math.hypot(b[0] - a[0], b[1] - a[1])
1185      u += (b[2] ? 0.9 : 0.55) / len
1186      if (u >= 1) {
1187        u = 0
1188        seg++
1189      }
1190    },
1191    draw(px) {
1192      sky(px, W, H, 0x8ac8f0, 0xd8f0d0, ground)
1193      rect(px, W, H, 0, ground, W, H - ground, 0x6aa84a)
1194      rect(px, W, H, 0, H * 0.3 + 1, 2, H, 0x6a4a2a)
1195      line(px, W, H, 2, H * 0.3 + 1, W * 0.45, H * 0.3 + 1, 0xd8c8a0)
1196      rect(px, W, H, W * 0.58, H * 0.45 + 1, W * 0.3, 2, 0xa8784a)
1197      ring(px, W, H, W * 0.85, H * 0.45 - 3, 3.5, 0x8a8a96)
1198      line(px, W, H, W * 0.85, H * 0.45 - 3, W * 0.85 + Math.cos(wheel) * 3, H * 0.45 - 3 + Math.sin(wheel) * 3, 0x8a8a96)
1199      rect(px, W, H, W * 0.28, H * 0.62 + 1, W * 0.34, 1, 0xa8784a)
1200      for (let x = W * 0.3; x < W * 0.6; x += 4) rect(px, W, H, x, H * 0.62 + 1, 1, ground - H * 0.62, 0x8a6a3a)
hooks/themes/arcade.ts 3923 lines
1import { disc, dot, hsv, mix, rect, scale, sky, sprite, text } from '../draw'
2import type { Maker } from '../draw'
3import { DIRS, clamp, irand, line, lit, litSprite, pick, ring, rot, shuffle, slab, textWidth } from '../kit'
4
5// Retro arcade: classic games, drawn with original sprites and played by bots.
6
7type Boom = { x: number; y: number; l: number; c: number }
8function booms(list: Boom[]): Boom[] {
9  for (const b of list) b.l--
10  return list.filter(b => b.l > 0)
11}
12function drawBoom(px: Uint32Array, W: number, H: number, b: Boom) {
13  const r = (12 - b.l) * 0.35
14  for (let k = 0; k < 8; k++) {
15    const a = (k / 8) * Math.PI * 2
16    dot(px, W, H, b.x + Math.cos(a) * r, b.y + Math.sin(a) * r, b.l > 6 ? 0xffffff : b.c)
17  }
18}
19
20// --- space invaders ---------------------------------------------------------------
21
22const invaders: Maker = (W, H, rng) => {
23  const FR = [
24    ['.x...x.', '..xxx..', '.xxxxx.', 'xx.x.xx', 'x.....x'],
25    ['.x...x.', 'x.xxx.x', 'xxxxxxx', '.x.x.x.', 'x.....x'],
26  ]
27  const SHIP = ['...x...', '.xxxxx.', 'xxxxxxx']
28  const ROWC = [0xff5ae0, 0x5ae0ff, 0x5aff8a, 0xffe05a]
29  const cols = Math.max(2, Math.floor((W - 6) / 8))
30  const rows = 4
31  const shieldY = H - 13
32  let alive: boolean[] = []
33  let fx = 0
34  let fy = 0
35  let dir = 1
36  let tick = 0
37  let frame = 0
38  let px0 = W / 2
39  let shot: { x: number; y: number } | undefined
40  let bombs: { x: number; y: number }[] = []
41  let shields = new Uint8Array(W * 4)
42  let fx2: Boom[] = []
43  let score = 0
44  let lives = 3
45  let dead = 0
46  let ufo = -20
47
48  function wave() {
49    alive = Array(cols * rows).fill(true)
50    fx = 1
51    fy = 6
52    dir = 1
53    bombs = []
54  }
55  function newGame() {
56    score = 0
57    lives = 3
58    shields = new Uint8Array(W * 4)
59    const n = Math.max(1, Math.floor(W / 11))
60    for (let k = 0; k < n; k++) {
61      const cx = Math.round(((k + 0.5) * W) / n)
62      for (let y = 0; y < 4; y++) for (let x = -2; x <= 2; x++) if (!(y === 3 && Math.abs(x) < 1) && !(y === 0 && Math.abs(x) === 2)) shields[y * W + cx + x] = 1
63    }
64    wave()
65  }
66  newGame()
67  const ax = (i: number) => fx + (i % cols) * 8
68  const ay = (i: number) => fy + Math.floor(i / cols) * 6
69
70  return {
71    step() {
72      frame++
73      fx2 = booms(fx2)
74      if (dead > 0) {
75        if (--dead === 0 && lives === 0) newGame()
76        return
77      }
78      const n = alive.filter(Boolean).length
79      if (n === 0) {
80        wave()
81        return
82      }
83      if (++tick >= 2 + Math.floor(n / 2)) {
84        tick = 0
85        let edge = false
86        alive.forEach((a, i) => {
87          if (a && (ax(i) + dir < 0 || ax(i) + 7 + dir > W)) edge = true
88        })
89        if (edge) {
90          dir = -dir
91          fy += 2
92        } else fx += dir
93      }
94      if (ufo > -20) ufo -= 0.5
95      else if (rng() < 0.003) ufo = W + 8
96      if (rng() < 0.025 + 0.002 * (cols * rows - n)) {
97        const shooters = alive.map((a, i) => (a ? i : -1)).filter(i => i >= 0 && !alive.some((b, j) => b && j % cols === i % cols && j > i))
98        const s = shooters[irand(rng, shooters.length)]
99        if (s !== undefined) bombs.push({ x: ax(s) + 3, y: ay(s) + 5 })
100      }
101      // the bot: under the nearest column, out of the way of bombs
102      let target = px0
103      let best = Infinity
104      alive.forEach((a, i) => {
105        if (!a) return
106        const d = Math.abs(ax(i) + 3 + dir * 2 - px0) - Math.floor(i / cols) * 0.5
107        if (d < best) {
108          best = d
109          target = ax(i) + 3 + dir * 2
110        }
111      })
112      const danger = bombs.find(b => b.y > H - 22 && Math.abs(b.x - px0) < 4)
113      if (danger) target = px0 + (danger.x < px0 ? 6 : -6)
114      px0 = clamp(px0 + clamp(target - px0, -0.7, 0.7), 3, W - 4)
115      if (!shot && Math.abs(target - px0) < 2 && !danger) shot = { x: Math.round(px0), y: H - 7 }
116      if (shot) {
117        shot.y -= 2
118        const sx = shot.x
119        const sy = shot.y
120        const si = (sy - shieldY) * W + sx
121        if (sy >= shieldY && sy < shieldY + 4 && shields[si]) {
122          shields[si] = 0
123          shot = undefined
124        } else if (ufo > -20 && sy < 5 && Math.abs(sx - ufo - 3) < 4) {
125          score += 300
126          fx2.push({ x: ufo + 3, y: 2, l: 12, c: 0xff4040 })
127          ufo = -20
128          shot = undefined
129        } else if (sy < 0) shot = undefined
130        else {
131          const hit = alive.findIndex((a, i) => a && sx >= ax(i) && sx < ax(i) + 7 && sy >= ay(i) && sy < ay(i) + 5)
132          if (hit >= 0) {
133            alive[hit] = false
134            score += (rows - Math.floor(hit / cols)) * 10
135            fx2.push({ x: ax(hit) + 3, y: ay(hit) + 2, l: 12, c: ROWC[Math.floor(hit / cols)] as number })
136            shot = undefined
137          }
138        }
139      }
140      for (const b of bombs) b.y += 0.8
141      bombs = bombs.filter(b => {
142        const si = (Math.round(b.y) - shieldY) * W + Math.round(b.x)
143        if (b.y >= shieldY && b.y < shieldY + 4 && shields[si]) {
144          shields[si] = 0
145          if (si + W < shields.length) shields[si + W] = 0
146          return false
147        }
148        if (b.y > H - 7 && b.y < H - 3 && Math.abs(b.x - px0) < 3.5) {
149          fx2.push({ x: px0, y: H - 5, l: 12, c: 0x5aff8a })
150          lives--
151          dead = 40
152          return false
153        }
154        return b.y < H
155      })
156      if (alive.some((a, i) => a && ay(i) + 5 >= shieldY)) {
157        lives = 0
158        dead = 40
159      }
160    },
161    draw(px) {
162      px.fill(0x05050c)
163      for (let i = 0; i < 12; i++) dot(px, W, H, (i * 37) % W, (i * 23) % H, 0x2a2a44)
164      const f = FR[Math.floor(frame / 15) % 2] as string[]
165      alive.forEach((a, i) => {
166        if (a) sprite(px, W, H, ax(i), ay(i), f, { x: ROWC[Math.floor(i / cols)] as number })
167      })
168      if (ufo > -20) sprite(px, W, H, ufo, 1, ['..xxx..', '.xxxxx.', 'x.x.x.x'], { x: 0xff4040 })
169      for (let y = 0; y < 4; y++) for (let x = 0; x < W; x++) if (shields[y * W + x]) dot(px, W, H, x, shieldY + y, 0x3ad04a)
170      if (!dead || dead % 6 < 3) sprite(px, W, H, px0 - 3, H - 6, SHIP, { x: 0x5aff8a })
171      if (shot) rect(px, W, H, shot.x, shot.y, 1, 2, 0xffffff)
172      for (const b of bombs) dot(px, W, H, b.x + ((frame >> 1) % 2), b.y, 0xffe05a)
173      for (const b of fx2) drawBoom(px, W, H, b)
174      text(px, W, H, 1, 0, String(score), 0xffffff)
175      for (let i = 0; i < lives; i++) dot(px, W, H, W - 2 - i * 2, 1, 0x5aff8a)
176    },
177  }
178}
179
180// --- asteroids --------------------------------------------------------------------
181
182const asteroids: Maker = (W, H, rng) => {
183  type Rock = { x: number; y: number; vx: number; vy: number; s: number; shape: number[]; a: number; w: number }
184  const RAD = [0, 1.6, 3, 4.6]
185  let rocks: Rock[] = []
186  let ship = { x: W / 2, y: H / 2, a: -Math.PI / 2, vx: 0, vy: 0 }
187  let bullets: { x: number; y: number; vx: number; vy: number; l: number }[] = []
188  let debris: Boom[] = []
189  let cool = 0
190  let dead = 0
191  let thrust = 0
192  let score = 0
193  let lives = 3
194
195  const rock = (x: number, y: number, s: number): Rock => {
196    const a = rng() * Math.PI * 2
197    const v = 0.15 + rng() * 0.25 + (3 - s) * 0.1
198    return { x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, s, shape: Array.from({ length: 8 }, () => 0.7 + rng() * 0.4), a: 0, w: (rng() - 0.5) * 0.05 }
199  }
200  function wave() {
201    rocks = []
202    for (let i = 0; i < 4; i++) rocks.push(rock(i % 2 ? 0 : W - 1, rng() * H, 3))
203  }
204  wave()
205  const wrap = (o: { x: number; y: number }) => {
206    o.x = ((o.x % W) + W) % W
207    o.y = ((o.y % H) + H) % H
208  }
209  const delta = (a: number, b: number, m: number) => {
210    let d = b - a
211    if (d > m / 2) d -= m
212    if (d < -m / 2) d += m
213    return d
214  }
215
216  return {
217    step() {
218      debris = booms(debris)
219      for (const r of rocks) {
220        r.x += r.vx
221        r.y += r.vy
222        r.a += r.w
223        wrap(r)
224      }
225      for (const b of bullets) {
226        b.x += b.vx
227        b.y += b.vy
228        b.l--
229        wrap(b)
230      }
231      bullets = bullets.filter(b => b.l > 0)
232      if (dead > 0) {
233        if (--dead === 0) {
234          if (lives === 0) {
235            lives = 3
236            score = 0
237            wave()
238          }
239          ship = { x: W / 2, y: H / 2, a: -Math.PI / 2, vx: 0, vy: 0 }
240        }
241        return
242      }
243      // the bot: turn towards where the nearest rock will be, fire, dodge
244      let near: Rock | undefined
245      let nd = Infinity
246      for (const r of rocks) {
247        const d = Math.hypot(delta(ship.x, r.x, W), delta(ship.y, r.y, H))
248        if (d < nd) {
249          nd = d
250          near = r
251        }
252      }
253      thrust = 0
254      if (near) {
255        const tt = nd / 1.6
256        const want = Math.atan2(delta(ship.y, near.y + near.vy * tt, H), delta(ship.x, near.x + near.vx * tt, W))
257        let da = want - ship.a
258        da = Math.atan2(Math.sin(da), Math.cos(da))
259        ship.a += clamp(da, -0.12, 0.12)
260        if (--cool <= 0 && Math.abs(da) < 0.2) {
261          bullets.push({ x: ship.x, y: ship.y, vx: Math.cos(ship.a) * 1.6, vy: Math.sin(ship.a) * 1.6, l: 26 })
262          cool = 7
263        }
264        if (nd < RAD[near.s]! + 6) {
265          ship.vx -= Math.cos(ship.a) * 0.06
266          ship.vy -= Math.sin(ship.a) * 0.06
267          thrust = 1
268        }
269      }
270      ship.vx *= 0.97
271      ship.vy *= 0.97
272      ship.x += ship.vx
273      ship.y += ship.vy
274      wrap(ship)
275      const born: Rock[] = []
276      rocks = rocks.filter(r => {
277        const hit = bullets.find(b => Math.hypot(delta(b.x, r.x, W), delta(b.y, r.y, H)) < RAD[r.s]! + 0.5)
278        if (!hit) return true
279        hit.l = 0
280        score += [0, 100, 50, 20][r.s] as number
281        debris.push({ x: r.x, y: r.y, l: 12, c: 0xc8c8d0 })
282        if (r.s > 1) born.push(rock(r.x, r.y, r.s - 1), rock(r.x, r.y, r.s - 1))
283        return false
284      })
285      rocks.push(...born)
286      if (rocks.some(r => Math.hypot(delta(ship.x, r.x, W), delta(ship.y, r.y, H)) < RAD[r.s]! + 1)) {
287        debris.push({ x: ship.x, y: ship.y, l: 12, c: 0xffffff })
288        lives--
289        dead = 45
290      }
291      if (!rocks.length) wave()
292    },
293    draw(px) {
294      px.fill(0x000006)
295      for (let i = 0; i < 14; i++) dot(px, W, H, (i * 41) % W, (i * 29 + 7) % H, 0x22223a)
296      for (const r of rocks) {
297        const R = RAD[r.s] as number
298        for (let k = 0; k < 8; k++) {
299          const a0 = r.a + (k / 8) * Math.PI * 2
300          const a1 = r.a + ((k + 1) / 8) * Math.PI * 2
301          const r0 = R * (r.shape[k] as number)
302          const r1 = R * (r.shape[(k + 1) % 8] as number)
303          line(px, W, H, r.x + Math.cos(a0) * r0, r.y + Math.sin(a0) * r0, r.x + Math.cos(a1) * r1, r.y + Math.sin(a1) * r1, 0xd0d0d8)
304        }
305      }
306      for (const b of bullets) dot(px, W, H, b.x, b.y, 0xffffff)
307      if (!dead) {
308        const c = Math.cos(ship.a)
309        const s = Math.sin(ship.a)
310        const p = (f: number, g: number): [number, number] => [ship.x + c * f - s * g, ship.y + s * f + c * g]
311        const [nx, ny] = p(3, 0)
312        const [lx, ly] = p(-2, -2)
313        const [rx, ry] = p(-2, 2)
314        line(px, W, H, nx, ny, lx, ly, 0xffffff)
315        line(px, W, H, nx, ny, rx, ry, 0xffffff)
316        line(px, W, H, lx, ly, rx, ry, 0xffffff)
317        if (thrust) {
318          const [fx, fy] = p(-4, 0)
319          dot(px, W, H, fx, fy, 0xffa030)
320        }
321      }
322      for (const d of debris) drawBoom(px, W, H, d)
323      text(px, W, H, 1, 0, String(score), 0xffffff)
324      for (let i = 0; i < lives; i++) dot(px, W, H, W - 2 - i * 2, 1, 0xffffff)
325    },
326  }
327}
328
329// --- frogger ----------------------------------------------------------------------
330
331const frogger: Maker = (W, H, rng) => {
332  const cs = 4
333  const cols = Math.floor(W / cs)
334  const rows = Math.max(9, Math.floor((H - 5) / cs))
335  const oy = H - rows * cs
336  const river = Math.floor((rows - 3) / 2)
337  // rows: 0 goal, 1..river river, river+1 median, then road, last start
338  const roadA = river + 2
339  type Lane = { v: number; len: number; gap: number; off: number; kind: 'log' | 'turtle' | 'car' | 'truck'; c: number }
340  const lanes: (Lane | undefined)[] = []
341  for (let r = 0; r < rows; r++) {
342    if (r >= 1 && r <= river) {
343      const turtle = r % 2 === 0
344      lanes.push({ v: (0.15 + rng() * 0.25) * (r % 2 ? 1 : -1), len: turtle ? 2 : 3 + irand(rng, 2), gap: 2 + irand(rng, 3), off: rng() * 20, kind: turtle ? 'turtle' : 'log', c: 0 })
345    } else if (r >= roadA && r < rows - 1) {
346      const truck = rng() < 0.3
347      lanes.push({ v: (0.2 + rng() * 0.35) * (r % 2 ? -1 : 1), len: truck ? 3 : 1.5, gap: 3 + irand(rng, 3), off: rng() * 20, kind: truck ? 'truck' : 'car', c: hsv(rng(), 0.7, 1) })
348    } else lanes.push(undefined)
349  }
350  const slots = Math.max(2, Math.floor(cols / 2))
351  const slotX = (k: number) => Math.round(((k + 0.5) * W) / slots)
352  let filled: boolean[] = Array(slots).fill(false)
353  let t = 0
354  let fx = W / 2
355  let fr = rows - 1
356  let hop = 0
357  let from: [number, number] = [0, 0]
358  let to: [number, number] = [0, 0]
359  let dead = 0
360  let saved = 0
361
362  const period = (l: Lane) => (l.len + l.gap) * cs
363  const objects = (r: number, time: number): [number, number][] => {
364    const l = lanes[r]
365    if (!l) return []
366    const p = period(l)
367    const out: [number, number][] = []
368    const shift = (((l.off * cs + l.v * time) % p) + p) % p
369    for (let x = shift - p; x < W + p; x += p) out.push([x, x + l.len * cs])
370    return out
371  }
372  const onObject = (r: number, x: number, time: number, margin: number) => objects(r, time).some(([a, b]) => x >= a + margin && x <= b - margin)
373  const safe = (r: number, x: number, time: number) => {
374    if (x < 1 || x > W - 2) return false
375    if (r === 0) return slotFree(x)
376    if (r >= 1 && r <= river) return onObject(r, x, time, 0.8)
377    if (lanes[r]) return !objects(r, time).some(([a, b]) => x + 1.5 > a && x - 1.5 < b)
378    return true
379  }
380  const slotFree = (x: number) => {
381    for (let k = 0; k < slots; k++) if (!filled[k] && Math.abs(x - slotX(k)) < 1.6) return true
382    return false
383  }
384  const riverDrift = (r: number) => (r >= 1 && r <= river ? (lanes[r] as Lane).v : 0)
385
386  function plan() {
387    // simulate a hop of 4 frames, then staying 6 more frames
388    const ok = (r: number, x: number) => {
389      let xx = x
390      for (let k = 1; k <= 10; k++) {
391        if (k > 4) xx += riverDrift(r)
392        if (k >= 4 && !safe(r, xx, t + k)) return false
393      }
394      return true
395    }
396    const stay = (() => {
397      let xx = fx
398      for (let k = 1; k <= 6; k++) {
399        xx += riverDrift(fr)
400        if (!safe(fr, xx, t + k)) return false
401      }
402      return true
403    })()
404    const opts: [number, number][] = [[fr - 1, fx]]
405    if (fr === 1) {
406      for (let k = 0; k < slots; k++) if (!filled[k] && Math.abs(fx - slotX(k)) < 1.6) opts[0] = [0, slotX(k)]
407    }
408    const side: [number, number][] = [
409      [fr, fx - cs],
410      [fr, fx + cs],
411      [fr + 1, fx],
412    ]
413    if (fr > 0 && ok(opts[0]![0], opts[0]![1]) && (fr !== 1 || opts[0]![0] === 0 ? true : false)) return opts[0]
414    if (fr === 1) {
415      // wait on the log for a slot, or shuffle along it
416      if (stay) return undefined
417    }
418    if (stay && rng() < 0.85) return undefined
419    for (const o of shuffle(rng, side)) if (o[0] < rows && ok(o[0], o[1])) return o
420    return undefined
421  }
422
423  return {
424    step() {
425      t++
426      if (dead > 0) {
427        if (--dead === 0) {
428          fx = W / 2
429          fr = rows - 1
430        }
431        return
432      }
433      if (hop > 0) {
434        hop--
435        fx = to[1] + (from[1] - to[1]) * (hop / 4)
436        if (hop === 0) {
437          fr = to[0]
438          if (fr === 0) {
439            for (let k = 0; k < slots; k++) if (Math.abs(fx - slotX(k)) < 1.6) filled[k] = true
440            saved++
441            if (filled.every(Boolean)) filled = Array(slots).fill(false)
442            fx = W / 2
443            fr = rows - 1
444          }
445        }
446      } else {
447        const p = plan()
448        if (p) {
449          from = [fr, fx]
450          to = p
451          hop = 4
452        }
453      }
454      if (hop === 0) fx += riverDrift(fr)
455      if (hop === 0 && !safe(fr, fx, t)) dead = 30
456    },
457    draw(px) {
458      px.fill(0x0a0a14)
459      for (let r = 0; r < rows; r++) {
460        const y = oy + r * cs
461        let bg = 0x202028
462        if (r === 0) bg = 0x1a6a2a
463        else if (r <= river) bg = 0x1a3a8a
464        else if (r === river + 1 || r === rows - 1) bg = 0x5a3a8a
465        rect(px, W, H, 0, y, W, cs, bg)
466        if (r > river + 1 && r < rows - 1) for (let x = 0; x < W; x += 4) dot(px, W, H, x + 1, y + cs - 1, 0x4a4a52)
467        const l = lanes[r]
468        if (l)
469          for (const [a, b] of objects(r, t)) {
470            if (l.kind === 'log') {
471              rect(px, W, H, a, y + 1, b - a, cs - 2, 0x8a5a2a)
472              rect(px, W, H, a, y + 1, b - a, 1, 0xa8783a)
473            } else if (l.kind === 'turtle') {
474              for (let x = a; x < b - 1; x += cs) {
475                rect(px, W, H, x, y + 1, cs - 1, cs - 2, 0x2a9a4a)
476                dot(px, W, H, x + 1, y + 1, 0x5aca6a)
477              }
478            } else {
479              rect(px, W, H, a, y + 1, b - a, cs - 2, l.c)
480              dot(px, W, H, l.v > 0 ? b - 1 : a, y + 1, 0xfff0a0)
481              rect(px, W, H, a + 1, y + cs - 2, b - a - 2, 1, 0x101010)
482            }
483          }
484      }
485      for (let k = 0; k < slots; k++) {
486        rect(px, W, H, slotX(k) - 1.5, oy, 3, cs, 0x1a3a8a)
487        if (filled[k]) sprite(px, W, H, slotX(k) - 1, oy + 1, ['x.x', 'xxx', '.x.'], { x: 0x8aff5a })
488      }
489      const y = oy + fr * cs + (hop > 0 ? (to[0] - from[0]) * cs * (1 - hop / 4) : 0)
490      if (dead > 0) sprite(px, W, H, fx - 1, y, ['x.x', '.x.', 'x.x'], { x: 0xff4040 })
491      else sprite(px, W, H, fx - 1, y + 0.5, ['x.x', 'xxx', 'x.x'], { x: 0x6aff4a })
492      text(px, W, H, 1, 0, String(saved), 0xffffff)
493    },
494  }
495}
496
497// --- pong, bot vs bot -------------------------------------------------------------
498
499const pong: Maker = (W, H, rng) => {
500  const pw = Math.max(4, Math.round(W * 0.22))
501  const top = 4
502  const bottom = H - 4
503  let a = W / 2
504  let b = W / 2
505  let ball = { x: W / 2, y: H / 2, vx: 0.4, vy: 0.7 }
506  let errA = 0
507  let errB = 0
508  let sa = 0
509  let sb = 0
510  let wait = 20
511  const trail: [number, number][] = []
512
513  function serve(dir: number) {
514    const ang = (rng() - 0.5) * 1.1
515    ball = { x: W / 2, y: H / 2, vx: Math.sin(ang) * 0.75, vy: Math.cos(ang) * 0.75 * dir }
516    errA = (rng() - 0.5) * pw * 1.4
517    errB = (rng() - 0.5) * pw * 1.4
518    wait = 25
519  }
520  serve(1)
521  const predict = (yTarget: number) => {
522    let x = ball.x
523    let y = ball.y
524    let vx = ball.vx
525    for (let k = 0; k < 400 && Math.abs(y - yTarget) > Math.abs(ball.vy); k++) {
526      x += vx
527      y += ball.vy
528      if (x < 1 || x > W - 2) vx = -vx
529    }
530    return x
531  }
532
533  return {
534    step() {
535      if (wait > 0) {
536        wait--
537        return
538      }
539      const ta = ball.vy < 0 ? predict(top + 1) + errA * 0.8 : W / 2
540      const tb = ball.vy > 0 ? predict(bottom - 1) + errB * 0.8 : W / 2
541      a += clamp(ta - a, -0.75, 0.75)
542      b += clamp(tb - b, -0.75, 0.75)
543      ball.x += ball.vx
544      ball.y += ball.vy
545      if (ball.x < 1 || ball.x > W - 2) {
546        ball.vx = -ball.vx
547        ball.x = clamp(ball.x, 1, W - 2)
548      }
549      const hit = (p: number, y: number, dir: number) => {
550        if (Math.abs(ball.x - p) <= pw / 2 + 0.5) {
551          const off = (ball.x - p) / (pw / 2)
552          const s = Math.min(2.2, Math.hypot(ball.vx, ball.vy) * 1.06)
553          ball.vx = off * s * 0.7
554          ball.vy = dir * Math.sqrt(Math.max(0.2, s * s - ball.vx * ball.vx))
555          ball.y = y
556          return true
557        }
558        return false
559      }
560      if (ball.vy < 0 && ball.y <= top + 1 && ball.y > top - 1) {
561        if (hit(a, top + 1, 1)) errA = (rng() - 0.5) * pw * 1.6
562      }
563      if (ball.vy > 0 && ball.y >= bottom - 1 && ball.y < bottom + 1) {
564        if (hit(b, bottom - 1, -1)) errB = (rng() - 0.5) * pw * 1.6
565      }
566      if (ball.y < 0) {
567        sb++
568        serve(-1)
569      } else if (ball.y > H) {
570        sa++
571        serve(1)
572      }
573      if (sa > 9 || sb > 9) {
574        sa = 0
575        sb = 0
576      }
577      trail.push([ball.x, ball.y])
578      if (trail.length > 4) trail.shift()
579    },
580    draw(px) {
581      px.fill(0x000000)
582      for (let x = 0; x < W; x += 3) dot(px, W, H, x, H / 2, 0x5a5a5a)
583      text(px, W, H, 2, H / 2 - 7, String(sa), 0x9a9a9a)
584      text(px, W, H, 2, H / 2 + 3, String(sb), 0x9a9a9a)
585      rect(px, W, H, a - pw / 2, top, pw, 1, 0xffffff)
586      rect(px, W, H, b - pw / 2, bottom, pw, 1, 0xffffff)
587      trail.forEach(([x, y], i) => dot(px, W, H, x, y, mix(0, 0xffffff, (i + 1) / 8)))
588      rect(px, W, H, ball.x - 0.5, ball.y - 0.5, 1, 1, 0xffffff)
589      dot(px, W, H, ball.x, ball.y, 0xffffff)
590    },
591  }
592}
593
594// --- galaga dive ------------------------------------------------------------------
595
596const galaga: Maker = (W, H, rng) => {
597  const BEE = ['x.x.x', '.xxx.', 'xxxxx', '.x.x.']
598  const BUG = ['.x.x.', 'xxxxx', 'x.x.x', 'x...x']
599  const SHIP = ['..x..', '..x..', '.xxx.', 'xxxxx', 'x.x.x']
600  type Foe = { hx: number; hy: number; x: number; y: number; st: number; u: number; dir: number; kind: number; shot: boolean }
601  let foes: Foe[] = []
602  let t = 0
603  let sx = W / 2
604  let shots: { x: number; y: number }[] = []
605  let bombs: { x: number; y: number; vx: number }[] = []
606  let fx2: Boom[] = []
607  let dead = 0
608  let score = 0
609  const stars = Array.from({ length: 16 }, () => ({ x: rng() * W, y: rng() * H, c: hsv(rng(), 0.5, 1) }))
610
611  function wave() {
612    foes = []
613    const per = Math.max(2, Math.floor((W - 4) / 6))
614    for (let r = 0; r < 3; r++)
615      for (let c = 0; c < per; c++) {
616        const hx = 2 + c * 6 + (W - 4 - per * 6) / 2 + 0.5
617        const hy = 6 + r * 6
618        foes.push({ hx, hy, x: hx, y: -10 - r * 8 - c * 3, st: 2, u: 0, dir: 1, kind: r === 0 ? 1 : 0, shot: false })
619      }
620  }
621  wave()
622
623  return {
624    step() {
625      t++
626      fx2 = booms(fx2)
627      for (const s of stars) {
628        s.y += 0.5
629        if (s.y > H) s.y = 0
630      }
631      const sway = Math.sin(t * 0.03) * 2
632      for (const f of foes) {
633        if (f.st === 0) {
634          f.x = f.hx + sway
635          f.y = f.hy
636        } else if (f.st === 1) {
637          f.u += 0.012
638          const u = f.u
639          f.x = f.hx + sway + Math.sin(u * Math.PI * 3) * W * 0.3 * f.dir
640          f.y = f.hy + u * (H + 10)
641          if (!f.shot && u > 0.35) {
642            f.shot = true
643            bombs.push({ x: f.x, y: f.y + 4, vx: clamp((sx - f.x) * 0.02, -0.3, 0.3) })
644          }
645          if (f.y > H + 4) {
646            f.st = 2
647            f.y = -8
648          }
649        } else {
650          f.x += (f.hx + sway - f.x) * 0.08
651          f.y += Math.min(1, f.hy - f.y) * 0.8
652          if (Math.abs(f.y - f.hy) < 0.5) f.st = 0
653        }
654      }
655      if (rng() < 0.02) {
656        const idle = foes.filter(f => f.st === 0)
657        const f = idle[irand(rng, idle.length)]
658        if (f) {
659          f.st = 1
660          f.u = 0
661          f.dir = rng() < 0.5 ? -1 : 1
662          f.shot = false
663        }
664      }
665      for (const s of shots) s.y -= 2
666      shots = shots.filter(s => s.y > -2)
667      for (const b of bombs) {
668        b.y += 0.9
669        b.x += b.vx
670      }
671      bombs = bombs.filter(b => b.y < H)
672      foes = foes.filter(f => {
673        const s = shots.find(q => Math.abs(q.x - f.x - 2) < 3 && q.y >= f.y && q.y < f.y + 4)
674        if (!s) return true
675        s.y = -10
676        score += f.st === 1 ? 160 : 50
677        fx2.push({ x: f.x + 2, y: f.y + 2, l: 12, c: f.kind ? 0xff4a4a : 0xffe04a })
678        return false
679      })
680      if (!foes.length) wave()
681      if (dead > 0) {
682        dead--
683        return
684      }
685      const diver = foes.filter(f => f.st === 1).sort((p, q) => q.y - p.y)[0]
686      let target = diver ? diver.x + 2 : (foes[Math.floor((t / 60) % Math.max(1, foes.length))]?.x ?? W / 2) + 2
687      const danger = bombs.find(b => b.y > H - 18 && Math.abs(b.x - sx) < 3)
688      if (danger) target = sx + (danger.x < sx ? 5 : -5)
689      sx = clamp(sx + clamp(target - sx, -0.8, 0.8), 3, W - 4)
690      if (t % 8 === 0 && shots.length < 2) shots.push({ x: Math.round(sx), y: H - 8 })
691      if (bombs.some(b => b.y > H - 7 && Math.abs(b.x - sx) < 2.5) || foes.some(f => f.st === 1 && f.y > H - 9 && Math.abs(f.x + 2 - sx) < 3)) {
692        fx2.push({ x: sx, y: H - 5, l: 12, c: 0xffffff })
693        dead = 40
694        bombs = []
695      }
696    },
697    draw(px) {
698      px.fill(0x000008)
699      for (const s of stars) dot(px, W, H, s.x, s.y, (t + s.x) % 20 < 14 ? s.c : 0x000008)
700      for (const f of foes) sprite(px, W, H, f.x, f.y, f.kind ? BUG : BEE, { x: f.kind ? (Math.floor(t / 10) % 2 ? 0xff4a4a : 0xffffff) : 0xffe04a })
701      for (const f of foes) if (!f.kind) dot(px, W, H, f.x + 2, f.y + 1, 0x4a8aff)
702      for (const s of shots) rect(px, W, H, s.x, s.y, 1, 2, 0xffffff)
703      for (const b of bombs) dot(px, W, H, b.x, b.y, 0xff6a6a)
704      if (!dead) sprite(px, W, H, sx - 2, H - 7, SHIP, { x: 0xffffff })
705      if (!dead) dot(px, W, H, sx, H - 5, 0xff4a4a)
706      for (const b of fx2) drawBoom(px, W, H, b)
707      text(px, W, H, 1, 0, String(score), 0xff4a4a)
708    },
709  }
710}
711
712// --- missile command --------------------------------------------------------------
713
714const missile: Maker = (W, H, rng) => {
715  const ground = H - 4
716  const bases = [2, W / 2, W - 3].map(x => ({ x, ammo: 10 }))
717  const cityX = [W * 0.17, W * 0.33, W * 0.67, W * 0.83].map(Math.round)
718  let cities = [true, true, true, true]
719  type Head = { sx: number; sy: number; tx: number; ty: number; u: number; v: number; targeted: boolean }
720  let heads: Head[] = []
721  let ours: { sx: number; sy: number; tx: number; ty: number; u: number; v: number }[] = []
722  let blasts: { x: number; y: number; t: number; c: number }[] = []
723  let spawned = 0
724  let wave = 1
725  let pause = 0
726  let score = 0
727  let t = 0
728
729  const targets = () => [...cityX.filter((_, i) => cities[i]), ...bases.map(b => b.x)]
730  const posOf = (h: { sx: number; sy: number; tx: number; ty: number; u: number }): [number, number] => [h.sx + (h.tx - h.sx) * h.u, h.sy + (h.ty - h.sy) * h.u]
731
732  return {
733    step() {
734      t++
735      if (pause > 0) {
736        if (--pause === 0) {
737          if (!cities.some(Boolean)) {
738            cities = [true, true, true, true]
739            score = 0
740            wave = 1
741          } else wave++
742          for (const b of bases) b.ammo = 10
743          spawned = 0
744        }
745        return
746      }
747      if (spawned < 8 + wave * 2 && rng() < 0.03 + wave * 0.005) {
748        const tg = targets()
749        heads.push({ sx: rng() * W, sy: 0, tx: pick(rng, tg), ty: ground, u: 0, v: 0.003 + wave * 0.0008, targeted: false })
750        spawned++
751      }
752      for (const h of heads) h.u += h.v
753      // the bot: intercept the most urgent warhead, from the nearest base with ammo
754      if (t % 10 === 0) {
755        const free = heads.filter(h => !h.targeted).sort((a, b) => b.u - a.u)[0]
756        if (free) {
757          const base = bases.filter(b => b.ammo > 0).sort((a, b) => Math.abs(a.x - free.tx) - Math.abs(b.x - free.tx))[0]
758          if (base) {
759            let k = 20
760            for (let it = 0; it < 4; it++) {
761              const [x, y] = posOf({ ...free, u: Math.min(1, free.u + free.v * k) })
762              k = Math.hypot(x - base.x, y - ground) / 1.4
763            }
764            const [x, y] = posOf({ ...free, u: Math.min(0.97, free.u + free.v * k) })
765            ours.push({ sx: base.x, sy: ground - 1, tx: x + (rng() - 0.5), ty: y, u: 0, v: 1.4 / Math.max(1, Math.hypot(x - base.x, y - ground)) })
766            base.ammo--
767            free.targeted = true
768          }
769        }
770      }
771      for (const o of ours) o.u += o.v
772      ours = ours.filter(o => {
773        if (o.u < 1) return true
774        blasts.push({ x: o.tx, y: o.ty, t: 0, c: 0xffffff })
775        return false
776      })
777      for (const b of blasts) b.t++
778      blasts = blasts.filter(b => b.t < 24)
779      heads = heads.filter(h => {
780        const [x, y] = posOf(h)
781        const r = (b: { t: number }) => (b.t < 12 ? b.t / 3 : (24 - b.t) / 3)
782        if (blasts.some(b => Math.hypot(x - b.x, y - b.y) <= r(b))) {
783          score += 25
784          blasts.push({ x, y, t: 3, c: 0xffa040 })
785          return false
786        }
787        if (h.u >= 1) {
788          blasts.push({ x: h.tx, y: ground - 1, t: 0, c: 0xff4040 })
789          const ci = cityX.indexOf(h.tx)
790          if (ci >= 0) cities[ci] = false
791          const bi = bases.findIndex(b => b.x === h.tx)
792          if (bi >= 0) (bases[bi] as { ammo: number }).ammo = 0
793          return false
794        }
795        return true
796      })
797      if (spawned >= 8 + wave * 2 && !heads.length && !blasts.length && !ours.length) pause = 50
798      if (!cities.some(Boolean) && !pause) pause = 60
799    },
800    draw(px) {
801      sky(px, W, H, 0x000010, 0x101040, ground)
802      rect(px, W, H, 0, ground, W, H - ground, 0xc8a040)
803      for (const b of bases) {
804        sprite(px, W, H, b.x - 2, ground - 2, ['..x..', '.xxx.'], { x: 0xc8a040 })
805        for (let i = 0; i < Math.min(5, b.ammo); i++) dot(px, W, H, b.x - 2 + i, ground + 1, 0x2a6aff)
806      }
807      cityX.forEach((x, i) => {
808        if (cities[i]) sprite(px, W, H, x - 2, ground - 3, ['.x.x.', 'xxxxx', 'xxxxx'], { x: 0x3ac8ff })
809        else rect(px, W, H, x - 2, ground - 1, 5, 1, 0x5a4a3a)
810      })
811      for (const h of heads) {
812        const [x, y] = posOf(h)
813        line(px, W, H, h.sx, h.sy, x, y, 0xe03a3a)
814        dot(px, W, H, x, y, 0xffffff)
815      }
816      for (const o of ours) {
817        const [x, y] = posOf(o)
818        line(px, W, H, o.sx, o.sy, x, y, 0x2a4aff)
819        dot(px, W, H, o.tx, o.ty, 0x8aa0ff)
820      }
821      for (const b of blasts) {
822        const r = b.t < 12 ? b.t / 3 : (24 - b.t) / 3
823        disc(px, W, H, b.x, b.y, r, hsv(b.t * 0.08, 0.6, 1))
824      }
825      text(px, W, H, 1, 0, String(score), 0xff4040)
826    },
827  }
828}
829
830// --- barrel climber ---------------------------------------------------------------
831
832const barrels: Maker = (W, H, rng) => {
833  const levels = Math.max(3, Math.min(5, Math.floor((H - 14) / 9)))
834  const gap = (H - 14) / levels
835  const baseY = (i: number) => H - 4 - i * gap
836  const slope = (i: number) => (i % 2 === 0 ? -2 : 2)
837  const gy = (i: number, x: number) => baseY(i) + (slope(i) * (x - W / 2)) / W
838  const ladderX = (i: number) => (i % 2 === 0 ? W - 6 : 5)
839  type Bar = { x: number; lv: number; dir: number; fall: number; y: number; ladder: boolean; jumped: boolean }
840  let bars: Bar[] = []
841  let hx = 4
842  let lv = 0
843  let climb = -1
844  let jy = 0
845  let jv = 0
846  let timer = 30
847  let dead = 0
848  let won = 0
849  let score = 0
850  let t = 0
851  const HERO = ['.r.', 'rrr', '.s.', 'bbb', 'b.b']
852  const APE = ['.bbbb.', 'bbssbb', 'bbbbbb', 'b.bb.b', 'bb..bb']
853
854  function reset() {
855    bars = []
856    hx = 4
857    lv = 0
858    climb = -1
859    jy = 0
860    jv = 0
861  }
862  reset()
863  const top = levels - 1
864
865  return {
866    step() {
867      t++
868      if (dead > 0) {
869        if (--dead === 0) reset()
870        return
871      }
872      if (won > 0) {
873        if (--won === 0) reset()
874        return
875      }
876      if (--timer <= 0) {
877        bars.push({ x: 8, lv: top, dir: 1, fall: -1, y: 0, ladder: false, jumped: false })
878        timer = 55 + irand(rng, 50)
879      }
880      for (const b of bars) {
881        if (b.fall >= 0) {
882          b.y += 0.8
883          if (b.y >= gy(b.fall, b.x)) {
884            b.lv = b.fall
885            b.fall = -1
886            b.dir = b.lv % 2 === 0 ? -1 : 1
887            if (b.ladder) b.dir = b.lv % 2 === 0 ? -1 : 1
888            b.ladder = false
889          }
890          continue
891        }
892        b.x += b.dir * 0.55
893        b.y = gy(b.lv, b.x)
894        const lx = ladderX(b.lv - 1)
895        if (b.lv > 0 && Math.abs(b.x - lx) < 0.3 && rng() < 0.3) {
896          b.fall = b.lv - 1
897          b.ladder = true
898        } else if (b.x < 1 || b.x > W - 2) {
899          if (b.lv === 0) b.lv = -1
900          else {
901            b.fall = b.lv - 1
902            b.x = clamp(b.x, 1, W - 2)
903          }
904        }
905      }
906      bars = bars.filter(b => b.lv >= 0)
907      // the hero: walk to the ladder, climb, jump the barrels
908      const lx = ladderX(lv)
909      if (climb >= 0) {
910        climb += 0.35
911        if (climb >= gap) {
912          lv++
913          climb = -1
914          if (lv >= top) {
915            won = 60
916            score += 1000
917          }
918        }
919      } else {
920        const d = lx - hx
921        if (Math.abs(d) < 0.4 && jy === 0) climb = 0
922        else hx += Math.sign(d) * 0.4
923        const near = bars.find(b => b.fall < 0 && b.lv === lv && Math.abs(b.x - hx) < 5 && Math.sign(b.x - hx) === b.dir * -1)
924        if (near && jy === 0 && jv === 0) jv = -1.1
925      }
926      if (jv !== 0 || jy < 0) {
927        jv += 0.12
928        jy += jv
929        if (jy >= 0) {
930          jy = 0
931          jv = 0
932        }
933      }
934      const y = climb >= 0 ? gy(lv, hx) - climb : gy(lv, hx) + jy
935      for (const b of bars) {
936        if (Math.abs(b.x - hx) < 2 && Math.abs(b.y - 1 - y) < 2.5) {
937          dead = 50
938        } else if (!b.jumped && b.lv === lv && Math.abs(b.x - hx) < 1 && jy < -1.5) {
939          b.jumped = true
940          score += 100
941        }
942      }
943    },
944    draw(px) {
945      px.fill(0x000000)
946      for (let i = 0; i < levels; i++) {
947        for (let x = 0; x < W; x++) {
948          const y = gy(i, x)
949          dot(px, W, H, x, y + 1, 0xe0204a)
950          dot(px, W, H, x, y + 3, 0xe0204a)
951          if (x % 3 === 0) dot(px, W, H, x, y + 2, 0xe0204a)
952        }
953        if (i < levels - 1) {
954          const x = ladderX(i)
955          const y0 = gy(i, x)
956          const y1 = gy(i + 1, x)
957          for (let y = Math.round(y1) + 1; y <= y0; y++) {
958            dot(px, W, H, x - 1, y, 0x3ad0e0)
959            dot(px, W, H, x + 1, y, 0x3ad0e0)
960            if (y % 2 === 0) dot(px, W, H, x, y, 0x3ad0e0)
961          }
962        }
963      }
964      sprite(px, W, H, 2, gy(top, 4) - 5, APE, { b: 0x8a4a1a, s: 0xe0a070 })
965      sprite(px, W, H, W - 6, gy(top, W - 5) - 5, ['.p.', 'ppp', '.p.', 'p.p'], { p: 0xff8ad0 })
966      if (won && won % 10 < 5) sprite(px, W, H, W - 9, gy(top, W - 8) - 8, ['x.x', 'xxx', '.x.'], { x: 0xff4a8a })
967      rect(px, W, H, W - 5, gy(0, W - 4) - 3, 3, 3, 0x3a5aaa)
968      if (t % 6 < 3) dot(px, W, H, W - 4, gy(0, W - 4) - 4, 0xffa020)
969      for (const b of bars) {
970        lit(px, W, H, b.x, b.y - 1.5, 1.6, 1.6, 0xa86a2a)
971        dot(px, W, H, b.x + ((t >> 2) % 2 ? 1 : -1), b.y - 1.5, 0xe0a050)
972      }
973      const y = climb >= 0 ? gy(lv, hx) - climb : gy(lv, hx) + jy
974      if (!dead || dead % 6 < 3) sprite(px, W, H, hx - 1, y - 5, HERO, { r: 0xff3a3a, s: 0xffd0a0, b: 0x3a6aff })
975      text(px, W, H, 1, 0, String(score), 0xffffff)
976    },
977  }
978}
979
980// --- lunar lander -----------------------------------------------------------------
981
982const lander: Maker = (W, H, rng) => {
983  let ground: number[] = []
984  let pad = 0
985  const pw = 6
986  let s = { x: 4, y: 6, vx: 0.25, vy: 0 }
987  let fuel = 999
988  let fire = 0
989  let side = 0
990  let phase = 0
991  let t = 0
992  let panic = false
993  let landed = 0
994  let debris: { x: number; y: number; vx: number; vy: number }[] = []
995  const stars = Array.from({ length: 14 }, () => [rng() * W, rng() * H * 0.6] as [number, number])
996
997  function terrain() {
998    pad = 3 + irand(rng, Math.max(1, W - pw - 6))
999    let y = H * 0.75
1000    ground = []
1001    for (let x = 0; x < W; x++) {
1002      if (x >= pad && x < pad + pw) {
1003        ground.push(Math.round(ground[pad - 1] ?? y))
1004        continue
1005      }
1006      y = clamp(y + (rng() - 0.5) * 5, H * 0.55, H - 5)
1007      ground.push(y)
1008    }
1009    s = { x: rng() < 0.5 ? 4 : W - 5, y: 6, vx: (rng() - 0.5) * 0.5, vy: 0 }
1010    fuel = 999
1011    phase = 0
1012    t = 0
1013    panic = rng() < 0.18
1014  }
1015  terrain()
1016
1017  return {
1018    step() {
1019      t++
1020      for (const d of debris) {
1021        d.x += d.vx
1022        d.y += d.vy
1023        d.vy += 0.05
1024      }
1025      if (phase > 0) {
1026        if (t > 60) {
1027          debris = []
1028          terrain()
1029        }
1030        return
1031      }
1032      const tx = pad + pw / 2
1033      const gnd = ground[Math.round(clamp(s.x, 0, W - 1))] as number
1034      const alt = gnd - (s.y + 2)
1035      const over = Math.abs(s.x - tx) < 1.2
1036      const wantVx = clamp((tx - s.x) * 0.03, -0.3, 0.3)
1037      side = wantVx - s.vx > 0.02 ? 1 : wantVx - s.vx < -0.02 ? -1 : 0
1038      const wantVy = over ? (alt > 12 ? 0.35 : 0.12) : alt < 10 ? -0.1 : 0.05
1039      fire = s.vy > wantVy && fuel > 0 && !(panic && alt < 8) ? 1 : 0
1040      s.vx += side * 0.01
1041      s.vy += 0.012 - fire * 0.03
1042      fuel = Math.max(0, fuel - fire * 3 - Math.abs(side))
1043      s.x = clamp(s.x + s.vx, 1, W - 2)
1044      s.y += s.vy
1045      const under = Math.max(ground[Math.round(clamp(s.x - 1, 0, W - 1))] as number, ground[Math.round(clamp(s.x + 1, 0, W - 1))] as number)
1046      if (s.y + 2 >= Math.min(gnd, under) - 0.2) {
1047        const onPad = s.x - 1 >= pad && s.x + 1 < pad + pw
1048        if (onPad && s.vy < 0.3 && Math.abs(s.vx) < 0.15) {
1049          phase = 1
1050          landed++
1051          s.y = (ground[pad] as number) - 2
1052        } else {
1053          phase = 2
1054          for (let k = 0; k < 14; k++) debris.push({ x: s.x, y: s.y, vx: (rng() - 0.5) * 1.4, vy: -rng() * 1.2 })
1055        }
1056        t = 0
1057      }
1058    },
1059    draw(px) {
1060      px.fill(0x000000)
1061      for (const [x, y] of stars) dot(px, W, H, x, y, 0x5a5a6a)
1062      for (let x = 0; x < W; x++) {
1063        const g = Math.round(ground[x] as number)
1064        rect(px, W, H, x, g, 1, H - g, 0x3a3a44)
1065        dot(px, W, H, x, g, x >= pad && x < pad + pw ? 0xffe040 : 0xd0d0d8)
1066      }
1067      if (phase !== 2) {
1068        litSprite(px, W, H, s.x - 2, s.y - 3, ['.xxx.', 'xwwwx', '.xxx.', 'x...x'], { x: 0xd0d0d8, w: 0x4ab0ff })
1069        if (fire && phase === 0 && t % 3 !== 0) {
1070          dot(px, W, H, s.x, s.y + 1, 0xffa020)
1071          dot(px, W, H, s.x, s.y + 2, 0xff4020)
1072        }
1073        if (side && phase === 0) dot(px, W, H, s.x - side * 3, s.y - 2, 0xffe0a0)
1074      }
1075      for (const d of debris) dot(px, W, H, d.x, d.y, t % 4 < 2 ? 0xffa020 : 0xffffff)
1076      if (phase === 1 && t % 10 < 6) {
1077        rect(px, W, H, s.x + 3, s.y - 5, 1, 6, 0xffffff)
1078        rect(px, W, H, s.x + 4, s.y - 5, 2, 2, 0xff3a3a)
1079      }
1080      text(px, W, H, 1, 0, String(Math.floor(fuel / 10)), 0xffe040)
1081      text(px, W, H, W - 1 - textWidth(String(landed)), 0, String(landed), 0x8aff8a)
1082    },
1083  }
1084}
1085
1086// --- centipede --------------------------------------------------------------------
1087
1088const centipede: Maker = (W, H, rng) => {
1089  const cs = 3
1090  const cols = Math.floor(W / cs)
1091  const rows = Math.max(8, Math.floor((H - 4) / cs))
1092  const oy = H - rows * cs
1093  const zone = rows - 4
1094  let shrooms = new Map<number, number>()
1095  type Seg = { x: number; y: number }
1096  let bugs: { segs: Seg[]; dx: number; dy: number }[] = []
1097  let gx = W / 2
1098  let shot: { x: number; y: number } | undefined
1099  let t = 0
1100  let wave = 0
1101  let score = 0
1102  let fx2: Boom[] = []
1103
1104  function spawn() {
1105    const n = Math.min(10, cols - 1)
1106    bugs = [{ segs: Array.from({ length: n }, (_, i) => ({ x: Math.max(0, cols - 1 - i), y: 0 })), dx: -1, dy: 1 }]
1107    for (let x = cols - n; x < cols; x++) bugs[0]!.segs.forEach(() => 0)
1108    wave++
1109  }
1110  function field() {
1111    shrooms = new Map()
1112    for (let k = 0; k < cols * rows * 0.1; k++) {
1113      const x = irand(rng, cols)
1114      const y = 1 + irand(rng, zone - 1)
1115      shrooms.set(y * cols + x, 3)
1116    }
1117  }
1118  field()
1119  spawn()
1120  wave = 1
1121
1122  return {
1123    step() {
1124      t++
1125      fx2 = booms(fx2)
1126      if (t % 3 === 0)
1127        for (const b of bugs) {
1128          const head = b.segs[0] as Seg
1129          let nx = head.x + b.dx
1130          let ny = head.y
1131          if (nx < 0 || nx >= cols || shrooms.has(ny * cols + nx)) {
1132            nx = head.x
1133            ny = head.y + b.dy
1134            if (ny >= rows - 1 || ny < zone - 2 && b.dy < 0) b.dy = -b.dy
1135            if (ny >= rows) ny = rows - 2
1136            b.dx = -b.dx
1137          }
1138          for (let i = b.segs.length - 1; i > 0; i--) b.segs[i] = { ...(b.segs[i - 1] as Seg) }
1139          b.segs[0] = { x: nx, y: ny }
1140        }
1141      // the shooter: under the nearest head, firing
1142      const heads = bugs.map(b => b.segs[0] as Seg)
1143      const h = heads.sort((p, q) => q.y - p.y)[0]
1144      if (h) gx += clamp(h.x * cs + 1 + (bugs[0]?.dx ?? 0) * 2 - gx, -0.9, 0.9)
1145      gx = clamp(gx, 1, W - 2)
1146      if (!shot) shot = { x: Math.round(gx), y: H - 5 }
1147      shot.y -= 2.5
1148      const cx = Math.floor(shot.x / cs)
1149      const cy = Math.floor((shot.y - oy) / cs)
1150      const k = cy * cols + cx
1151      if (shot.y < oy) shot = undefined
1152      else if (shrooms.has(k)) {
1153        const v = (shrooms.get(k) as number) - 1
1154        if (v <= 0) shrooms.delete(k)
1155        else shrooms.set(k, v)
1156        score++
1157        shot = undefined
1158      } else {
1159        for (let bi = 0; bi < bugs.length && shot; bi++) {
1160          const b = bugs[bi] as { segs: Seg[]; dx: number; dy: number }
1161          const si = b.segs.findIndex(s => s.x === cx && s.y === cy)
1162          if (si >= 0) {
1163            score += si === 0 ? 100 : 10
1164            shrooms.set(k, 3)
1165            fx2.push({ x: cx * cs + 1, y: oy + cy * cs + 1, l: 10, c: 0x5aff5a })
1166            const tail = b.segs.slice(si + 1)
1167            b.segs = b.segs.slice(0, si)
1168            if (tail.length) bugs.push({ segs: tail, dx: -b.dx, dy: b.dy })
1169            shot = undefined
1170          }
1171        }
1172        bugs = bugs.filter(b => b.segs.length > 0)
1173      }
1174      if (!bugs.length) {
1175        spawn()
1176        if (wave % 3 === 0) field()
1177      }
1178    },
1179    draw(px) {
1180      px.fill(0x000000)
1181      const mc = hsv(wave * 0.17, 0.8, 1)
1182      for (const [k, v] of shrooms) {
1183        const x = (k % cols) * cs
1184        const y = oy + Math.floor(k / cols) * cs
1185        rect(px, W, H, x, y, cs - 1, v > 1 ? 1 : 0, mc)
1186        rect(px, W, H, x, y, cs - 1, 1, mc)
1187        if (v > 1) rect(px, W, H, x, y + 1, cs - 1, 1, scale(mc, 0.7))
1188        if (v > 2) dot(px, W, H, x + 1, y + 1, 0xffffff)
1189      }
1190      for (const b of bugs)
1191        b.segs.forEach((s, i) => {
1192          const x = s.x * cs
1193          const y = oy + s.y * cs
1194          rect(px, W, H, x, y, cs - 1, cs - 1, i === 0 ? 0xff4a4a : 0x5aff5a)
1195          if (i === 0) dot(px, W, H, x + (b.dx > 0 ? 1 : 0), y, 0xffffff)
1196        })
1197      if (shot) rect(px, W, H, shot.x, shot.y, 1, 2, 0xffffff)
1198      sprite(px, W, H, gx - 1, H - 4, ['.x.', 'xxx', 'x.x'], { x: 0x4ab0ff })
1199      for (const b of fx2) drawBoom(px, W, H, b)
1200      text(px, W, H, 1, 0, String(score), 0xffe04a)
hooks/themes/city.ts 1411 lines
1import { disc, dot, hsv, mix, rect, scale, sky, sprite, text } from '../draw'
2import type { Maker } from '../draw'
3import { burst, clamp, ellipse, hash, irand, line, lit, pick, poly, ring, slab, sparks, tint } from '../kit'
4import type { Spark } from '../kit'
5
6// Vehicles & city: busy little worlds to watch from above.
7
8const span = (t: number, a: number, b: number) => clamp((t - a) / (b - a), 0, 1)
9
10// A car seen from above: a shadow down and right, a body lit on its top-left edge and shaded on the other, a windscreen.
11function car(px: Uint32Array, W: number, H: number, x: number, y: number, horiz: boolean, c: number) {
12  const w = horiz ? 5 : 3
13  const h = horiz ? 3 : 5
14  const x0 = Math.round(x - (horiz ? 2 : 1))
15  const y0 = Math.round(y - (horiz ? 1 : 2))
16  for (let k = 0; k < w; k++) tint(px, W, H, x0 + 1 + k, y0 + h, 0x000000, 0.35)
17  for (let k = 0; k < h; k++) tint(px, W, H, x0 + w, y0 + 1 + k, 0x000000, 0.35)
18  rect(px, W, H, x0, y0, w, h, c)
19  rect(px, W, H, x0, y0, w, 1, mix(c, 0xffffff, 0.35))
20  rect(px, W, H, x0, y0 + h - 1, w, 1, scale(c, 0.7))
21  rect(px, W, H, x0 + w - 1, y0, 1, h, scale(c, 0.8))
22  dot(px, W, H, x, y, 0xa8d8ff)
23}
24
25// --- traffic lights ---------------------------------------------------------------
26
27const traffic: Maker = (W, H, rng) => {
28  const cx = W / 2
29  const cy = H / 2
30  const half = 4
31  type C = { d: number; p: number; c: number }
32  let cars: C[] = []
33  let t = 0
34  // d: 0 down, 1 up, 2 right, 3 left; p: distance travelled from its entry edge
35  const len = (d: number) => (d < 2 ? H : W)
36  const stop = (d: number) => (d < 2 ? cy - half - 3 : cx - half - 3)
37  const pos = (c: C): [number, number] => {
38    if (c.d === 0) return [cx + 2, c.p]
39    if (c.d === 1) return [cx - 2, H - c.p]
40    if (c.d === 2) return [c.p, cy - 2]
41    return [W - c.p, cy + 2]
42  }
43
44  return {
45    step() {
46      t++
47      const ns = Math.floor(t / 180) % 2 === 0
48      const amber = t % 180 > 150
49      if (rng() < 0.05) {
50        const d = irand(rng, 4)
51        if (!cars.some(c => c.d === d && c.p < 6)) cars.push({ d, p: -3, c: hsv(rng(), 0.7, 0.95) })
52      }
53      for (const c of cars) {
54        const green = (c.d < 2) === ns && !amber
55        const s = stop(c.d)
56        const ahead = cars.filter(o => o.d === c.d && o.p > c.p).sort((a, b) => a.p - b.p)[0]
57        let v = 0.5
58        if (ahead && ahead.p - c.p < 6) v = 0
59        if (!green && c.p < s && c.p + v >= s - 0.5) v = Math.max(0, s - c.p - 0.5)
60        c.p += v
61      }
62      cars = cars.filter(c => c.p < len(c.d) + 4)
63    },
64    draw(px) {
65      px.fill(0x5aa04a)
66      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (hash(x, y, 2) > 0.92) px[y * W + x] = 0x4a903a
67      rect(px, W, H, cx - half, 0, half * 2, H, 0x4a4a52)
68      rect(px, W, H, 0, cy - half, W, half * 2, 0x4a4a52)
69      for (let y = 0; y < H; y += 4) if (Math.abs(y - cy) > half) dot(px, W, H, cx, y, 0xe0e0a0)
70      for (let x = 0; x < W; x += 4) if (Math.abs(x - cx) > half) dot(px, W, H, x, cy, 0xe0e0a0)
71      for (let k = -half; k < half; k += 2) {
72        dot(px, W, H, cx + k, cy - half - 1, 0xffffff)
73        dot(px, W, H, cx + k, cy + half, 0xffffff)
74        dot(px, W, H, cx - half - 1, cy + k, 0xffffff)
75        dot(px, W, H, cx + half, cy + k, 0xffffff)
76      }
77      for (const c of cars) {
78        const [x, y] = pos(c)
79        car(px, W, H, x, y, c.d >= 2, c.c)
80      }
81      const ns = Math.floor(t / 180) % 2 === 0
82      const amber = t % 180 > 150
83      const light = (x: number, y: number, on: boolean) => {
84        rect(px, W, H, x, y, 2, 4, 0x1a1a1a)
85        dot(px, W, H, x, y, on ? 0x2a2a2a : amber ? 0x2a2a2a : 0xff3a3a)
86        dot(px, W, H, x, y + 1, on && amber ? 0xffb020 : 0x2a2a2a)
87        dot(px, W, H, x, y + 2, on && !amber ? 0x3aff6a : 0x2a2a2a)
88      }
89      light(cx + half + 1, cy - half - 5, ns)
90      light(cx - half - 3, cy + half + 1, ns)
91      light(cx - half - 3, cy - half - 5, !ns)
92      light(cx + half + 1, cy + half + 1, !ns)
93    },
94  }
95}
96
97// --- roundabout -------------------------------------------------------------------
98
99const roundabout: Maker = (W, H, rng) => {
100  const cx = W / 2
101  const cy = H / 2
102  const R = Math.min(W, H) * 0.3
103  type C = { stage: number; a: number; p: number; arm: number; exit: number; c: number }
104  let cars: C[] = []
105  const ARM = [-Math.PI / 2, 0, Math.PI / 2, Math.PI]
106  const armLen = Math.max(W, H)
107
108  return {
109    step() {
110      if (rng() < 0.05) {
111        const arm = irand(rng, 4)
112        cars.push({ stage: 0, a: 0, p: armLen / 2 + 4, arm, exit: (arm + 1 + irand(rng, 3)) % 4, c: hsv(rng(), 0.7, 0.95) })
113      }
114      for (const c of cars) {
115        if (c.stage === 0) {
116          const blocked = cars.some(o => o.stage === 0 && o.arm === c.arm && o.p < c.p && c.p - o.p < 5)
117          const yieldRing = c.p < R + 4 && cars.some(o => o.stage === 1 && Math.abs(Math.atan2(Math.sin(ARM[c.arm]! - 0.6 - o.a), Math.cos(ARM[c.arm]! - 0.6 - o.a))) < 0.5)
118          if (!blocked && !yieldRing) c.p -= 0.45
119          if (c.p <= R) {
120            c.stage = 1
121            c.a = ARM[c.arm] as number
122          }
123        } else if (c.stage === 1) {
124          // keep a car's length behind the one ahead on the ring
125          const ahead = cars.some(o => {
126            if (o === c || o.stage !== 1) return false
127            const gap = (((o.a - c.a) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)
128            return gap > 0.01 && gap < 5 / R
129          })
130          if (!ahead) c.a += 0.45 / R
131          const target = ARM[c.exit] as number
132          if (Math.abs(Math.atan2(Math.sin(c.a - target), Math.cos(c.a - target))) < 0.05 && c.a - (ARM[c.arm] as number) > 0.5) {
133            c.stage = 2
134            c.p = R
135            c.arm = c.exit
136          }
137        } else c.p += 0.45
138      }
139      cars = cars.filter(c => c.p < armLen)
140    },
141    draw(px) {
142      px.fill(0x5aa04a)
143      for (const a of ARM) {
144        const ex = cx + Math.cos(a) * armLen
145        const ey = cy + Math.sin(a) * armLen
146        for (let k = -3; k <= 3; k++) line(px, W, H, cx + Math.sin(a) * k, cy - Math.cos(a) * k, ex + Math.sin(a) * k, ey - Math.cos(a) * k, 0x4a4a52)
147      }
148      disc(px, W, H, cx, cy, R + 3, 0x4a4a52)
149      disc(px, W, H, cx, cy, R - 3, 0x6ac05a)
150      ring(px, W, H, cx, cy, R - 3, 0xffffff)
151      disc(px, W, H, cx, cy, 2, 0xff6a9a)
152      for (const c of cars) {
153        let x: number
154        let y: number
155        // the way it drives: in along its arm, clockwise round the ring, out along the next
156        let h: number
157        if (c.stage === 1) {
158          x = cx + Math.cos(c.a) * R
159          y = cy + Math.sin(c.a) * R
160          h = c.a + Math.PI / 2
161        } else {
162          const a = ARM[c.arm] as number
163          const side = c.stage === 0 ? -1.5 : 1.5
164          x = cx + Math.cos(a) * c.p + Math.sin(a) * side
165          y = cy + Math.sin(a) * c.p - Math.cos(a) * side
166          h = c.stage === 0 ? a + Math.PI : a
167        }
168        // a small car seen from above: a body four pixels long, a windscreen at the front
169        const hx = Math.cos(h)
170        const hy = Math.sin(h)
171        for (let k = -1.5; k <= 1.5; k += 0.5) for (const w of [-0.5, 0.5]) dot(px, W, H, x + hx * k - hy * w, y + hy * k + hx * w, c.c)
172        dot(px, W, H, x + hx * 0.8, y + hy * 0.8, 0xa8d8f0)
173      }
174    },
175  }
176}
177
178// --- parallel parking -------------------------------------------------------------
179
180const parallel: Maker = (W, H, rng) => {
181  const curb = Math.round(W * 0.3)
182  const gapY = H * 0.5
183  let tries: { x: number; y: number; a: number }[][] = []
184  let path: { x: number; y: number; a: number }[] = []
185  let pi = 0
186  let attempt = 0
187  let bump = 0
188  let hold = 0
189  const parkedC = [0x3a6ae0, 0xe0c03a, 0x6a6a72, 0x3ac06a]
190
191  function plan() {
192    attempt = 0
193    tries = []
194    const n = 1 + irand(rng, 3)
195    for (let k = 0; k < n; k++) tries.push(maneuver(k < n - 1 ? (rng() < 0.5 ? 3 : -3) : 0))
196    pi = 0
197    path = tries[0] as { x: number; y: number; a: number }[]
198  }
199  function maneuver(err: number) {
200    const out: { x: number; y: number; a: number }[] = []
201    const sx = curb + 7
202    const sy = gapY - 12
203    for (let k = 0; k <= 30; k++) out.push({ x: sx, y: sy - 10 + k * (10 / 30), a: 0 })
204    for (let k = 0; k <= 40; k++) {
205      const u = k / 40
206      const x = sx - (sx - curb - 2 + err * 0.3) * (u < 0.5 ? 2 * u * u : 1 - 2 * (1 - u) * (1 - u))
207      const y = sy + u * (14 + err)
208      const a = Math.sin(u * Math.PI) * 0.6
209      out.push({ x, y, a })
210    }
211    return out
212  }
213  plan()
214
215  return {
216    step() {
217      if (bump > 0) bump--
218      if (hold > 0) {
219        if (--hold === 0) plan()
220        return
221      }
222      pi++
223      if (pi >= path.length) {
224        attempt++
225        if (attempt >= tries.length) hold = 70
226        else {
227          bump = 10
228          path = tries[attempt] as { x: number; y: number; a: number }[]
229          pi = 0
230        }
231      }
232    },
233    draw(px) {
234      px.fill(0x4a4a52)
235      rect(px, W, H, 0, 0, curb - 4, H, 0xb8b0a0)
236      rect(px, W, H, curb - 4, 0, 1, H, 0xe0d8c8)
237      for (let y = 0; y < H; y += 6) dot(px, W, H, W * 0.75, y, 0xe0e0a0)
238      const park = (y: number, c: number) => {
239        rect(px, W, H, curb - 2, y - 4, 4, 8, c)
240        rect(px, W, H, curb - 1, y - 3, 2, 2, 0xa8d8ff)
241      }
242      park(gapY - 14, parkedC[0] as number)
243      park(gapY + 14, parkedC[1] as number)
244      park(gapY - 26, parkedC[2] as number)
245      park(gapY + 26, parkedC[3] as number)
246      const p = path[Math.min(pi, path.length - 1)] as { x: number; y: number; a: number }
247      const shake = bump > 0 ? Math.sin(bump * 2) : 0
248      const c = Math.cos(p.a)
249      const s = Math.sin(p.a)
250      const pts: [number, number][] = [
251        [-2, -4],
252        [2, -4],
253        [2, 4],
254        [-2, 4],
255      ].map(([x, y]) => [p.x + shake + x * c - y * s, p.y + x * s + y * c] as [number, number])
256      poly(px, W, H, pts, 0xe03a3a)
257      dot(px, W, H, p.x + shake + 2 * s, p.y - 2 * c, 0xa8d8ff)
258      if (bump > 5) text(px, W, H, p.x + 4, p.y - 8, '!', 0xffe040)
259      if (hold > 0 && hold % 10 < 6) text(px, W, H, W - 8, 2, String(attempt), 0xffffff)
260    },
261  }
262}
263
264// --- level crossing ---------------------------------------------------------------
265
266const levelcross: Maker = (W, H, rng) => {
267  const road = Math.round(H * 0.62)
268  const rail = Math.round(W * 0.55)
269  let t = 0
270  let train = -1
271  let cars: { x: number; c: number }[] = []
272  let wagons = 6
273
274  return {
275    step() {
276      t++
277      const cycle = t % 420
278      if (cycle === 60) {
279        train = -60 - wagons * 9
280        wagons = 4 + irand(rng, 6)
281      }
282      if (train > -999) {
283        train += 1.4
284        if (train > H + 10) train = -999
285      }
286      const closed = cycle > 40 && cycle < 300
287      if (rng() < 0.03 && !cars.some(c => c.x < 6)) cars.push({ x: -4, c: hsv(rng(), 0.7, 0.95) })
288      for (const c of cars) {
289        const ahead = cars.filter(o => o.x > c.x).sort((a, b) => a.x - b.x)[0]
290        let v = 0.45
291        if (ahead && ahead.x - c.x < 7) v = 0
292        if (closed && c.x < rail - 6 && c.x + v >= rail - 6) v = 0
293        c.x += v
294      }
295      cars = cars.filter(c => c.x < W + 5)
296    },
297    draw(px) {
298      px.fill(0x6ab04a)
299      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (hash(x, y, 7) > 0.93) px[y * W + x] = 0x5aa03a
300      rect(px, W, H, 0, road - 3, W, 6, 0x4a4a52)
301      for (let x = 0; x < W; x += 4) dot(px, W, H, x, road, 0xe0e0a0)
302      rect(px, W, H, rail - 3, 0, 6, H, 0x8a7a6a)
303      for (let y = 0; y < H; y += 2) rect(px, W, H, rail - 3, y, 6, 1, 0x6a4a2a)
304      rect(px, W, H, rail - 2, 0, 1, H, 0xb0b0b8)
305      rect(px, W, H, rail + 1, 0, 1, H, 0xb0b0b8)
306      const cycle = t % 420
307      const closed = cycle > 40 && cycle < 300
308      const arm = closed ? span(cycle, 40, 55) : 1 - span(cycle, 300, 315)
309      const bx = rail - 6
310      rect(px, W, H, bx, road - 8, 1, 5, 0x3a3a3a)
311      line(px, W, H, bx, road - 8, bx + (1 - arm) * 1 + arm * 0, road - 8 + arm * 0 - (1 - arm) * 8, 0xffffff)
312      if (arm > 0.5) for (let k = 0; k < 8; k++) dot(px, W, H, bx - k, road - 8, k % 2 ? 0xe03a3a : 0xffffff)
313      const blink = closed && t % 12 < 6
314      dot(px, W, H, bx - 1, road - 10, blink ? 0xff3a3a : 0x4a1a1a)
315      dot(px, W, H, bx + 1, road - 10, !blink && closed ? 0xff3a3a : 0x4a1a1a)
316      for (const c of cars) car(px, W, H, c.x, road + 1.5, true, c.c)
317      if (train > -999) {
318        for (let k = 0; k <= wagons; k++) {
319          const y = train - k * 9
320          rect(px, W, H, rail - 3, y - 8, 6, 8, k === 0 ? 0xe03a3a : k % 2 ? 0x3a6ae0 : 0x2a8a5a)
321          rect(px, W, H, rail - 2, y - 7, 4, 2, k === 0 ? 0xffe060 : 0xa8d8ff)
322        }
323      }
324    },
325  }
326}
327
328// --- runway -----------------------------------------------------------------------
329
330const runway: Maker = (W, H, rng) => {
331  const cx = W / 2
332  let t = 0
333  type P = { y: number; z: number; mode: number; c: number }
334  let planes: P[] = []
335
336  return {
337    step() {
338      t++
339      if (t % 200 === 1) planes.push({ y: -10, z: 10, mode: 0, c: pick(rng, [0xffffff, 0xe0e8f0]) })
340      // one runway, one direction (into the wind): landings and take-offs both head down the
341      // screen, a take-off only once the landing plane has rolled clear
342      if (t % 200 === 150) planes.push({ y: H * 0.15, z: 0, mode: 1, c: pick(rng, [0xffffff, 0xe0e8f0]) })
343      for (const p of planes) {
344        if (p.mode === 0) {
345          p.y += 0.9 - Math.min(0.5, Math.max(0, p.y - H * 0.4) * 0.03)
346          p.z = Math.max(0, 10 - (p.y + 10) * 0.25)
347        } else {
348          p.y += 0.3 + Math.max(0, p.y - H * 0.15) * 0.04
349          p.z = Math.max(0, (p.y - H * 0.4) * 0.2)
350        }
351      }
352      planes = planes.filter(p => p.y > -16 && p.y < H + 6)
353    },
354    draw(px) {
355      px.fill(0x6aa04a)
356      rect(px, W, H, cx - 6, 0, 12, H, 0x4a4a52)
357      for (let y = 0; y < H; y += 6) rect(px, W, H, cx, y, 1, 3, 0xffffff)
358      for (let y = 0; y < H; y += 4) {
359        dot(px, W, H, cx - 7, y, (t >> 2) % 6 === (y >> 2) % 6 ? 0xffffff : 0x3a8aff)
360        dot(px, W, H, cx + 6, y, (t >> 2) % 6 === (y >> 2) % 6 ? 0xffffff : 0x3a8aff)
361      }
362      rect(px, W, H, 1, H * 0.3, 6, 8, 0xb8b8c0)
363      rect(px, W, H, 3, H * 0.3 - 4, 2, 4, 0xd0d0d8)
364      for (const p of planes) {
365        const s = 1 + p.z * 0.06
366        ellipse(px, W, H, cx + p.z * 0.6, p.y + p.z, 2 * s, 1, 0x3a5a3a)
367        const x = cx
368        const y = p.y
369        // nose down the screen: the cockpit at the front, the tailplane at the back
370        rect(px, W, H, x - 0.5, y - 4 * s, 2, 8 * s, p.c)
371        rect(px, W, H, x - 5 * s, y, 10 * s + 1, 2, p.c)
372        rect(px, W, H, x - 2 * s, y - 3 * s - 1, 4 * s + 1, 1, p.c)
373        dot(px, W, H, x, y + 4 * s - 1, 0x3a8aff)
374        // flying south, its left wing (red light) is on the screen's right, the right (green) on its left
375        if (t % 10 < 5) {
376          dot(px, W, H, x + 5 * s + 1, y, 0xff3a3a)
377          dot(px, W, H, x - 5 * s, y, 0x3aff6a)
378        }
379      }
380    },
381  }
382}
383
384// --- car wash tunnel --------------------------------------------------------------
385
386const carwash: Maker = (W, H, rng) => {
387  const floor = Math.round(H * 0.68)
388  let x = -14
389  let t = 0
390  let foam: { x: number; y: number; l: number }[] = []
391  let sparkle: Spark[] = []
392  let c = 0xe03a3a
393
394  return {
395    step() {
396      t++
397      x += 0.2
398      if (x > W + 4) {
399        x = -14
400        c = hsv(rng(), 0.7, 0.9)
401      }
402      foam = foam.filter(f => --f.l > 0)
403      if (x > W * 0.2 && x < W * 0.55) for (let k = 0; k < 2; k++) foam.push({ x: x + rng() * 12, y: floor - 3 - rng() * 6, l: 30 })
404      sparkle = sparks(sparkle, 0)
405      if (x > W * 0.75 && rng() < 0.3) sparkle.push({ x: x + rng() * 12, y: floor - 2 - rng() * 6, vx: 0, vy: 0, l: 6, c: 0xffffff })
406    },
407    draw(px) {
408      sky(px, W, H, 0x8ac8f0, 0xd8f0ff, floor)
409      rect(px, W, H, 0, floor, W, H - floor, 0x6a6a72)
410      slab(px, W, H, W * 0.18, floor - 22, W * 0.62, 22, 0x2a5a8a)
411      rect(px, W, H, W * 0.18, floor - 23, W * 0.62, 2, 0xffd040)
412      rect(px, W, H, W * 0.2, floor - 20, W * 0.58, 20, 0x3a6a9a)
413      for (const bxr of [0.32, 0.45]) {
414        const bx = W * bxr
415        for (let y = floor - 18; y < floor; y++) dot(px, W, H, bx + Math.sin(y + t * 0.6) * 1.5, y, hsv(0.55 + bxr, 0.6, 1))
416        rect(px, W, H, bx - 0.5, floor - 19, 1, 19, 0x9a9aa0)
417      }
418      if (x > W * 0.5) for (let k = 0; k < 6; k++) dot(px, W, H, W * 0.58 + (k % 3), floor - 18 + ((t * 2 + k * 3) % 16), 0x8ad0ff)
419      const clean = span(x, W * 0.45, W * 0.6)
420      const body = mix(mix(c, 0x6a5a3a, 0.6), c, clean)
421      ellipse(px, W, H, x + 6.5, floor, 7, 0.8, 0x4a4a52)
422      slab(px, W, H, x, floor - 5, 12, 3, body)
423      slab(px, W, H, x + 2, floor - 8, 7, 3, body)
424      rect(px, W, H, x + 3, floor - 7, 5, 1, 0xa8d8ff)
425      lit(px, W, H, x + 2.5, floor - 1.5, 1.5, 1.5, 0x3a3a3e)
426      lit(px, W, H, x + 9.5, floor - 1.5, 1.5, 1.5, 0x3a3a3e)
427      if (clean < 1) for (let k = 0; k < 6; k++) dot(px, W, H, x + 1 + ((k * 5) % 11), floor - 4 - (k % 3), 0x5a4a2a)
428      for (const f of foam) disc(px, W, H, f.x, f.y, 1, 0xf8fcff)
429      for (const s of sparkle) dot(px, W, H, s.x, s.y, 0xffffff)
430    },
431  }
432}
433
434// --- tower crane ------------------------------------------------------------------
435
436const crane: Maker = (W, H, rng) => {
437  const ground = H - 4
438  const mast = Math.round(W * 0.2)
439  const jibY = 6
440  const bx = Math.round(W * 0.48)
441  const bw = Math.round(W * 0.4)
442  let floors = 0
443  let t = 0
444  let trolley = mast + 3
445  let hook = jibY + 3
446  let phase = 0
447  let colour = 0xc8b8a0
448
449  return {
450    step() {
451      t++
452      const top = ground - floors * 3
453      if (phase === 0) {
454        trolley += (mast + 4 - trolley) * 0.1
455        hook += (ground - 2 - hook) * 0.08
456        if (hook > ground - 3) phase = 1
457      } else if (phase === 1) {
458        hook += (jibY + 4 - hook) * 0.06
459        if (hook < jibY + 6) phase = 2
460      } else if (phase === 2) {
461        trolley += (bx + bw / 2 - trolley) * 0.06
462        if (Math.abs(trolley - bx - bw / 2) < 0.5) phase = 3
463      } else if (phase === 3) {
464        hook += (top - 3 - hook) * 0.08
465        if (hook > top - 4) {
466          floors++
467          phase = 0
468          if (ground - floors * 3 < jibY + 12) {
469            floors = 0
470            colour = hsv(rng(), 0.2, 0.8)
471          }
472        }
473      }
474    },
475    draw(px) {
476      sky(px, W, H, 0x6ab0f0, 0xd8eef8, ground)
477      rect(px, W, H, 0, ground, W, H - ground, 0x8a7a5a)
478      for (let y = jibY; y < ground; y++) {
479        dot(px, W, H, mast - 1, y, 0xffc020)
480        dot(px, W, H, mast + 1, y, 0xffc020)
481        if (y % 3 === 0) dot(px, W, H, mast, y, 0xd8a010)
482      }
483      rect(px, W, H, mast - 6, jibY, W - mast + 4, 1, 0xffc020)
484      for (let x = mast - 6; x < W - 2; x += 3) dot(px, W, H, x, jibY + 1, 0xd8a010)
485      slab(px, W, H, mast - 7, jibY + 1, 4, 3, 0x7a7a82)
486      slab(px, W, H, mast - 1, jibY - 3, 3, 3, 0x3a6aaa)
487      for (let f = 0; f < floors; f++) {
488        const y = ground - (f + 1) * 3
489        rect(px, W, H, bx, y, bw, 3, colour)
490        rect(px, W, H, bx, y, bw, 1, scale(colour, 0.8))
491        rect(px, W, H, bx, y + 1, 1, 2, mix(colour, 0xffffff, 0.2))
492        rect(px, W, H, bx + bw - 1, y + 1, 1, 2, scale(colour, 0.7))
493        for (let wx = bx + 1; wx < bx + bw - 1; wx += 3) dot(px, W, H, wx, y + 1, f % 2 ? 0x3a5a8a : 0x5a8aba)
494      }
495      // the next slab waits on the ground at the mast's foot until the hook comes down for it
496      if (phase === 0) {
497        rect(px, W, H, mast + 4 - bw / 2, ground - 2, bw, 2, colour)
498        rect(px, W, H, mast + 4 - bw / 2, ground - 2, bw, 1, scale(colour, 0.8))
499      }
500      line(px, W, H, trolley, jibY + 1, trolley, hook, 0x3a3a3a)
501      if (phase >= 1 && phase <= 3) {
502        rect(px, W, H, trolley - bw / 2, hook + 1, bw, 2, colour)
503        rect(px, W, H, trolley - bw / 2, hook + 1, bw, 1, scale(colour, 0.8))
504      } else dot(px, W, H, trolley, hook + 1, 0x3a3a3a)
505      rect(px, W, H, trolley - 1, jibY + 1, 3, 1, 0x3a3a3a)
506      text(px, W, H, W - 1 - 3 * String(floors).length - (String(floors).length - 1), ground - 7 > jibY + 4 ? 9 : 9, String(floors), 0x2a3a6a)
507    },
508  }
509}
510
511// --- city builder -----------------------------------------------------------------
512
513const citybuilder: Maker = (W, H, rng) => {
514  const cs = 3
515  const GW = Math.max(4, Math.floor(W / cs))
516  const GH = Math.max(4, Math.floor(H / cs))
517  let grid = new Uint8Array(GW * GH)
518  let col = new Uint32Array(GW * GH)
519  let t = 0
520  // 0 grass, 1 road, 2 house, 3 shop, 4 tower, 5 park
521
522  function reset() {
523    grid = new Uint8Array(GW * GH)
524    const cx = Math.floor(GW / 2)
525    const cy = Math.floor(GH / 2)
526    grid[cy * GW + cx] = 1
527    t = 0
528  }
529  reset()
530
531  return {
532    step() {
533      t++
534      const roads: number[] = []
535      for (let k = 0; k < grid.length; k++) if (grid[k] === 1) roads.push(k)
536      const r = pick(rng, roads)
537      const x = r % GW
538      const y = Math.floor(r / GW)
539      const d = irand(rng, 4)
540      const [dx, dy] = [
541        [1, 0],
542        [-1, 0],
543        [0, 1],
544        [0, -1],
545      ][d] as [number, number]
546      const nx = x + dx
547      const ny = y + dy
548      if (nx < 0 || ny < 0 || nx >= GW || ny >= GH) return
549      const nk = ny * GW + nx
550      if (grid[nk] === 0) {
551        const ox = Math.floor(GW / 2)
552        const oy = Math.floor(GH / 2)
553        const straight = ((nx - ox) % 4 === 0 && dx === 0) || ((ny - oy) % 4 === 0 && dy === 0)
554        if (straight) grid[nk] = 1
555        else {
556          const dist = Math.hypot(nx - GW / 2, ny - GH / 2)
557          grid[nk] = rng() < 0.08 ? 5 : dist < 4 ? 4 : dist < 7 ? 3 : 2
558          col[nk] = grid[nk] === 4 ? hsv(0.6, 0.15, 0.6 + rng() * 0.3) : grid[nk] === 3 ? hsv(rng(), 0.4, 0.9) : hsv(rng() * 0.1, 0.5, 0.8)
559        }
560      } else if (grid[nk]! >= 2 && grid[nk]! < 4 && rng() < 0.05) {
561        grid[nk]!++
562        col[nk] = grid[nk] === 4 ? hsv(0.6, 0.15, 0.7) : hsv(rng(), 0.4, 0.9)
563      }
564      if (t > 1600 || !grid.includes(0)) reset()
565    },
566    draw(px) {
567      px.fill(0x6ab04a)
568      for (let k = 0; k < GW * GH; k++) {
569        const x = (k % GW) * cs
570        const y = Math.floor(k / GW) * cs
571        const g = grid[k]!
572        if (g === 1) rect(px, W, H, x, y, cs, cs, 0x5a5a62)
573        else if (g === 2) {
574          rect(px, W, H, x, y + 1, cs - 1, cs - 1, col[k]!)
575          rect(px, W, H, x, y, cs - 1, 1, 0xc84a3a)
576        } else if (g === 3) {
577          rect(px, W, H, x, y, cs, cs, col[k]!)
578          dot(px, W, H, x + 1, y + 1, 0xffffff)
579        } else if (g === 4) {
580          rect(px, W, H, x, y, cs, cs, col[k]!)
581          dot(px, W, H, x, y, 0xa8d8ff)
582          dot(px, W, H, x + 2, y + 2, 0x3a3a4a)
583        } else if (g === 5) {
584          rect(px, W, H, x, y, cs, cs, 0x4ac04a)
585          dot(px, W, H, x + 1, y + 1, 0x2a8a2a)
586        }
587      }
588      const pop = grid.filter(g => g >= 2).length * 37
589      text(px, W, H, 1, 1, String(pop), 0xffffff)
590    },
591  }
592}
593
594// --- metro map --------------------------------------------------------------------
595
596const metro: Maker = (W, H, rng) => {
597  type St = { x: number; y: number; shape: number; wait: number[] }
598  type Ln = { stops: number[]; c: number; trains: { u: number; dir: number; load: number[] }[] }
599  let stations: St[] = []
600  let lines: Ln[] = []
601  let t = 0
602  let delivered = 0
603  const COLS = [0xe03a3a, 0x3a8ae0, 0xf0c020, 0x3ac06a]
604
605  function reset() {
606    stations = []
607    lines = []
608    for (let k = 0; k < 3; k++) addStation()
609    lines.push({ stops: [0, 1, 2], c: COLS[0] as number, trains: [{ u: 0, dir: 1, load: [] }] })
610    t = 0
611    delivered = 0
612  }
613  function addStation() {
614    for (let tries = 0; tries < 50; tries++) {
615      const x = 4 + rng() * (W - 8)
616      const y = 8 + rng() * (H - 14)
617      if (stations.every(s => Math.hypot(s.x - x, s.y - y) > 8)) {
618        stations.push({ x, y, shape: irand(rng, 3), wait: [] })
619        return stations.length - 1
620      }
621    }
622    // no room left far from the others: place it anyway
623    stations.push({ x: 4 + rng() * (W - 8), y: 8 + rng() * (H - 14), shape: irand(rng, 3), wait: [] })
624    return stations.length - 1
625  }
626  reset()
627  const pointOn = (l: Ln, u: number): [number, number] => {
628    const n = l.stops.length - 1
629    const i = clamp(Math.floor(u), 0, n - 1)
630    const f = u - i
631    const a = stations[l.stops[i] as number] as St
632    const b = stations[l.stops[i + 1] as number] as St
633    return [a.x + (b.x - a.x) * f, a.y + (b.y - a.y) * f]
634  }
635
636  return {
637    step() {
638      t++
639      if (t % 200 === 0 && stations.length < 12) {
640        const s = addStation()
641        if (s >= 0) {
642          const l = lines.length < 4 && rng() < 0.4 ? undefined : pick(rng, lines)
643          if (l) l.stops.push(s)
644          else {
645            const near = stations.map((q, i) => [i, Math.hypot(q.x - stations[s]!.x, q.y - stations[s]!.y)] as [number, number]).filter(([i]) => i !== s).sort((a, b) => a[1] - b[1])[0]
646            lines.push({ stops: [near ? near[0] : 0, s], c: COLS[lines.length] as number, trains: [{ u: 0, dir: 1, load: [] }] })
647          }
648        }
649      }
650      if (rng() < 0.06) {
651        const s = pick(rng, stations)
652        if (s.wait.length < 6) s.wait.push(irand(rng, 3))
653      }
654      for (const l of lines)
655        for (const tr of l.trains) {
656          tr.u += tr.dir * 0.02
657          const n = l.stops.length - 1
658          if (tr.u >= n) {
659            tr.u = n
660            tr.dir = -1
661          }
662          if (tr.u <= 0) {
663            tr.u = 0
664            tr.dir = 1
665          }
666          const near = Math.abs(tr.u - Math.round(tr.u)) < 0.02
667          if (near) {
668            const st = stations[l.stops[Math.round(tr.u)] as number] as St
669            const before = tr.load.length
670            tr.load = tr.load.filter(p => p !== st.shape)
671            delivered += before - tr.load.length
672            while (st.wait.length && tr.load.length < 6) tr.load.push(st.wait.shift() as number)
673          }
674        }
675      if (t > 2400) reset()
676    },
677    draw(px) {
678      px.fill(0xf4f0e8)
679      for (const l of lines)
680        for (let i = 0; i + 1 < l.stops.length; i++) {
681          const a = stations[l.stops[i] as number] as St
682          const b = stations[l.stops[i + 1] as number] as St
683          line(px, W, H, a.x, a.y, b.x, b.y, l.c)
684          line(px, W, H, a.x + 0.5, a.y + 0.5, b.x + 0.5, b.y + 0.5, l.c)
685        }
686      const shape = (x: number, y: number, s: number, c: number, big: boolean) => {
687        const r = big ? 2 : 1
688        if (s === 0) disc(px, W, H, x, y, r, c)
689        else if (s === 1) rect(px, W, H, x - r, y - r, r * 2 + 1, r * 2 + 1, c)
690        else poly(px, W, H, [[x, y - r - 0.5], [x + r + 0.5, y + r], [x - r - 0.5, y + r]], c)
691      }
692      for (const s of stations) {
693        shape(s.x, s.y, s.shape, 0x2a2a2a, true)
694        shape(s.x, s.y, s.shape, 0xffffff, false)
695        s.wait.forEach((w, i) => dot(px, W, H, s.x + 3 + i, s.y - 2, [0x2a2a2a, 0x5a5a5a, 0x8a8a8a][w] as number))
696      }
697      for (const l of lines)
698        for (const tr of l.trains) {
699          const [x, y] = pointOn(l, tr.u)
700          rect(px, W, H, x - 1.5, y - 1, 4, 3, l.c)
701          tr.load.forEach((_, i) => dot(px, W, H, x - 1 + (i % 3), y + (i < 3 ? 0 : 1) - 0.5, 0xffffff))
702        }
703      text(px, W, H, 1, 1, String(delivered), 0x2a2a2a)
704    },
705  }
706}
707
708// --- drag race --------------------------------------------------------------------
709
710const dragrace: Maker = (W, H, rng) => {
711  let t = 0
712  let cars = [0, 0]
713  let v = [0, 0]
714  let react = [0, 0]
715  let wins = [0, 0]
716  let winner = -1
717  const finish = H * 0.08
718  const start = H - 8
719
720  function reset() {
721    t = 0
722    cars = [start, start]
723    v = [0, 0]
724    react = [irand(rng, 10), irand(rng, 10)]
725    winner = -1
726  }
727  reset()
728
729  return {
730    step() {
731      t++
732      if (t > 260) reset()
733      if (t < 60) return
734      for (let i = 0; i < 2; i++) {
735        if (t - 60 < (react[i] as number)) continue
736        v[i] = Math.min(2.2, (v[i] as number) + 0.03 + (i ? 0.002 : 0) * (react[1]! > react[0]! ? 1 : 0))
737        cars[i] = (cars[i] as number) - (v[i] as number)
738      }
739      if (winner < 0 && (cars[0]! <= finish || cars[1]! <= finish)) {
740        winner = cars[0]! <= cars[1]! ? 0 : 1
741        wins[winner]!++
742      }
743    },
744    draw(px) {
745      px.fill(0x3a3a42)
746      rect(px, W, H, 0, 0, 3, H, 0x8a8a92)
747      rect(px, W, H, W - 3, 0, 3, H, 0x8a8a92)
748      rect(px, W, H, W / 2, 0, 1, H, 0xe0e0e0)
749      for (let x = 3; x < W - 3; x++) dot(px, W, H, x, finish, (x + Math.floor(finish)) % 2 ? 0xffffff : 0x000000)
750      for (let x = 3; x < W - 3; x++) dot(px, W, H, x, start + 4, 0xffffff)
751      for (let y = 0; y < H; y++) if ((y * 7) % 13 === 0) dot(px, W, H, W * 0.25 + (y % 3), y, 0x2a2a2e)
752      const lights = Math.floor(t / 15)
753      for (let k = 0; k < 3; k++) dot(px, W, H, W / 2, start - 14 + k * 2, lights > k && t < 60 ? 0xffb020 : 0x2a2a2a)
754      dot(px, W, H, W / 2, start - 8, t >= 60 ? 0x3aff6a : 0x2a2a2a)
755      const cols = [0xe03a3a, 0x3a8ae0]
756      for (let i = 0; i < 2; i++) {
757        const x = i ? W * 0.75 : W * 0.25
758        const y = cars[i] as number
759        const wheelie = v[i]! > 0 && v[i]! < 0.6 ? 1 : 0
760        rect(px, W, H, x - 1.5, y - 4 - wheelie, 3, 8, cols[i] as number)
761        rect(px, W, H, x - 2.5, y + 2, 5, 2, 0x1a1a1a)
762        rect(px, W, H, x - 1.5, y - 5 - wheelie, 3, 1, 0x1a1a1a)
763        dot(px, W, H, x, y - 1, 0xa8d8ff)
764        if (v[i]! > 0.3) for (let k = 0; k < 3; k++) dot(px, W, H, x - 1 + k, y + 5 + ((t + k) % 3), 0xd8d8d8)
765        if (winner === i && t % 10 < 6) rect(px, W, H, x - 3, finish - 3, 7, 1, 0xffe040)
766      }
767      text(px, W, H, 4, H - 6, String(wins[0] % 10), cols[0] as number)
768      text(px, W, H, W - 7, H - 6, String(wins[1] % 10), cols[1] as number)
769    },
770  }
771}
772
773// --- monster truck ----------------------------------------------------------------
774
775const monstertruck: Maker = (W, H, rng) => {
776  const ground = H - 8
777  const rampX = W * 0.25
778  let x = -12
779  let y = ground
780  let vy = 0
781  let air = false
782  let crushed = new Set<number>()
783  let dust: Spark[] = []
784  let t = 0
785  const cars = [0, 1, 2, 3].map(k => W * 0.48 + k * 5)
786  const carC = cars.map(() => hsv(rng(), 0.6, 0.9))
787
788  return {
789    step() {
790      t++
791      dust = sparks(dust, 0.05)
792      x += 0.55
793      const onRamp = x > rampX - 8 && x < rampX
794      if (!air && onRamp) y = ground - (x - rampX + 8) * 0.6
795      if (!air && x >= rampX && x < rampX + 1) {
796        air = true
797        vy = -1.6
798      }
799      if (air) {
800        vy += 0.08
801        y += vy
802        const top = cars.some((cx, k) => !crushed.has(k) && Math.abs(cx - x - 5) < 4) ? ground - 4 : ground
803        if (y >= top) {
804          y = top
805          vy = -Math.abs(vy) * 0.35
806          if (Math.abs(vy) < 0.3) {
807            air = y < ground
808            vy = 0
809          }
810          cars.forEach((cx, k) => {
811            if (Math.abs(cx - x - 5) < 5 && !crushed.has(k)) {
812              crushed.add(k)
813              burst(dust, rng, cx, ground - 2, 0xc8a070, 6, 0.8)
814            }
815          })
816        }
817      } else if (!onRamp) {
818        const top = cars.some((cx, k) => Math.abs(cx - x - 5) < 4) ? ground - 2 : ground
819        y += (top - y) * 0.3
820      }
821      if (x > W + 14) {
822        x = -12
823        y = ground
824        air = false
825        crushed = new Set()
826      }
827    },
828    draw(px) {
829      sky(px, W, H, 0x2a3a6a, 0x6a5a8a, ground)
830      for (let k = 0; k < 6; k++) dot(px, W, H, k * 6 + 2, 3, (t >> 3) % 2 ? 0xffffa0 : 0xffffff)
831      for (let yy = ground - 14; yy < ground - 8; yy++) for (let xx = 0; xx < W; xx += 2) dot(px, W, H, xx + (yy % 2), yy, hsv(((xx * 7 + yy) % 10) / 10, 0.4, 0.5))
832      rect(px, W, H, 0, ground, W, H - ground, 0x8a6a4a)
833      poly(px, W, H, [[rampX - 8, ground], [rampX, ground - 5], [rampX, ground]], 0x6a5a4a)
834      cars.forEach((cx, k) => {
835        if (crushed.has(k)) rect(px, W, H, cx - 2, ground - 1, 5, 1, carC[k] as number)
836        else {
837          slab(px, W, H, cx - 2, ground - 3, 5, 3, carC[k] as number)
838          dot(px, W, H, cx, ground - 3, 0xa8d8ff)
839        }
840      })
841      const ty = y
842      slab(px, W, H, x, ty - 9, 10, 4, 0x3aa03a)
843      slab(px, W, H, x + 5, ty - 12, 4, 3, 0x3aa03a)
844      dot(px, W, H, x + 7, ty - 11, 0xa8d8ff)
845      lit(px, W, H, x + 2, ty - 3, 3, 3, 0x2e2e32, 0x0e0e10)
846      lit(px, W, H, x + 8, ty - 3, 3, 3, 0x2e2e32, 0x0e0e10)
847      dot(px, W, H, x + 2, ty - 3, 0x8a8a8a)
848      dot(px, W, H, x + 8, ty - 3, 0x8a8a8a)
849      for (const d of dust) dot(px, W, H, d.x, d.y, d.c)
850      text(px, W, H, 1, 1, String(crushed.size), 0xffe040)
851    },
852  }
853}
854
855// --- container port ---------------------------------------------------------------
856
857const port: Maker = (W, H, rng) => {
858  const quay = Math.round(H * 0.5)
859  const sea = quay + 2
860  const COLS = [0xe03a3a, 0x3a8ae0, 0xf0b020, 0x3ab06a, 0xe07a2a]
861  let yard: number[] = []
862  let ship: number[] = []
863  let shipX = -20
864  let gx = W * 0.3
865  let hook = 6
866  let carry = -1
867  let phase = 0
868  const slots = 6
869
870  function reset() {
871    yard = Array.from({ length: 10 }, () => pick(rng, COLS))
872    ship = []
873    shipX = -24
874    phase = 0
875  }
876  reset()
877
878  return {
879    step() {
880      if (phase === 0) {
881        shipX += 0.3
882        if (shipX >= 2) phase = 1
883        return
884      }
885      if (phase === 5) {
886        shipX -= 0.4
887        if (shipX < -26) reset()
888        return
889      }
890      const yardX = W * 0.75
891      const shipSlot = shipX + 3 + (ship.length % slots) * 3.5
892      if (phase === 1) {
893        gx += clamp(yardX - gx, -0.6, 0.6)
894        if (Math.abs(gx - yardX) < 0.5) {
895          hook += 0.6
896          if (hook > quay - 3 - Math.ceil(yard.length / 2) * 3) {
897            carry = yard.pop() ?? -1
898            phase = 2
899          }
900        }
901      } else if (phase === 2) {
902        hook -= 0.6
903        if (hook < 8) phase = 3
904      } else if (phase === 3) {
905        gx += clamp(shipSlot - gx, -0.6, 0.6)
906        if (Math.abs(gx - shipSlot) < 0.5) {
907          hook += 0.6
908          if (hook > sea - 4 - Math.floor(ship.length / slots) * 3) {
909            ship.push(carry)
910            carry = -1
911            phase = yard.length ? 4 : 5
912          }
913        }
914      } else if (phase === 4) {
915        hook -= 0.6
916        if (hook < 8) phase = 1
917      }
918    },
919    draw(px) {
920      sky(px, W, H, 0x8ac8f0, 0xe0f0f8, sea)
921      for (let y = sea; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x + y * 3) % 11 === 0 ? 0x5a9ad0 : 0x2a6aa8
922      rect(px, W, H, W * 0.55, quay, W * 0.45, H - quay, 0x8a8a92)
923      rect(px, W, H, W * 0.55, quay, W * 0.45, 1, 0xb0b0b8)
924      yard.forEach((c, i) => slab(px, W, H, W * 0.62 + (i % 2) * 4, quay - 3 - Math.floor(i / 2) * 3, 4, 3, c))
925      rect(px, W, H, shipX, sea - 3, 24, 4, 0x2a2a3a)
926      rect(px, W, H, shipX, sea + 1, 24, 2, 0xc83a2a)
927      rect(px, W, H, shipX + 20, sea - 8, 4, 5, 0xf0f0f0)
928      rect(px, W, H, shipX, sea - 3, 24, 1, 0x4a4a5c)
929      ship.forEach((c, i) => slab(px, W, H, shipX + 3 + (i % slots) * 3.5 - 1.5, sea - 6 - Math.floor(i / slots) * 3, 3, 3, c))
930      rect(px, W, H, W * 0.15, 4, W * 0.8, 2, 0xf0b020)
931      rect(px, W, H, W * 0.9, 4, 2, quay - 4, 0xf0b020)
932      rect(px, W, H, gx - 2, 5, 4, 2, 0x3a3a3a)
933      line(px, W, H, gx, 7, gx, hook, 0x3a3a3a)
934      if (carry >= 0) slab(px, W, H, gx - 2, hook, 4, 3, carry)
935    },
936  }
937}
938
939// --- drawbridge -------------------------------------------------------------------
940
941const drawbridge: Maker = (W, H, rng) => {
942  const road = Math.round(H * 0.42)
943  const water = road + 3
944  const pivotL = W * 0.2
945  const pivotR = W * 0.8
946  let t = 0
947  let ship = -16
948  let cars: { x: number; v: number }[] = []
949
950  return {
951    step() {
952      t++
953      const cycle = t % 500
954      if (cycle > 100 && cycle < 350) ship += 0.25
955      else if (cycle === 0) ship = -16
956      const open = cycle > 60 && cycle < 380
957      if (!open && rng() < 0.04) cars.push({ x: rng() < 0.5 ? -3 : W + 3, v: 0 })
958      for (const c of cars) {
959        if (c.v === 0) c.v = c.x < 0 ? 0.5 : -0.5
960        const before = c.v > 0 ? c.x < pivotL - 2 : c.x > pivotR + 2
961        if (open && before && Math.abs(c.x - (c.v > 0 ? pivotL - 3 : pivotR + 3)) < 1) continue
962        c.x += c.v
963      }
964      cars = cars.filter(c => c.x > -5 && c.x < W + 5)
965    },
966    draw(px) {
967      sky(px, W, H, 0xffb07a, 0xffe0c0, water)
968      for (let y = water; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x * 2 + y + (t >> 2)) % 9 === 0 ? 0x8ac0e0 : 0x3a7ab0
969      rect(px, W, H, 0, road, pivotL, 2, 0x5a5a62)
970      rect(px, W, H, pivotR, road, W - pivotR, 2, 0x5a5a62)
971      rect(px, W, H, pivotL - 3, road + 2, 3, H, 0x8a7a6a)
972      rect(px, W, H, pivotR, road + 2, 3, H, 0x8a7a6a)
973      const cycle = t % 500
974      const lift = cycle < 60 ? 0 : cycle < 100 ? (cycle - 60) / 40 : cycle < 340 ? 1 : cycle < 380 ? 1 - (cycle - 340) / 40 : 0
975      const a = lift * 1.2
976      const half = (pivotR - pivotL) / 2
977      line(px, W, H, pivotL, road, pivotL + Math.cos(a) * half, road - Math.sin(a) * half, 0x5a5a62)
978      line(px, W, H, pivotL, road + 1, pivotL + Math.cos(a) * half, road + 1 - Math.sin(a) * half, 0x3a3a42)
979      line(px, W, H, pivotR, road, pivotR - Math.cos(a) * half, road - Math.sin(a) * half, 0x5a5a62)
980      line(px, W, H, pivotR, road + 1, pivotR - Math.cos(a) * half, road + 1 - Math.sin(a) * half, 0x3a3a42)
981      slab(px, W, H, pivotL - 4, road - 12, 3, 12, 0x9a8a7a)
982      slab(px, W, H, pivotR + 1, road - 12, 3, 12, 0x9a8a7a)
983      if (ship > -16) {
984        rect(px, W, H, ship, water - 2, 14, 3, 0xf0f0f0)
985        slab(px, W, H, ship + 2, water - 6, 6, 4, 0xe0e0e0)
986        rect(px, W, H, ship + 4, water - 12, 1, 6, 0x5a5a5a)
987        rect(px, W, H, ship, water + 1, 14, 1, 0x2a5a8a)
988      }
989      for (const c of cars) car(px, W, H, c.x, road - 2, true, c.v > 0 ? 0xe03a3a : 0x3a8ae0)
990    },
991  }
992}
993
994// --- night highway ----------------------------------------------------------------
995
996const nighthighway: Maker = (W, H, rng) => {
997  const trails = new Float32Array(W * H * 2)
998  const horizon = Math.round(H * 0.3)
999  type V = { z: number; lane: number; dir: number }
1000  let vs: V[] = []
1001  let t = 0
1002
1003  return {
1004    step() {
1005      t++
1006      for (let i = 0; i < trails.length; i++) trails[i]! *= 0.9
1007      if (rng() < 0.25) {
1008        const dir = rng() < 0.5 ? 1 : -1
1009        vs.push({ z: dir > 0 ? 1 : 0.05, lane: irand(rng, 2), dir })
1010      }
1011      for (const v of vs) v.z *= v.dir > 0 ? 0.985 : 1.012
1012      vs = vs.filter(v => v.z > 0.04 && v.z < 1.05)
1013      for (const v of vs) {
1014        const s = 1 - v.z
1015        const y = horizon + s * (H - horizon)
1016        const side = v.dir > 0 ? 1 : -1
1017        const x = W / 2 + side * (1 + s * W * (0.12 + v.lane * 0.12))
1018        const k = (Math.round(y) * W + Math.round(x)) * 2 + (v.dir > 0 ? 0 : 1)
1019        if (y >= 0 && y < H && x >= 0 && x < W) trails[k] = 1
1020        const k2 = (Math.round(y) * W + Math.round(x + side)) * 2 + (v.dir > 0 ? 0 : 1)
1021        if (x + side >= 0 && x + side < W) trails[k2] = 1
1022      }
1023    },
1024    draw(px) {
1025      sky(px, W, H, 0x05051a, 0x1a1030, horizon)
1026      for (let x = 0; x < W; x++) rect(px, W, H, x, horizon - 4 - ((x * 7) % 5), 1, 6, 0x0a0a18)
1027      for (let y = horizon; y < H; y++) {
1028        const s = (y - horizon) / (H - horizon)
1029        for (let x = 0; x < W; x++) {
1030          const on = Math.abs(x - W / 2) < 1 + s * W * 0.4
1031          px[y * W + x] = on ? 0x16161e : 0x0a0a10
1032        }
1033        if (Math.floor(y * 0.5 + t * 0.5) % 4 < 2) dot(px, W, H, W / 2, y, 0x4a4a3a)
1034      }
1035      for (let k = 0; k < W * H; k++) {
1036        const w = trails[k * 2]!
1037        const r = trails[k * 2 + 1]!
1038        if (w > 0.05) px[k] = mix(px[k] as number, 0xfff4d0, Math.min(1, w))
1039        if (r > 0.05) px[k] = mix(px[k] as number, 0xff3a2a, Math.min(1, r))
1040      }
1041    },
1042  }
1043}
1044
1045// --- rollercoaster ----------------------------------------------------------------
1046
1047const coaster: Maker = (W, H, rng) => {
1048  // a closed track: lift hill, drop, loop, hills, back to the station
1049  const pts: [number, number][] = []
1050  const base = H * 0.82
1051  const N = 400
1052  for (let i = 0; i < N; i++) {
1053    const u = i / N
1054    let x: number
1055    let y: number
1056    if (u < 0.2) {
1057      x = W * 0.08 + u * 5 * W * 0.25
1058      y = base - u * 5 * H * 0.55
1059    } else if (u < 0.35) {
1060      const v = (u - 0.2) / 0.15
1061      x = W * 0.33 + v * W * 0.15
1062      y = base - H * 0.55 + Math.sin(v * Math.PI / 2) * H * 0.55
1063    } else if (u < 0.6) {
1064      const v = (u - 0.35) / 0.25
1065      const r = H * 0.14
1066      x = W * 0.62 + Math.sin(v * Math.PI * 2) * r * 0.9 + v * 2
1067      y = base - r + Math.cos(v * Math.PI * 2) * r
1068    } else if (u < 0.8) {
1069      const v = (u - 0.6) / 0.2
1070      x = W * 0.64 + v * W * 0.28
1071      y = base - Math.sin(v * Math.PI * 2) * H * 0.12 - Math.abs(Math.sin(v * Math.PI)) * H * 0.08
1072    } else {
1073      const v = (u - 0.8) / 0.2
1074      x = W * 0.92 - v * W * 0.84
1075      y = base + 3
1076    }
1077    pts.push([x, y])
1078  }
1079  let s = 0
1080  let v = 0.5
1081  let t = 0
1082  void rng
1083
1084  return {
1085    step() {
1086      t++
1087      const u = (s % N) / N
1088      const i = Math.floor(s) % N
1089      const a = pts[i] as [number, number]
1090      const b = pts[(i + 1) % N] as [number, number]
1091      const dy = b[1] - a[1]
1092      const seg = Math.hypot(b[0] - a[0], dy) || 1
1093      if (u < 0.2 || u > 0.85) v = 0.6
1094      else v = Math.max(0.6, v + (dy / seg) * 0.04)
1095      v *= 0.998
1096      s += v / seg
1097    },
1098    draw(px) {
1099      sky(px, W, H, 0x6ab8f0, 0xffe0b0)
1100      rect(px, W, H, 0, base + 4, W, H - base - 4, 0x5aa04a)
1101      for (let i = 0; i < N; i += 8) {
1102        const [x, y] = pts[i] as [number, number]
1103        if (y < base) line(px, W, H, x, y + 1, x, base + 4, 0xb0b0b8)
1104      }
1105      for (let i = 0; i < N; i++) {
1106        const [x, y] = pts[i] as [number, number]
1107        dot(px, W, H, x, y, 0xe03a3a)
1108        if (i % 2) dot(px, W, H, x, y + 1, 0x8a2a2a)
1109      }
1110      slab(px, W, H, W * 0.06, base, 8, 4, 0x8a5a3a)
1111      for (let k = 0; k < 3; k++) {
1112        const i = Math.floor(s - k * 7 + N * 10) % N
1113        const [x, y] = pts[i] as [number, number]
1114        rect(px, W, H, x - 1, y - 2, 3, 2, hsv(k * 0.3, 0.7, 1))
1115        const hands = (s % N) / N > 0.25 && (s % N) / N < 0.8
1116        dot(px, W, H, x, y - 3 - (hands ? 1 : 0), 0xffd0a0)
1117      }
1118    },
1119  }
1120}
1121
1122// --- building demolition ----------------------------------------------------------
1123
1124const demolition: Maker = (W, H, rng) => {
1125  const ground = H - 5
1126  const floors = Math.max(6, Math.floor((H * 0.65) / 3))
1127  const bx = Math.round(W * 0.25)
1128  const bw = Math.round(W * 0.5)
1129  let t = 0
1130  let dust: { x: number; y: number; r: number; vx: number }[] = []
1131  let flashes: [number, number][] = []
1132
1133  return {
1134    step() {
1135      t++
1136      const fall = t - 90
1137      if (t > 50 && t < 90 && t % 4 === 0) flashes.push([bx + rng() * bw, ground - rng() * floors * 3])
1138      if (t >= 90) flashes = []
1139      if (fall > 0 && fall < 80) for (let k = 0; k < 3; k++) dust.push({ x: bx + rng() * bw, y: ground - 2, r: 1, vx: (rng() - 0.5) * 1.2 })
1140      for (const d of dust) {
1141        d.r += 0.08
1142        d.x += d.vx
1143        d.y -= 0.12
1144        d.vx *= 0.97
1145      }
1146      dust = dust.filter(d => d.r < 9)
1147      if (t > 300) {
1148        t = 0
1149        dust = []
1150      }
1151    },
1152    draw(px) {
1153      sky(px, W, H, 0x8ab8e0, 0xe0ecf4, ground)
1154      rect(px, W, H, 0, ground, W, H - ground, 0x7a6a5a)
1155      rect(px, W, H, 0, ground, W, 1, 0x9a8a78)
1156      // the skyline behind: lit left faces, shaded right faces
1157      for (let k = 0; k < 4; k++) {
1158        const x = k * 9 - 2
1159        const h = 8 + (k % 2) * 4
1160        rect(px, W, H, x, ground - h, 6, h, 0x9aa0b0)
1161        rect(px, W, H, x, ground - h, 1, h, 0xb4bac8)
1162        rect(px, W, H, x + 5, ground - h, 1, h, 0x7a8090)
1163      }
1164      const fall = Math.max(0, t - 90)
1165      const gone = Math.min(floors, fall * fall * 0.004)
1166      const standing = t > 260 ? Math.min(floors, (t - 260) * 0.4) : floors - gone
1167      // the tower: a lit left edge, a shaded right edge, a cornice line per floor, windows with a glint
1168      for (let f = 0; f < Math.floor(standing); f++) {
1169        const y = ground - (f + 1) * 3
1170        const tilt = t > 90 && t < 260 ? (f / floors) * Math.sin(fall * 0.05) * 1.5 : 0
1171        rect(px, W, H, bx + tilt, y, bw, 3, 0xc8b8a0)
1172        rect(px, W, H, bx + tilt, y, bw, 1, 0xd8caa4)
1173        rect(px, W, H, bx + tilt, y, 1, 3, 0xe0d4bc)
1174        rect(px, W, H, bx + tilt + bw - 2, y, 2, 3, 0x9a8a76)
1175        for (let wx = bx + 2; wx < bx + bw - 2; wx += 3) {
1176          dot(px, W, H, wx + tilt, y + 1, 0x3a4a6a)
1177          dot(px, W, H, wx + tilt + 1, y + 1, (f + wx) % 5 === 0 ? 0xc8e0f8 : 0x5a6a8a)
1178        }
1179      }
1180      if (t > 90 && t < 260) slab(px, W, H, bx - 2, ground - 3, bw + 4, 3, 0x9a8a78)
1181      for (const [x, y] of flashes) {
1182        disc(px, W, H, x, y, 2, 0xff9a2a)
1183        disc(px, W, H, x, y, 1, 0xfff0a0)
1184      }
1185      // dust: a shaded body, a lit cap
1186      for (const d of dust) disc(px, W, H, d.x + 0.4, d.y + 0.4, d.r, 0xb8ac98)
1187      for (const d of dust) disc(px, W, H, d.x - d.r * 0.2, d.y - d.r * 0.25, d.r * 0.75, 0xe0d6c4)
1188      if (t < 90) text(px, W, H, 1, 1, String(Math.max(0, 3 - Math.floor(t / 30))), 0xe03a3a)
1189    },
1190  }
1191}
1192
1193// --- a cyberpunk city in the rain -------------------------------------------------
1194
1195const cyber: Maker = (W, H, rng) => {
1196  const street = Math.round(H * 0.78)
1197  const NEON = [0xff2a8a, 0x2ae8ff, 0xb44aff, 0xffd23a]
1198  type B = { x: number; w: number; h: number; c: number; sign?: { y: number; c: number; ph: number } }
1199  const far: B[] = []
1200  const near: B[] = []
hooks/themes/cozy.ts 1508 lines
1import { add, disc, dot, hsv, mix, rect, scale, sky, text } from '../draw'
2import type { Maker } from '../draw'
3import { clamp, ellipse, hash, irand, line, lit, litSprite, pick, ring, shuffle, slab, tint } from '../kit'
4
5// Cozy & lo-fi: slow, steady motion to rest the eyes while Claude thinks.
6
7const span = (t: number, a: number, b: number) => clamp((t - a) / (b - a), 0, 1)
8
9// --- lo-fi study desk -------------------------------------------------------------
10
11const lofi: Maker = (W, H, rng) => {
12  const desk = Math.round(H * 0.66)
13  let t = 0
14  const drops = Array.from({ length: 16 }, () => ({ x: rng(), y: rng() }))
15  const wx0 = 2
16  const wx1 = Math.round(W * 0.55)
17  const wy0 = 4
18  const wy1 = Math.round(H * 0.4)
19
20  return {
21    step() {
22      t++
23      for (const d of drops) {
24        d.y += 0.03
25        if (d.y > 1) {
26          d.y = 0
27          d.x = rng()
28        }
29      }
30    },
31    draw(px) {
32      sky(px, W, H, 0x2a2440, 0x3a2e48, desk)
33      rect(px, W, H, wx0, wy0, wx1 - wx0, wy1 - wy0, 0x101830)
34      for (let k = 0; k < 7; k++) {
35        const bx = wx0 + 1 + k * 3
36        const bh = 4 + ((k * 7) % 9)
37        rect(px, W, H, bx, wy1 - bh, 3, bh, 0x1a2240)
38        for (let y = wy1 - bh + 1; y < wy1; y += 2) if (hash(bx, y, 1) > 0.5) dot(px, W, H, bx + 1, y, 0xffd070)
39      }
40      for (const d of drops) line(px, W, H, wx0 + d.x * (wx1 - wx0), wy0 + d.y * (wy1 - wy0), wx0 + d.x * (wx1 - wx0), wy0 + d.y * (wy1 - wy0) + 1.5, 0x6a7ab0)
41      rect(px, W, H, wx0 - 1, wy0 - 1, wx1 - wx0 + 2, 1, 0x6a4a3a)
42      rect(px, W, H, wx0 - 1, wy1, wx1 - wx0 + 2, 1, 0x6a4a3a)
43      rect(px, W, H, wx0 - 1, wy0, 1, wy1 - wy0, 0x6a4a3a)
44      rect(px, W, H, wx1, wy0, 1, wy1 - wy0, 0x6a4a3a)
45      rect(px, W, H, (wx0 + wx1) / 2, wy0, 1, wy1 - wy0, 0x6a4a3a)
46      // lamp glow
47      for (let y = 0; y < desk; y++) for (let x = 0; x < W; x++) {
48        const d = Math.hypot(x - W * 0.82, y - desk + 4)
49        if (d < 14) px[y * W + x] = mix(px[y * W + x] as number, 0xffc070, (1 - d / 14) * 0.35)
50      }
51      rect(px, W, H, 0, desk, W, H - desk, 0x6a4026)
52      rect(px, W, H, 0, desk, W, 1, 0x8a5a36)
53      line(px, W, H, W * 0.82, desk, W * 0.82, desk - 9, 0x3a3a40)
54      line(px, W, H, W * 0.82, desk - 9, W * 0.74, desk - 12, 0x3a3a40)
55      rect(px, W, H, W * 0.7, desk - 13, 4, 2, 0xe8b050)
56      rect(px, W, H, W * 0.38, desk - 1, 8, 1, 0xf4f0e0)
57      const hand = Math.sin(t * 0.3) * 1.5
58      const sx = Math.round(W * 0.18)
59      litSprite(px, W, H, sx, desk - 13, ['..hhh..', '.hhhhh.', 'phhhssp', 'p.hsss.', '..sss..', '.ccccc.', 'ccccccc', 'ccccccc', 'ccccccc', 'ccccccc', 'ccccccc', 'ccccccc', 'ccccccc'], {
60        h: 0x3a2a24,
61        s: 0xffd0b0,
62        p: 0xd04a6a,
63        c: 0xe8a0b0,
64      })
65      line(px, W, H, sx + 6, desk - 6, W * 0.4 + hand, desk - 2, 0xe8a0b0)
66      dot(px, W, H, W * 0.4 + hand, desk - 2, 0xffd0b0)
67      rect(px, W, H, W * 0.6, desk - 4, 3, 4, 0xf0f0f0)
68      for (let k = 0; k < 3; k++) {
69        const s = ((t * 0.2 + k * 7) % 21) / 21
70        dot(px, W, H, W * 0.6 + 1 + Math.sin(t * 0.05 + k) * 1, desk - 5 - s * 8, mix(0xe8e8f0, 0x3a2e48, s))
71      }
72      litSprite(px, W, H, W - 8, desk - 4, ['o...o.', 'ooooo.', 'ooooot', '.oooot'], { o: 0x5a5a62, t: 0x5a5a62 })
73      dot(px, W, H, W - 2 + Math.sin(t * 0.08), desk - 5, 0x5a5a62)
74    },
75  }
76}
77
78// --- aquarium ---------------------------------------------------------------------
79
80const aquarium: Maker = (W, H, rng) => {
81  const top = 4
82  const gravel = H - 6
83  type F = { x: number; y: number; vx: number; c: number; s: number }
84  const fish: F[] = Array.from({ length: 7 }, () => ({ x: rng() * W, y: top + 6 + rng() * (gravel - top - 12), vx: (rng() < 0.5 ? -1 : 1) * (0.15 + rng() * 0.25), c: pick(rng, [0xff8a2a, 0xff4a6a, 0x4ad0ff, 0xffe040, 0xa06aff, 0x6aff9a]), s: rng() < 0.3 ? 2 : 1 }))
85  let bubbles: { x: number; y: number }[] = []
86  let t = 0
87
88  return {
89    step() {
90      t++
91      for (const f of fish) {
92        f.x += f.vx
93        f.y += Math.sin(t * 0.03 + f.x) * 0.05
94        if (f.x < 2 || f.x > W - 3) f.vx = -f.vx
95        if (rng() < 0.003) f.vx = -f.vx
96      }
97      if (t % 5 === 0) bubbles.push({ x: W * 0.85 + rng(), y: gravel - 2 })
98      for (const b of bubbles) {
99        b.y -= 0.5
100        b.x += Math.sin(b.y * 0.5) * 0.2
101      }
102      bubbles = bubbles.filter(b => b.y > top)
103    },
104    draw(px) {
105      // water, light rays from the surface, the lid and its lamp
106      sky(px, W, H, 0x3aa4dc, 0x0a2e5a)
107      for (let y = top; y < gravel; y++) {
108        const fade = 0.2 * (1 - (y - top) / (gravel - top))
109        for (let x = 0; x < W; x++) if ((((x + y * 0.45 + t * 0.03) % 9) + 9) % 9 < 2) px[y * W + x] = mix(px[y * W + x] as number, 0xb8ecff, fade)
110      }
111      rect(px, W, H, 0, 0, W, top, 0x1a1a22)
112      rect(px, W, H, 0, top - 1, W, 1, 0x3a3a4a)
113      rect(px, W, H, W * 0.25, top - 1, W * 0.5, 1, 0xfff0c0)
114      for (let x = 0; x < W; x++) dot(px, W, H, x, top, Math.sin(x * 0.7 + t * 0.1) > 0.6 ? 0xe0f8ff : 0x7acdf0)
115      // gravel: pebbles two pixels wide, a lit top row, darker deeper down
116      for (let y = gravel; y < H; y++)
117        for (let x = 0; x < W; x++) {
118          const h = hash(Math.floor((x + (y % 2)) / 2), y, 4)
119          const deep = (y - gravel) / (H - gravel)
120          const c = y === gravel ? 0xf0dcb0 : h > 0.66 ? 0xe0c48e : h > 0.33 ? 0xc0a070 : 0x8a7050
121          px[y * W + x] = mix(c, 0x3a2e28, deep * 0.45)
122        }
123      rect(px, W, H, 0, gravel - 1, W, 1, 0x0e3a5e)
124      // a rock, lit from the upper left
125      const rx = W * 0.2
126      ellipse(px, W, H, rx, gravel - 1, 4, 2.6, 0x4a4a5c)
127      ellipse(px, W, H, rx - 0.6, gravel - 1.6, 3, 1.8, 0x707084)
128      ellipse(px, W, H, rx - 1.6, gravel - 2.4, 1.2, 0.7, 0x9a9ab0)
129      // plants: a swaying stem with leaves, lit side and shaded side
130      for (const [i, f] of [0.1, 0.32, 0.72].entries()) {
131        const x0 = W * f
132        const h = H * (0.22 + i * 0.04)
133        for (let y = gravel; y > gravel - h; y--) {
134          const x = x0 + Math.sin(y * 0.4 + t * 0.04 + i) * 1.2
135          dot(px, W, H, x, y, 0x2a8a3a)
136          if ((y + i) % 3 === 0) {
137            const side = (y + i) % 6 === 0 ? -1 : 1
138            dot(px, W, H, x + side, y, side < 0 ? 0x6ad86a : 0x1a6a2a)
139            dot(px, W, H, x + side * 2, y - 1, side < 0 ? 0x8ae87a : 0x2a7a32)
140          }
141        }
142        dot(px, W, H, x0 + Math.sin((gravel - h) * 0.4 + t * 0.04 + i) * 1.2, gravel - h, 0x9af08a)
143      }
144      // a little castle: lit face, mid, shaded face, a dark door
145      const kx = Math.round(W * 0.52)
146      const ky = gravel - 7
147      for (let x = 0; x < 6; x++) {
148        const c = x < 2 ? 0xb4b4c4 : x < 4 ? 0x8a8a9c : 0x5a5a6c
149        rect(px, W, H, kx + x, ky + 1, 1, 6, c)
150        if (x % 2 === 0) dot(px, W, H, kx + x, ky, c)
151      }
152      rect(px, W, H, kx + 2, ky + 4, 2, 3, 0x14243a)
153      dot(px, W, H, kx + 1, ky + 2, 0x14243a)
154      dot(px, W, H, kx + 4, ky + 2, 0x14243a)
155      dot(px, W, H, kx + 2, ky - 1, 0x5a5a6c)
156      dot(px, W, H, kx + 3, ky - 1, 0xff4a4a)
157      // bubbles: some bigger, with a highlight
158      for (const [i, b] of bubbles.entries()) {
159        if (i % 3 === 0) {
160          dot(px, W, H, b.x, b.y, 0x7ac8f0)
161          dot(px, W, H, b.x + 1, b.y, 0x7ac8f0)
162          dot(px, W, H, b.x, b.y - 1, 0xffffff)
163        } else dot(px, W, H, b.x, b.y, 0xc8ecff)
164      }
165      // fish: back darker, belly lighter, a darker tail, an eye; clownfish get a stripe
166      for (const f of fish) {
167        const d = f.vx > 0 ? 1 : -1
168        if (f.s === 2) {
169          ellipse(px, W, H, f.x, f.y, 2.2, 1.4, f.c)
170          rect(px, W, H, f.x - 1, f.y - 1, 3, 1, scale(f.c, 0.75))
171          rect(px, W, H, f.x - 1, f.y + 1, 3, 1, mix(f.c, 0xffffff, 0.4))
172          dot(px, W, H, f.x, f.y - 2, scale(f.c, 0.6))
173          for (const v of [-1, 0, 1]) dot(px, W, H, f.x - d * 3, f.y + v, scale(f.c, v ? 0.6 : 0.8))
174          if (f.c === 0xff8a2a) for (const v of [-1, 0, 1]) dot(px, W, H, f.x - d * 0.5, f.y + v, 0xfff4ec)
175          dot(px, W, H, f.x + d * 1.5, f.y - 0.5, 0x101018)
176        } else {
177          dot(px, W, H, f.x + d, f.y, mix(f.c, 0xffffff, 0.3))
178          dot(px, W, H, f.x, f.y, f.c)
179          dot(px, W, H, f.x, f.y - 1, mix(f.c, 0xffffff, 0.2))
180          dot(px, W, H, f.x - d, f.y, scale(f.c, 0.75))
181          dot(px, W, H, f.x - d * 2, f.y - 1, scale(f.c, 0.6))
182          dot(px, W, H, f.x - d * 2, f.y + 1, scale(f.c, 0.6))
183          dot(px, W, H, f.x + d, f.y - 1, 0x101018)
184        }
185      }
186    },
187  }
188}
189
190// --- windowsill plant -------------------------------------------------------------
191
192const plant: Maker = (W, H, rng) => {
193  const sill = Math.round(H * 0.76)
194  const cx = W / 2
195  let t = 0
196  let leaves: { y: number; side: number; born: number }[] = []
197  let water = 0
198
199  function reset() {
200    leaves = []
201    t = 0
202  }
203  reset()
204
205  return {
206    step() {
207      t++
208      if (t % 45 === 0 && leaves.length < 9) leaves.push({ y: leaves.length, side: leaves.length % 2 ? 1 : -1, born: t })
209      if (t % 200 === 120) water = 40
210      if (water > 0) water--
211      if (t > 520) reset()
212      void rng
213    },
214    draw(px) {
215      const day = (Math.sin(t * 0.012) + 1) / 2
216      sky(px, W, H, mix(0x1a2048, 0x8ad0ff, day), mix(0x3a2a5a, 0xe8f4ff, day), sill)
217      disc(px, W, H, 4 + t * 0.05, sill - 8 - day * 20, 2.5, day > 0.3 ? 0xfff0a0 : 0xe8e8f8)
218      for (let x = 0; x < W; x++) rect(px, W, H, x, sill - 6 - Math.sin(x * 0.3) * 2, 1, 8, mix(0x1a2a3a, 0x6aa86a, day))
219      rect(px, W, H, 0, 0, 2, sill, 0xf0ece4)
220      rect(px, W, H, W - 2, 0, 2, sill, 0xf0ece4)
221      rect(px, W, H, 0, 0, W, 2, 0xf0ece4)
222      rect(px, W, H, W / 2 - 0.5, 0, 1, sill, 0xf0ece4)
223      rect(px, W, H, 0, sill, W, H - sill, 0xe8e0d0)
224      rect(px, W, H, 0, sill, W, 1, 0xffffff)
225      slab(px, W, H, cx - 4, sill - 6, 9, 6, 0xc8603a)
226      slab(px, W, H, cx - 5, sill - 7, 11, 2, 0xd8704a)
227      const h = Math.min(leaves.length * 3 + span(t % 45, 0, 45) * 3, 30)
228      line(px, W, H, cx, sill - 7, cx + Math.sin(t * 0.02) * 0.6, sill - 7 - h, 0x3a8a2a)
229      for (const l of leaves) {
230        const g = span(t, l.born, l.born + 30)
231        const ly = sill - 9 - l.y * 3
232        const len = 1 + g * 3.5
233        line(px, W, H, cx, ly, cx + l.side * len, ly - len * 0.5, 0x4ab03a)
234        line(px, W, H, cx, ly + 1, cx + l.side * len * 0.8, ly - len * 0.3 + 1, 0x3a9a2a)
235      }
236      if (water > 0) {
237        const wx = cx + 8
238        rect(px, W, H, wx, sill - 18, 6, 4, 0x6a9ad0)
239        line(px, W, H, wx, sill - 17, wx - 4, sill - 19, 0x6a9ad0)
240        for (let k = 0; k < 3; k++) dot(px, W, H, wx - 4, sill - 18 + ((water + k * 3) % 9), 0x8ac8ff)
241      }
242    },
243  }
244}
245
246// --- café window ------------------------------------------------------------------
247
248const cafe: Maker = (W, H, rng) => {
249  const street = Math.round(H * 0.5)
250  const table = Math.round(H * 0.74)
251  type P = { x: number; v: number; c: number; umb: boolean; h: number }
252  let ps: P[] = []
253  let t = 0
254  let rain = false
255
256  return {
257    step() {
258      t++
259      if (t % 400 === 0) rain = !rain
260      if (rng() < 0.02) {
261        const v = (rng() < 0.5 ? 1 : -1) * (0.15 + rng() * 0.2)
262        ps.push({ x: v > 0 ? -4 : W + 4, v, c: hsv(rng(), 0.5, 0.7), umb: rain && rng() < 0.8, h: 7 + irand(rng, 3) })
263      }
264      for (const p of ps) p.x += p.v
265      ps = ps.filter(p => p.x > -6 && p.x < W + 6)
266    },
267    draw(px) {
268      sky(px, W, H, rain ? 0x6a7a8a : 0x9ad0f0, rain ? 0xa0a8b0 : 0xe0f0f8, street)
269      for (let x = 0; x < W; x += 9) rect(px, W, H, x, street - 18, 8, 18, rain ? 0x7a6a6a : 0xc88a6a)
270      rect(px, W, H, 0, street, W, 4, 0x8a8a92)
271      for (const p of ps) {
272        const y = street - 1
273        rect(px, W, H, p.x, y - p.h + 3, 2, p.h - 3, p.c)
274        dot(px, W, H, p.x, y - p.h + 2, 0xffd0a0)
275        if (Math.floor(t * 0.2 + p.x) % 2) dot(px, W, H, p.x + 1, y, 0x2a2a2a)
276        else dot(px, W, H, p.x, y, 0x2a2a2a)
277        if (p.umb) {
278          rect(px, W, H, p.x - 2, y - p.h, 6, 1, scale(p.c, 0.8))
279          line(px, W, H, p.x + 1, y - p.h, p.x + 1, y - p.h + 3, 0x3a3a3a)
280        }
281      }
282      if (rain) for (let k = 0; k < 20; k++) dot(px, W, H, (k * 7 + t) % W, (k * 13 + t * 2) % street, 0xc8d8e8)
283      for (let y = street + 4; y < table; y++) for (let x = 0; x < W; x++) px[y * W + x] = mix(0x5a3a28, 0x3a2418, (y - street) / (table - street))
284      rect(px, W, H, 0, street + 4, W, 1, 0x8a6a4a)
285      if (rain) for (let k = 0; k < 6; k++) line(px, W, H, (k * 6 + 2) % W, (k * 5 + (t >> 2)) % street, (k * 6 + 2) % W, ((k * 5 + (t >> 2)) % street) + 3, 0xd0e0f0)
286      rect(px, W, H, 0, table, W, H - table, 0xa86a3a)
287      rect(px, W, H, 0, table, W, 1, 0xc88a52)
288      const cx = W * 0.6
289      slab(px, W, H, cx - 3, table - 5, 6, 5, 0xf8f4ec)
290      rect(px, W, H, cx + 3, table - 4, 2, 2, 0xf8f4ec)
291      ellipse(px, W, H, cx, table, 5, 1, 0xf0ece4)
292      rect(px, W, H, cx - 2, table - 5, 4, 1, 0x8a5a2a)
293      for (let k = 0; k < 3; k++) {
294        const s = ((t * 0.25 + k * 8) % 24) / 24
295        dot(px, W, H, cx - 1 + k + Math.sin(t * 0.05 + k * 2) * 1.2, table - 6 - s * 10, mix(0xf0f0f0, 0x3a2418, s))
296      }
297      litSprite(px, W, H, W * 0.15, table - 3, ['.oo.', 'oooo', 'oooo'], { o: 0xd8a860 })
298    },
299  }
300}
301
302// --- train window -----------------------------------------------------------------
303
304const trainwindow: Maker = (W, H, rng) => {
305  const fx0 = 2
306  const fy0 = Math.round(H * 0.12)
307  const fx1 = W - 3
308  const fy1 = Math.round(H * 0.62)
309  let t = 0
310  let scroll = 0
311  const hue = rng()
312
313  return {
314    step() {
315      t++
316      scroll += 1
317    },
318    draw(px) {
319      const time = (Math.sin(t * 0.004) + 1) / 2
320      sky(px, W, H, mix(0xff9a6a, 0x6ab0f0, time), mix(0xffd8a0, 0xd8f0ff, time), fy1)
321      disc(px, W, H, W * 0.7, fy0 + 8 + (1 - time) * 10, 3, mix(0xff6a3a, 0xfff4c0, time))
322      for (let x = 0; x < W; x++) {
323        const m = fy1 - 14 - Math.abs(Math.sin((x + scroll * 0.1) * 0.08 + hue)) * 10
324        rect(px, W, H, x, m, 1, fy1 - m, mix(0x8a7aa8, 0x7a9ac0, time))
325        const hll = fy1 - 6 - Math.sin((x + scroll * 0.4) * 0.15) * 3
326        rect(px, W, H, x, hll, 1, fy1 - hll, mix(0x4a6a3a, 0x5a9a4a, time))
327      }
328      for (let k = 0; k < 3; k++) {
329        const x = ((k * 23 - scroll * 0.4) % (W + 20) + W + 20) % (W + 20) - 10
330        litSprite(px, W, H, x, fy1 - 9, ['.rr.', 'rrrr', 'wwww', 'wdww'], { r: 0xc84a3a, w: 0xf0e8d8, d: 0x6a4a2a })
331      }
332      for (let k = 0; k < 4; k++) {
333        const x = ((k * 17 - scroll * 1.6) % (W + 30) + W + 30) % (W + 30) - 10
334        lit(px, W, H, x, fy1 - 6, 3, 3, 0x2a5a2a)
335        rect(px, W, H, x, fy1 - 3, 1, 4, 0x3a2a1a)
336      }
337      const pole = ((-scroll * 3) % 60 + 60) % 60 - 10
338      rect(px, W, H, pole, fy0, 1, fy1 - fy0, 0x3a3a3a)
339      line(px, W, H, 0, fy0 + 3, W, fy0 + 4, 0x2a2a2a)
340      rect(px, W, H, 0, 0, W, fy0, 0x8a2a2a)
341      rect(px, W, H, 0, fy1, W, H - fy1, 0x8a2a2a)
342      rect(px, W, H, 0, fy0, fx0, fy1 - fy0, 0x8a2a2a)
343      rect(px, W, H, fx1, fy0, W - fx1, fy1 - fy0, 0x8a2a2a)
344      rect(px, W, H, fx0 - 1, fy0 - 1, fx1 - fx0 + 2, 1, 0xc8a050)
345      rect(px, W, H, fx0 - 1, fy1, fx1 - fx0 + 2, 1, 0xc8a050)
346      rect(px, W, H, 0, fy1 + 4, W, 3, 0x5a6a9a)
347      rect(px, W, H, 0, fy1 + 7, W, H - fy1 - 7, 0x3a4a7a)
348      for (let x = 2; x < W; x += 4) dot(px, W, H, x, fy1 + 9, 0x4a5a8a)
349      rect(px, W, H, W * 0.6, fy1 + 1, 4, 3, 0xf4f0e0)
350      dot(px, W, H, W * 0.6 + 1, fy1 + 1, 0x8a5a2a)
351    },
352  }
353}
354
355// --- bookshelf tidying ------------------------------------------------------------
356
357const bookshelf: Maker = (W, H, rng) => {
358  const shelves = 3
359  const gap = Math.floor((H - 8) / shelves)
360  const per = Math.max(3, Math.floor((W - 4) / 3))
361  type B = { hue: number; h: number; w: number }
362  let rows: B[][] = []
363  let row = 0
364  let i = 0
365  let j = 0
366  let swap: { a: number; b: number; t: number } | undefined
367  let hold = 0
368
369  function reset() {
370    rows = Array.from({ length: shelves }, () => shuffle(rng, Array.from({ length: per }, (_, k) => ({ hue: k / per, h: gap - 3 - irand(rng, 4), w: 2 + (rng() < 0.3 ? 1 : 0) }))))
371    row = 0
372    i = 1
373    j = 1
374  }
375  reset()
376
377  return {
378    step() {
379      if (hold > 0) {
380        if (--hold === 0) reset()
381        return
382      }
383      if (swap) {
384        if (++swap.t >= 6) {
385          const r = rows[row] as B[]
386          ;[r[swap.a], r[swap.b]] = [r[swap.b] as B, r[swap.a] as B]
387          swap = undefined
388        }
389        return
390      }
391      // one insertion-sort step at a time, by colour
392      const r = rows[row] as B[]
393      if (i >= r.length) {
394        row++
395        i = 1
396        j = 1
397        if (row >= shelves) hold = 90
398        return
399      }
400      if (j > 0 && (r[j - 1] as B).hue > (r[j] as B).hue) {
401        swap = { a: j - 1, b: j, t: 0 }
402        j--
403      } else {
404        i++
405        j = i
406      }
407    },
408    draw(px) {
409      px.fill(0x5a3a26)
410      for (let y = 0; y < H; y += 4) rect(px, W, H, 0, y, W, 1, 0x52341f)
411      rows.forEach((r, s) => {
412        const base = 4 + (s + 1) * gap
413        slab(px, W, H, 0, base, W, 2, 0x8a5a36)
414        let x = 2
415        r.forEach((b, k) => {
416          let dx = 0
417          if (swap && s === row) {
418            const f = swap.t / 6
419            if (k === swap.a) dx = 3 * f
420            if (k === swap.b) dx = -3 * f
421          }
422          const c = hsv(b.hue * 0.85, 0.6, 0.85)
423          rect(px, W, H, x + dx, base - b.h, b.w, b.h, c)
424          rect(px, W, H, x + dx, base - b.h + 2, b.w, 1, mix(c, 0xffffff, 0.5))
425          dot(px, W, H, x + dx, base - b.h, mix(c, 0xffffff, 0.3))
426          x += 3
427        })
428      })
429    },
430  }
431}
432
433// --- night city windows -----------------------------------------------------------
434
435const citywindows: Maker = (W, H, rng) => {
436  type B = { x: number; w: number; h: number; lit: Uint8Array; cols: number; rows: number }
437  const bs: B[] = []
438  let x = 0
439  while (x < W) {
440    const w = 7 + irand(rng, 6)
441    const h = Math.round(H * (0.4 + rng() * 0.45))
442    const cols = Math.floor((w - 1) / 2)
443    const rows = Math.floor((h - 3) / 3)
444    const lit = new Uint8Array(cols * rows).map(() => (rng() < 0.4 ? 1 + irand(rng, 3) : 0))
445    bs.push({ x, w, h, lit, cols, rows })
446    x += w + 1
447  }
448  let t = 0
449  let cars: { x: number; v: number }[] = []
450
451  return {
452    step() {
453      t++
454      for (const b of bs) if (rng() < 0.15) {
455        const k = irand(rng, b.lit.length)
456        b.lit[k] = b.lit[k] ? 0 : 1 + irand(rng, 3)
457      }
458      if (rng() < 0.03) cars.push({ x: rng() < 0.5 ? -3 : W + 3, v: rng() < 0.5 ? 0.6 : -0.6 })
459      for (const c of cars) c.x += c.v
460      cars = cars.filter(c => c.x > -5 && c.x < W + 5)
461      if (cars[0] && cars[0].v === 0) cars = []
462    },
463    draw(px) {
464      sky(px, W, H, 0x050820, 0x2a1a4a)
465      disc(px, W, H, W * 0.2, 8, 3, 0xf0f0e0)
466      disc(px, W, H, W * 0.2 + 1.5, 7, 2.5, 0x0a0e28)
467      for (let k = 0; k < 12; k++) dot(px, W, H, (k * 37) % W, (k * 11) % 20, 0xa0a0c0)
468      const ground = H - 3
469      for (const b of bs) {
470        rect(px, W, H, b.x, ground - b.h, b.w, b.h, 0x14142a)
471        for (let r = 0; r < b.rows; r++)
472          for (let c = 0; c < b.cols; c++) {
473            const v = b.lit[r * b.cols + c]!
474            const col = v === 1 ? 0xffd870 : v === 2 ? 0xffb050 : v === 3 ? 0xa0c8ff : 0x22223a
475            dot(px, W, H, b.x + 1 + c * 2, ground - b.h + 2 + r * 3, col)
476            dot(px, W, H, b.x + 1 + c * 2, ground - b.h + 3 + r * 3, v ? scale(col, 0.8) : 0x1a1a30)
477          }
478      }
479      rect(px, W, H, 0, ground, W, 3, 0x1a1a24)
480      for (const c of cars) {
481        dot(px, W, H, c.x, ground + 1, c.v > 0 ? 0xffffe0 : 0xff3a3a)
482        dot(px, W, H, c.x - Math.sign(c.v), ground + 1, c.v > 0 ? 0xff3a3a : 0xffffe0)
483      }
484    },
485  }
486}
487
488// --- knitting ---------------------------------------------------------------------
489
490const knitting: Maker = (W, H, rng) => {
491  const sw = Math.round(W * 0.5)
492  const sx = Math.round((W - sw) / 2)
493  let rowsDone = 0
494  let t = 0
495  let stripes: number[] = []
496
497  function reset() {
498    rowsDone = 0
499    const a = rng()
500    stripes = [hsv(a, 0.55, 0.9), hsv(a + 0.5, 0.45, 0.95), 0xf4f0e8]
501  }
502  reset()
503
504  return {
505    step() {
506      t++
507      if (t % 8 === 0) rowsDone++
508      if (6 + rowsDone * 2 > H + 10) reset()
509    },
510    draw(px) {
511      sky(px, W, H, 0xf0e4d4, 0xd8c4a8)
512      const shown = Math.min(rowsDone, Math.floor((H - 8) / 2))
513      const offset = Math.max(0, rowsDone - shown)
514      for (let r = 0; r < shown; r++) {
515        const y = 6 + (shown - 1 - r) * 2
516        const c = stripes[Math.floor((r + offset) / 6) % stripes.length] as number
517        for (let x = 0; x < sw; x += 2) {
518          dot(px, W, H, sx + x, y, c)
519          dot(px, W, H, sx + x + 1, y, c)
520          dot(px, W, H, sx + x, y + 1, scale(c, 0.85))
521          dot(px, W, H, sx + x + 1, y + 1, scale(c, 0.85))
522        }
523      }
524      const prog = (t % 8) / 8
525      line(px, W, H, sx - 4, 5, sx + sw + 3, 4, 0xb8885a)
526      line(px, W, H, sx - 3 + prog * sw, 2, sx + 3 + prog * sw, 8, 0xc8985a)
527      const ball = 6 - Math.min(4, rowsDone * 0.03)
528      lit(px, W, H, W - 6, H - 8, ball, ball, stripes[0] as number)
529      line(px, W, H, W - 6 - ball * 0.5, H - 8, W - 6 + ball * 0.5, H - 8 - ball * 0.3, scale(stripes[0] as number, 0.8))
530      line(px, W, H, W - 6, H - 8 - ball, sx + sw, 6, stripes[0] as number)
531    },
532  }
533}
534
535// --- hot air balloons -------------------------------------------------------------
536
537const balloons: Maker = (W, H, rng) => {
538  type B = { x: number; y: number; s: number; c1: number; c2: number; v: number; ph: number }
539  const bs: B[] = Array.from({ length: 5 }, () => ({ x: rng() * W, y: H * 0.2 + rng() * H * 0.6, s: 0.6 + rng() * 0.8, c1: hsv(rng(), 0.7, 0.95), c2: hsv(rng(), 0.6, 1), v: 0.05 + rng() * 0.1, ph: rng() * 6 }))
540  bs.sort((a, b) => a.s - b.s)
541  let t = 0
542
543  return {
544    step() {
545      t++
546      for (const b of bs) {
547        b.y -= b.v * b.s
548        b.x += 0.05 * b.s
549        if (b.y < -10) {
550          b.y = H + 4
551          b.x = rng() * W
552        }
553      }
554    },
555    draw(px) {
556      sky(px, W, H, 0x8aa8e0, 0xffd0a8)
557      disc(px, W, H, W * 0.3, H * 0.8, 6, 0xfff0c0)
558      for (let x = 0; x < W; x++) {
559        const h1 = H * 0.78 + Math.sin(x * 0.15) * 3
560        rect(px, W, H, x, h1, 1, H - h1, 0x9aa080)
561        const h2 = H * 0.88 + Math.sin(x * 0.25 + 2) * 2
562        rect(px, W, H, x, h2, 1, H - h2, 0x6a8a5a)
563      }
564      for (const b of bs) {
565        const r = 3.5 * b.s + 1
566        for (let y = -r; y <= r * 1.2; y++) {
567          const k = y / r
568          const hw = y < 0 ? r * Math.sqrt(1 - k * k) : r * (1 - (y / (r * 1.2)) * 0.6)
569          for (let x = -hw; x <= hw; x++) {
570            const stripe = Math.floor(((x / hw) + 1) * 2.5) % 2
571            dot(px, W, H, b.x + x, b.y + y, stripe ? b.c1 : b.c2)
572          }
573        }
574        const by = b.y + r * 1.2
575        line(px, W, H, b.x - r * 0.4, by, b.x - r * 0.25, by + 2 * b.s, 0x5a4a3a)
576        line(px, W, H, b.x + r * 0.4, by, b.x + r * 0.25, by + 2 * b.s, 0x5a4a3a)
577        rect(px, W, H, b.x - r * 0.3, by + 2 * b.s, r * 0.6 + 1, 1.5 * b.s, 0x8a5a2a)
578        if (Math.sin(t * 0.1 + b.ph) > 0.7) dot(px, W, H, b.x, by + 0.5, 0xffa020)
579      }
580    },
581  }
582}
583
584// --- lantern festival -------------------------------------------------------------
585
586const lanterns: Maker = (W, H, rng) => {
587  const water = Math.round(H * 0.72)
588  type L = { x: number; y: number; v: number; ph: number }
589  let ls: L[] = Array.from({ length: 16 }, () => ({ x: rng() * W, y: rng() * water, v: 0.1 + rng() * 0.15, ph: rng() * 6 }))
590  let t = 0
591
592  return {
593    step() {
594      t++
595      for (const l of ls) {
596        l.y -= l.v
597        l.x += Math.sin(t * 0.02 + l.ph) * 0.05
598        if (l.y < -4) {
599          l.y = water - 2
600          l.x = rng() * W
601        }
602      }
603    },
604    draw(px) {
605      sky(px, W, H, 0x0a0a2a, 0x2a1a4a, water)
606      for (let k = 0; k < 14; k++) dot(px, W, H, (k * 29) % W, (k * 13) % (water / 2), 0x8a8ab0)
607      for (let x = 0; x < W; x++) rect(px, W, H, x, water - 3 - Math.abs(Math.sin(x * 0.2)) * 3, 1, 4, 0x0a0a18)
608      for (let y = water; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = mix(0x14143a, 0x0a0a20, (y - water) / (H - water))
609      for (const l of ls) {
610        const s = 1 + (1 - l.y / water) * 0
611        const flick = Math.sin(t * 0.3 + l.ph) > 0 ? 0xffc060 : 0xffa040
612        rect(px, W, H, l.x - 1, l.y - 1, 2 * s + 1, 3, flick)
613        dot(px, W, H, l.x, l.y + 1, 0xfff0a0)
614        const ry = water + (water - l.y) * 0.25
615        if (ry < H) dot(px, W, H, l.x + Math.sin(t * 0.1 + l.y) * 0.5, ry, mix(flick, 0x14143a, 0.5))
616      }
617    },
618  }
619}
620
621// --- fishing dock -----------------------------------------------------------------
622
623const fishing: Maker = (W, H, rng) => {
624  const water = Math.round(H * 0.6)
625  const dock = water - 2
626  let t = 0
627  let phase = 0
628  let caught = 0
629  let fishC = 0xc0c8d0
630  let fx = 0
631  let fy = 0
632
633  return {
634    step() {
635      t++
636      if (phase === 0 && t > 150 + irand(rng, 2) && rng() < 0.01) {
637        phase = 1
638        t = 0
639      } else if (phase === 1 && t > 30) {
640        phase = 2
641        t = 0
642        fishC = pick(rng, [0xc0c8d0, 0xf0a040, 0x6ab0e0, 0xe06a6a])
643      } else if (phase === 2 && t > 50) {
644        phase = 0
645        t = 0
646        caught++
647      }
648    },
649    draw(px) {
650      sky(px, W, H, 0xff8a5a, 0xffd0a0, water)
651      disc(px, W, H, W * 0.75, water - 2, 5, 0xffe080)
652      for (let y = water; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x + y * 2 + (t >> 3)) % 9 === 0 ? 0xffb080 : mix(0x3a5a8a, 0x1a2a4a, (y - water) / (H - water))
653      for (let y = water; y < H; y++) if ((y - water) % 3 === 0) dot(px, W, H, W * 0.75 + ((y * 3 + t) % 5) - 2, y, 0xffe080)
654      slab(px, W, H, 0, dock, W * 0.4, 2, 0x8a5a36)
655      for (let x = 2; x < W * 0.4; x += 5) rect(px, W, H, x, dock + 2, 1, H - dock, 0x5a3a24)
656      litSprite(px, W, H, W * 0.22, dock - 7, ['.hh', 'hss', '.cc', 'ccc', 'cbb', 'b.b'], { h: 0x6a5a2a, s: 0xffd0a0, c: 0x3a6a8a, b: 0x2a2a3a })
657      const tipX = W * 0.62
658      const tipY = dock - 16 - (phase === 2 ? 4 : 0)
659      line(px, W, H, W * 0.22 + 2, dock - 4, tipX, tipY, 0x6a4a2a)
660      const floatX = W * 0.78
661      const bob = phase === 1 ? (t % 6 < 3 ? 2 : 0) : Math.sin(t * 0.1) * 0.5
662      if (phase < 2) {
663        line(px, W, H, tipX, tipY, floatX, water - 1 + bob, 0xe0e0e0)
664        dot(px, W, H, floatX, water - 1 + bob, 0xff3a3a)
665        dot(px, W, H, floatX, water + bob, 0xffffff)
666      } else {
667        const u = span(t, 0, 30)
668        fx = floatX + (tipX - floatX) * u
669        fy = water - Math.sin(u * Math.PI) * 10 + (tipY + 4 - water) * u
670        line(px, W, H, tipX, tipY, fx, fy - 1, 0xe0e0e0)
671        litSprite(px, W, H, fx - 1, fy, ['.x.', 'xxx', 'xxx', '.x.', 'x.x'], { x: fishC })
672      }
673      for (let i = 0; i < Math.min(caught, 6); i++) dot(px, W, H, 2 + i, dock - 1, 0xc0c8d0)
674      slab(px, W, H, 1, dock - 3, 8, 3, 0x4a6a8a)
675      text(px, W, H, 1, 1, String(caught), 0xffffff)
676    },
677  }
678}
679
680// --- farm crops -------------------------------------------------------------------
681
682const farm: Maker = (W, H, rng) => {
683  const cs = 4
684  const cols = Math.max(3, Math.floor((W - 4) / cs))
685  const rows = Math.max(3, Math.floor((H - 16) / cs))
686  const ox = Math.floor((W - cols * cs) / 2)
687  const oy = 10
688  let stage = new Uint8Array(cols * rows)
689  let kind = new Uint8Array(cols * rows)
690  let fx = 0
691  let fy = 0
692  let t = 0
693  let todo = 0
694  // 0 grass, 1 tilled, 2 seed, 3 sprout, 4 grown
695
696  const CROPS = [
697    { c: 0xe8c040, top: 0xf8e080 },
698    { c: 0xf08a2a, top: 0x4ab03a },
699    { c: 0xe8702a, top: 0xf0a040 },
700  ]
701
702  return {
703    step() {
704      t++
705      if (t % 12 === 0) for (let k = 0; k < stage.length; k++) if ((stage[k] === 2 || stage[k] === 3) && rng() < 0.3) stage[k]!++
706      if (t % 2) return
707      const k = todo % stage.length
708      const tx = k % cols
709      const ty = Math.floor(k / cols)
710      fx += clamp(tx - fx, -1, 1)
711      fy += clamp(ty - fy, -1, 1)
712      if (fx === tx && fy === ty) {
713        const s = stage[k]!
714        if (s === 0) stage[k] = 1
715        else if (s === 1) {
716          stage[k] = 2
717          kind[k] = Math.floor(ty / 2) % 3
718        } else if (s === 4) stage[k] = 0
719        else if (s === 2 || s === 3) {
720          todo++
721          return
722        }
723        todo++
724      }
725    },
726    draw(px) {
727      sky(px, W, H, 0x8ad0ff, 0xd8f0ff, 8)
728      rect(px, W, H, 0, 8, W, H - 8, 0x6ac04a)
729      for (let y = 8; y < H; y++) for (let x = 0; x < W; x++) if (hash(x, y, 3) > 0.9) dot(px, W, H, x, y, 0x5aa83a)
730      litSprite(px, W, H, W - 9, 0, ['..rrr..', '.rrrrr.', 'rrrrrrr', '.wwdww.', '.wwdww.'], { r: 0xc84a3a, w: 0xf0e0c0, d: 0x6a4a2a })
731      for (let k = 0; k < cols * rows; k++) {
732        const x = ox + (k % cols) * cs
733        const y = oy + Math.floor(k / cols) * cs
734        const s = stage[k]!
735        if (s === 0) continue
736        slab(px, W, H, x, y, cs - 1, cs - 1, 0x8a5a36)
737        dot(px, W, H, x, y + 1, 0x6a4026)
738        const cr = CROPS[kind[k]!] as { c: number; top: number }
739        if (s === 2) dot(px, W, H, x + 1, y + 1, 0xd8c890)
740        if (s === 3) {
741          dot(px, W, H, x + 1, y + 1, 0x5ad03a)
742          dot(px, W, H, x + 1, y, 0x5ad03a)
743        }
744        if (s === 4) {
745          rect(px, W, H, x, y + 1, 3, 2, cr.c)
746          dot(px, W, H, x + 1, y, cr.top)
747        }
748      }
749      const x = ox + fx * cs
750      const y = oy + fy * cs - 3
751      litSprite(px, W, H, x, y, ['.h.', 'hhh', '.s.', 'bbb', 'b.b'], { h: 0xe8c860, s: 0xffd0a0, b: 0x3a6aaa })
752    },
753  }
754}
755
756// --- campfire marshmallows --------------------------------------------------------
757
758const marshmallow: Maker = (W, H, rng) => {
759  const fire = Math.round(H * 0.82)
760  let t = 0
761  let flames = Array.from({ length: 24 }, () => ({ x: 0, y: 0, l: 0 }))
762
763  return {
764    step() {
765      t++
766      for (const f of flames) {
767        if (f.l <= 0) {
768          f.x = W / 2 + (rng() - 0.5) * 10
769          f.y = fire
770          f.l = 8 + irand(rng, 10)
771        }
772        f.y -= 0.6
773        f.x += (rng() - 0.5) * 0.4
774        f.l--
775      }
776      if (t > 300) t = 0
777    },
778    draw(px) {
779      sky(px, W, H, 0x05050f, 0x1a0e14)
780      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
781        const d = Math.hypot(x - W / 2, y - fire)
782        if (d < 22) px[y * W + x] = mix(px[y * W + x] as number, 0xff8a2a, (1 - d / 22) * 0.35)
783      }
784      for (const f of flames) {
785        const u = f.l / 18
786        dot(px, W, H, f.x, f.y, u > 0.6 ? 0xffe080 : u > 0.3 ? 0xff9a2a : 0xd03a1a)
787      }
788      slab(px, W, H, W / 2 - 7, fire, 14, 2, 0x5a3a20)
789      line(px, W, H, W / 2 - 6, fire + 2, W / 2 + 6, fire - 1, 0x6a4428)
790      // the marshmallow: golden, then on fire, blown out, black
791      const toast = span(t, 0, 140)
792      const burn = t > 150 && t < 200
793      const charred = t >= 200
794      const mx = W / 2 + 3
795      const my = fire - 14
796      line(px, W, H, W - 1, my - 10, mx + 2, my, 0xb8885a)
797      const c = charred ? 0x2a2420 : mix(0xfaf8f0, 0xd89040, toast)
798      rect(px, W, H, mx - 2, my - 2, 4, 4, c)
799      dot(px, W, H, mx - 2, my - 2, charred ? 0x3a3430 : mix(0xffffff, 0xe8a850, toast))
800      if (burn) for (let k = 0; k < 6; k++) dot(px, W, H, mx - 2 + ((k * 3 + t) % 5), my - 3 - ((t + k * 2) % 4), k % 2 ? 0xffd040 : 0xff6a1a)
801      if (t > 198 && t < 230) for (let k = 0; k < 3; k++) dot(px, W, H, mx - 1 + k, my - 4 - (t - 198) * 0.3 - k, 0x8a8a8a)
802    },
803  }
804}
805
806// --- jellyfish --------------------------------------------------------------------
807
808const jellyfish: Maker = (W, H, rng) => {
809  type J = { x: number; y: number; ph: number; c: number; s: number }
810  const js: J[] = Array.from({ length: 4 }, () => ({ x: rng() * W, y: rng() * H, ph: rng() * 6, c: pick(rng, [0xff8ad0, 0x8ad0ff, 0xc08aff, 0xffd08a]), s: 0.7 + rng() * 0.6 }))
811  let t = 0
812  const motes = Array.from({ length: 20 }, () => ({ x: rng() * W, y: rng() * H }))
813
814  return {
815    step() {
816      t++
817      for (const j of js) {
818        const pulse = Math.max(0, Math.sin(t * 0.08 + j.ph))
819        j.y -= pulse * 0.25 * j.s
820        j.y += 0.04
821        j.x += Math.sin(t * 0.01 + j.ph) * 0.05
822        if (j.y < -10) {
823          j.y = H + 6
824          j.x = rng() * W
825        }
826      }
827      for (const m of motes) {
828        m.y -= 0.05
829        if (m.y < 0) m.y = H
830      }
831    },
832    draw(px) {
833      sky(px, W, H, 0x0a1a3a, 0x02040e)
834      for (const m of motes) dot(px, W, H, m.x, m.y, 0x2a4a6a)
835      for (const j of js) {
836        const pulse = Math.max(0, Math.sin(t * 0.08 + j.ph))
837        const rx = (3 - pulse) * j.s + 1
838        const ry = (2 + pulse) * j.s
839        for (let y = -ry; y <= 0; y++) {
840          const hw = rx * Math.sqrt(1 - (y / ry) ** 2)
841          for (let x = -hw; x <= hw; x++) dot(px, W, H, j.x + x, j.y + y, mix(j.c, 0xffffff, y < -ry * 0.6 ? 0.4 : 0))
842        }
843        for (let k = -2; k <= 2; k++) {
844          for (let s = 1; s < 8 * j.s; s++) dot(px, W, H, j.x + k * rx * 0.35 + Math.sin(t * 0.1 + s * 0.5 + k) * 0.8, j.y + s, mix(j.c, 0x02040e, s / (9 * j.s)))
845        }
846      }
847    },
848  }
849}
850
851// --- ferris wheel -----------------------------------------------------------------
852
853const ferris: Maker = (W, H, rng) => {
854  const cx = W / 2
855  const cy = H * 0.42
856  const R = Math.min(W * 0.4, H * 0.3)
857  let a = 0
858  let t = 0
859  const colours = Array.from({ length: 8 }, () => hsv(rng(), 0.6, 1))
860
861  return {
862    step() {
863      t++
864      a += 0.006
865    },
866    draw(px) {
867      sky(px, W, H, 0x2a2060, 0xff9a6a)
868      for (let x = 0; x < W; x++) {
869        const h = 5 + ((x * 13) % 9)
870        rect(px, W, H, x, H - 8 - h, 1, h, 0x2a1a3a)
871      }
872      rect(px, W, H, 0, H - 8, W, 8, 0x1a1028)
873      line(px, W, H, cx, cy, cx - R * 0.6, H - 8, 0x5a4a6a)
874      line(px, W, H, cx, cy, cx + R * 0.6, H - 8, 0x5a4a6a)
875      ring(px, W, H, cx, cy, R, 0xe0e0f0)
876      for (let k = 0; k < 8; k++) {
877        const q = a + (k / 8) * Math.PI * 2
878        line(px, W, H, cx, cy, cx + Math.cos(q) * R, cy + Math.sin(q) * R, 0x8a8aa0)
879        for (let s = 0.3; s < 1; s += 0.35) dot(px, W, H, cx + Math.cos(q) * R * s, cy + Math.sin(q) * R * s, (t >> 3) % 8 === k ? 0xffffff : 0xffd060)
880      }
881      for (let k = 0; k < 8; k++) {
882        const q = a + (k / 8) * Math.PI * 2
883        const gx = cx + Math.cos(q) * R
884        const gy = cy + Math.sin(q) * R
885        rect(px, W, H, gx - 1.5, gy + 1, 3, 3, colours[k] as number)
886        dot(px, W, H, gx, gy, 0x8a8aa0)
887      }
888      disc(px, W, H, cx, cy, 1.5, 0xffd060)
889      for (let x = 0; x < W; x += 3) dot(px, W, H, x, H - 6, (x + (t >> 2)) % 6 < 3 ? 0xffd060 : 0xff6aa0)
890    },
891  }
892}
893
894// --- sailing ----------------------------------------------------------------------
895
896const sailing: Maker = (W, H, rng) => {
897  const sea = Math.round(H * 0.58)
898  let bx = -10
899  let t = 0
900  const gulls = Array.from({ length: 3 }, () => ({ x: rng() * W, y: 6 + rng() * 14, ph: rng() * 6 }))
901
902  return {
903    step() {
904      t++
905      bx += 0.12
906      if (bx > W + 10) bx = -12
907      for (const g of gulls) {
908        g.x += 0.2
909        if (g.x > W + 3) g.x = -3
910      }
911    },
912    draw(px) {
913      sky(px, W, H, 0x6ab8f0, 0xe0f4ff, sea)
914      disc(px, W, H, W * 0.8, 8, 3, 0xfff8c0)
915      for (const [cx, cy] of [
916        [W * 0.25 + (t * 0.03) % W, 10],
917        [W * 0.6 + (t * 0.02) % W, 16],
918      ] as [number, number][]) {
919        const x = (cx % (W + 12)) - 6
920        ellipse(px, W, H, x + 0.5, cy + 0.6, 5, 1.5, 0xd8e8f8)
921        ellipse(px, W, H, x, cy, 5, 1.5, 0xffffff)
922        ellipse(px, W, H, x + 2, cy - 1, 3, 1.5, 0xffffff)
923      }
924      for (const g of gulls) {
925        const f = Math.sin(t * 0.2 + g.ph) > 0
926        dot(px, W, H, g.x, g.y, 0x3a3a3a)
927        dot(px, W, H, g.x - 1, g.y - (f ? 1 : 0), 0x3a3a3a)
928        dot(px, W, H, g.x + 1, g.y - (f ? 1 : 0), 0x3a3a3a)
929      }
930      for (let y = sea; y < H; y++)
931        for (let x = 0; x < W; x++) {
932          const w = Math.sin(x * 0.5 + y * 0.8 - t * 0.08)
933          px[y * W + x] = w > 0.85 ? 0xc8ecff : mix(0x3a90d0, 0x1a5a9a, (y - sea) / (H - sea))
934        }
935      const by = sea + Math.sin(t * 0.06) * 0.8
936      for (let k = 0; k < 4; k++) rect(px, W, H, bx - 5 + k, by + k * 0.5, 10 - k * 2, 1, 0x8a4a2a)
937      rect(px, W, H, bx, by - 13, 1, 13, 0x5a3a24)
938      const billow = Math.sin(t * 0.05) * 0.5
939      for (let y = 0; y < 11; y++) {
940        const w = (y / 11) * 6 + billow
941        rect(px, W, H, bx + 1, by - 12 + y, w, 1, y % 4 === 0 ? 0xe03a3a : 0xffffff)
942        rect(px, W, H, bx - (y / 11) * 3, by - 10 + y * 0.8, (y / 11) * 3, 1, 0xf0f0f0)
943      }
944    },
945  }
946}
947
948// --- lighthouse -------------------------------------------------------------------
949
950const lighthouse: Maker = (W, H, rng) => {
951  const sea = Math.round(H * 0.7)
952  const lx = Math.round(W * 0.28)
953  const top = Math.round(H * 0.3)
954  let t = 0
955  let spray: { x: number; y: number; vy: number; vx: number; l: number }[] = []
956
957  return {
958    step() {
959      t++
960      if (t % 40 === 0) for (let k = 0; k < 10; k++) spray.push({ x: lx + 6 + rng() * 4, y: sea - 1, vx: (rng() - 0.3) * 0.6, vy: -1 - rng() * 1.2, l: 20 })
961      for (const s of spray) {
962        s.vy += 0.08
963        s.x += s.vx
964        s.y += s.vy
965        s.l--
966      }
967      spray = spray.filter(s => s.l > 0)
968    },
969    draw(px) {
970      sky(px, W, H, 0x0a1020, 0x2a3048, sea)
971      const a = t * 0.05
972      const dir = Math.cos(a)
973      if (Math.sin(a) < 0.3)
974        for (let k = 0; k < W * 1.2; k++) {
975          const x = lx + dir * k
976          for (let s = -k * 0.15; s <= k * 0.15; s++) {
977            const y = top + 1 + s
978            const kk = Math.round(y) * W + Math.round(x)
979            if (x >= 0 && x < W && y >= 0 && y < H) px[kk] = mix(px[kk] as number, 0xfff4c0, 0.25 * (1 - k / (W * 1.2)))
980          }
981        }
982      for (let y = sea; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = (x * 2 + y + (t >> 2)) % 7 === 0 ? 0x5a7aa0 : mix(0x2a3a5a, 0x101828, (y - sea) / (H - sea))
983      for (let x = 0; x < W * 0.55; x++) {
984        const h = 4 + Math.abs(Math.sin(x * 0.7)) * 3
985        rect(px, W, H, x, sea - h + 2, 1, h, 0x3a3a42)
986      }
987      for (let y = top; y < sea - 2; y++) {
988        const hw = 2 + ((y - top) / (sea - top)) * 2
989        const band = Math.floor((y - top) / 4) % 2
990        rect(px, W, H, lx - hw, y, hw * 2, 1, band ? 0xe03a3a : 0xf4f4f0)
991        rect(px, W, H, lx + hw * 0.35, y, hw * 0.65, 1, band ? 0xa82a2a : 0xc8c8d0)
992      }
993      rect(px, W, H, lx - 3, top - 4, 6, 4, 0x2a2a32)
994      rect(px, W, H, lx - 2, top - 3, 4, 2, 0xfff4a0)
995      rect(px, W, H, lx - 3, top - 5, 6, 1, 0x3a3a42)
996      for (const s of spray) dot(px, W, H, s.x, s.y, 0xd8e8f8)
997      for (let k = 0; k < 14; k++) line(px, W, H, (k * 5 + t * 0.7) % W, (k * 9 + t * 1.6) % H, (k * 5 + t * 0.7) % W - 0.5, ((k * 9 + t * 1.6) % H) + 1.5, 0x5a6a8a)
998    },
999  }
1000}
1001
1002// --- lava lamp -------------------------------------------------------------
1003
1004const lava: Maker = (W, H, rng) => {
1005  const palettes = [
1006    [0x3a0f5c, 0x7a1f6b, 0xff7a1a, 0xffd23a],
1007    [0x0b2f4f, 0x165a6e, 0x7dff6a, 0xe6ffb0],
1008    [0x1a1450, 0x3b1e7a, 0xff4fa3, 0xffc4e6],
1009    [0x07281f, 0x0f4a3c, 0xff5a3a, 0xffcf7a],
1010  ]
1011  const [liq0, liq1, wax0, wax1] = palettes[Math.floor(rng() * palettes.length)] as [number, number, number, number]
1012  const cx = W / 2
1013  const topY = Math.round(H * 0.12)
1014  const botY = Math.round(H * 0.8)
1015  const hw = (y: number) => W * 0.16 + W * 0.24 * Math.pow((y - topY) / (botY - topY), 0.7)
1016  const blobs = Array.from({ length: 6 }, () => ({
1017    x: cx + (rng() - 0.5) * W * 0.25,
1018    r: W * (0.07 + rng() * 0.07),
1019    period: 220 + rng() * 260,
1020    phase: rng() * Math.PI * 2,
1021    sway: rng() * W * 0.06,
1022  }))
1023  let t = rng() * 1000
1024
1025  return {
1026    step() {
1027      t++
1028    },
1029    draw(px) {
1030      for (let y = 0; y < H; y++)
1031        for (let x = 0; x < W; x++) {
1032          const halo = Math.max(0, 1 - Math.hypot(x - cx, (y - H * 0.5) * 0.6) / (W * 0.75))
1033          px[y * W + x] = mix(0x0a0810, wax0, halo * 0.22)
1034        }
1035      const pos = blobs.map(b => ({
1036        x: b.x + Math.sin(t / (b.period * 0.7) + b.phase) * b.sway,
1037        y: botY - 3 - (botY - topY - 8) * (0.5 - 0.5 * Math.cos((t / b.period) * Math.PI * 2 + b.phase)),
1038        r2: b.r * b.r,
1039      }))
1040      pos.push({ x: cx, y: botY + 3, r2: (W * 0.3) ** 2 })
1041      for (let y = topY; y < botY; y++) {
1042        const half = hw(y)
1043        for (let x = 0; x < W; x++) {
1044          const dx = x + 0.5 - cx
1045          if (Math.abs(dx) > half) continue
1046          let f = 0
1047          for (const p of pos) f += p.r2 / ((x - p.x) ** 2 + (y - p.y) ** 2 + 0.5)
1048          const light = 1 - Math.abs(dx) / half
1049          px[y * W + x] =
1050            f > 1 ? mix(wax0, wax1, Math.min(1, (f - 1) * 0.5) * (0.4 + 0.6 * light)) : mix(liq0, liq1, Math.min(1, f) * 0.8 + light * 0.2)
1051        }
1052      }
1053      const metal = (y: number, half: number) => {
1054        for (let x = 0; x < W; x++) {
1055          const dx = x + 0.5 - cx
1056          if (Math.abs(dx) <= half) px[y * W + x] = mix(0x4a4f5a, 0xdfe4ec, Math.cos((dx / half) * 1.3) * 0.5 + 0.5)
1057        }
1058      }
1059      for (let y = 0; y < topY; y++) metal(y, W * 0.1 + (W * 0.06 * y) / topY)
1060      for (let y = botY; y < H; y++) metal(y, W * 0.4 - (W * 0.12 * (y - botY)) / (H - botY))
1061    },
1062  }
1063}
1064
1065// --- fireworks ----------------------------------------------------------
1066
1067const feux: Maker = (W, H, rng) => {
1068  const trail = new Uint32Array(W * H)
1069  const stars = Array.from({ length: 22 }, () => ({ x: Math.floor(rng() * W), y: Math.floor(rng() * H * 0.6), p: rng() * 6 }))
1070  const skyline: number[] = []
1071  while (skyline.length < W) {
1072    const w = 3 + Math.floor(rng() * 4)
1073    const h = 3 + Math.floor(rng() * H * 0.14)
1074    for (let i = 0; i < w; i++) skyline.push(h)
1075  }
1076  const windows = Array.from({ length: W * H }, () => rng() < 0.18)
1077  type P = { x: number; y: number; vx: number; vy: number; life: number; max: number; c: number; drag: number }
1078  let rockets: P[] = []
1079  let parts: P[] = []
1080  let frame = 0
1081  let boom = 0
1082  const ground = (x: number) => H - (skyline[x] ?? 0)
1083
1084  function launch() {
1085    const x = W * (0.15 + rng() * 0.7)
1086    rockets.push({ x, y: H - 2, vx: (rng() - 0.5) * 0.2, vy: -(1.1 + rng() * 0.55) * Math.sqrt(H / 56), life: 0, max: 0, c: hsv(rng(), 0.7, 1), drag: 0 })
1087  }
1088  function explode(r: P) {
1089    boom = 6
1090    const kind = rng()
1091    const n = 26 + Math.floor(rng() * 16)
1092    const base = rng()
1093    for (let i = 0; i < n; i++) {
1094      const a = (i / n) * Math.PI * 2 + rng() * 0.2
1095      const sp = kind < 0.4 ? 0.9 : 0.3 + rng() * 0.8
1096      const willow = kind > 0.8
1097      parts.push({
1098        x: r.x,
1099        y: r.y,
1100        vx: Math.cos(a) * sp,
1101        vy: Math.sin(a) * sp,
1102        life: willow ? 45 : 28,
1103        max: willow ? 45 : 28,
1104        c: willow ? 0xffcf5a : hsv(base + (rng() - 0.5) * 0.08, 0.85, 1),
1105        drag: willow ? 0.93 : 0.96,
1106      })
1107    }
1108  }
1109
1110  return {
1111    step() {
1112      frame++
1113      if (boom > 0) boom--
1114      for (let i = 0; i < trail.length; i++) trail[i] = scale(trail[i] as number, 0.82)
1115      if (rockets.length < 2 && rng() < 0.06) launch()
1116      for (const r of rockets) {
1117        r.vy += 0.035
1118        r.x += r.vx
1119        r.y += r.vy
1120        dot(trail, W, H, r.x, r.y + 1, 0xff9a3a)
1121        if (r.vy > -0.15) {
1122          explode(r)
1123          r.life = -1
1124        }
1125      }
1126      rockets = rockets.filter(r => r.life >= 0)
1127      for (const p of parts) {
1128        p.vx *= p.drag
1129        p.vy = p.vy * p.drag + 0.025
1130        p.x += p.vx
1131        p.y += p.vy
1132        p.life--
1133        const k = p.life / p.max
1134        if (k > 0.15 || frame % 2 === 0) dot(trail, W, H, p.x, p.y, k > 0.8 ? mix(p.c, 0xffffff, 0.5) : scale(p.c, 0.45 + 0.55 * k))
1135      }
1136      parts = parts.filter(p => p.life > 0)
1137    },
1138    draw(px) {
1139      sky(px, W, H, 0x050816, mix(0x1a1240, 0x3a1a50, boom / 6))
1140      for (const s of stars) if ((frame / 8 + s.p) % 6 > 0.6) dot(px, W, H, s.x, s.y, 0x8890b0)
1141      for (let i = 0; i < px.length; i++) if (trail[i]) px[i] = add(px[i] as number, trail[i] as number)
1142      for (let x = 0; x < W; x++)
1143        for (let y = ground(x); y < H; y++) {
1144          const lit = windows[y * W + x] && y > ground(x) && x % 2 === 0
1145          px[y * W + x] = lit ? 0xffd27a : mix(0x0c0a18, 0x3a2050, boom / 12)
1146        }
1147    },
1148  }
1149}
1150
1151// --- campfire ---------------------------------------------------------------
1152
1153const feu: Maker = (W, H, rng) => {
1154  const MAX = 36
1155  const heat = new Uint8Array(W * H)
1156  const base = H - 7
1157  const palette = Array.from({ length: MAX + 1 }, (_, i) => {
1158    const k = i / MAX
1159    if (k < 0.33) return mix(0x000000, 0xbf1f07, k / 0.33)
1160    if (k < 0.66) return mix(0xbf1f07, 0xff9f0f, (k - 0.33) / 0.33)
1161    return mix(0xff9f0f, 0xffffc0, (k - 0.66) / 0.34)
1162  })
1163  const stars = Array.from({ length: 18 }, () => ({ x: Math.floor(rng() * W), y: Math.floor(rng() * H * 0.45), p: rng() * 9 }))
1164  let embers: { x: number; y: number; vx: number; vy: number; life: number }[] = []
1165  let t = 0
1166  let glow = 1
1167
1168  return {
1169    step() {
1170      t++
1171      for (let x = 0; x < W; x++) {
1172        const dx = Math.abs(x + 0.5 - W / 2) / (W * 0.16)
1173        heat[base * W + x] = dx < 1 ? Math.round(MAX * (1 - dx * dx * 0.5) * (rng() < 0.85 ? 1 : 0.6)) : 0
1174      }
1175      for (let y = 1; y <= base; y++)
1176        for (let x = 0; x < W; x++) {
1177          const src = y * W + x
1178          const p = heat[src] as number
1179          if (p === 0) {
1180            heat[src - W] = 0
1181            continue
1182          }
1183          const r = Math.floor(rng() * 3)
1184          const dst = Math.max(0, Math.min(W * H - 1, src - r + 1 - W))
1185          heat[dst] = Math.max(0, p - (r & 1) - (rng() < 0.75 ? 1 : 0))
1186        }
1187      if (rng() < 0.3) embers.push({ x: W / 2 + (rng() - 0.5) * W * 0.3, y: base - 6, vx: (rng() - 0.5) * 0.3, vy: -0.4 - rng() * 0.5, life: 30 + rng() * 30 })
1188      for (const e of embers) {
1189        e.x += e.vx + Math.sin(t * 0.2 + e.life) * 0.2
1190        e.y += e.vy
1191        e.life--
1192      }
1193      embers = embers.filter(e => e.life > 0 && e.y > 0)
1194      glow = 0.85 + rng() * 0.15
1195    },
1196    draw(px) {
1197      sky(px, W, H, 0x04060f, 0x15101a, base)
1198      for (const s of stars) if ((t / 10 + s.p) % 9 > 1) dot(px, W, H, s.x, s.y, 0x7a7f9a)
1199      for (let y = base; y < H; y++)
1200        for (let x = 0; x < W; x++) {
hooks/themes/crafts.ts 1107 lines
1import { disc, dot, hsv, mix, rect, scale, sky, sprite, text } from '../draw'
2import type { Maker } from '../draw'
3import { clamp, ellipse, hash, irand, line, lit, pick, shuffle, slab, tint, tones } from '../kit'
4
5// Crafts & makers: watching something get built, piece by piece.
6
7const span = (t: number, a: number, b: number) => clamp((t - a) / (b - a), 0, 1)
8
9// Little pictures the crafts reuse, one character per pixel ('.' is empty).
10const PICS: { rows: string[]; pal: Record<string, number> }[] = [
11  {
12    rows: ['..rr.rr..', '.rrrrrrr.', '.rrrrrrr.', '..rrrrr..', '...rrr...', '....r....'],
13    pal: { r: 0xe8344a },
14  },
15  {
16    rows: ['..rrrrr..', '.rrwrrwr.', 'rrrrrrrrr', '.wsssssw.', '..s.s.s..', '..sssss..', '...s.s...'],
17    pal: { r: 0xe03a2a, w: 0xffffff, s: 0xf0d8b8 },
18  },
19  {
20    rows: ['g.......g', 'gg.....gg', 'ggggggggg', 'gyggggyg.', 'ggggpgggg', '.ggggggg.', '..g...g..'],
21    pal: { g: 0x8a8a96, y: 0x5ae04a, p: 0xff8aa0 },
22  },
23  {
24    rows: ['..g...g..', '...g.g...', '..ggggg..', '.gg.g.gg.', 'ggggggggg', 'g.ggggg.g', 'g.g...g.g', '...g.g...'],
25    pal: { g: 0x5ae05a },
26  },
27  {
28    rows: ['....y....', '..yyyyy..', '.yyyyyyy.', 'yyyyyyyyy', 'bbbbbbbbb', 'bwbbbbwbb', 'bbbbbbbbb'],
29    pal: { y: 0xffc030, b: 0x3a8ae0, w: 0xffffff },
30  },
31]
32
33// The cells of a picture in the order a person fills them: colour by colour, the darkest first
34// (usually the outline), each colour row by row.
35const byColour = (p: { rows: string[]; pal: Record<string, number> }): number[] => {
36  const cols = (p.rows[0] as string).length
37  const lum = (c: number) => ((c >> 16) & 255) * 0.3 + ((c >> 8) & 255) * 0.59 + (c & 255) * 0.11
38  const cells: { i: number; l: number }[] = []
39  p.rows.forEach((row, y) => [...row].forEach((ch, x) => ch !== '.' && cells.push({ i: y * cols + x, l: lum(p.pal[ch] as number) })))
40  return cells.sort((a, b) => a.l - b.l || a.i - b.i).map(c => c.i)
41}
42
43
44// --- brick build ------------------------------------------------------------------
45
46const brickbuild: Maker = (W, H, rng) => {
47  const ground = H - 5
48  const unit = 3
49  type B = { x: number; y: number; w: number; c: number }
50  let plan: B[] = []
51  let placed = 0
52  let fall = 0
53  let t = 0
54
55  function design() {
56    plan = []
57    const cols = Math.floor((W - 6) / unit)
58    const left = Math.floor((W - cols * unit) / 2)
59    const wallC = pick(rng, [0xe03a3a, 0x3a8ae0, 0xf0c020, 0x3ac06a])
60    const levels = 6
61    for (let l = 0; l < levels; l++) {
62      let x = l % 2 ? 0 : 1
63      if (l % 2) plan.push({ x: 0, y: l, w: 1, c: wallC })
64      while (x < cols) {
65        const w = Math.min(cols - x, 2)
66        const window = l >= 2 && l <= 3 && x >= 2 && x < cols - 2 && (x / 2) % 2 === 1
67        plan.push({ x, y: l, w, c: window ? 0x8ad0f0 : wallC })
68        x += w
69      }
70    }
71    for (let r = 0; r < Math.floor(cols / 2); r++) {
72      const w = cols - r * 2
73      if (w <= 0) break
74      plan.push({ x: r, y: levels + r, w, c: 0x8a3a2a })
75    }
76    plan = plan.map(b => ({ ...b, x: left + b.x * unit }))
77    placed = 0
78  }
79  design()
80
81  return {
82    step() {
83      t++
84      if (placed >= plan.length) {
85        if (++fall > 70) {
86          fall = 0
87          design()
88        }
89        return
90      }
91      if (t % 6 === 0) placed++
92    },
93    draw(px) {
94      sky(px, W, H, 0xd8e8f8, 0xf8f0e0, ground)
95      rect(px, W, H, 0, ground, W, H - ground, 0x2e9a3c)
96      rect(px, W, H, 0, ground + 1, W, 1, 0x258030)
97      for (let x = 1; x < W; x += 3) {
98        dot(px, W, H, x, ground, 0x6ae07a)
99        dot(px, W, H, x + 1, ground, 0x3ab04a)
100      }
101      const bh = 3
102      for (let i = 0; i < placed; i++) {
103        const b = plan[i] as B
104        const y = ground - (b.y + 1) * bh
105        const drop = i === placed - 1 ? Math.max(0, 5 - (t % 6)) : 0
106        rect(px, W, H, b.x, y - drop, b.w * unit, bh, b.c)
107        rect(px, W, H, b.x, y - drop, b.w * unit, 1, mix(b.c, 0xffffff, 0.35))
108        rect(px, W, H, b.x, y - drop + bh - 1, b.w * unit, 1, scale(b.c, 0.82))
109        rect(px, W, H, b.x + b.w * unit - 1, y - drop, 1, bh, scale(b.c, 0.7))
110        for (let s = 0; s < b.w; s++) {
111          dot(px, W, H, b.x + s * unit, y - drop - 1, mix(b.c, 0xffffff, 0.45))
112          dot(px, W, H, b.x + s * unit + 1, y - drop - 1, mix(b.c, 0xffffff, 0.15))
113        }
114      }
115    },
116  }
117}
118
119// --- pixel art drawing ------------------------------------------------------------
120
121const pixeldraw: Maker = (W, H) => {
122  let pi = 0
123  let k = 0
124  let hold = 0
125  let order: number[] = []
126  const scaleK = 3
127
128  function start() {
129    order = byColour(PICS[pi] as { rows: string[]; pal: Record<string, number> })
130    k = 0
131  }
132  start()
133
134  return {
135    step() {
136      if (hold > 0) {
137        if (--hold === 0) {
138          pi = (pi + 1) % PICS.length
139          start()
140        }
141        return
142      }
143      k++
144      if (k >= order.length) hold = 60
145    },
146    draw(px) {
147      px.fill(0xf8f8fc)
148      for (let y = 0; y < H; y += scaleK) for (let x = 0; x < W; x++) dot(px, W, H, x, y, 0xe8e8f0)
149      for (let x = 0; x < W; x += scaleK) for (let y = 0; y < H; y++) dot(px, W, H, x, y, 0xe8e8f0)
150      const p = PICS[pi] as { rows: string[]; pal: Record<string, number> }
151      const cols = (p.rows[0] as string).length
152      const ox = Math.floor((W - cols * scaleK) / 2)
153      const oy = Math.floor((H - p.rows.length * scaleK) / 2)
154      let cur = [ox, oy]
155      for (let i = 0; i < Math.min(k, order.length); i++) {
156        const idx = order[i] as number
157        const x = idx % cols
158        const y = Math.floor(idx / cols)
159        const ch = (p.rows[y] as string)[x] as string
160        if (ch !== '.') rect(px, W, H, ox + x * scaleK, oy + y * scaleK, scaleK, scaleK, p.pal[ch] as number)
161        cur = [ox + x * scaleK + 1, oy + y * scaleK + 1]
162      }
163      if (!hold) {
164        const [cx, cy] = cur as [number, number]
165        line(px, W, H, cx + 1, cy, cx + 6, cy - 7, 0xc8901a)
166        line(px, W, H, cx, cy, cx + 5, cy - 7, 0xf8d050)
167        dot(px, W, H, cx, cy, 0x3a3a3a)
168        dot(px, W, H, cx + 5, cy - 7, 0xff8aa0)
169      }
170      for (const [i, c] of Object.values(p.pal).entries()) slab(px, W, H, 2 + i * 4, H - 5, 3, 3, c)
171    },
172  }
173}
174
175// --- pottery wheel ----------------------------------------------------------------
176
177const pottery: Maker = (W, H, rng) => {
178  const wheel = Math.round(H * 0.72)
179  const cx = W / 2
180  let t = 0
181  let shape = 0
182  let clay = 0xc87a4a
183
184  const profile = (u: number, f: number) => {
185    // u from 0 (bottom) to 1 (top); f from 0 (lump) to 1 (vase)
186    const lump = 6 * Math.sqrt(Math.max(0, 1 - u * u * 1.4))
187    const vases = [
188      4 + 3 * Math.sin(u * Math.PI * 0.9) - (u > 0.8 ? (u - 0.8) * 8 : 0) + (u > 0.9 ? 2 : 0),
189      3 + u * 4,
190      5 - 2 * Math.sin(u * Math.PI) + (u > 0.85 ? 1.5 : 0),
191    ]
192    return lump + ((vases[shape] as number) - lump) * f
193  }
194
195  return {
196    step() {
197      t++
198      if (t > 300) {
199        t = 0
200        shape = (shape + 1) % 3
201        clay = pick(rng, [0xc87a4a, 0xb8a090, 0xd89a6a])
202      }
203    },
204    draw(px) {
205      sky(px, W, H, 0xece0cc, 0xc4b49c)
206      rect(px, W, H, 0, wheel + 2, W, H - wheel - 2, 0x5e4e40)
207      rect(px, W, H, 0, wheel + 2, W, 1, 0x7a6a58)
208      slab(px, W, H, cx - 3, wheel + 2, 7, H - wheel - 2, 0x6a6a74)
209      lit(px, W, H, cx, wheel + 1, W * 0.38, 2, 0x9a9aa4, 0x4a4a54)
210      const f = span(t, 30, 220)
211      const height = 8 + f * H * 0.32
212      for (let y = 0; y < height; y++) {
213        const u = y / height
214        const r = Math.max(0.5, profile(u, f))
215        const yy = wheel - y
216        for (let x = -r; x <= r; x++) {
217          const k = x / r
218          const [hi, base, sh, deep] = tones(clay)
219          let c = k < -0.55 ? hi : k < 0.15 ? base : k < 0.7 ? sh : deep
220          if (y >= Math.floor(height) - 1) c = mix(c, 0xffffff, 0.25)
221          if (Math.sin(k * 3 + t * 0.6) > 0.95) c = mix(c, 0xffffff, 0.2)
222          dot(px, W, H, cx + x, yy, c)
223        }
224      }
225      if (t < 240) {
226        const hy = wheel - height * (0.3 + 0.4 * Math.sin(t * 0.03) ** 2)
227        const r = profile((wheel - hy) / height, f)
228        slab(px, W, H, cx - r - 4, hy - 1, 3, 3, 0xf0c8a0)
229        slab(px, W, H, cx + r + 1, hy - 1, 3, 3, 0xe0b48c)
230      }
231      for (let k = 0; k < 4; k++) dot(px, W, H, cx + Math.cos(t * 0.6 + k * 1.6) * W * 0.36, wheel + 1 + Math.sin(t * 0.6 + k * 1.6), 0x5a5a62)
232    },
233  }
234}
235
236// --- cross-stitch -----------------------------------------------------------------
237
238const crossstitch: Maker = (W, H) => {
239  const cs = 3
240  let pi = 0
241  let k = 0
242  let order: number[] = []
243  let hold = 0
244
245  function start() {
246    const p = PICS[pi] as { rows: string[] }
247    const cols = (p.rows[0] as string).length
248    order = []
249    p.rows.forEach((row, y) => [...row].forEach((ch, x) => ch !== '.' && order.push(y * cols + x)))
250    k = 0
251  }
252  start()
253
254  return {
255    step() {
256      if (hold > 0) {
257        if (--hold === 0) {
258          pi = (pi + 1) % PICS.length
259          start()
260        }
261        return
262      }
263      if (++k > order.length * 4) hold = 60
264    },
265    draw(px) {
266      px.fill(0xf4efe0)
267      for (let y = 0; y < H; y += cs) for (let x = 0; x < W; x += cs) dot(px, W, H, x, y, 0xd8d0b8)
268      const p = PICS[pi] as { rows: string[]; pal: Record<string, number> }
269      const cols = (p.rows[0] as string).length
270      const ox = Math.floor((W - cols * cs) / 2)
271      const oy = Math.floor((H - p.rows.length * cs) / 2)
272      // the hoop: two rings of wood, lit on the upper left, and its screw
273      const hr = Math.min(W / 2 - 1, Math.max(cols, p.rows.length) * cs * 0.75 + 2)
274      for (let a = 0; a < 6.283; a += 0.02) {
275        const up = Math.cos(a + 2.4) > 0.2
276        dot(px, W, H, W / 2 + Math.cos(a) * hr, H / 2 + Math.sin(a) * hr, up ? 0xe0b070 : 0xa87a40)
277        dot(px, W, H, W / 2 + Math.cos(a) * (hr - 1), H / 2 + Math.sin(a) * (hr - 1), up ? 0xc89a5a : 0x8a6232)
278      }
279      slab(px, W, H, W / 2 - 1, H / 2 - hr - 3, 3, 3, 0x9aa0ac)
280      const done = Math.floor(k / 4)
281      for (let i = 0; i < Math.min(done + 1, order.length); i++) {
282        const idx = order[i] as number
283        const x = ox + (idx % cols) * cs
284        const y = oy + Math.floor(idx / cols) * cs
285        const c = p.pal[(p.rows[Math.floor(idx / cols)] as string)[idx % cols] as string] as number
286        const half = i === done ? k % 4 : 4
287        if (half >= 1) {
288          dot(px, W, H, x + 1, y + 2, 0xc8bea4)
289          dot(px, W, H, x, y, c)
290          dot(px, W, H, x + 1, y + 1, c)
291          dot(px, W, H, x + 2, y + 2, c)
292        }
293        if (half >= 3) {
294          dot(px, W, H, x + 2, y, scale(c, 0.85))
295          dot(px, W, H, x, y + 2, scale(c, 0.85))
296        }
297      }
298      if (!hold && order[done] !== undefined) {
299        const idx = order[done] as number
300        const x = ox + (idx % cols) * cs + 1
301        const y = oy + Math.floor(idx / cols) * cs + 1
302        line(px, W, H, x, y, x + 3, y - 6, 0xc8c8d0)
303        line(px, W, H, x + 3, y - 6, W - 2, 3, 0xe8344a)
304      }
305    },
306  }
307}
308
309// --- fuse beads -------------------------------------------------------------------
310
311const fusebeads: Maker = (W, H) => {
312  const pitch = 3
313  let pi = 1
314  let k = 0
315  let iron = -1
316  let hold = 0
317  let order: number[] = []
318
319  function start() {
320    order = byColour(PICS[pi] as { rows: string[]; pal: Record<string, number> })
321    k = 0
322    iron = -1
323  }
324  start()
325
326  return {
327    step() {
328      if (hold > 0) {
329        if (--hold === 0) {
330          pi = (pi + 1) % PICS.length
331          start()
332        }
333        return
334      }
335      if (k < order.length) k += 0.5
336      else if (iron < 0) iron = 0
337      else {
338        iron += 0.6
339        if (iron > H + 10) hold = 50
340      }
341    },
342    draw(px) {
343      px.fill(0xe8e0f0)
344      const p = PICS[pi] as { rows: string[]; pal: Record<string, number> }
345      const cols = (p.rows[0] as string).length
346      const ox = Math.floor((W - cols * pitch) / 2)
347      const oy = Math.floor((H - p.rows.length * pitch) / 2)
348      rect(px, W, H, ox - 1, oy - 1, cols * pitch + 3, p.rows.length * pitch + 3, 0xb8b0c8)
349      slab(px, W, H, ox - 2, oy - 2, cols * pitch + 3, p.rows.length * pitch + 3, 0xd4cce4)
350      for (let y = 0; y < p.rows.length; y++) for (let x = 0; x < cols; x++) dot(px, W, H, ox + x * pitch + 1, oy + y * pitch + 1, 0xb8b0c8)
351      for (let i = 0; i < Math.floor(k); i++) {
352        const idx = order[i] as number
353        const x = ox + (idx % cols) * pitch
354        const y = oy + Math.floor(idx / cols) * pitch
355        const c = p.pal[(p.rows[Math.floor(idx / cols)] as string)[idx % cols] as string] as number
356        const fused = iron >= 0 && y < iron - 6
357        if (fused) rect(px, W, H, x, y, pitch, pitch, c)
358        else {
359          rect(px, W, H, x, y, 2, 2, c)
360          dot(px, W, H, x, y, mix(c, 0xffffff, 0.4))
361          dot(px, W, H, x + 1, y + 1, scale(c, 0.55))
362        }
363      }
364      if (iron >= 0 && iron < H + 6) {
365        slab(px, W, H, 1, iron - 6, W - 2, 6, 0xd8dce6)
366        slab(px, W, H, W / 2 - 3, iron - 10, 6, 4, 0x3a8ae0, 0x1a4a8a)
367        for (let x = 3; x < W - 3; x += 4) dot(px, W, H, x, iron - 8 - ((iron | 0) % 3), 0xf0f4f8)
368      }
369    },
370  }
371}
372
373// --- mosaic -----------------------------------------------------------------------
374
375const mosaic: Maker = (W, H, rng) => {
376  type T = { x: number; y: number; w: number; h: number; c: number }
377  let tiles: T[] = []
378  let k = 0
379  let hold = 0
380  let scene = 0
381
382  const colourAt = (x: number, y: number) => {
383    const u = x / W
384    const v = y / H
385    if (scene === 0) {
386      if (Math.hypot(u - 0.7, v - 0.25) < 0.14) return 0xffc030
387      if (v > 0.55 + Math.sin(u * 9) * 0.04) return v > 0.75 ? 0x1a5ab0 : 0x3a8ae0
388      return 0x8ad0f0
389    }
390    if (scene === 1) {
391      const d = Math.hypot(u - 0.5, (v - 0.45) * 0.7)
392      if (d < 0.08) return 0xffd040
393      if (d < 0.32) return Math.floor(Math.atan2(v - 0.45, u - 0.5) * 2.5 + 10) % 2 ? 0xe84a7a : 0xf06a9a
394      if (Math.abs(u - 0.5) < 0.03 && v > 0.6) return 0x3a9a3a
395      return 0xf4ecd8
396    }
397    return Math.floor(u * 4 + v * 6) % 2 ? 0x2a6a8a : 0xe8a040
398  }
399  function plan() {
400    tiles = []
401    for (let y = 1; y < H - 1; y += 3)
402      for (let x = 1; x < W - 1; x += 3) {
403        const w = 2 + (hash(x, y, scene) > 0.7 ? 1 : 0)
404        tiles.push({ x: x + (hash(y, x, 1) > 0.5 ? 0 : 0), y, w, h: 2, c: colourAt(x + 1, y + 1) })
405      }
406    shuffle(rng, tiles)
407    tiles.sort((a, b) => a.y + a.x * 0.3 - (b.y + b.x * 0.3) + (rng() - 0.5) * 20)
408    k = 0
409  }
410  plan()
411
412  return {
413    step() {
414      if (hold > 0) {
415        if (--hold === 0) {
416          scene = (scene + 1) % 3
417          plan()
418        }
419        return
420      }
421      k += 2
422      if (k >= tiles.length) hold = 70
423    },
424    draw(px) {
425      px.fill(0xc8bfb2)
426      for (let i = 0; i < Math.min(k, tiles.length); i++) {
427        const tl = tiles[i] as T
428        const c = mix(tl.c, hash(tl.x, tl.y, 3) > 0.5 ? 0xffffff : 0x000000, 0.08)
429        const w = Math.min(tl.w, W - 1 - tl.x)
430        rect(px, W, H, tl.x, tl.y, w, tl.h, c)
431        rect(px, W, H, tl.x, tl.y + tl.h - 1, w, 1, scale(c, 0.82))
432        dot(px, W, H, tl.x, tl.y, mix(c, 0xffffff, 0.4))
433      }
434    },
435  }
436}
437
438// --- weaving loom -----------------------------------------------------------------
439
440const loom: Maker = (W, H, rng) => {
441  const top = 8
442  let rows: number[][] = []
443  let shuttle = 0
444  let dir = 1
445  let t = 0
446  let pal: number[] = []
447
448  function reset() {
449    rows = []
450    const h = rng()
451    pal = [hsv(h, 0.6, 0.9), hsv(h + 0.5, 0.5, 0.9), 0xf4ecd8, hsv(h + 0.1, 0.7, 0.6)]
452  }
453  reset()
454
455  return {
456    step() {
457      t++
458      shuttle += dir * 1.2
459      if (shuttle > W - 2 || shuttle < 1) {
460        dir = -dir
461        const r = rows.length
462        const band = Math.floor(r / 4) % pal.length
463        rows.push(Array.from({ length: W }, (_, x) => ((x + r) % 6 < 3 && r % 8 > 3 ? pal[(band + 1) % pal.length] : pal[band]) as number))
464        if (H - 4 - rows.length < top + 1) reset()
465      }
466    },
467    draw(px) {
468      px.fill(0x5a3e22)
469      slab(px, W, H, 0, top - 5, W, 3, 0x9a7444)
470      for (let x = 1; x < W - 1; x += 2) {
471        line(px, W, H, x, top - 2, x, H - 3, 0xe8e0d0)
472        line(px, W, H, x + 1, top - 2, x + 1, H - 3, 0x4a321a)
473      }
474      // woven from the bottom beam up: each pass is beaten down onto the last, and what is woven stays put
475      rows.forEach((r, i) => {
476        const y = H - 4 - i
477        for (let x = 1; x < W - 1; x++) dot(px, W, H, x, y, (x + i) % 2 ? (r[x] as number) : scale(r[x] as number, 0.85))
478      })
479      const y = H - 4 - rows.length
480      line(px, W, H, 1, y, W - 2, y, 0x9a8a7a)
481      slab(px, W, H, shuttle - 3, y - 1, 6, 2, 0xc8964e)
482      dot(px, W, H, shuttle - 4, y, 0x8a6030)
483      dot(px, W, H, shuttle + 3, y, 0x8a6030)
484      line(px, W, H, dir > 0 ? 1 : W - 2, y, shuttle, y, pal[Math.floor(rows.length / 4) % pal.length] as number)
485      slab(px, W, H, 0, H - 3, W, 3, 0x9a7444)
486    },
487  }
488}
489
490// --- mural ------------------------------------------------------------------------
491
492const mural: Maker = (W, H, rng) => {
493  const paint = new Uint32Array(W * H)
494  const done = new Uint8Array(W * H)
495  let layer = 0
496  let row = 0
497  let hold = 0
498  let hue = rng()
499
500  const target = (x: number, y: number, l: number): number | undefined => {
501    const u = x / W
502    const v = y / H
503    if (l === 0) return mix(hsv(hue, 0.5, 0.95), hsv(hue + 0.1, 0.4, 1), v)
504    if (l === 1) return Math.hypot(u - 0.7, v - 0.25) < 0.13 ? 0xffe070 : undefined
505    if (l === 2) return v > 0.5 - Math.abs(Math.sin(u * 4)) * 0.18 ? hsv(hue + 0.6, 0.4, 0.6) : undefined
506    if (l === 3) return v > 0.72 + Math.sin(u * 7) * 0.03 ? hsv(0.3, 0.5, 0.55) : undefined
507    return Math.abs(u - 0.25) < 0.02 && v > 0.6 && v < 0.78 ? 0x5a3a2a : Math.hypot(u - 0.25, v - 0.58) < 0.08 ? 0x2a7a3a : undefined
508  }
509  function reset() {
510    paint.fill(0xe8e4dc)
511    done.fill(0)
512    layer = 0
513    row = 0
514    hue = rng()
515  }
516  reset()
517
518  return {
519    step() {
520      if (hold > 0) {
521        if (--hold === 0) reset()
522        return
523      }
524      for (let k = 0; k < 2; k++) {
525        for (let x = 0; x < W; x++) {
526          const c = target(x, row, layer)
527          if (c !== undefined) paint[row * W + x] = c
528        }
529        row++
530        if (row >= H) {
531          row = 0
532          layer++
533          if (layer > 4) {
534            hold = 60
535            return
536          }
537        }
538      }
539    },
540    draw(px) {
541      px.set(paint)
542      for (let y = 3; y < H; y += 4) for (let x = 0; x < W; x++) px[y * W + x] = scale(px[y * W + x] as number, 0.93)
543      for (let y = 0; y < H; y++) for (let x = Math.floor(y / 4) % 2 ? 3 : 7; x < W; x += 8) px[y * W + x] = scale(px[y * W + x] as number, 0.93)
544      if (!hold) {
545        slab(px, W, H, 1, row - 2, W - 2, 2, 0xe0e0e8)
546        rect(px, W, H, W / 2, row, 1, 9, 0xc89a5a)
547        rect(px, W, H, W / 2 + 1, row, 1, 9, 0x8a6232)
548      }
549    },
550  }
551}
552
553// --- sandcastle -------------------------------------------------------------------
554
555const sandcastle: Maker = (W, H, rng) => {
556  const beach = Math.round(H * 0.65)
557  let towers: { x: number; h: number; born: number }[] = []
558  let tide = 0
559  let t = 0
560
561  function reset() {
562    towers = []
563    const n = 4
564    for (let i = 0; i < n; i++) towers.push({ x: W * (0.15 + i * 0.22), h: 6 + rng() * 6, born: 30 + i * 40 })
565    tide = 0
566    t = 0
567  }
568  reset()
569
570  return {
571    step() {
572      t++
573      if (t > 260) tide = Math.min(1, tide + 0.006)
574      if (t > 420) reset()
575    },
576    draw(px) {
577      sky(px, W, H, 0x6ac0f0, 0xd8f0ff, beach - 6)
578      for (let y = beach - 6; y < beach; y++) rect(px, W, H, 0, y, W, 1, (y + Math.floor(t / 8)) % 3 ? 0x3a90d0 : 0x6ab8f0)
579      disc(px, W, H, W * 0.8, H * 0.12, 2.5, 0xfff0a0)
580      rect(px, W, H, 0, beach, W, H - beach, 0xf0d898)
581      rect(px, W, H, 0, beach, W, 1, 0xf8e8b8)
582      for (let x = 0; x < W; x++) if (hash(x, 1, 2) > 0.8) dot(px, W, H, x, beach + 3 + (x % 5), 0xd8c080)
583      const wash = tide * (H - beach + 10)
584      for (const tw of towers) {
585        const g = span(t, tw.born, tw.born + 15)
586        if (g <= 0) continue
587        const h = tw.h * g * (1 - span(wash, 4, 14))
588        if (h <= 0.5) continue
589        rect(px, W, H, tw.x - 2, beach - h, 5, h, 0xe8c880)
590        rect(px, W, H, tw.x - 2, beach - h, 1, h, 0xf8e0a0)
591        rect(px, W, H, tw.x + 2, beach - h, 1, h, 0xc8a460)
592        for (let k = 0; k < 3; k++) dot(px, W, H, tw.x - 2 + k * 2, beach - h - 1, k === 2 ? 0xc8a460 : 0xf4d898)
593        dot(px, W, H, tw.x, beach - h + 2, 0x6a4a2a)
594        dot(px, W, H, tw.x, beach - h + 3, 0x6a4a2a)
595        if (h > 8) {
596          line(px, W, H, tw.x, beach - h - 1, tw.x, beach - h - 5, 0x8a6a3a)
597          rect(px, W, H, tw.x + 1, beach - h - 5, 2, 2, 0xe03a3a)
598          dot(px, W, H, tw.x + 1, beach - h - 5, 0xff7a6a)
599        }
600      }
601      if (towers.length > 1 && t > 200) for (let i = 0; i + 1 < towers.length; i++) rect(px, W, H, towers[i]!.x + 2, beach - 3 * (1 - tide), towers[i + 1]!.x - towers[i]!.x - 4, 3 * (1 - tide), 0xdcbc74)
602      if (tide > 0) for (let y = beach - 6; y < beach - 6 + wash && y < H; y++) for (let x = 0; x < W; x++) if ((x + y + t) % 9 !== 0) dot(px, W, H, x, y, y > beach - 6 + wash - 2 ? 0xffffff : 0x4aa0e0)
603      if (t > 30 && t < 200) {
604        const i = Math.min(towers.length - 1, Math.floor((t - 30) / 40))
605        const tw = towers[i]!
606        const flip = ((t - 30) % 40) / 40
607        rect(px, W, H, tw.x - 2, beach - tw.h - 4 - (1 - flip) * 6, 5, 4, 0xe03a3a)
608      }
609    },
610  }
611}
612
613// --- block-game house -------------------------------------------------------------
614
615const blockhouse: Maker = (W, H) => {
616  const cs = 3
617  const cols = Math.floor(W / cs)
618  const ground = Math.round(H * 0.75)
619  type Bl = { x: number; y: number; k: number }
620  let plan: Bl[] = []
621  let placed = 0
622  let hold = 0
623  let t = 0
624  // 0 plank, 1 cobble, 2 glass, 3 door, 4 roof
625  const COL = [
626    [0xb8884a, 0x9a6a3a],
627    [0x8a8a8a, 0x6a6a6a],
628    [0xb8e0f0, 0xe8f8ff],
629    [0x7a4a2a, 0x5a3a1a],
630    [0x8a3a2a, 0x6a2a1a],
631  ]
632
633  function design() {
634    plan = []
635    const w = Math.min(cols - 2, 7)
636    const x0 = Math.floor((cols - w) / 2)
637    const h = 4
638    for (let y = 0; y < h; y++)
639      for (let x = 0; x < w; x++) {
640        const edge = x === 0 || x === w - 1
641        const door = x === Math.floor(w / 2) && y < 2
642        const win = !edge && y === 2 && x !== Math.floor(w / 2)
643        plan.push({ x: x0 + x, y, k: y === 0 && edge ? 1 : door ? 3 : win ? 2 : edge ? 1 : 0 })
644      }
645    for (let r = 0; r < Math.ceil(w / 2); r++) for (let x = r; x < w - r; x++) plan.push({ x: x0 + x, y: h + r, k: 4 })
646    placed = 0
647  }
648  design()
649
650  return {
651    step() {
652      t++
653      if (hold > 0) {
654        if (--hold === 0) design()
655        return
656      }
657      if (t % 5 === 0) placed++
658      if (placed >= plan.length) hold = 70
659    },
660    draw(px) {
661      sky(px, W, H, 0x7ab8ff, 0xb8dcff, ground)
662      for (let y = ground; y < H; y++) for (let x = 0; x < W; x++) px[y * W + x] = y < ground + 2 ? ((x + y) % 3 ? 0x5ab03a : 0x4a9a2a) : (hash(x >> 1, y >> 1, 4) > 0.8 ? 0x6a4a2a : 0x8a5a3a)
663      disc(px, W, H, W * 0.8, 6, 3, 0xffffa0)
664      rect(px, W, H, W * 0.1, 8, 7, 2, 0xffffff)
665      rect(px, W, H, W * 0.1 + 1, 10, 7, 1, 0xd0e4f8)
666      const block = (bx: number, by: number, k: number) => {
667        const [a, b] = COL[k] as [number, number]
668        const x = bx * cs
669        const y = ground - (by + 1) * cs
670        rect(px, W, H, x, y, cs, cs, a)
671        dot(px, W, H, x + 1, y + 1, b)
672        if (k === 0) rect(px, W, H, x, y + cs - 1, cs, 1, b)
673        dot(px, W, H, x, y, mix(a, 0xffffff, 0.35))
674        dot(px, W, H, x + cs - 1, y + cs - 1, scale(a, 0.7))
675      }
676      for (let i = 0; i < Math.min(placed, plan.length); i++) {
677        const b = plan[i] as Bl
678        block(b.x, b.y, b.k)
679      }
680      const next = plan[Math.min(placed, plan.length - 1)] as Bl
681      const sx = (next.x + 1) * cs + 2
682      const sy = ground - 9
683      sprite(px, W, H, Math.min(W - 4, sx), sy, ['.h.', 'sss', 'ccc', 'bbb', 'b.b'], { h: 0x4a2a1a, s: 0xd8a070, c: 0x3ac0c0, b: 0x3a3aaa })
684      if (!hold && t % 5 < 2) dot(px, W, H, next.x * cs + 1, ground - (next.y + 1) * cs + 1, 0xffffff)
685    },
686  }
687}
688
689// --- card tower -------------------------------------------------------------------
690
691const cardtower: Maker = (W, H, rng) => {
692  const floor = H - 5
693  const levels = 4
694  const ch = 7
695  type C = { x0: number; y0: number; x1: number; y1: number; vx: number; vy: number; a: number; fall: boolean }
696  let cards: C[] = []
697  let plan: { x0: number; y0: number; x1: number; y1: number }[] = []
698  let placed = 0
699  let t = 0
700  let gust = 0
701
702  function design() {
703    plan = []
704    for (let l = 0; l < levels; l++) {
705      const n = levels - l
706      const y = floor - l * ch
707      // as by hand: every leaning pair of the storey first, then the flat cards that floor the next
708      const at = (i: number) => W / 2 + (i - (n - 1) / 2) * 6
709      for (let i = 0; i < n; i++) {
710        plan.push({ x0: at(i) - 3, y0: y, x1: at(i) - 1, y1: y - ch })
711        plan.push({ x0: at(i) + 1, y0: y - ch, x1: at(i) + 3, y1: y })
712      }
713      if (l < levels - 1) for (let i = 0; i < n - 1; i++) plan.push({ x0: at(i), y0: y - ch, x1: at(i) + 6, y1: y - ch })
714    }
715    cards = []
716    placed = 0
717    gust = 0
718    t = 0
719  }
720  design()
721
722  return {
723    step() {
724      t++
725      if (gust === 0) {
726        if (t % 8 === 0 && placed < plan.length) {
727          const p = plan[placed++]!
728          cards.push({ ...p, vx: 0, vy: 0, a: 0, fall: false })
729        }
730        if (placed >= plan.length && t > plan.length * 8 + 40) {
731          gust = 1
732          for (const c of cards) {
733            c.fall = true
734            c.vx = (rng() - 0.3) * 1
735            c.vy = -rng() * 0.8
736          }
737        }
738      } else {
739        gust++
740        for (const c of cards) {
741          c.vy += 0.08
742          for (const k of ['0', '1'] as const) {
743            ;(c as unknown as Record<string, number>)['x' + k] += c.vx
744            ;(c as unknown as Record<string, number>)['y' + k] += c.vy
745          }
746          if (Math.max(c.y0, c.y1) > floor) {
747            const d = Math.max(c.y0, c.y1) - floor
748            c.y0 -= d
749            c.y1 -= d
750            c.vy = 0
751            c.vx *= 0.8
752            c.y0 += (floor - c.y0) * 0.2
753            c.y1 += (floor - c.y1) * 0.2
754          }
755        }
756        if (gust > 120) design()
757      }
758    },
759    draw(px) {
760      // green felt, lighter under the lamp, then a wooden table edge
761      px.fill(0x16502f)
762      ellipse(px, W, H, W / 2, floor - levels * ch * 0.5, W * 0.7, levels * ch, 0x1a5a3a)
763      ellipse(px, W, H, W / 2, floor - levels * ch * 0.5, W * 0.45, levels * ch * 0.65, 0x1e6442)
764      for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) if (hash(x, y, 7) > 0.92) px[y * W + x] = scale(px[y * W + x] as number, 0.85)
765      rect(px, W, H, 0, floor + 1, W, H - floor - 1, 0x6a4026)
766      rect(px, W, H, 0, floor + 1, W, 1, 0x9a6a40)
767      // cards two pixels thick: a white face, a grey edge in shadow, a red pip
768      for (const c of cards) {
769        line(px, W, H, c.x0 + 1, c.y0, c.x1 + 1, c.y1, 0xa8acb8)
770        line(px, W, H, c.x0, c.y0, c.x1, c.y1, 0xfafaf6)
771        dot(px, W, H, (c.x0 + c.x1) / 2, (c.y0 + c.y1) / 2, 0xe03a3a)
772      }
773      if (gust > 0 && gust < 30) for (let k = 0; k < 5; k++) line(px, W, H, (gust * 2 + k * 9) % W, 10 + k * 7, (gust * 2 + k * 9) % W + 4, 10 + k * 7, 0x8ad0ff)
774    },
775  }
776}
777
778// --- jenga collapse ---------------------------------------------------------------
779
780const jenga: Maker = (W, H, rng) => {
781  const floor = H - 4
782  const bw = Math.round(W * 0.42)
783  const bh = 2
784  type B = { layer: number; slot: number; gone: boolean; x: number; y: number; vx: number; vy: number; fall: boolean }
785  let blocks: B[] = []
786  let layers = 0
787  let t = 0
788  let pull: B | undefined
789  let pullT = 0
790  let collapse = 0
791  let pulls = 0
792
793  function build() {
794    blocks = []
795    layers = Math.min(18, Math.floor((H - 10) / bh))
796    for (let l = 0; l < layers; l++) for (let s = 0; s < 3; s++) blocks.push({ layer: l, slot: s, gone: false, x: 0, y: 0, vx: 0, vy: 0, fall: false })
797    collapse = 0
798    pulls = 0
799  }
800  build()
801  const pos = (b: B): [number, number, number] => {
802    const y = floor - (b.layer + 1) * bh
803    if (b.layer % 2 === 0) return [W / 2 - bw / 2, y, bw]
804    const w = bw / 3
805    return [W / 2 - bw / 2 + b.slot * w, y, w - 0.5]
806  }
807
808  return {
809    step() {
810      t++
811      if (collapse > 0) {
812        collapse++
813        for (const b of blocks) {
814          if (!b.fall) continue
815          b.vy += 0.12
816          b.x += b.vx
817          b.y += b.vy
818          if (b.y > floor - pos(b)[1] - bh) {
819            b.vy *= -0.3
820            b.vx *= 0.7
821            b.y = floor - pos(b)[1] - bh
822          }
823        }
824        if (collapse > 120) build()
825        return
826      }
827      if (!pull) {
828        if (t % 40 === 0) {
829          const cand = blocks.filter(b => !b.gone && b.layer < layers - 3 && b.layer > 0 && b.slot !== 1 && b.layer % 2 === 1)
830          pull = pick(rng, cand)
831          pullT = 0
832        }
833        return
834      }
835      pullT++
836      if (pullT > 20) {
837        pull.gone = true
838        blocks.push({ layer: layers + Math.floor(pulls / 3), slot: pulls % 3, gone: false, x: 0, y: 0, vx: 0, vy: 0, fall: false })
839        pulls++
840        pull = undefined
841        const risk = pulls * 0.04
842        if (rng() < risk) {
843          collapse = 1
844          for (const b of blocks) if (!b.gone && b.layer > 2) {
845            b.fall = true
846            b.vx = (rng() - 0.5) * 1.2 + (b.layer - layers / 2) * 0.03
847            b.vy = -rng() * 0.5
848          }
849        }
850      }
851    },
852    draw(px) {
853      sky(px, W, H, 0x2e2030, 0x5a4048, floor)
854      ellipse(px, W, H, W / 2, floor, W * 0.6, H * 0.55, 0x4a3442)
855      rect(px, W, H, 0, floor, W, H - floor, 0x8a5a36)
856      rect(px, W, H, 0, floor, W, 1, 0xb07a4a)
857      ellipse(px, W, H, W / 2 + 1, floor + 1, bw * 0.6, 1, 0x5a3a22)
858      const sway = collapse === 0 ? Math.sin(t * 0.05) * pulls * 0.04 : 0
859      for (const b of blocks) {
860        if (b.gone) continue
861        const [x, y, w] = pos(b)
862        let dx = sway * (b.layer / layers) * 3
863        if (b === pull) dx += (pullT / 20) * (b.slot === 0 ? -bw : bw)
864        // a block: a lit top row, the wood, a shaded right end, end grain on the long ones
865        const c = (b.layer + b.slot) % 2 ? 0xe8c890 : 0xd8b070
866        const bx = x + dx + b.x
867        const by = y + b.y
868        rect(px, W, H, bx, by, w, bh, scale(c, 0.86))
869        rect(px, W, H, bx, by, w, 1, mix(c, 0xfff4e0, 0.35))
870        rect(px, W, H, bx + w - 1, by, 1, bh, scale(c, 0.7))
871        if (b.layer % 2 === 0) for (let k = 1; k < 3; k++) dot(px, W, H, bx + (w * k) / 3, by + 1, scale(c, 0.7))
872      }
873      if (pull) {
874        const [x, y] = pos(pull)
875        const hx = pull.slot === 0 ? x - 2 - (pullT / 20) * bw : x + bw / 3 + 1 + (pullT / 20) * bw
876        slab(px, W, H, hx, y - 1, 3, 3, 0xf0c8a0)
877      }
878    },
879  }
880}
881
882// --- flip-book --------------------------------------------------------------------
883
884const flipbook: Maker = (W, H) => {
885  const pl = 2
886  const pt = 6
887  const pw = W - 5
888  const ph = Math.round(H * 0.72)
889  let page = 0
890  let t = 0
891  let flip = 0
892  const pencil = 0x3a3a48
893  // a stick figure runs in, kicks a ball, the ball comes back down on its head
894  const pose = (n: number) => {
895    const k = n % 48
896    const run = Math.min(k, 20)
897    const x = pl + 4 + run * ((pw - 14) / 20)
898    const ground = pt + ph - 5
899    const stride = k < 20 ? Math.sin(k * 1.1) : 0
900    const kick = k >= 20 && k < 26 ? (k - 20) / 6 : 0
901    const ballK = k - 24
902    const ball: [number, number] = ballK < 0 ? [x + 6, ground] : ballK < 16 ? [x + 6 + ballK * 0.2, ground - Math.sin((ballK / 16) * Math.PI) * (ph * 0.6)] : [x + 9, ground]
903    const fall = k >= 40 ? Math.min(1, (k - 40) / 3) : 0
904    return { x, ground, stride, kick, ball, fall }
905  }
906
907  return {
908    step() {
909      t++
910      // a new page every two frames, a curl that sweeps across
911      if (t % 2 === 0) {
912        page++
913        flip = 2
914      } else if (flip) flip--
915    },
916    draw(px) {
917      sky(px, W, H, 0x4a5a6a, 0x3a4858)
918      // the stack of pages under and to the right, the spiral at the top
919      for (let k = 3; k >= 1; k--) slab(px, W, H, pl + k, pt + k, pw, ph, 0xe8e4d8)
920      rect(px, W, H, pl, pt, pw, ph, 0xfaf8f0)
921      for (let y = pt + 4; y < pt + ph; y += 4) rect(px, W, H, pl, y, pw, 1, 0xc8d8f0)
922      rect(px, W, H, pl + 4, pt, 1, ph, 0xf0b0b0)
923      for (let x = pl + 2; x < pl + pw; x += 3) {
924        dot(px, W, H, x, pt - 1, 0x9aa0a8)
925        dot(px, W, H, x, pt - 2, 0xd0d4dc)
926      }
927      const p = pose(page)
928      const g = p.ground
929      const lean = p.fall * 1.4
930      const hip: [number, number] = [p.x, g - 5 + p.fall * 4]
931      const neck: [number, number] = [p.x + Math.sin(lean) * 5, hip[1] - Math.cos(lean) * 5]
932      line(px, W, H, ...hip, ...neck, pencil)
933      for (let a = 0; a < 10; a++) dot(px, W, H, neck[0] + Math.sin(lean) * 2 + Math.cos(a) * 1.6, neck[1] - Math.cos(lean) * 2 + Math.sin(a) * 1.6, pencil)
934      line(px, W, H, ...hip, hip[0] + 2 * p.stride + p.kick * 4, g - p.kick * 3, pencil)
935      line(px, W, H, ...hip, hip[0] - 2 * p.stride, g, pencil)
936      line(px, W, H, ...neck, neck[0] + 3, neck[1] + 2 + p.stride, pencil)
937      line(px, W, H, ...neck, neck[0] - 3, neck[1] + 2 - p.stride, pencil)
938      line(px, W, H, pl + 1, g + 1, pl + pw - 1, g + 1, 0x8a8a98)
939      // the ball, and stars when it lands on the head
940      for (let a = 0; a < 8; a++) dot(px, W, H, p.ball[0] + Math.cos(a) * 1.3, p.ball[1] - 1.3 + Math.sin(a) * 1.3, 0xd04040)
941      if (p.fall > 0) for (let k = 0; k < 3; k++) dot(px, W, H, neck[0] - 3 + k * 3, neck[1] - 5 - (k % 2), 0xe0b020)
942      // the page turning: a curl sweeping across, and the thumb holding the corner
943      if (flip) {
944        const cx = pl + pw - (2 - flip) * (pw / 2)
945        for (let y = pt; y < pt + ph; y++) {
946          tint(px, W, H, cx, y, 0x8a8a98, 0.5)
947          tint(px, W, H, cx + 1, y, 0xffffff, 0.6)
948        }
949      }
950      lit(px, W, H, pl + pw + 1, pt + ph - 3, 3, 4, 0xf0c8a0, 0x8a5a3a)
951      text(px, W, H, 1, H - 7, String(page % 1000), 0xd0d8e0)
952    },
953  }
954}
955
956// --- rug tufting --------------------------------------------------------------------
957
958const tufting: Maker = (W, H, rng) => {
959  // a tufting gun fills a pattern colour by colour, then the rug is flipped and sheared to show it
960  const x0 = 3
961  const y0 = Math.round(H * 0.14)
962  const cw = W - 6
963  const ch = Math.round(H * 0.74)
964  const PATTERNS: ((u: number, v: number) => number)[] = [
965    // a smiley
966    (u, v) => {
967      const d = Math.hypot(u - 0.5, (v - 0.5) * 1.4)
968      if (d > 0.42) return 0
969      if (d > 0.36) return 2
970      if (Math.hypot(u - 0.36, (v - 0.4) * 1.4) < 0.06 || Math.hypot(u - 0.64, (v - 0.4) * 1.4) < 0.06) return 2
971      if (d > 0.18 && d < 0.25 && v > 0.52) return 2
972      return 1
973    },
974    // a mushroom
975    (u, v) => {
976      if (v < 0.5 && Math.hypot(u - 0.5, (v - 0.48) * 1.2) < 0.42) return Math.hypot(u - 0.35, v - 0.3) < 0.08 || Math.hypot(u - 0.66, v - 0.36) < 0.07 ? 3 : 1
977      if (v >= 0.5 && v < 0.82 && Math.abs(u - 0.5) < 0.14) return 3
978      return 0
979    },
980    // a checkerboard
981    (u, v) => ((Math.floor(u * 4) + Math.floor(v * 6)) % 2 ? 1 : 2),
982    // a heart
983    (u, v) => {
984      const x = (u - 0.5) * 2.4
985      const y = (0.45 - v) * 2.8
986      return (x * x + y * y - 1) ** 3 - x * x * y * y * y < 0 ? 1 : 0
987    },
988  ]
989  let pal: number[] = []
990  let design: Uint8Array = new Uint8Array(cw * ch)
991  let done = new Uint8Array(cw * ch)
992  let order: number[] = []
993  let k = 0
994  let phase: 'tuft' | 'flip' | 'shear' | 'show' = 'tuft'
995  let t = 0
996  let shear = 0
997  const reset = () => {
998    const f = PATTERNS[irand(rng, PATTERNS.length)] as (u: number, v: number) => number
999    const h = rng()
1000    pal = [hsv(h + 0.5, 0.15, 0.92), hsv(h, 0.7, 0.95), hsv(h + 0.08, 0.6, 0.35), hsv(h + 0.33, 0.1, 0.98)]
1001    design = new Uint8Array(cw * ch)
1002    for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) design[y * cw + x] = f((x + 0.5) / cw, (y + 0.5) / ch)
1003    done = new Uint8Array(cw * ch)
1004    // colour by colour, each in rows that snake back and forth
1005    order = []
1006    for (const c of [1, 2, 3, 0])
1007      for (let y = 0; y < ch; y++)
1008        for (let i = 0; i < cw; i++) {
1009          const x = y % 2 ? cw - 1 - i : i
1010          if (design[y * cw + x] === c) order.push(y * cw + x)
1011        }
1012    k = 0
1013    phase = 'tuft'
1014    t = 0
1015    shear = 0
1016  }
1017  reset()
1018
1019  return {
1020    step() {
1021      t++
1022      if (phase === 'tuft') {
1023        for (let n = 0; n < 5 && k < order.length; n++) done[order[k++] as number] = 1
1024        if (k >= order.length) {
1025          phase = 'flip'
1026          t = 0
1027        }
1028      } else if (phase === 'flip' && t > 30) {
1029        phase = 'shear'
1030        t = 0
1031      } else if (phase === 'shear') {
1032        shear += 0.6
1033        if (shear > ch + 4) {
1034          phase = 'show'
1035          t = 0
1036        }
1037      } else if (phase === 'show' && t > 50) reset()
1038    },
1039    draw(px) {
1040      // a workshop wall, the wooden frame, the stretched cloth with the pattern drawn on it
1041      sky(px, W, H, 0x3a3a48, 0x24242e)
1042      slab(px, W, H, x0 - 3, y0 - 3, cw + 6, ch + 6, 0xc08a52, 0x4a2a12)
1043      // the flip: the rug narrows to a line and widens again, mirrored
1044      const squeeze = phase === 'flip' ? Math.abs(Math.cos((t / 30) * Math.PI)) : 1
1045      const front = phase !== 'tuft' && !(phase === 'flip' && t < 15)
1046      for (let y = 0; y < ch; y++)
1047        for (let x = 0; x < cw; x++) {
1048          const sx = (x - cw / 2) / Math.max(0.05, squeeze) + cw / 2
1049          const X = Math.floor(sx)
1050          if (X < 0 || X >= cw) {
1051            px[(y0 + y) * W + x0 + x] = 0x2a2a34
1052            continue
1053          }
1054          const i = y * cw + (front ? cw - 1 - X : X)
1055          const c = pal[design[i] as number] as number
1056          let col: number
1057          if (!done[i]) {
1058            // bare cloth, the marker outline where colours meet
1059            const edge = (X > 0 && design[i] !== design[i - (front ? -1 : 1)]) || (y > 0 && design[i] !== design[i - cw])
1060            col = edge ? 0x3a3a8a : 0xe8e4d8
1061          } else if (!front) col = mix(c, 0x000000, y % 2 ? 0.12 : 0.28)
1062          else {
1063            // the face: fluffy and uneven until the shears pass, crisp after
1064            const sheared = phase === 'show' || (phase === 'shear' && y < shear)
1065            col = sheared ? (y % 2 ? c : mix(c, 0xffffff, 0.12)) : mix(c, 0xffffff, hash(x, y, 7) < 0.3 ? 0.25 : 0.05)
1066          }
1067          px[(y0 + y) * W + x0 + x] = col
1068        }
1069      // the gun on its yarn while tufting; the shears on their pass
1070      if (phase === 'tuft' && k < order.length) {
1071        const i = order[k] as number
1072        const gx = x0 + (i % cw)
1073        const gy = y0 + Math.floor(i / cw)
1074        const yarn = pal[design[i] as number] as number
1075        line(px, W, H, gx, gy - 4, W - 2, -1, yarn)
1076        slab(px, W, H, gx - 2, gy - 5, 5, 5, 0xd8d8e0, 0x3a3a48)
1077        rect(px, W, H, gx - 1, gy - 4, 3, 1, 0xf0c030)
1078        dot(px, W, H, gx, gy, 0x202028)
1079      }
1080      if (phase === 'shear') {
1081        const sy = y0 + Math.min(ch - 1, Math.round(shear))
1082        slab(px, W, H, x0 - 2, sy - 2, 6, 4, 0x5a6a7a, 0x1a2028)
1083        rect(px, W, H, x0 + 4, sy - 1, cw - 4, 1, 0xf0f0f0)
1084        for (let x = x0; x < x0 + cw; x += 3) dot(px, W, H, x + (t % 3), sy + 1, 0xffffff)
1085      }
1086    },
1087  }
1088}
1089
1090export const CRAFTS = {
1091  brickbuild,
1092  pixeldraw,
1093  pottery,
1094  crossstitch,
1095  fusebeads,
1096  mosaic,
1097  loom,
1098  mural,
1099  sandcastle,
1100  blockhouse,
1101  cardtower,
1102  jenga,
1103  flipbook,
1104  tufting,
1105}
1106
1107