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

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.

Try the feed in your browser: every video, playing as in the mod.
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.
| Action | Mouse | Keys* |
|---|---|---|
| Next / previous video | wheel, ▼ / ▲ | j / k |
| Pause | click the video | space |
| Like | double-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 column | t |
| Shortcuts | ? under ··· | h, or ? once the video has been clicked |
| Keep scrolling / close the end card | buttons | c / 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.
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).t), at the top of the side column, on the playing video:| Command | What it does | |||||
|---|---|---|---|---|---|---|
/brainrot | Open the feed now | |||||
/brainrot stats | Videos watched, likes, time and your favorite kinds, this session and all time | |||||
/brainrot off / on | Turn 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-15 | Desktop 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.
454 of them, in 33 themes:

\* 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.
/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.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.
MIT. What changed in each version: CHANGELOG.md.
hooks/register.tsx 1435 lines1import { 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}
1200hooks/cells.ts 57 lines1// 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}
57hooks/draw.ts 163 lines1// 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}
163hooks/themes/index.ts 222 lines1import 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] ?? [])]
222hooks/i18n.ts 348 lines1import 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]))
348hooks/controls.tsx 76 lines1import 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
76hooks/tap.tsx 37 lines1import 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
37hooks/themes/animals.ts 1595 lines1import { 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 lines1import { 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 lines1import { 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 lines1import { 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 lines1import { 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