A /crossy-code command that opens a Crossy Road style game in a pane: hop across bugs, merge trains and streams to /dev/null

/crossy-code opens a Crossy Road style game in a pane. It is drawn as a voxel world in the terminal. A small software renderer draws boxes with shadows, lighting and antialiasing, two pixels to a cell.

Video. The run is played by a demo autopilot, see demo/README.md.
Crossy Code is a Claude Code mod: a plugin whose hooks module, hooks/register.tsx, registers the /crossy-code command and opens a pane beside the chat that holds a Raster, a grid of colored terminal cells. About 30 times a second a frame loop steps the game and rasterizes the voxel world in plain TypeScript, into a z-buffer with a shadow map, then blits the result to the pane as half-block characters, two pixels to a cell. There is no game engine, WebGL or native code: keys arrive as pane hotkeys, the save lives in the plugin's store, and everything on screen is boxes from hooks/models.ts.
This repository is its own plugin marketplace:
claude plugin marketplace add samlevine03/crossy-code
claude plugin install crossy-code@crossy-code
Or, inside a session: /plugin marketplace add samlevine03/crossy-code, then /plugin install crossy-code@crossy-code.
The pane takes keys while it has focus.
w a s d hop. The arrow keys and h j k l work once the status line is clicked.p pauses. The game also pauses when the pane loses focus.c opens the character picker. a and d browse, w buys or picks.r starts over. f shows frames per second, render time and supersampling. q or esc closes the pane.Tokens appear on grass and roads. They buy characters:
| Character | Tokens |
|---|---|
| Clawd | free |
| Rubber Duck | 50 |
| Ferris | 100 |
| CI Bot | 100 |
| Cold Brew | 150 |
| Save Icon | 150 |
| Kernel Hacker | 200 |
| Zombie Process | 300 |
Top score, tokens, characters and death counts are saved to the plugin store and carry across sessions.
The renderer redraws the whole world each frame, at about 30 frames per second:
The terminal paints at most 1024 color pairs a frame. Each frame is rounded to 4 bits a channel, and the rarest pairs merge into their nearest neighbor so the score and banners stay sharp.
The game needs a truecolor terminal, such as iTerm2, Ghostty, WezTerm, kitty or Windows Terminal. In other terminals the colors come out wrong. In tmux, set COLORTERM=truecolor and set -as terminal-features ',*:RGB'. On other surfaces the pane shows a note to open it in the terminal.
claude plugin validate .
claude plugin test .
Contributions: see CONTRIBUTING.md.
Crossy Code is an unofficial fan project and a parody. It is not affiliated with or endorsed by Hipster Whale, the makers of Crossy Road, or by Anthropic. Crossy Road is a trademark of Hipster Whale. Claude, Claude Code and Clawd belong to Anthropic.
No code, art, models or sound from Crossy Road are used. The game rules, the renderer, the font and every voxel model were written with Claude Code "from scratch" in this repository. Ferris, the Rust crab, is in the public domain.
MIT, see LICENSE.
hooks/register.tsx 419 lines1// /crossy-code: a Crossy Road style game in a pane, drawn as half-block cells.
2
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, Timer } from 'claude-code'
5
6import type { CrossySave, CrossyUi } from '../types'
7import { Game } from './game'
8import type { DeathKind, Dir } from './game'
9import { CHARACTERS, character } from './models'
10import { Renderer } from './render'
11import { deathLine, drawHud, drawPicker, drawWorld, viewFor } from './scene'
12
13const PANE = 'crossy-code'
14const SCREEN = 'screen'
15const FRAME_MS = 33
16// the rows under the picture: the status line and the controls
17const CHROME_ROWS = 2
18
19const ui = atom({ plugin: 'crossy-code', key: 'ui' } as const, { screen: 'game', status: '' } as CrossyUi)
20
21const TITLE_STATUS = 'Hop with w a s d. Click this line to use arrow keys or h j k l instead.'
22const PLAY_STATUS = 'Keep moving: the cursor comes for anyone who idles.'
23
24export function newSave(): CrossySave {
25 return { best: 0, tokens: 0, owned: ['clawd'], selected: 'clawd', games: 0, hops: 0, deaths: {} }
26}
27
28// Fills in anything missing from a save written by an older version.
29export function loadSave(raw: unknown): CrossySave {
30 const base = newSave()
31 if (!raw || typeof raw !== 'object') return base
32 const s = raw as Partial<CrossySave>
33 const owned = Array.isArray(s.owned) ? s.owned.filter(id => CHARACTERS.some(c => c.id === id)) : []
34 if (!owned.includes('clawd')) owned.unshift('clawd')
35 const selected = typeof s.selected === 'string' && owned.includes(s.selected) ? s.selected : 'clawd'
36 return {
37 best: typeof s.best === 'number' ? s.best : 0,
38 tokens: typeof s.tokens === 'number' ? s.tokens : 0,
39 owned,
40 selected,
41 games: typeof s.games === 'number' ? s.games : 0,
42 hops: typeof s.hops === 'number' ? s.hops : 0,
43 deaths: s.deaths && typeof s.deaths === 'object' ? { ...s.deaths } : {},
44 }
45}
46
47// What a key means on each screen.
48export type Action = Dir | 'pause' | 'chars' | 'retry' | 'quit' | 'go' | 'stats'
49
50export function actionFor(key: string): Action | null {
51 switch (key) {
52 case 'f':
53 return 'stats'
54 case 'up':
55 case 'w':
56 case 'k':
57 return 'up'
58 case 'down':
59 case 's':
60 case 'j':
61 return 'down'
62 case 'left':
63 case 'a':
64 case 'h':
65 return 'left'
66 case 'right':
67 case 'd':
68 case 'l':
69 return 'right'
70 case 'return':
71 case ' ':
72 case 'space':
73 return 'go'
74 case 'p':
75 return 'pause'
76 case 'c':
77 return 'chars'
78 case 'r':
79 return 'retry'
80 case 'q':
81 return 'quit'
82 default:
83 return null
84 }
85}
86
87let save = newSave()
88let game = new Game(Date.now() >>> 0, 0, 0)
89const renderer = new Renderer()
90let screen: CrossyUi['screen'] = 'game'
91let paused = false
92let newBest = 0
93let pickIndex = 0
94let spin = 0
95let clock = 0
96
97// the mounted Raster's size, and the frame loop that repaints it
98let cols = 0
99let rows = 0
100let ss = 2
101let slow = 0
102let fast = 0
103let loop: Timer | undefined
104let wait = FRAME_MS / 2
105let lastTick = 0
106let last = 0
107let inFlight = 0
108let ticks = 0
109let tps = 0
110let showStats = false
111let renderMs = 0
112let fps = 0
113let frames = 0
114let fpsSince = 0
115
116function who() {
117 return character(save.selected)
118}
119
120async function persist($: EngineInterface) {
121 await $.store.set('save', save)
122}
123
124async function setStatus($: EngineInterface, status: string) {
125 await update($, ui, u => (u.status === status && u.screen === screen ? u : { screen, status }))
126}
127
128function restart() {
129 game = new Game(Date.now() >>> 0, save.best, save.tokens)
130 paused = false
131 newBest = 0
132}
133
134function frame(): string {
135 const t0 = performance.now()
136 if (screen === 'picker') {
137 drawPicker(renderer, { index: pickIndex, owned: new Set(save.owned), selected: save.selected, tokens: save.tokens, spin }, clock)
138 } else {
139 drawWorld(renderer, game, who(), viewFor(renderer.width, renderer.height))
140 const stats = showStats ? [`${fps} FPS`, `${Math.round(renderMs)} MS ${ss}X`] : undefined
141 drawHud(renderer, game, { paused, newBest, stats })
142 }
143 const ms = performance.now() - t0
144 renderMs = renderMs * 0.9 + ms * 0.1
145 adapt(ms)
146 frames++
147 if (t0 - fpsSince > 1000) {
148 fps = Math.round((frames * 1000) / (t0 - fpsSince))
149 tps = Math.round((ticks * 1000) / (t0 - fpsSince))
150 frames = 0
151 ticks = 0
152 fpsSince = t0
153 }
154 return renderer.encode()
155}
156
157// Trades supersampling for speed: drop a level when frames run long, add one back when there is room.
158function adapt(ms: number) {
159 if (ms > FRAME_MS * 0.75) slow++
160 else slow = Math.max(0, slow - 1)
161 if (ms < FRAME_MS * 0.25) fast++
162 else fast = 0
163 if (slow > 8 && ss > 1) {
164 ss--
165 slow = 0
166 renderer.resize(cols, rows, ss)
167 } else if (fast > 90 && ss < 3) {
168 ss++
169 fast = 0
170 renderer.resize(cols, rows, ss)
171 }
172}
173
174async function step($: EngineInterface, dt: number) {
175 clock += dt
176 if (screen === 'picker') {
177 spin += dt * 1.2
178 return
179 }
180 if (paused) return
181 game.update(dt)
182 newBest = Math.max(0, newBest - dt)
183 for (const ev of game.drainEvents()) {
184 if (ev.type === 'death') {
185 if (ev.kind === 'train') game.shatter(who().bits)
186 await setStatus($, deathLine(ev.kind, ev.by))
187 } else if (ev.type === 'over') {
188 const kind: DeathKind = game.death?.kind ?? 'squash'
189 save = {
190 ...save,
191 best: Math.max(save.best, game.score),
192 tokens: game.tokensTotal,
193 games: save.games + 1,
194 hops: save.hops + game.hops,
195 deaths: { ...save.deaths, [kind]: (save.deaths[kind] ?? 0) + 1 },
196 }
197 await persist($)
198 await setStatus($, `${deathLine(kind, game.death?.by ?? null)} Score ${game.score}, top ${save.best}. Press w or r to go again.`)
199 } else if (ev.type === 'token') {
200 save = { ...save, tokens: ev.total }
201 } else if (ev.type === 'best') {
202 newBest = 2
203 } else if (ev.type === 'hop' && game.hops === 1) {
204 await setStatus($, PLAY_STATUS)
205 }
206 }
207}
208
209function stopLoop() {
210 loop?.cancel()
211 loop = undefined
212}
213
214// Each wait starts after the last frame's work, so the wait is cut by however late the frames have been coming.
215function schedule($: EngineInterface) {
216 loop = $.clock.after(Math.max(1, Math.round(wait)), () => {
217 const now = performance.now()
218 wait = Math.max(1, Math.min(FRAME_MS, wait + (FRAME_MS - (now - lastTick)) * 0.3))
219 lastTick = now
220 void tick($).finally(() => {
221 if (loop) schedule($)
222 })
223 })
224}
225
226function startLoop($: EngineInterface) {
227 if (loop) return
228 last = performance.now()
229 lastTick = last
230 schedule($)
231}
232
233async function tick($: EngineInterface) {
234 const now = performance.now()
235 const dt = Math.min(0.1, (now - last) / 1000)
236 last = now
237 ticks++
238 await step($, dt)
239 // blits resolve a frame or so later; keep two on the way at most rather than queue up
240 if (inFlight >= 2 || cols === 0) return
241 inFlight++
242 void $.ui.blit({ requestId: PANE, key: SCREEN, cells: frame(), columns: cols, rows }).then(res => {
243 inFlight--
244 if (res.deny) {
245 // closed, hidden or resized: stop until the pane is drawn again
246 stopLoop()
247 if (game.phase === 'play') paused = true
248 }
249 })
250}
251
252async function act($: EngineInterface, action: Action) {
253 if (action === 'stats') {
254 showStats = !showStats
255 return
256 }
257 if (screen === 'picker') return pick($, action)
258 if (action === 'quit') return close($)
259 if (action === 'chars') {
260 if (game.phase === 'play' && !paused) paused = true
261 screen = 'picker'
262 pickIndex = Math.max(0, CHARACTERS.findIndex(c => c.id === save.selected))
263 await setStatus($, pickerStatus())
264 return
265 }
266 if (action === 'pause') {
267 if (game.phase !== 'play') return
268 paused = !paused
269 return
270 }
271 if (game.phase === 'over') {
272 if (action === 'retry' || action === 'up' || action === 'go') {
273 restart()
274 await setStatus($, TITLE_STATUS)
275 }
276 return
277 }
278 if (action === 'retry') {
279 if (game.phase === 'play' || game.phase === 'dying') {
280 restart()
281 await setStatus($, TITLE_STATUS)
282 }
283 return
284 }
285 if (paused) {
286 paused = false
287 if (action === 'go') return
288 }
289 game.input(action === 'go' ? 'up' : action)
290}
291
292function pickerStatus(): string {
293 const c = CHARACTERS[pickIndex]!
294 if (save.owned.includes(c.id)) return `${c.name}: ${c.blurb} Press w to play as ${c.name}, a and d to browse, c to go back.`
295 const short = c.price - save.tokens
296 return short > 0
297 ? `${c.name} costs ${c.price} tokens. You have ${save.tokens}: ${short} to go. Pick up tokens on the way.`
298 : `${c.name} costs ${c.price} tokens. You have ${save.tokens}. Press w to buy.`
299}
300
301async function pick($: EngineInterface, action: Action) {
302 const n = CHARACTERS.length
303 if (action === 'left' || action === 'right') {
304 pickIndex = (pickIndex + (action === 'left' ? -1 : 1) + n) % n
305 spin = 0
306 } else if (action === 'up' || action === 'go') {
307 const c = CHARACTERS[pickIndex]!
308 if (!save.owned.includes(c.id)) {
309 if (save.tokens < c.price) return
310 save = { ...save, tokens: save.tokens - c.price, owned: [...save.owned, c.id] }
311 }
312 save = { ...save, selected: c.id }
313 await persist($)
314 screen = 'game'
315 // a new character starts a new run, unless the run has not begun
316 if (game.phase !== 'title') restart()
317 game.tokensTotal = save.tokens
318 await setStatus($, TITLE_STATUS)
319 return
320 } else if (action === 'chars' || action === 'quit' || action === 'down') {
321 screen = 'game'
322 await setStatus($, game.phase === 'title' ? TITLE_STATUS : PLAY_STATUS)
323 return
324 }
325 await setStatus($, pickerStatus())
326}
327
328async function close($: EngineInterface) {
329 stopLoop()
330 if (game.phase === 'play') paused = true
331 await persist($)
332 await $.ui.close({ id: PANE })
333}
334
335export const register: Register = on => {
336 on('session.start', async ($, e, next) => {
337 await $.command.register({ name: 'crossy-code', description: 'Play Crossy Code: hop across bugs, merge trains and log streams' })
338 save = loadSave(await $.store.get('save'))
339 restart()
340 return next(e)
341 })
342
343 on('command.run', { command: 'crossy-code' }, async $ => {
344 await setStatus($, game.phase === 'title' ? TITLE_STATUS : PLAY_STATUS)
345 const { isPlaced } = await $.ui.open({ id: PANE, title: 'Crossy Code', focus: true, closeOnEscape: true, rows: 44, columns: 110 })
346 return { text: isPlaced ? 'Crossy Code is open. Hop with w a s d.' : 'Crossy Code will open once there is room for the pane.' }
347 })
348
349 on('ui.close', { id: PANE }, async ($, e, next) => {
350 stopLoop()
351 if (game.phase === 'play') paused = true
352 void persist($).catch(() => {})
353 return next(e)
354 })
355
356 on('ui.message', async ($, e, next) => {
357 const data = e.data as { key?: unknown } | null
358 const action = typeof data?.key === 'string' ? actionFor(data.key) : null
359 if (action) await act($, action)
360 return next(e)
361 })
362
363 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
364 const state = await read($, ui)
365 const elements = $.ui.resolve(e)
366 const { Box, Text, Button } = elements
367 const Raster = 'Raster' in elements ? elements.Raster : undefined
368 const Client = 'Client' in elements ? elements.Client : undefined
369 // Raster draws only in the terminal; elsewhere it is an empty fragment
370 if (!Raster || e.surface !== 'terminal') {
371 return (
372 <Box flexDirection="column">
373 <Text>Crossy Code draws its picture in the terminal. Open it there with /crossy-code.</Text>
374 </Box>
375 )
376 }
377 // typing elsewhere would leave the player to the cursor
378 if (!e.props.isFocused && game.phase === 'play') paused = true
379 const width = Math.max(20, Math.min(512, e.props.bodyColumns))
380 const height = Math.max(6, Math.min(256, e.props.scroll.bodyRows - CHROME_ROWS))
381 if (width !== cols || height !== rows) {
382 cols = width
383 rows = height
384 renderer.resize(cols, rows, ss)
385 }
386 const press = (action: Action) => () => act($, action)
387 const control = (key: string, hotkey: string, label: string, action: Action) => (
388 <Button key={key} plain hotkey={hotkey} onPress={press(action)}>
389 {label}
390 </Button>
391 )
392 const cells = frame()
393 startLoop($)
394 return (
395 <Box flexDirection="column">
396 <Raster key={SCREEN} columns={cols} rows={rows} cells={cells} />
397 {Client ? (
398 <Client key="keys" module="./keys.tsx" props={{ label: state.status }} width={cols} height={1} />
399 ) : (
400 <Text wrap="truncate-end">{state.status}</Text>
401 )}
402 <Box flexDirection="row" columnGap={1}>
403 <Button key="up" plain hotkey="w" autoFocus onPress={press('go')}>
404 ↑
405 </Button>
406 {control('left', 'a', '←', 'left')}
407 {control('down', 's', '↓', 'down')}
408 {control('right', 'd', '→', 'right')}
409 {control('pause', 'p', 'pause', 'pause')}
410 {control('chars', 'c', 'chars', 'chars')}
411 {control('retry', 'r', 'retry', 'retry')}
412 {control('stats', 'f', 'fps', 'stats')}
413 {control('quit', 'q', 'quit', 'quit')}
414 </Box>
415 </Box>
416 )
417 })
418}
419hooks/game.ts 868 lines1// The game's rules: an endless strip of rows, the things that move on them,
2// and a character hopping forward one tile at a time.
3//
4// x runs across the screen (the playfield is -4..4), z runs forward, y is up.
5// Times are in seconds and speeds in tiles per second.
6
7import { BUGS, TRAIN_CAR } from './models'
8import type { BugKind, PropKind } from './models'
9
10export type RowKind = 'grass' | 'road' | 'rail' | 'water'
11export type Phase = 'title' | 'play' | 'dying' | 'over'
12export type Dir = 'up' | 'down' | 'left' | 'right'
13export type DeathKind = 'squash' | 'sideswipe' | 'train' | 'drown' | 'edge' | 'cursor'
14export type TrainCar = 'engine' | 'passing' | 'failing' | 'pending'
15
16export type Mover = {
17 x: number
18 length: number
19 // logs dip when landed on; 1 just after, easing back to 0
20 sink: number
21 // which log model or bug kind
22 kind: number
23}
24
25export type Rail = {
26 state: 'idle' | 'warn' | 'pass'
27 timer: number
28 // the front of the train
29 x: number
30 cars: TrainCar[]
31}
32
33export type Decor = { x: number; dz: number; kind: number }
34
35export type Row = {
36 z: number
37 kind: RowKind
38 // grass: what stands on each tile, indexed x + SPAN
39 props: (PropKind | null)[]
40 decor: Decor[]
41 dir: 1 | -1
42 speed: number
43 bug: BugKind
44 movers: Mover[]
45 rail: Rail | null
46 pads: number[]
47 padSink: number[]
48 token: number | null
49}
50
51export type Particle = {
52 x: number
53 y: number
54 z: number
55 vx: number
56 vy: number
57 vz: number
58 life: number
59 max: number
60 color: number
61 size: number
62 spin: number
63 floor: number
64 emissive: boolean
65}
66
67export type Popup = { x: number; y: number; z: number; text: string; t: number }
68
69export type Death = {
70 kind: DeathKind
71 t: number
72 // what did it: a bug kind, or nothing
73 by: BugKind | null
74 // the row and mover the character is stuck to, if any
75 row: Row | null
76 mover: Mover | null
77 offset: number
78}
79
80export type GameEvent =
81 | { type: 'hop' }
82 | { type: 'token'; total: number }
83 | { type: 'death'; kind: DeathKind; by: BugKind | null }
84 | { type: 'over' }
85 | { type: 'best'; score: number }
86
87// Movers wrap around a loop this wide, centered on the playfield.
88export const SPAN = 15
89const LOOP = SPAN * 2
90export const EDGE = 4
91const HOP = 0.16
92const HOP_HEIGHT = 0.5
93const BODY = 0.3
94const TRAIN_SPEED = 30
95const DANGER = 2.8
96const GRAVITY = 24
97export const OVER_DELAY = 1.3
98
99export function groundY(kind: RowKind): number {
100 switch (kind) {
101 case 'grass':
102 return 0
103 case 'road':
104 return -0.1
105 case 'rail':
106 return -0.06
107 case 'water':
108 return -0.3
109 }
110}
111
112// The top of a log, which floats with its top just above the grass.
113export const LOG_TOP = 0.05
114export const PAD_TOP = -0.24
115
116function mulberry32(seed: number): () => number {
117 let a = seed >>> 0
118 return () => {
119 a = (a + 0x6d2b79f5) >>> 0
120 let t = a
121 t = Math.imul(t ^ (t >>> 15), t | 1)
122 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
123 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
124 }
125}
126
127const PROP_WEIGHTS: [PropKind, number][] = [
128 ['tree', 0.42],
129 ['tallTree', 0.16],
130 ['rack', 0.16],
131 ['box', 0.12],
132 ['rock', 0.14],
133]
134
135const BUG_WEIGHTS: [BugKind, number][] = [
136 ['ladybug', 0.34],
137 ['beetle', 0.28],
138 ['roach', 0.22],
139 ['centipede', 0.16],
140]
141
142export class Game {
143 rand: () => number
144 rows = new Map<number, Row>()
145 phase: Phase = 'title'
146 time = 0
147 // time since the phase began
148 phaseTime = 0
149
150 // the character
151 x = 0
152 y = 0
153 z = 0
154 // facing the camera until the first hop
155 yaw = Math.PI
156 private yawFrom = Math.PI
157 private yawTo = Math.PI
158 private yawT = 1
159 // the hop in progress: t runs 0..1, or -1 standing
160 hopT = -1
161 private fromX = 0
162 private fromY = 0
163 private fromZ = 0
164 private toX = 0
165 private toY = 0
166 private toZ = 0
167 // the squash after landing, 1 just landed, easing to 0
168 land = 0
169 // a hop into a wall: the character turns and flinches in place
170 bump = 0
171 private queued: Dir | null = null
172 riding: { row: Row; mover: Mover } | null = null
173 onPad: { row: Row; index: number } | null = null
174
175 camX = 0
176 camZ = 0
177 // set from the view each frame: how many tiles fit across, and rows below the center
178 viewHalfX = 6
179 viewHalfRows = 6
180
181 score = 0
182 best: number
183 tokens = 0
184 tokensTotal: number
185 hops = 0
186 death: Death | null = null
187 cursor: { t: number; x: number; y: number; z: number; grabbed: boolean } | null = null
188 particles: Particle[] = []
189 popups: Popup[] = []
190 shake = 0
191 events: GameEvent[] = []
192 private beatBest = false
193
194 // generator state
195 private nextZ = 0
196 private reach: boolean[] = []
197 private keepClear = new Set<number>()
198 private lastCluster: RowKind = 'grass'
199
200 constructor(seed: number, best: number, tokensTotal: number) {
201 this.rand = mulberry32(seed)
202 this.best = best
203 this.tokensTotal = tokensTotal
204 this.reach = new Array(EDGE * 2 + 1).fill(true)
205 this.nextZ = -9
206 // a wall of trees behind, and open grass in front of the camera
207 for (let z = -9; z < -3; z++) this.addRow(this.grassRow(z, 'solid'))
208 for (let z = -3; z < 4; z++) this.addRow(this.grassRow(z, 'empty'))
209 this.fill()
210 }
211
212 get difficulty(): number {
213 return Math.min(1, Math.max(0, this.z / 220))
214 }
215
216 // ---- generation ----
217
218 private pick<T>(weights: [T, number][]): T {
219 let r = this.rand() * weights.reduce((s, w) => s + w[1], 0)
220 for (const [v, w] of weights) {
221 r -= w
222 if (r <= 0) return v
223 }
224 return weights[weights.length - 1]![0]
225 }
226
227 private between(lo: number, hi: number): number {
228 return lo + this.rand() * (hi - lo)
229 }
230
231 private int(lo: number, hi: number): number {
232 return lo + Math.floor(this.rand() * (hi - lo + 1))
233 }
234
235 private blankRow(z: number, kind: RowKind): Row {
236 return {
237 z,
238 kind,
239 props: new Array(SPAN * 2 + 1).fill(null),
240 decor: [],
241 dir: 1,
242 speed: 0,
243 bug: 'ladybug',
244 movers: [],
245 rail: null,
246 pads: [],
247 padSink: [],
248 token: null,
249 }
250 }
251
252 private addRow(row: Row) {
253 this.rows.set(row.z, row)
254 this.nextZ = row.z + 1
255 }
256
257 private grassRow(z: number, fill: 'solid' | 'empty' | 'random'): Row {
258 const row = this.blankRow(z, 'grass')
259 // the walls of trees past the playfield
260 for (let x = -SPAN; x <= SPAN; x++) {
261 if (Math.abs(x) <= EDGE && fill !== 'solid') continue
262 const r = this.rand()
263 row.props[x + SPAN] = r < 0.62 ? 'tree' : r < 0.82 ? 'tallTree' : r < 0.92 ? 'rack' : 'rock'
264 }
265 for (let i = this.int(1, 4); i > 0; i--) {
266 row.decor.push({ x: this.between(-8, 8), dz: this.between(-0.38, 0.38), kind: this.int(0, 3) })
267 }
268 if (fill === 'random') {
269 const d = this.difficulty
270 for (let attempt = 0; attempt < 12; attempt++) {
271 const props: (PropKind | null)[] = new Array(EDGE * 2 + 1).fill(null)
272 const count = Math.min(5, Math.floor(this.rand() * (3.2 + d * 2.2)) + (attempt < 6 ? 0 : -9))
273 for (let i = 0; i < count; i++) {
274 const x = this.int(-EDGE, EDGE)
275 if (this.keepClear.has(x)) continue
276 props[x + EDGE] = this.pick(PROP_WEIGHTS)
277 }
278 const reach = this.reachable(props)
279 if (reach.some(Boolean)) {
280 for (let x = -EDGE; x <= EDGE; x++) row.props[x + SPAN] = props[x + EDGE]!
281 this.reach = reach
282 break
283 }
284 }
285 } else {
286 this.reach = new Array(EDGE * 2 + 1).fill(fill === 'empty')
287 }
288 this.keepClear.clear()
289 if (fill === 'random' && this.rand() < 0.09) {
290 const free = this.reach.map((ok, i) => (ok ? i - EDGE : null)).filter((x): x is number => x !== null)
291 if (free.length > 0) row.token = free[this.int(0, free.length - 1)]!
292 }
293 return row
294 }
295
296 // Which tiles of a new grass row can be reached from the row before it, walking sideways around obstacles.
297 private reachable(props: (PropKind | null)[]): boolean[] {
298 const n = EDGE * 2 + 1
299 const out = new Array(n).fill(false)
300 for (let i = 0; i < n; i++) if (!props[i] && this.reach[i]) out[i] = true
301 for (let pass = 0; pass < 2; pass++) {
302 for (let i = 1; i < n; i++) if (out[i - 1] && !props[i]) out[i] = true
303 for (let i = n - 2; i >= 0; i--) if (out[i + 1] && !props[i]) out[i] = true
304 }
305 return out
306 }
307
308 private roadRow(z: number): Row {
309 const row = this.blankRow(z, 'road')
310 const d = this.difficulty
311 row.dir = this.rand() < 0.5 ? 1 : -1
312 row.bug = this.pick(BUG_WEIGHTS)
313 const length = BUGS[row.bug].length
314 row.speed = (1.6 + this.rand() * 2.2 + d * 1.8) * (row.bug === 'centipede' ? 0.7 : 1)
315 const most = row.bug === 'centipede' ? 2 : 3
316 const n = this.int(1, most)
317 const spacing = LOOP / n
318 const start = this.between(-SPAN, SPAN)
319 for (let i = 0; i < n; i++) {
320 const jitter = this.between(0, Math.max(0, spacing - length - 4))
321 row.movers.push({ x: wrap(start + i * spacing + jitter), length, sink: 0, kind: 0 })
322 }
323 this.reach = new Array(EDGE * 2 + 1).fill(true)
324 if (this.rand() < 0.06) row.token = this.int(-EDGE, EDGE)
325 return row
326 }
327
328 private railRow(z: number): Row {
329 const row = this.blankRow(z, 'rail')
330 row.dir = this.rand() < 0.5 ? 1 : -1
331 row.rail = { state: 'idle', timer: this.between(1, 6), x: 0, cars: [] }
332 this.reach = new Array(EDGE * 2 + 1).fill(true)
333 return row
334 }
335
336 private waterRow(z: number, prev: Row | undefined): Row {
337 const row = this.blankRow(z, 'water')
338 const d = this.difficulty
339 const padsBefore = prev?.kind === 'water' && prev.pads.length > 0
340 const logsBefore = prev?.kind === 'water' && prev.movers.length > 0
341 if (!padsBefore && this.rand() < 0.3) {
342 // lily pads, at least one reachable from the row before
343 const reachable = this.reach.map((ok, i) => (ok ? i - EDGE : null)).filter((x): x is number => x !== null)
344 const first = reachable.length > 0 ? reachable[this.int(0, reachable.length - 1)]! : 0
345 const pads = new Set([first])
346 for (let i = this.int(1, 3); i > 0; i--) pads.add(this.int(-EDGE, EDGE))
347 row.pads = [...pads].sort((a, b) => a - b)
348 row.padSink = row.pads.map(() => 0)
349 this.reach = new Array(EDGE * 2 + 1).fill(false)
350 for (const x of row.pads) {
351 this.reach[x + EDGE] = true
352 this.keepClear.add(x)
353 }
354 return row
355 }
356 row.dir = logsBefore ? (-prev!.dir as 1 | -1) : this.rand() < 0.5 ? 1 : -1
357 row.speed = 1.0 + this.rand() * 1.4 + d * 0.9
358 let pos = this.between(0, LOOP)
359 const start = pos
360 while (true) {
361 const kind = this.int(0, 2)
362 const length = kind + 2
363 if (pos - start + length > LOOP - 1.5) break
364 row.movers.push({ x: wrap(pos + length / 2), length, sink: 0, kind })
365 pos += length + this.between(1.5, 3.6 + d)
366 }
367 this.reach = new Array(EDGE * 2 + 1).fill(true)
368 this.keepClear.clear()
369 return row
370 }
371
372 // Adds rows until the strip reaches well past the top of the view.
373 private fill() {
374 while (this.nextZ < this.camZ + this.viewHalfRows + 26) this.cluster()
375 for (const z of [...this.rows.keys()]) if (z < this.camZ - this.viewHalfRows - 12) this.rows.delete(z)
376 }
377
378 private cluster() {
379 const d = this.difficulty
380 let kind: RowKind
381 if (this.lastCluster !== 'grass' && this.rand() < 0.72) kind = 'grass'
382 else {
383 const options: [RowKind, number][] = [
384 ['road', 0.42],
385 ['water', 0.3],
386 ['rail', 0.16 + d * 0.12],
387 ]
388 kind = this.pick(options.filter(([k]) => k !== this.lastCluster))
389 }
390 this.lastCluster = kind
391 const count =
392 kind === 'grass'
393 ? this.rand() < 0.65
394 ? 1
395 : this.int(2, 3)
396 : kind === 'road'
397 ? Math.min(5, this.int(1, 2 + Math.round(d * 2)))
398 : kind === 'water'
399 ? Math.min(4, this.int(1, 2 + Math.round(d)))
400 : this.rand() < 0.35
401 ? 2
402 : 1
403 for (let i = 0; i < count; i++) {
404 const z = this.nextZ
405 const prev = this.rows.get(z - 1)
406 const row =
407 kind === 'grass'
408 ? this.grassRow(z, 'random')
409 : kind === 'road'
410 ? this.roadRow(z)
411 : kind === 'rail'
412 ? this.railRow(z)
413 : this.waterRow(z, prev)
414 this.addRow(row)
415 }
416 // a row of lily pads at the end of a river leaves its tiles open on the grass after it
417 if (kind !== 'water') this.keepClear.clear()
418 }
419
420 // ---- queries ----
421
422 row(z: number): Row | undefined {
423 return this.rows.get(z)
424 }
425
426 private blocked(x: number, z: number): boolean {
427 const row = this.rows.get(z)
428 if (!row) return true
429 if (row.kind === 'water') return Math.abs(x) > EDGE + 0.5
430 if (Math.abs(x) > EDGE) return true
431 return row.kind === 'grass' && row.props[Math.round(x) + SPAN] !== null
432 }
433
434 private standY(): number {
435 if (this.riding) return LOG_TOP - this.riding.mover.sink * 0.06
436 if (this.onPad) return PAD_TOP - this.onPad.row.padSink[this.onPad.index]! * 0.05
437 const row = this.rows.get(Math.round(this.z))
438 return row ? groundY(row.kind) : 0
439 }
440
441 // ---- input ----
442
443 input(dir: Dir) {
444 if (this.phase === 'dying' || this.phase === 'over') return
445 if (this.hopT >= 0) {
446 if (this.hopT < 0.5) {
447 this.queued = dir
448 return
449 }
450 this.finishHop()
451 if (this.phase !== 'play') return
452 }
453 this.hop(dir)
454 }
455
456 private face(dir: Dir) {
457 const target = dir === 'up' ? 0 : dir === 'down' ? Math.PI : dir === 'right' ? Math.PI / 2 : -Math.PI / 2
458 let delta = target - this.yaw
459 while (delta > Math.PI) delta -= Math.PI * 2
460 while (delta < -Math.PI) delta += Math.PI * 2
461 this.yawFrom = this.yaw
462 this.yawTo = this.yaw + delta
463 this.yawT = 0
464 }
465
466 private hop(dir: Dir) {
467 this.face(dir)
468 const dz = dir === 'up' ? 1 : dir === 'down' ? -1 : 0
469 const dx = dir === 'right' ? 1 : dir === 'left' ? -1 : 0
470 const tz = Math.round(this.z) + dz
471 const target = this.rows.get(tz)
472 // on water the character keeps its place across; on land it lines up with the tiles
473 let tx = this.x + dx
474 // off a log near the edge, land on the nearest tile inside the playfield
475 if (!target || target.kind !== 'water' || target.pads.length > 0) tx = Math.max(-EDGE, Math.min(EDGE, Math.round(tx)))
476 if (dx !== 0 && Math.abs(this.x + dx) > EDGE + 0.5 && target?.kind !== 'water') tx = this.x + dx
477 if (target?.kind === 'water' && target.pads.length > 0) {
478 const near = target.pads.reduce((a, b) => (Math.abs(b - tx) < Math.abs(a - tx) ? b : a))
479 if (Math.abs(near - this.x - dx) < 0.75) tx = near
480 }
481 if (this.blocked(tx, tz)) {
482 this.bump = 1
483 return
484 }
485 if (this.phase === 'title') {
486 this.phase = 'play'
487 this.phaseTime = 0
488 }
489 this.fromX = this.x
490 this.fromY = this.y
491 this.fromZ = this.z
492 this.toX = tx
493 this.toZ = tz
494 this.toY = target ? (target.kind === 'water' ? (target.pads.length > 0 ? PAD_TOP : LOG_TOP) : groundY(target.kind)) : 0
495 this.hopT = 0
496 this.riding = null
497 this.onPad = null
498 this.hops++
499 this.events.push({ type: 'hop' })
500 }
501
502 private finishHop() {
503 this.hopT = -1
504 this.x = this.toX
505 this.z = this.toZ
506 this.y = this.toY
507 this.land = 1
508 this.landed()
509 }
510
511 private landed() {
512 const z = Math.round(this.z)
513 const row = this.rows.get(z)
514 if (!row) return
515 if (z > this.score) {
516 this.score = z
517 if (!this.beatBest && this.best > 0 && this.score > this.best) {
518 this.beatBest = true
519 this.events.push({ type: 'best', score: this.score })
520 }
521 }
522 if (row.kind === 'water') {
523 if (row.pads.length > 0) {
524 const index = row.pads.findIndex(p => Math.abs(p - this.x) < 0.45)
525 if (index >= 0) {
526 this.onPad = { row, index }
527 row.padSink[index] = 1
528 this.x = row.pads[index]!
529 return
530 }
531 } else {
532 const mover = row.movers.find(m => Math.abs(wrap(this.x - m.x)) < m.length / 2 + 0.12)
533 if (mover) {
534 this.riding = { row, mover }
535 mover.sink = 1
536 // stay on the log, even when landing on its very end
537 const off = wrap(this.x - mover.x)
538 const limit = mover.length / 2 - 0.3
539 if (Math.abs(off) > limit) this.x = mover.x + Math.sign(off) * limit
540 return
541 }
542 }
543 this.die('drown', null)
544 return
545 }
546 if (row.token !== null && Math.round(this.x) === row.token) {
547 row.token = null
548 this.tokens++
549 this.tokensTotal++
550 this.events.push({ type: 'token', total: this.tokensTotal })
551 this.popups.push({ x: this.x, y: this.y + 1.1, z: this.z, text: '+1', t: 0 })
552 this.burst(this.x, this.y + 0.5, this.z, [0xf9ca24, 0xffe66d, 0xffffff], 9, 3.5, 0.06, true)
553 }
554 }
555
556 // ---- update ----
557
558 update(dt: number) {
559 dt = Math.min(dt, 0.1)
560 this.time += dt
561 this.phaseTime += dt
562 this.moveWorld(dt)
563 if (this.phase === 'play' || this.phase === 'title') this.moveCharacter(dt)
564 if (this.phase === 'play') {
565 this.checkHits()
566 this.moveCamera(dt)
567 } else if (this.phase === 'title') {
568 this.moveCamera(dt)
569 } else if (this.phase === 'dying') {
570 this.animateDeath(dt)
571 if (this.phaseTime > OVER_DELAY) {
572 this.phase = 'over'
573 this.phaseTime = 0
574 if (this.score > this.best) this.best = this.score
575 this.events.push({ type: 'over' })
576 }
577 } else if (this.phase === 'over') {
578 this.animateDeath(dt)
579 }
580 this.moveParticles(dt)
581 this.shake = Math.max(0, this.shake - dt * 3)
582 this.fill()
583 }
584
585 private moveWorld(dt: number) {
586 for (const row of this.rows.values()) {
587 for (const m of row.movers) {
588 m.x = wrap(m.x + row.dir * row.speed * dt)
589 m.sink = Math.max(0, m.sink - dt * 4)
590 }
591 for (let i = 0; i < row.padSink.length; i++) row.padSink[i] = Math.max(0, row.padSink[i]! - dt * 4)
592 const rail = row.rail
593 if (!rail) continue
594 rail.timer -= dt
595 if (rail.state === 'idle' && rail.timer <= 0) {
596 rail.state = 'warn'
597 rail.timer = 1.1
598 } else if (rail.state === 'warn' && rail.timer <= 0) {
599 rail.state = 'pass'
600 const n = this.int(3, 5)
601 const colors: TrainCar[] = ['passing', 'passing', 'failing', 'pending']
602 rail.cars = ['engine', ...Array.from({ length: n }, () => colors[this.int(0, 3)]!)]
603 rail.x = -row.dir * (SPAN + 2)
604 if (Math.abs(row.z - this.z) < this.viewHalfRows + 3) this.shake = Math.max(this.shake, 0.35)
605 } else if (rail.state === 'pass') {
606 rail.x += row.dir * TRAIN_SPEED * dt
607 const back = rail.x - row.dir * rail.cars.length * TRAIN_CAR
608 if (back * row.dir > SPAN + 2) {
609 rail.state = 'idle'
610 rail.timer = this.between(2.5, 7 - this.difficulty * 2.5)
611 rail.cars = []
612 }
613 }
614 }
615 }
616
617 private moveCharacter(dt: number) {
618 if (this.yawT < 1) {
619 this.yawT = Math.min(1, this.yawT + dt / 0.08)
620 this.yaw = this.yawFrom + (this.yawTo - this.yawFrom) * this.yawT
621 }
622 this.land = Math.max(0, this.land - dt / 0.14)
623 this.bump = Math.max(0, this.bump - dt / 0.18)
624 if (this.hopT >= 0) {
625 this.hopT += dt / HOP
626 if (this.hopT >= 1) {
627 this.finishHop()
628 if (this.queued && this.phase === 'play') {
629 const q = this.queued
630 this.queued = null
631 this.hop(q)
632 }
633 } else {
634 const t = this.hopT
635 this.x = this.fromX + (this.toX - this.fromX) * t
636 this.z = this.fromZ + (this.toZ - this.fromZ) * t
637 this.y = this.fromY + (this.toY - this.fromY) * t + HOP_HEIGHT * Math.sin(Math.PI * t)
638 }
639 return
640 }
641 if (this.riding) {
642 const { row } = this.riding
643 this.x += row.dir * row.speed * dt
644 if (Math.abs(this.x) > EDGE + 0.85) {
645 this.riding = null
646 this.die('edge', null)
647 return
648 }
649 }
650 this.y = this.standY()
651 }
652
653 private checkHits() {
654 if (this.phase !== 'play') return
655 const z = Math.round(this.z)
656 const row = this.rows.get(z)
657 if (!row) return
658 const airborne = this.hopT > 0.15 && this.hopT < 0.85
659 if (row.kind === 'road') {
660 for (const m of row.movers) {
661 const off = wrap(this.x - m.x)
662 if (Math.abs(off) < m.length / 2 + BODY - 0.05) {
663 if (airborne && Math.abs(this.toZ - this.fromZ) > 0) this.die('sideswipe', row.bug, row, m)
664 else this.die('squash', row.bug, row, m)
665 return
666 }
667 }
668 } else if (row.kind === 'rail' && row.rail?.state === 'pass') {
669 const front = row.rail.x
670 const back = front - row.dir * row.rail.cars.length * TRAIN_CAR
671 if (this.x > Math.min(front, back) - BODY && this.x < Math.max(front, back) + BODY) this.die('train', null, row)
672 }
673 if (this.z < this.camZ - DANGER && !this.cursor) this.die('cursor', null)
674 }
675
676 private moveCamera(dt: number) {
677 const d = this.difficulty
678 if (this.phase === 'play') this.camZ += (0.36 + d * 0.3) * dt
679 if (this.z > this.camZ) this.camZ += (this.z - this.camZ) * Math.min(1, dt * 4)
680 const pan = Math.max(0, EDGE + 0.8 - this.viewHalfX)
681 const goal = Math.max(-pan, Math.min(pan, this.x))
682 this.camX += (goal - this.camX) * Math.min(1, dt * 5)
683 }
684
685 // ---- dying ----
686
687 private die(kind: DeathKind, by: BugKind | null, row: Row | null = null, mover: Mover | null = null) {
688 if (this.phase !== 'play') return
689 this.phase = 'dying'
690 this.phaseTime = 0
691 this.queued = null
692 this.death = { kind, t: 0, by, row, mover, offset: 0 }
693 this.events.push({ type: 'death', kind, by })
694 if (kind === 'sideswipe' && row && mover) {
695 // stuck flat to the bug's side
696 this.hopT = -1
697 const side = Math.sign(wrap(this.x - mover.x)) || -row.dir
698 this.death.offset = side * (mover.length / 2 + 0.1)
699 this.z = row.z
700 this.y = groundY(row.kind) + 0.15
701 this.yaw = side > 0 ? Math.PI / 2 : -Math.PI / 2
702 } else if (kind === 'squash') {
703 this.hopT = -1
704 if (row) this.z = row.z
705 this.y = row ? groundY(row.kind) : 0
706 } else if (kind === 'train' && row) {
707 this.hopT = -1
708 this.shake = 1
709 this.burst(this.x, this.y + 0.4, row.z, [0xffffff, 0xdfe6e9], 6, 4, 0.08, false, row.dir * 8)
710 } else if (kind === 'drown' || kind === 'edge') {
711 this.hopT = -1
712 this.splash(this.x, this.z)
713 } else if (kind === 'cursor') {
714 this.hopT = -1
715 this.cursor = { t: 0, x: this.x, y: 4, z: this.z + 8, grabbed: false }
716 }
717 }
718
719 private animateDeath(dt: number) {
720 const death = this.death
721 if (!death) return
722 death.t += dt
723 if (death.kind === 'sideswipe' && death.mover) this.x = death.mover.x + death.offset
724 if (death.kind === 'drown' || death.kind === 'edge') {
725 this.y = Math.max(-1.2, this.y - dt * 2.2)
726 if (death.kind === 'edge' && death.t < 0.1) this.x += Math.sign(this.x) * dt * 2
727 }
728 const c = this.cursor
729 if (c) {
730 c.t += dt
731 // swoop down from the top of the screen, grab, and carry off past the bottom
732 c.z -= (c.grabbed ? 9 : 12) * dt
733 if (!c.grabbed && c.z <= this.z) {
734 c.grabbed = true
735 this.shake = 0.5
736 }
737 if (c.grabbed) {
738 c.y += 1.6 * dt
739 this.x = c.x
740 this.y = c.y - 0.75
741 this.z = c.z - 0.15
742 } else {
743 c.y = Math.max(this.y + 0.75, 4 - (8 - (c.z - this.z)) * 0.45)
744 }
745 }
746 }
747
748 // ---- particles ----
749
750 private burst(
751 x: number,
752 y: number,
753 z: number,
754 colors: number[],
755 count: number,
756 speed: number,
757 size: number,
758 emissive: boolean,
759 push = 0,
760 ) {
761 for (let i = 0; i < count; i++) {
762 const a = this.rand() * Math.PI * 2
763 const s = speed * (0.4 + this.rand() * 0.6)
764 this.particles.push({
765 x,
766 y,
767 z,
768 vx: Math.cos(a) * s + push,
769 vy: speed * (0.8 + this.rand()),
770 vz: Math.sin(a) * s,
771 life: 0,
772 max: 0.6 + this.rand() * 0.5,
773 color: colors[i % colors.length]!,
774 size: size * (0.6 + this.rand() * 0.8),
775 spin: this.rand() * 10,
776 floor: groundY(this.rows.get(Math.round(z))?.kind ?? 'grass'),
777 emissive,
778 })
779 }
780 }
781
782 splash(x: number, z: number) {
783 const colors = [0xffffff, 0xd6f0ff, 0x9fd8ff]
784 for (let i = 0; i < 16; i++) {
785 const a = (i / 16) * Math.PI * 2
786 const s = 1 + this.rand() * 1.5
787 this.particles.push({
788 x: x + Math.cos(a) * 0.15,
789 y: groundY('water'),
790 z: z + Math.sin(a) * 0.15,
791 vx: Math.cos(a) * s,
792 vy: 4 + this.rand() * 3,
793 vz: Math.sin(a) * s,
794 life: 0,
795 max: 0.7 + this.rand() * 0.3,
796 color: colors[i % 3]!,
797 size: 0.05 + this.rand() * 0.05,
798 spin: 0,
799 floor: groundY('water') - 0.2,
800 emissive: false,
801 })
802 }
803 }
804
805 // Bits of the character flying apart; the scene picks the colors.
806 shatter(colors: number[]) {
807 this.burst(this.x, this.y + 0.3, this.z, colors, 18, 4.5, 0.07, false)
808 }
809
810 private moveParticles(dt: number) {
811 const ps = this.particles
812 let n = 0
813 for (let i = 0; i < ps.length; i++) {
814 const p = ps[i]!
815 p.life += dt
816 if (p.life >= p.max) continue
817 p.vy -= GRAVITY * dt
818 p.x += p.vx * dt
819 p.y += p.vy * dt
820 p.z += p.vz * dt
821 if (p.y < p.floor) {
822 p.y = p.floor
823 p.vy = -p.vy * 0.3
824 p.vx *= 0.6
825 p.vz *= 0.6
826 }
827 ps[n++] = p
828 }
829 ps.length = n
830 const pops = this.popups
831 let k = 0
832 for (let i = 0; i < pops.length; i++) {
833 const p = pops[i]!
834 p.t += dt
835 p.y += dt * 1.2
836 if (p.t < 0.9) pops[k++] = p
837 }
838 pops.length = k
839 }
840
841 // The squash and stretch to draw the character with: [across, up, along].
842 pose(): [number, number, number] {
843 if (this.death?.kind === 'squash') return [1.7, 0.08, 1.7]
844 if (this.death?.kind === 'sideswipe') return [1.25, 1.25, 0.1]
845 if (this.hopT >= 0) {
846 const s = Math.sin(Math.PI * this.hopT)
847 return [1 - 0.1 * s, 1 + 0.25 * s, 1 - 0.1 * s]
848 }
849 const l = this.land * this.land
850 const b = Math.sin(this.bump * Math.PI) * 0.12
851 // a slow breath while waiting to start
852 const idle = this.phase === 'title' ? Math.sin(this.time * 3) * 0.03 : 0
853 return [1 + 0.15 * l + b - idle, 1 - 0.25 * l - b + idle, 1 + 0.15 * l + b - idle]
854 }
855
856 drainEvents(): GameEvent[] {
857 const out = this.events
858 this.events = []
859 return out
860 }
861}
862
863export function wrap(x: number): number {
864 let v = (x + SPAN) % LOOP
865 if (v < 0) v += LOOP
866 return v - SPAN
867}
868hooks/models.ts 523 lines1// Voxel models, written as boxes in sixteenths of a tile.
2//
3// Each model stands on y = 0, is centered on x and z, and faces +z.
4
5import { FLAG_EMISSIVE, FLAG_GROUND } from './render'
6import type { Model } from './render'
7
8const U = 1 / 16
9
10type Box = [number, number, number, number, number, number, number, number?]
11
12function model(boxes: Box[]): Model {
13 const out = new Float32Array(boxes.length * 8)
14 boxes.forEach((b, i) => {
15 out.set([b[0] * U, b[1] * U, b[2] * U, b[3] * U, b[4] * U, b[5] * U, b[6], b[7] ?? 0], i * 8)
16 })
17 return out
18}
19
20// Mirrors boxes across x = 0.
21function mirror(boxes: Box[]): Box[] {
22 return boxes.map(([x0, y0, z0, x1, y1, z1, c, f]) => [-x1, y0, z0, -x0, y1, z1, c, f] as Box)
23}
24
25function both(boxes: Box[]): Box[] {
26 return [...boxes, ...mirror(boxes)]
27}
28
29// ---- characters ----
30
31export type CharacterId = 'clawd' | 'duck' | 'ferris' | 'robot' | 'coffee' | 'floppy' | 'ghost' | 'penguin'
32
33export type Character = {
34 id: CharacterId
35 name: string
36 blurb: string
37 price: number
38 model: Model
39 // the color its debris flies apart in
40 bits: number[]
41 alpha?: number
42}
43
44const CLAUDE = 0xd97757
45const CLAUDE_DARK = 0xb85f42
46const INK = 0x1b1b1f
47
48const clawd = model([
49 [-6, 3, -4.5, 6, 10, 4.5, CLAUDE],
50 // eyes, as the banner's notches
51 [-3.5, 6.5, 4.5, -1.5, 9, 4.9, INK],
52 [1.5, 6.5, 4.5, 3.5, 9, 4.9, INK],
53 // arms
54 ...both([[6, 5, -1.5, 8, 7.5, 1.5, CLAUDE]]),
55 // four legs in a row, as in the banner
56 ...both([
57 [3.5, 0, -1, 5, 3, 1, CLAUDE_DARK],
58 [1.5, 0, -1, 3, 3, 1, CLAUDE_DARK],
59 ]),
60])
61
62const DUCK = 0xffd43b
63const duck = model([
64 [-5, 0, -6, 5, 7, 5, DUCK],
65 [-4, 7, -6, 4, 8.5, 3, DUCK],
66 [-2, 7, -7.5, 2, 10, -5, DUCK],
67 [-3.5, 8, -0.5, 3.5, 14, 5.5, DUCK],
68 [-2, 9.5, 5.5, 2, 11.5, 8.5, 0xff8c1a],
69 [-1.5, 9.5, 8.5, 1.5, 10.5, 9.5, 0xf76707],
70 ...both([[3.5, 11, 2.5, 3.8, 12.5, 4, INK]]),
71 ...both([[5, 2, -3, 5.6, 5.5, 2, 0xfcc419]]),
72])
73
74const RUST = 0xf74c00
75const RUST_DARK = 0xc23b00
76const ferris = model([
77 [-6, 2.5, -4, 6, 7, 4, RUST],
78 [-5, 7, -3, 5, 8, 3, RUST],
79 // eye stalks
80 ...both([
81 [1.75, 8, 1.5, 3.25, 10.5, 3, RUST_DARK],
82 [1.25, 10.5, 1, 3.75, 13, 3.5, 0xffffff],
83 [1.9, 11, 3.5, 3.1, 12.5, 3.8, INK],
84 ]),
85 // claws
86 ...both([
87 [6, 3.5, 1, 8, 5.5, 3, RUST_DARK],
88 [7, 3, 3, 10, 7, 6, RUST],
89 [7, 4.5, 6, 8.5, 7, 8, RUST],
90 [8.5, 3, 6, 10, 5, 7.5, RUST],
91 ]),
92 // legs
93 ...both([
94 [6, 0, -3.5, 8.5, 3, -2.5, RUST_DARK],
95 [6, 0, -1.5, 8.5, 3, -0.5, RUST_DARK],
96 [6, 0, 0.5, 8, 3, 1.25, RUST_DARK],
97 ]),
98 [-3, 5, 4, -1.5, 6, 4.3, INK],
99 [1.5, 5, 4, 3, 6, 4.3, INK],
100 [-1.5, 4, 4, 1.5, 4.6, 4.3, INK],
101])
102
103const robot = model([
104 ...both([[1, 0, -2, 4, 2.5, 2.5, 0x4b5563]]),
105 [-5, 2.5, -3.5, 5, 10, 3.5, 0x9aa5b1],
106 [-3, 4.5, 3.5, 3, 8, 3.8, 0x6b7280],
107 [-2, 6, 3.8, -0.5, 7, 4, 0x2ecc71, FLAG_EMISSIVE],
108 [0.5, 6, 3.8, 2, 7, 4, 0xf1c40f, FLAG_EMISSIVE],
109 ...both([[5, 4.5, -1.5, 7, 9, 1.5, 0x6b7280]]),
110 [-4, 10, -3.5, 4, 15, 3.5, 0x7b8794],
111 [-3, 11.5, 3.5, 3, 13.5, 3.9, 0x22d3ee, FLAG_EMISSIVE],
112 [-0.5, 15, -0.5, 0.5, 18, 0.5, 0x4b5563],
113 [-1, 18, -1, 1, 20, 1, 0xff4d4f, FLAG_EMISSIVE],
114])
115
116const coffee = model([
117 [-5, 0, -5, 5, 11, 5, 0xf1f2f6],
118 [-5.15, 4, -5.15, 5.15, 6, 5.15, CLAUDE],
119 [-4, 10.5, -4, 4, 11.15, 4, 0x6f4e37],
120 // handle
121 [5, 3, -1, 8, 4.5, 1, 0xe4e6ec],
122 [5, 8.5, -1, 8, 10, 1, 0xe4e6ec],
123 [6.5, 4.5, -1, 8, 8.5, 1, 0xe4e6ec],
124 // face
125 [-3, 7.5, 5, -1.5, 9, 5.3, INK],
126 [1.5, 7.5, 5, 3, 9, 5.3, INK],
127 // steam
128 [-2, 12.5, -0.5, -1, 15, 0.5, 0xffffff, FLAG_EMISSIVE],
129 [1, 13.5, -0.5, 2, 16.5, 0.5, 0xffffff, FLAG_EMISSIVE],
130])
131
132const floppy = model([
133 [-6, 1.5, -1, 6, 13.5, 1, 0x3451d1],
134 [-6, 1.5, -1, -4.5, 3, 1.1, 0x2541b2],
135 // shutter and hub on the back
136 [-3, 9, -1.3, 3.5, 13.5, -1, 0xc8ccd4],
137 [0.5, 10, -1.5, 2, 12.5, -1.3, 0x7d8592],
138 [-1.5, 4, -1.3, 1.5, 7, -1, 0xc8ccd4],
139 // label and face on the front
140 [-4.5, 2.5, 1, 4.5, 9, 1.3, 0xf8f9fa],
141 [-4.5, 7.5, 1.3, 4.5, 8.2, 1.4, 0xff6b6b],
142 [-2.5, 4.5, 1.3, -1, 6, 1.5, INK],
143 [1, 4.5, 1.3, 2.5, 6, 1.5, INK],
144 ...both([[2, 0, -0.75, 3.5, 1.5, 0.75, 0x2541b2]]),
145])
146
147const GHOST = 0xf5f6ff
148const ghost = model([
149 [-5, 3, -5, 5, 13, 5, GHOST],
150 [-4, 13, -4, 4, 15, 4, GHOST],
151 [-5, 1, -5, -2.5, 3, 5, GHOST],
152 [0, 1, -5, 2.5, 3, 5, GHOST],
153 [-2.5, 2, -5, 0, 3, 5, GHOST],
154 [2.5, 2, -5, 5, 3, 5, GHOST],
155 [-3.5, 8, 5, -1.5, 11.5, 5.3, INK],
156 [1.5, 8, 5, 3.5, 11.5, 5.3, INK],
157 [-1, 5.5, 5, 1, 7, 5.3, INK],
158 ...both([[5, 7, -1.5, 6.5, 10, 1.5, GHOST]]),
159])
160
161const penguin = model([
162 [-5, 1, -4, 5, 13, 4, 0x2d3436],
163 [-4, 13, -3.5, 4, 14.5, 3.5, 0x2d3436],
164 [-3.5, 2, 4, 3.5, 10.5, 4.3, 0xf8f9fa],
165 ...both([[1.25, 10.5, 4, 3, 12.5, 4.3, 0xf8f9fa]]),
166 ...both([[1.75, 11, 4.3, 2.5, 12, 4.5, INK]]),
167 [-1.5, 9, 4, 1.5, 10.5, 6.5, 0xfd9644],
168 ...both([[5, 4, -2, 6.5, 10, 2, 0x1e272e]]),
169 ...both([[1, 0, -1, 4, 1, 5, 0xfd9644]]),
170])
171
172export const CHARACTERS: Character[] = [
173 { id: 'clawd', name: 'Clawd', blurb: 'Ships it.', price: 0, model: clawd, bits: [CLAUDE, CLAUDE_DARK, 0xf0a58a] },
174 { id: 'duck', name: 'Rubber Duck', blurb: 'Listens to your bugs.', price: 50, model: duck, bits: [DUCK, 0xff8c1a] },
175 { id: 'ferris', name: 'Ferris', blurb: 'Fearless concurrency.', price: 100, model: ferris, bits: [RUST, RUST_DARK] },
176 { id: 'robot', name: 'CI Bot', blurb: 'All checks have passed.', price: 100, model: robot, bits: [0x9aa5b1, 0x22d3ee] },
177 { id: 'coffee', name: 'Cold Brew', blurb: 'Turns into code.', price: 150, model: coffee, bits: [0xf1f2f6, 0x6f4e37] },
178 { id: 'floppy', name: 'Save Icon', blurb: 'Ctrl+S, obviously.', price: 150, model: floppy, bits: [0x3451d1, 0xf8f9fa] },
179 { id: 'penguin', name: 'Kernel Hacker', blurb: 'Compiles from source.', price: 200, model: penguin, bits: [0x2d3436, 0xf8f9fa, 0xfd9644] },
180 { id: 'ghost', name: 'Zombie Process', blurb: 'Kill -9 it. Go on.', price: 300, model: ghost, bits: [GHOST, 0xdfe4ea], alpha: 0.8 },
181]
182
183export function character(id: string): Character {
184 return CHARACTERS.find(c => c.id === id) ?? CHARACTERS[0]!
185}
186
187// ---- bugs (the traffic) ----
188
189export type BugKind = 'ladybug' | 'beetle' | 'roach' | 'centipede'
190
191// A bug is a body and two sets of legs that step in turn.
192export type BugModel = { body: Model; legsA: Model; legsB: Model; length: number }
193
194function legs(zs: number[], reach: number, color: number, side: 1 | -1, start: number): Box[] {
195 return zs
196 .filter((_, i) => (i + start) % 2 === 0)
197 .map(z => {
198 const x0 = side > 0 ? reach - 2.5 : -reach
199 const x1 = side > 0 ? reach : -reach + 2.5
200 return [x0, 0, z - 0.75, x1, 3.5, z + 0.75, color] as Box
201 })
202}
203
204function gait(zs: number[], reach: number, color: number): [Box[], Box[]] {
205 return [
206 [...legs(zs, reach, color, 1, 0), ...legs(zs, reach, color, -1, 1)],
207 [...legs(zs, reach, color, 1, 1), ...legs(zs, reach, color, -1, 0)],
208 ]
209}
210
211function bug(body: Box[], zs: number[], reach: number, legColor: number, length: number): BugModel {
212 const [a, b] = gait(zs, reach, legColor)
213 return { body: model(body), legsA: model(a), legsB: model(b), length }
214}
215
216const ladybug = bug(
217 [
218 [-6, 2.5, -6, 6, 9, 5, 0xe53935],
219 [-5, 9, -5, 5, 10.5, 4, 0xef5350],
220 [-0.5, 2.5, -6.2, 0.5, 10.7, 4.2, INK],
221 ...both([
222 [2, 10.5, -3.5, 4, 10.8, -1.5, INK],
223 [1.5, 10.5, 1, 3.5, 10.8, 3, INK],
224 [6, 5, -3, 6.2, 7, -1, INK],
225 ]),
226 [-4, 2.5, 5, 4, 7.5, 8, INK],
227 ...both([
228 [1.5, 5, 8, 3, 6.5, 8.3, 0xffffff],
229 [1.5, 7, 7, 2, 11, 7.5, INK],
230 ]),
231 ],
232 [-3.5, 0, 3.5],
233 7.5,
234 INK,
235 1,
236)
237
238const beetle = bug(
239 [
240 [-6, 2.5, -9, 6, 8.5, 6, 0x1e8449],
241 [-5, 8.5, -8, 5, 10, 5, 0x27ae60],
242 [-0.4, 2.5, -9.2, 0.4, 10.2, 5.2, 0x145a32],
243 ...both([[1.5, 10, -6, 3, 10.3, 3, 0x58d68d]]),
244 [-4.5, 2.5, 6, 4.5, 7.5, 9.5, 0x0b3d20],
245 ...both([
246 [1.5, 5, 9.5, 3, 6.5, 9.8, 0xf4d03f, FLAG_EMISSIVE],
247 [2.5, 3.5, 9.5, 4, 5, 13, 0x0b3d20],
248 ]),
249 ],
250 [-5.5, -1, 3.5],
251 7.5,
252 0x0b3d20,
253 1.3,
254)
255
256const roach = bug(
257 [
258 [-6, 2, -12, 6, 6.5, 8, 0x6d4c41],
259 [-5, 6.5, -11, 5, 7.5, 7, 0x8d6e63],
260 [-0.4, 2, -12.2, 0.4, 7.7, 7.2, 0x4e342e],
261 [-4.5, 2, 8, 4.5, 6, 11, 0x4e342e],
262 ...both([
263 [1.5, 4, 11, 3, 5.5, 11.3, 0xffffff],
264 [1.5, 5, 11, 2, 5.6, 19, 0x4e342e],
265 [4, 3, -15, 4.6, 3.6, -11, 0x4e342e],
266 ]),
267 ],
268 [-8, -2, 4],
269 8,
270 0x3e2723,
271 1.6,
272)
273
274function centipedeBody(): Box[] {
275 const out: Box[] = []
276 const seg = 7
277 for (let i = 0; i < 6; i++) {
278 const z0 = -24 + i * (seg + 1)
279 const color = i % 2 === 0 ? 0xff7043 : 0xf4511e
280 out.push([-6, 2, z0, 6, 8.5, z0 + seg, color])
281 out.push([-5, 8.5, z0 + 1, 5, 9.5, z0 + seg - 1, 0xffab91])
282 out.push([-6.2, 3.5, z0 + 2, 6.2, 5, z0 + 5, 0xbf360c])
283 }
284 out.push([-5, 2, 24, 5, 8, 30, 0x3e2723])
285 out.push(...both([
286 [1.5, 5, 30, 3.5, 6.5, 30.3, 0xffee58, FLAG_EMISSIVE],
287 [3, 2.5, 30, 5, 4, 33, 0x3e2723],
288 ]))
289 return out
290}
291
292const centipede = bug(
293 centipedeBody(),
294 [-20.5, -12.5, -4.5, 3.5, 11.5, 19.5],
295 8,
296 0x3e2723,
297 3.8,
298)
299
300export const BUGS: Record<BugKind, BugModel> = { ladybug, beetle, roach, centipede }
301
302// ---- the merge train ----
303
304export const TRAIN_CAR = 2.5
305
306const trainEngine = model([
307 [-7, 0, -19, 7, 3, 19, 0x263238],
308 [-6.5, 3, -19, 6.5, 13, 10, 0x6c5ce7],
309 [-6.6, 7, -19.1, 6.6, 8.5, 10.1, 0xffffff],
310 [-5.5, 3, 10, 5.5, 9, 18, 0x5847d6],
311 [-4, 9, 12, 4, 10, 17, 0x463ab8],
312 [-6, 13, -18, 6, 14.5, 9, 0x463ab8],
313 ...both([
314 [6.5, 9.5, -15, 6.8, 12, -11, 0xa5f3fc, FLAG_EMISSIVE],
315 [6.5, 9.5, -9, 6.8, 12, -5, 0xa5f3fc, FLAG_EMISSIVE],
316 ]),
317 [-4, 9.5, 10, 4, 12, 10.2, 0xa5f3fc, FLAG_EMISSIVE],
318 [-2, 5, 18, 2, 7.5, 18.4, 0xfff3b0, FLAG_EMISSIVE],
319 [-6, 0, 18, 6, 2.5, 21, 0x95a5a6],
320 [-1.5, 9, 13, 1.5, 15, 16, 0x2d3436],
321 ...both([
322 [6.5, 0, -15, 7.5, 3.5, -10, INK],
323 [6.5, 0, -4, 7.5, 3.5, 1, INK],
324 [6.5, 0, 8, 7.5, 3.5, 13, INK],
325 ]),
326])
327
328function container(color: number, rib: number): Model {
329 const boxes: Box[] = [
330 [-7, 0, -19, 7, 3, 19, 0x263238],
331 [-6.5, 3, -18, 6.5, 15, 18, color],
332 ]
333 for (let z = -15; z <= 15; z += 5) {
334 boxes.push([-6.7, 3.5, z - 0.6, 6.7, 14.5, z + 0.6, rib])
335 }
336 boxes.push([-6.5, 15, -18, 6.5, 15.3, 18, rib])
337 boxes.push(...both([
338 [6.5, 0, -15, 7.5, 3.5, -10, INK],
339 [6.5, 0, 10, 7.5, 3.5, 15, INK],
340 ]))
341 return model(boxes)
342}
343
344export const TRAIN = {
345 engine: trainEngine,
346 passing: container(0x2ecc71, 0x27ae60),
347 failing: container(0xe74c3c, 0xc0392b),
348 pending: container(0xf1c40f, 0xd4ac0d),
349}
350
351// ---- the river ----
352
353export function log(length: number): Model {
354 const half = (length * 16) / 2 - 0.5
355 const boxes: Box[] = [
356 [-half, -2.5, -5.5, half, 3, 5.5, 0x8d5a2b],
357 [-half + 1, 3, -4.5, half - 1, 4, 4.5, 0x9c6a37],
358 [-half - 0.4, -2, -5, -half, 2.5, 5, 0xd9a066],
359 [half, -2, -5, half + 0.4, 2.5, 5, 0xd9a066],
360 [-half - 0.5, -0.5, -2, -half - 0.4, 1.5, 2, 0xa9713a],
361 [half + 0.4, -0.5, -2, half + 0.5, 1.5, 2, 0xa9713a],
362 ]
363 for (let x = -half + 3; x < half - 3; x += 7) {
364 boxes.push([x, 4, -2.5, x + 3, 4.2, -1.5, 0x6f451f])
365 boxes.push([x + 2, 4, 1.5, x + 5, 4.2, 2.5, 0x6f451f])
366 }
367 return model(boxes)
368}
369
370export const LOGS = [log(2), log(3), log(4)]
371
372export const LILY_PAD = model([
373 [-6, -0.5, -4, 6, 0.5, 4, 0x43a047],
374 [-4, -0.5, -6, 4, 0.5, 6, 0x43a047],
375 [-5, -0.5, -5, 5, 0.5, 5, 0x43a047],
376 [-0.5, -0.4, 0, 0.5, 0.6, 6.2, 0x2e7d32],
377 [-3, 0.5, -3, -1, 0.7, -1, 0x66bb6a],
378])
379
380// ---- tokens ----
381
382export const TOKEN = model([
383 [-3, -4, -0.75, 3, 4, 0.75, 0xf9ca24],
384 [-4, -3, -0.75, 4, 3, 0.75, 0xf9ca24],
385 [-3.5, -3.5, -0.8, 3.5, 3.5, 0.8, 0xf6b93b],
386 [-0.75, -2.5, -1, 0.75, 2.5, 1, CLAUDE],
387 [-2.5, -0.75, -1, 2.5, 0.75, 1, CLAUDE],
388 [-1.75, -1.75, -0.9, 1.75, 1.75, 0.9, 0xe58e6e],
389])
390
391// ---- things on the grass ----
392
393export type PropKind = 'tree' | 'tallTree' | 'rack' | 'box' | 'rock'
394
395function tree(tall: boolean): Model {
396 const top = tall ? 22 : 15
397 const boxes: Box[] = [
398 [-2, 0, -2, 2, 5, 2, 0x7a4f2a],
399 [-6, 5, -6, 6, top, 6, 0x4caf50],
400 [-5, top, -5, 5, top + 1.5, 5, 0x66bb6a],
401 // the dependencies: little packages hanging on the tree
402 [6, 7, -4, 6.6, 9, -2, 0xe74c3c],
403 [6, top - 5, 1, 6.6, top - 3, 3, 0x3498db],
404 [-2, 7, -6.6, 0, 9, -6, 0xf1c40f],
405 [2, top - 4, -6.6, 4, top - 2, -6, 0x9b59b6],
406 ]
407 return model(boxes)
408}
409
410export const PROPS: Record<PropKind, Model> = {
411 tree: tree(false),
412 tallTree: tree(true),
413 rack: model([
414 [-6, 0, -6, 6, 24, 6, 0x2f3640],
415 [-6.5, 23, -6.5, 6.5, 24.5, 6.5, 0x232831],
416 [-5, 3, -6.2, 5, 5, -6, 0x57606f],
417 [-5, 8, -6.2, 5, 10, -6, 0x57606f],
418 [-5, 13, -6.2, 5, 15, -6, 0x57606f],
419 [-5, 18, -6.2, 5, 20, -6, 0x57606f],
420 [6, 2, -4, 6.2, 22, 4, 0x3d4450],
421 ]),
422 box: model([
423 [-6.5, 0, -6.5, 6.5, 12, 6.5, 0xc8a165],
424 [-1.5, 12, -6.6, 1.5, 12.2, 6.6, 0xe6d3a3],
425 [-1.5, 0.5, -6.7, 1.5, 12, -6.5, 0xe6d3a3],
426 [-5, 4, -6.8, 5, 9, -6.6, 0xf8f9fa],
427 [-4, 7, -6.9, 3, 7.6, -6.8, 0x34495e],
428 [-4, 5.5, -6.9, 1, 6.1, -6.8, 0x2ecc71],
429 ]),
430 rock: model([
431 [-6, 0, -5, 6, 7, 5, 0x95a5a6],
432 [-4.5, 7, -4, 4.5, 10, 4, 0xa5b1b2],
433 [2, 0, -6, 6.5, 5, -2, 0x7f8c8d],
434 [-6.5, 0, 1, -2, 4, 5.5, 0x839192],
435 ]),
436}
437
438// Where each rack's lights sit, on its front face.
439export const RACK_LEDS: [number, number][] = [
440 [-4, 3.5],
441 [-2.5, 3.5],
442 [-4, 8.5],
443 [-4, 13.5],
444 [-2.5, 13.5],
445 [-4, 18.5],
446]
447
448export const TUFT = model([
449 [-1.5, 0, -0.5, -0.5, 2, 0.5, 0x7cb342, FLAG_GROUND],
450 [0, 0, -0.5, 1, 3, 0.5, 0x7cb342, FLAG_GROUND],
451 [1.5, 0, 0, 2.5, 1.5, 1, 0x7cb342, FLAG_GROUND],
452])
453
454export function flower(color: number): Model {
455 return model([
456 [-0.4, 0, -0.4, 0.4, 2.5, 0.4, 0x558b2f, FLAG_GROUND],
457 [-1.2, 2.5, -1.2, 1.2, 3.5, 1.2, color, FLAG_GROUND],
458 [-0.5, 3.5, -0.5, 0.5, 3.8, 0.5, 0xfff176, FLAG_GROUND],
459 ])
460}
461
462export const FLOWERS = [flower(0xff6b9d), flower(0xffffff), flower(0x74b9ff)]
463
464// The railway's status light: a post and a head; the lamps are drawn each frame.
465export const SIGNAL = model([
466 [-0.75, 0, -0.75, 0.75, 18, 0.75, 0x57606f],
467 [-3, 18, -1.5, 3, 25, 1.5, 0x1e272e],
468 [-3.5, 25, -2, 3.5, 25.5, 2, 0x1e272e],
469 [-2, 0, -2, 2, 1, 2, 0x57606f],
470])
471
472// ---- the cursor that swoops on idle players ----
473
474const CURSOR_ART = [
475 'B ',
476 'BB ',
477 'BWB ',
478 'BWWB ',
479 'BWWWB ',
480 'BWWWWB ',
481 'BWWWWWB ',
482 'BWWWWWWB ',
483 'BWWWWWWWB ',
484 'BWWWWWWWWB ',
485 'BWWWWWWWWWB ',
486 'BWWWWWWBBBBB',
487 'BWWWBWWB ',
488 'BWWBBWWB ',
489 'BWB BWWB ',
490 'BB BWWB ',
491 'B BWWB ',
492 ' BWWB ',
493 ' BB ',
494]
495
496// The tip is at the origin; the arrow hangs down and to the right in the xy plane.
497function cursor(): Model {
498 const px = 2.5
499 const boxes: Box[] = []
500 CURSOR_ART.forEach((row, y) => {
501 let x = 0
502 while (x < row.length) {
503 const ch = row[x]!
504 if (ch === ' ') {
505 x++
506 continue
507 }
508 let end = x
509 while (end < row.length && row[end] === ch) end++
510 const color = ch === 'B' ? 0x111111 : 0xffffff
511 boxes.push([x * px, -(y + 1) * px, -1.5, end * px, -y * px, 1.5, color, FLAG_EMISSIVE])
512 x = end
513 }
514 })
515 return model(boxes)
516}
517
518export const CURSOR = cursor()
519
520// ---- particles ----
521
522export const BIT = model([[-1, -1, -1, 1, 1, 1, 0xffffff]])
523hooks/render.ts 819 lines1// A small software renderer: boxes in, terminal cells out.
2//
3// The camera is orthographic and never turns, so the scene is boxes drawn
4// into a supersampled z-buffer, lit with one directional light and a shadow
5// map, averaged down, and packed two pixels per cell as upper half blocks.
6// The hooks runtime interprets rather than compiles, so the inner loops
7// avoid calls and allocation.
8
9export type Vec3 = [number, number, number]
10
11// A model is a flat list of boxes, 8 numbers each: x0 y0 z0 x1 y1 z1 color flags.
12export type Model = Float32Array
13
14// A 3x4 affine matrix, row major: [m00 m01 m02 tx m10 m11 m12 ty m20 m21 m22 tz].
15export type Mat = Float64Array
16
17export const FLAG_GROUND = 1 // casts no shadow
18export const FLAG_EMISSIVE = 2 // ignores light and shadow
19export const FLAG_WATER = 4 // gets moving ripples
20export const FLAG_NO_DARKEN = 8 // keeps full color outside the playfield
21
22type Draw = { model: Model; m: Mat; tint: number; amount: number; alpha: number }
23
24const SKY = 0x87c6ff
25const AMBIENT = 0.52
26const DIFFUSE = 0.6
27const SHADOW_KEEP = 0.32
28const PLAYFIELD_EDGE = 4.5
29const OUTSIDE_DARKEN = 0.6
30
31function norm(v: Vec3): Vec3 {
32 const l = Math.hypot(v[0], v[1], v[2])
33 return [v[0] / l, v[1] / l, v[2] / l]
34}
35
36function basis(view: Vec3): [Vec3, Vec3] {
37 const right = norm([view[2], 0, -view[0]])
38 const up = norm([-view[0] * view[1], view[0] * view[0] + view[2] * view[2], -view[2] * view[1]])
39 return [right, up]
40}
41
42// The camera looks forward, down and a little to the left, as Crossy Road's does.
43export const VIEW = norm([-1, -2.8, 2.9])
44export const [RIGHT, UP] = basis(VIEW)
45// Points toward the light: from the upper left and behind the camera.
46const LIGHT = norm([-0.45, 1, -0.6])
47const L_VIEW: Vec3 = [-LIGHT[0], -LIGHT[1], -LIGHT[2]]
48const [L_RIGHT, L_UP] = basis(L_VIEW)
49
50// How far up the screen one row forward moves, and one tile right moves, per unit of scale.
51export const ROW_RISE = UP[2]
52export const TILE_ACROSS = RIGHT[0]
53
54export function compose(
55 x: number,
56 y: number,
57 z: number,
58 yaw = 0,
59 sx = 1,
60 sy = 1,
61 sz = 1,
62 roll = 0,
63 pitch = 0,
64): Mat {
65 // M = Ry(yaw) * Rx(pitch) * Rz(roll) * diag(s)
66 const cy = Math.cos(yaw)
67 const sy_ = Math.sin(yaw)
68 const cp = Math.cos(pitch)
69 const sp = Math.sin(pitch)
70 const cr = Math.cos(roll)
71 const sr = Math.sin(roll)
72 const a10 = cp * sr
73 const a11 = cp * cr
74 const a20 = sp * sr
75 const a21 = sp * cr
76 const m = new Float64Array(12)
77 m[0] = (cy * cr + sy_ * a20) * sx
78 m[1] = (-cy * sr + sy_ * a21) * sy
79 m[2] = sy_ * cp * sz
80 m[3] = x
81 m[4] = a10 * sx
82 m[5] = a11 * sy
83 m[6] = -sp * sz
84 m[7] = y
85 m[8] = (-sy_ * cr + cy * a20) * sx
86 m[9] = (sy_ * sr + cy * a21) * sy
87 m[10] = cy * cp * sz
88 m[11] = z
89 return m
90}
91
92export function scaleColor(c: number, k: number): number {
93 const r = Math.min(255, Math.round(((c >> 16) & 255) * k))
94 const g = Math.min(255, Math.round(((c >> 8) & 255) * k))
95 const b = Math.min(255, Math.round((c & 255) * k))
96 return (r << 16) | (g << 8) | b
97}
98
99export function mixColor(a: number, b: number, t: number): number {
100 const r = ((a >> 16) & 255) * (1 - t) + ((b >> 16) & 255) * t
101 const g = ((a >> 8) & 255) * (1 - t) + ((b >> 8) & 255) * t
102 const bl = (a & 255) * (1 - t) + (b & 255) * t
103 return (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(bl)
104}
105
106// The corners of each face of a box, as corner numbers: bit 0 is x, bit 1 y, bit 2 z.
107// Faces come in pairs per axis, the low side first.
108const FACES = [
109 [0, 2, 6, 4],
110 [1, 3, 7, 5],
111 [0, 1, 5, 4],
112 [2, 3, 7, 6],
113 [0, 1, 3, 2],
114 [4, 5, 7, 6],
115]
116
117const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
118
119function base64Group(a: number, b: number, c: number): string {
120 const v = (a << 16) | (b << 8) | c
121 return BASE64[v >> 18]! + BASE64[(v >> 12) & 63]! + BASE64[(v >> 6) & 63]! + BASE64[v & 63]!
122}
123
124// The four groups of a cell's 12 bytes, indexed by the 4-bit channels they hold.
125const CELL_HEAD = base64Group(0x80, 0x25, 0)
126// 0, top blue, top green
127const CELL_A = Array.from({ length: 256 }, (_, i) => base64Group(0, (i & 15) * 17, (i >> 4) * 17))
128// top red, 0, bottom blue
129const CELL_B = Array.from({ length: 256 }, (_, i) => base64Group((i >> 4) * 17, 0, (i & 15) * 17))
130// bottom green, bottom red, 0
131const CELL_C = Array.from({ length: 256 }, (_, i) => base64Group((i & 15) * 17, (i >> 4) * 17, 0))
132
133// a little under the terminal's 1024, so its own mapping never has to step in
134const PAIR_BUDGET = 1000
135
136function dist12(a: number, b: number): number {
137 const dr = (a >> 8) - (b >> 8)
138 const dg = ((a >> 4) & 15) - ((b >> 4) & 15)
139 const db = (a & 15) - (b & 15)
140 return 2 * dr * dr + 4 * dg * dg + 3 * db * db
141}
142
143const bounds = new WeakMap<Model, Float64Array>()
144
145function boundsOf(model: Model): Float64Array {
146 let b = bounds.get(model)
147 if (b) return b
148 b = new Float64Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
149 for (let i = 0; i < model.length; i += 8) {
150 for (let k = 0; k < 3; k++) {
151 b[k] = Math.min(b[k]!, model[i + k]!)
152 b[k + 3] = Math.max(b[k + 3]!, model[i + 3 + k]!)
153 }
154 }
155 bounds.set(model, b)
156 return b
157}
158
159const MODE_FILL = 0
160const MODE_SHADOW = 1
161const MODE_BLEND = 2
162
163export class Renderer {
164 cols = 0
165 rows = 0
166 // output pixels: one column per cell, two rows per cell
167 width = 0
168 height = 0
169 ss = 2
170 sw = 0
171 sh = 0
172 depth = new Float32Array(0)
173 albedo = new Uint32Array(0)
174 lit = new Float32Array(0)
175 flags = new Uint8Array(0)
176 out = new Uint8Array(0)
177 private keys = new Int32Array(0)
178 private sorted = new Int32Array(0)
179
180 readonly smSize = 320
181 smDepth = new Float32Array(320 * 320)
182 smCenter: Vec3 = [0, 0, 0]
183 smScale = 1
184
185 target: Vec3 = [0, 0, 0]
186 scale = 10 // output pixels per world unit
187 time = 0
188 // the current's speed for water ripples, by row: flowRows[z - flowBase]
189 flowBase = 0
190 flowRows = new Float32Array(64)
191
192 private list: Draw[] = []
193 // projected corners of the box being drawn: x, y, depth for each of 8
194 private p = new Float64Array(24)
195 // the face being filled
196 private qx = new Float64Array(4)
197 private qy = new Float64Array(4)
198 private qz = new Float64Array(4)
199
200 resize(cols: number, rows: number, ss: number) {
201 if (cols === this.cols && rows === this.rows && ss === this.ss) return
202 this.cols = cols
203 this.rows = rows
204 this.ss = ss
205 this.width = cols
206 this.height = rows * 2
207 this.sw = this.width * ss
208 this.sh = this.height * ss
209 const n = this.sw * this.sh
210 this.depth = new Float32Array(n)
211 this.albedo = new Uint32Array(n)
212 this.lit = new Float32Array(n)
213 this.flags = new Uint8Array(n)
214 this.out = new Uint8Array(this.width * this.height * 3)
215 this.keys = new Int32Array(cols * rows)
216 this.sorted = new Int32Array(cols * rows)
217 }
218
219 begin(target: Vec3, scale: number, time: number) {
220 this.target = target
221 this.scale = scale
222 this.time = time
223 this.list.length = 0
224 }
225
226 // `tint` replaces each box's color by `amount` (0 to 1); `alpha` below 1 draws it see-through.
227 draw(model: Model, m: Mat, tint = 0, amount = 0, alpha = 1) {
228 this.list.push({ model, m, tint, amount, alpha })
229 }
230
231 // Renders the draw list into `out` (RGB bytes, width x height).
232 render() {
233 this.shadowPass()
234 this.depth.fill(Infinity)
235 this.flags.fill(0)
236 const list = this.list
237 for (let i = 0; i < list.length; i++) if (list[i]!.alpha >= 1) this.drawModel(list[i]!, MODE_FILL)
238 this.light()
239 for (let i = 0; i < list.length; i++) if (list[i]!.alpha < 1) this.drawModel(list[i]!, MODE_BLEND)
240 this.resolve()
241 }
242
243 // ---- geometry ----
244
245 private drawModel(d: Draw, mode: number) {
246 const model = d.model
247 const m = d.m
248 const shadow = mode === MODE_SHADOW
249 const r = shadow ? L_RIGHT : RIGHT
250 const u = shadow ? L_UP : UP
251 const f = shadow ? L_VIEW : VIEW
252 const o = shadow ? this.smCenter : this.target
253 const s = shadow ? this.smScale : this.scale * this.ss
254 const w = shadow ? this.smSize : this.sw
255 const h = shadow ? this.smSize : this.sh
256 const cx = w / 2
257 const cy = h / 2
258
259 // A maps the model's own coordinates straight to (screen x, screen y, depth).
260 const m0 = m[0]!
261 const m1 = m[1]!
262 const m2 = m[2]!
263 const m4 = m[4]!
264 const m5 = m[5]!
265 const m6 = m[6]!
266 const m8 = m[8]!
267 const m9 = m[9]!
268 const m10 = m[10]!
269 const tx = m[3]! - o[0]
270 const ty = m[7]! - o[1]
271 const tz = m[11]! - o[2]
272 const a00 = s * (r[0] * m0 + r[1] * m4 + r[2] * m8)
273 const a01 = s * (r[0] * m1 + r[1] * m5 + r[2] * m9)
274 const a02 = s * (r[0] * m2 + r[1] * m6 + r[2] * m10)
275 const a03 = cx + s * (r[0] * tx + r[1] * ty + r[2] * tz)
276 const a10 = -s * (u[0] * m0 + u[1] * m4 + u[2] * m8)
277 const a11 = -s * (u[0] * m1 + u[1] * m5 + u[2] * m9)
278 const a12 = -s * (u[0] * m2 + u[1] * m6 + u[2] * m10)
279 const a13 = cy - s * (u[0] * tx + u[1] * ty + u[2] * tz)
280 const a20 = f[0] * m0 + f[1] * m4 + f[2] * m8
281 const a21 = f[0] * m1 + f[1] * m5 + f[2] * m9
282 const a22 = f[0] * m2 + f[1] * m6 + f[2] * m10
283 const a23 = f[0] * tx + f[1] * ty + f[2] * tz
284
285 // skip the model when its bounding box lands off screen
286 const bb = boundsOf(model)
287 let minX = Infinity
288 let maxX = -Infinity
289 let minY = Infinity
290 let maxY = -Infinity
291 for (let i = 0; i < 8; i++) {
292 const x = i & 1 ? bb[3]! : bb[0]!
293 const y = i & 2 ? bb[4]! : bb[1]!
294 const z = i & 4 ? bb[5]! : bb[2]!
295 const sx = a00 * x + a01 * y + a02 * z + a03
296 const sy = a10 * x + a11 * y + a12 * z + a13
297 if (sx < minX) minX = sx
298 if (sx > maxX) maxX = sx
299 if (sy < minY) minY = sy
300 if (sy > maxY) maxY = sy
301 }
302 if (maxX < 0 || minX > w || maxY < 0 || minY > h) return
303
304 // Each local axis's direction in the world; the face on the side facing the viewer is the one drawn.
305 const l0 = Math.hypot(m0, m4, m8)
306 const l1 = Math.hypot(m1, m5, m9)
307 const l2 = Math.hypot(m2, m6, m10)
308 const v0 = l0 > 1e-9 ? (m0 * f[0] + m4 * f[1] + m8 * f[2]) / l0 : 0
309 const v1 = l1 > 1e-9 ? (m1 * f[0] + m5 * f[1] + m9 * f[2]) / l1 : 0
310 const v2 = l2 > 1e-9 ? (m2 * f[0] + m6 * f[1] + m10 * f[2]) / l2 : 0
311 const k0 = l0 > 1e-9 ? (m0 * LIGHT[0] + m4 * LIGHT[1] + m8 * LIGHT[2]) / l0 : 0
312 const k1 = l1 > 1e-9 ? (m1 * LIGHT[0] + m5 * LIGHT[1] + m9 * LIGHT[2]) / l1 : 0
313 const k2 = l2 > 1e-9 ? (m2 * LIGHT[0] + m6 * LIGHT[1] + m10 * LIGHT[2]) / l2 : 0
314 // the face index drawn per axis (-1 for none) and its light
315 const fx = v0 > 1e-6 ? 0 : v0 < -1e-6 ? 1 : -1
316 const fy = v1 > 1e-6 ? 2 : v1 < -1e-6 ? 3 : -1
317 const fz = v2 > 1e-6 ? 4 : v2 < -1e-6 ? 5 : -1
318 const nx = Math.max(0, fx === 0 ? -k0 : k0)
319 const ny = Math.max(0, fy === 2 ? -k1 : k1)
320 const nz = Math.max(0, fz === 4 ? -k2 : k2)
321
322 const p = this.p
323 const tint = d.tint
324 const amount = d.amount
325 const alpha = d.alpha
326 for (let b = 0; b < model.length; b += 8) {
327 const flags = model[b + 7]!
328 if (shadow && flags & FLAG_GROUND) continue
329 const x0 = model[b]!
330 const y0 = model[b + 1]!
331 const z0 = model[b + 2]!
332 const dx = model[b + 3]! - x0
333 const dy = model[b + 4]! - y0
334 const dz = model[b + 5]! - z0
335 // eyes, lights and other small details cast no shadow worth the time
336 if (shadow && dx < 0.16 && dy < 0.16 && dz < 0.16) continue
337 const px = a00 * x0 + a01 * y0 + a02 * z0 + a03
338 const py = a10 * x0 + a11 * y0 + a12 * z0 + a13
339 const pz = a20 * x0 + a21 * y0 + a22 * z0 + a23
340 const exx = a00 * dx
341 const exy = a10 * dx
342 const exz = a20 * dx
343 const eyx = a01 * dy
344 const eyy = a11 * dy
345 const eyz = a21 * dy
346 const ezx = a02 * dz
347 const ezy = a12 * dz
348 const ezz = a22 * dz
349 for (let i = 0; i < 8; i++) {
350 let x = px
351 let y = py
352 let z = pz
353 if (i & 1) {
354 x += exx
355 y += exy
356 z += exz
357 }
358 if (i & 2) {
359 x += eyx
360 y += eyy
361 z += eyz
362 }
363 if (i & 4) {
364 x += ezx
365 y += ezy
366 z += ezz
367 }
368 p[i * 3] = x
369 p[i * 3 + 1] = y
370 p[i * 3 + 2] = z
371 }
372 let color = model[b + 6]!
373 if (amount > 0) color = amount >= 1 ? tint : mixColor(color, tint, amount)
374 if (fx >= 0) this.face(FACES[fx]!, mode, color, flags, nx, alpha)
375 if (fy >= 0) this.face(FACES[fy]!, mode, color, flags, ny, alpha)
376 if (fz >= 0) this.face(FACES[fz]!, mode, color, flags, nz, alpha)
377 }
378 }
379
380 // Fills one projected face: the depth buffer (shadow), the g-buffer (fill), or a blend over the lit picture.
381 private face(corners: number[], mode: number, color: number, flags: number, ndl: number, alpha: number) {
382 const p = this.p
383 const xs = this.qx
384 const ys = this.qy
385 const zs = this.qz
386 for (let i = 0; i < 4; i++) {
387 const c = corners[i]! * 3
388 xs[i] = p[c]!
389 ys[i] = p[c + 1]!
390 zs[i] = p[c + 2]!
391 }
392 let area = 0
393 for (let i = 0; i < 4; i++) {
394 const j = (i + 1) & 3
395 area += xs[i]! * ys[j]! - xs[j]! * ys[i]!
396 }
397 if (area < 1e-6 && area > -1e-6) return
398 const sign = area > 0 ? 1 : -1
399 // depth is a plane over the screen: z = az + zx (x - ax) + zy (y - ay)
400 const ax = xs[0]!
401 const ay = ys[0]!
402 const az = zs[0]!
403 let ex1 = xs[1]! - ax
404 let ey1 = ys[1]! - ay
405 let ez1 = zs[1]! - az
406 let ex2 = xs[2]! - ax
407 let ey2 = ys[2]! - ay
408 let ez2 = zs[2]! - az
409 let den = ex1 * ey2 - ex2 * ey1
410 if (den < 1e-9 && den > -1e-9) {
411 ex1 = ex2
412 ey1 = ey2
413 ez1 = ez2
414 ex2 = xs[3]! - ax
415 ey2 = ys[3]! - ay
416 ez2 = zs[3]! - az
417 den = ex1 * ey2 - ex2 * ey1
418 if (den < 1e-9 && den > -1e-9) return
419 }
420 const zx = (ez1 * ey2 - ez2 * ey1) / den
421 const zy = (ez2 * ex1 - ez1 * ex2) / den
422
423 const shadow = mode === MODE_SHADOW
424 const w = shadow ? this.smSize : this.sw
425 const h = shadow ? this.smSize : this.sh
426 let minY = ys[0]!
427 let maxY = ys[0]!
428 for (let i = 1; i < 4; i++) {
429 if (ys[i]! < minY) minY = ys[i]!
430 if (ys[i]! > maxY) maxY = ys[i]!
431 }
432 const yStart = Math.max(0, Math.ceil(minY - 0.5))
433 const yEnd = Math.min(h - 1, Math.floor(maxY - 0.5))
434 if (yEnd < yStart) return
435
436 // A projected box face is a parallelogram: two edges bound each row on the left and two on
437 // the right, each as x = b + k * y. Edges are oriented so the inside is to their left.
438 let lk0 = 0
439 let lb0 = -1e30
440 let lk1 = 0
441 let lb1 = -1e30
442 let hk0 = 0
443 let hb0 = 1e30
444 let hk1 = 0
445 let hb1 = 1e30
446 let nl = 0
447 let nh = 0
448 for (let e = 0; e < 4; e++) {
449 const f = (e + 1) & 3
450 const ex = (xs[f]! - xs[e]!) * sign
451 const ey = (ys[f]! - ys[e]!) * sign
452 if (ey < 1e-9 && ey > -1e-9) continue
453 const k = ex / ey
454 const bb = xs[e]! - k * ys[e]!
455 if (ey < 0) {
456 if (nl === 0) {
457 lk0 = k
458 lb0 = bb
459 } else {
460 lk1 = k
461 lb1 = bb
462 }
463 nl++
464 } else {
465 if (nh === 0) {
466 hk0 = k
467 hb0 = bb
468 } else {
469 hk1 = k
470 hb1 = bb
471 }
472 nh++
473 }
474 }
475
476 const depth = shadow ? this.smDepth : this.depth
477 const albedo = this.albedo
478 const lit = this.lit
479 const fl = this.flags
480 let sr = 0
481 let sg = 0
482 let sb = 0
483 if (mode === MODE_BLEND) {
484 const shaded = scaleColor(color, flags & FLAG_EMISSIVE ? 1 : AMBIENT + DIFFUSE * ndl)
485 sr = ((shaded >> 16) & 255) * alpha
486 sg = ((shaded >> 8) & 255) * alpha
487 sb = (shaded & 255) * alpha
488 }
489 for (let y = yStart; y <= yEnd; y++) {
490 const py = y + 0.5
491 let lo = lb0 + lk0 * py
492 const lo1 = lb1 + lk1 * py
493 if (lo1 > lo) lo = lo1
494 let hi = hb0 + hk0 * py
495 const hi1 = hb1 + hk1 * py
496 if (hi1 < hi) hi = hi1
497 const x0 = Math.max(0, Math.ceil(lo - 0.5))
498 const x1 = Math.min(w - 1, Math.floor(hi - 0.5))
499 if (x1 < x0) continue
500 let z = az + zx * (x0 + 0.5 - ax) + zy * (py - ay)
501 let i = y * w + x0
502 const end = i + (x1 - x0)
503 if (mode === MODE_FILL) {
504 for (; i <= end; i++, z += zx) {
505 if (z < depth[i]!) {
506 depth[i] = z
507 albedo[i] = color
508 lit[i] = ndl
509 fl[i] = flags
510 }
511 }
512 } else if (shadow) {
513 for (; i <= end; i++, z += zx) if (z < depth[i]!) depth[i] = z
514 } else {
515 const keep = 1 - alpha
516 for (; i <= end; i++, z += zx) {
517 if (z < depth[i]!) {
518 const v = albedo[i]!
519 albedo[i] =
520 ((((v >> 16) & 255) * keep + sr) << 16) | ((((v >> 8) & 255) * keep + sg) << 8) | ((v & 255) * keep + sb)
521 }
522 }
523 }
524 }
525 }
526
527 private shadowPass() {
528 this.smDepth.fill(Infinity)
529 // cover what the camera sees, with room for the shadows of things just off screen
530 const viewW = this.width / this.scale
531 const viewH = this.height / this.scale / Math.abs(ROW_RISE)
532 const extent = Math.max(viewW, viewH) * 0.7 + 2.5
533 const texel = (extent * 2) / this.smSize
534 this.smScale = 1 / texel
535 // snap the center to whole texels so shadows do not shimmer as the camera moves
536 const t = this.target
537 const u = Math.round((t[0] * L_RIGHT[0] + t[1] * L_RIGHT[1] + t[2] * L_RIGHT[2]) / texel) * texel
538 const v = Math.round((t[0] * L_UP[0] + t[1] * L_UP[1] + t[2] * L_UP[2]) / texel) * texel
539 this.smCenter = [L_RIGHT[0] * u + L_UP[0] * v, L_RIGHT[1] * u + L_UP[1] * v, L_RIGHT[2] * u + L_UP[2] * v]
540 const list = this.list
541 for (let i = 0; i < list.length; i++) if (list[i]!.alpha >= 1) this.drawModel(list[i]!, MODE_SHADOW)
542 }
543
544 // ---- shading ----
545
546 private light() {
547 const w = this.sw
548 const h = this.sh
549 const s = this.scale * this.ss
550 const t = this.target
551 const depth = this.depth
552 const albedo = this.albedo
553 const lit = this.lit
554 const fl = this.flags
555 const sm = this.smDepth
556 const n = this.smSize
557 const half = n / 2
558 const sms = this.smScale
559 const time = this.time
560 const flowBase = this.flowBase
561 const flowRows = this.flowRows
562 const bias = 0.045
563 // The world point of a sample is base + RIGHT * u + VIEW * d, with base fixed per row,
564 // so everything needed from it is linear in u and d: work out the terms once.
565 const ox = t[0] - this.smCenter[0]
566 const oy = t[1] - this.smCenter[1]
567 const oz = t[2] - this.smCenter[2]
568 const du = 1 / s
569 // shadow map x, y and depth along the light, per unit of u and of d
570 const sxU = sms * (RIGHT[0] * L_RIGHT[0] + RIGHT[2] * L_RIGHT[2])
571 const sxD = sms * (VIEW[0] * L_RIGHT[0] + VIEW[2] * L_RIGHT[2])
572 const syU = -sms * (RIGHT[0] * L_UP[0] + RIGHT[1] * L_UP[1] + RIGHT[2] * L_UP[2])
573 const syD = -sms * (VIEW[0] * L_UP[0] + VIEW[1] * L_UP[1] + VIEW[2] * L_UP[2])
574 const ldU = RIGHT[0] * L_VIEW[0] + RIGHT[1] * L_VIEW[1] + RIGHT[2] * L_VIEW[2]
575 const ldD = VIEW[0] * L_VIEW[0] + VIEW[1] * L_VIEW[1] + VIEW[2] * L_VIEW[2]
576 const u0 = (0.5 - w / 2) / s
577 for (let y = 0; y < h; y++) {
578 const v = (h / 2 - (y + 0.5)) / s
579 const bx = UP[0] * v + ox
580 const by = UP[1] * v + oy
581 const bz = UP[2] * v + oz
582 const sxRow = half + sms * (bx * L_RIGHT[0] + bz * L_RIGHT[2]) + sxU * u0
583 const syRow = half - sms * (bx * L_UP[0] + by * L_UP[1] + bz * L_UP[2]) + syU * u0
584 const ldRow = bx * L_VIEW[0] + by * L_VIEW[1] + bz * L_VIEW[2] + ldU * u0 - bias
585 const wxRow = t[0] + UP[0] * v + RIGHT[0] * u0
586 const wzRow = t[2] + UP[2] * v + RIGHT[2] * u0
587 const sxStep = sxU * du
588 const syStep = syU * du
589 const ldStep = ldU * du
590 const wxStep = RIGHT[0] * du
591 const wzStep = RIGHT[2] * du
592 let i = y * w
593 for (let x = 0; x < w; x++, i++) {
594 const d = depth[i]!
595 if (d === Infinity) {
596 albedo[i] = SKY
597 continue
598 }
599 const f = fl[i]!
600 const base = albedo[i]!
601 const wx = wxRow + wxStep * x + VIEW[0] * d
602 let k = 1
603 if (!(f & FLAG_EMISSIVE)) {
604 const ndl = lit[i]!
605 let shade = 1
606 if (ndl > 0) {
607 const sx = Math.floor(sxRow + sxStep * x + sxD * d)
608 const sy = Math.floor(syRow + syStep * x + syD * d)
609 if (sx >= 0 && sy >= 0 && sx < n && sy < n) {
610 if (ldRow + ldStep * x + ldD * d > sm[sy * n + sx]!) shade = SHADOW_KEEP
611 }
612 }
613 k = AMBIENT + DIFFUSE * ndl * shade
614 }
615 let r = (base >> 16) & 255
616 let g = (base >> 8) & 255
617 let b = base & 255
618 if (f & FLAG_WATER) {
619 // long thin streaks drifting with the current, three lanes per row
620 const wz = wzRow + wzStep * x + VIEW[2] * d
621 const row = Math.round(wz)
622 const flow = flowRows[(row - flowBase) & 63]!
623 const band = (wz - row + 0.5) * 3
624 const lane = Math.floor(band)
625 const across = band - lane
626 if (across > 0.4 && across < 0.6) {
627 const along = (wx - time * flow) * 0.55 + lane * 0.37 + row * 0.61
628 if (along - Math.floor(along) < 0.22) {
629 r += (255 - r) * 0.45
630 g += (255 - g) * 0.45
631 b += (255 - b) * 0.45
632 }
633 }
634 }
635 if (!(f & FLAG_NO_DARKEN) && (wx > PLAYFIELD_EDGE || wx < -PLAYFIELD_EDGE)) k *= OUTSIDE_DARKEN
636 r *= k
637 g *= k
638 b *= k
639 albedo[i] = ((r > 255 ? 255 : r) << 16) | ((g > 255 ? 255 : g) << 8) | (b > 255 ? 255 : b)
640 }
641 }
642 }
643
644 private resolve() {
645 const ss = this.ss
646 const w = this.width
647 const h = this.height
648 const sw = this.sw
649 const src = this.albedo
650 const out = this.out
651 const inv = 1 / (ss * ss)
652 for (let y = 0; y < h; y++) {
653 for (let x = 0; x < w; x++) {
654 let r = 0
655 let g = 0
656 let b = 0
657 for (let dy = 0; dy < ss; dy++) {
658 let i = (y * ss + dy) * sw + x * ss
659 for (let dx = 0; dx < ss; dx++, i++) {
660 const c = src[i]!
661 r += (c >> 16) & 255
662 g += (c >> 8) & 255
663 b += c & 255
664 }
665 }
666 const o = (y * w + x) * 3
667 out[o] = r * inv
668 out[o + 1] = g * inv
669 out[o + 2] = b * inv
670 }
671 }
672 }
673
674 // ---- 2D overlay, in output pixels ----
675
676 // Where a world point lands in output pixels, for labels that follow things in the scene.
677 project(x: number, y: number, z: number): [number, number] {
678 const t = this.target
679 const dx = x - t[0]
680 const dy = y - t[1]
681 const dz = z - t[2]
682 return [
683 this.width / 2 + (RIGHT[0] * dx + RIGHT[1] * dy + RIGHT[2] * dz) * this.scale,
684 this.height / 2 - (UP[0] * dx + UP[1] * dy + UP[2] * dz) * this.scale,
685 ]
686 }
687
688 pixel(x: number, y: number, color: number, alpha = 1) {
689 if (x < 0 || y < 0 || x >= this.width || y >= this.height) return
690 const o = (y * this.width + x) * 3
691 const out = this.out
692 if (alpha >= 1) {
693 out[o] = (color >> 16) & 255
694 out[o + 1] = (color >> 8) & 255
695 out[o + 2] = color & 255
696 return
697 }
698 out[o] = out[o]! * (1 - alpha) + ((color >> 16) & 255) * alpha
699 out[o + 1] = out[o + 1]! * (1 - alpha) + ((color >> 8) & 255) * alpha
700 out[o + 2] = out[o + 2]! * (1 - alpha) + (color & 255) * alpha
701 }
702
703 rect(x: number, y: number, w: number, h: number, color: number, alpha = 1) {
704 for (let j = Math.max(0, y); j < Math.min(this.height, y + h); j++)
705 for (let i = Math.max(0, x); i < Math.min(this.width, x + w); i++) this.pixel(i, j, color, alpha)
706 }
707
708 // Packs `out` as Raster cells: one upper half block per cell, top pixel in front.
709 //
710 // The terminal paints at most 1024 distinct color pairs a frame, mapping the rest to its
711 // nearest, at 4 bits a channel. Rounding to that grid here and folding the rarest pairs into
712 // their closest kept neighbor keeps the choice ours, so small text never loses its colors.
713 encode(): string {
714 const w = this.width
715 const n = w * this.rows
716 const out = this.out
717 const keys = this.keys
718 let k = 0
719 for (let r = 0; r < this.rows; r++) {
720 let t = 2 * r * w * 3
721 let b = (2 * r + 1) * w * 3
722 for (let c = 0; c < w; c++, t += 3, b += 3) {
723 const top = (((out[t]! + 8) / 17) << 8) | (((out[t + 1]! + 8) / 17) << 4) | ((out[t + 2]! + 8) / 17)
724 const bot = (((out[b]! + 8) / 17) << 8) | (((out[b + 1]! + 8) / 17) << 4) | ((out[b + 2]! + 8) / 17)
725 keys[k++] = (top << 12) | bot
726 }
727 }
728 this.budgetPairs(n)
729 // each cell is 12 bytes, little-endian: U+2580, then the top and bottom colors, so 16 base64
730 // characters, and with 16 levels a channel every group of three bytes comes from a table
731 const parts: string[] = new Array(n)
732 for (let i = 0; i < n; i++) {
733 const key = keys[i]!
734 const top = key >> 12
735 const bot = key & 4095
736 parts[i] = CELL_HEAD + CELL_A[top & 255]! + CELL_B[((top >> 4) & 240) | (bot & 15)]! + CELL_C[(bot >> 4) & 255]!
737 }
738 return parts.join('')
739 }
740
741 private budgetPairs(n: number) {
742 const keys = this.keys
743 const sorted = this.sorted
744 sorted.set(keys)
745 sorted.sort()
746 let distinct = 0
747 for (let i = 0; i < n; i++) if (i === 0 || sorted[i] !== sorted[i - 1]) distinct++
748 if (distinct <= PAIR_BUDGET) return
749 // each distinct pair and how many cells use it, most used first
750 const pairs: number[] = []
751 const counts: number[] = []
752 for (let i = 0; i < n; i++) {
753 if (i > 0 && sorted[i] === sorted[i - 1]) counts[counts.length - 1]!++
754 else {
755 pairs.push(sorted[i]!)
756 counts.push(1)
757 }
758 }
759 const order = pairs.map((_, i) => i).sort((a, b) => counts[b]! - counts[a]!)
760 const byTop = new Map<number, number[]>()
761 const byBot = new Map<number, number[]>()
762 const kept: number[] = []
763 for (let i = 0; i < PAIR_BUDGET; i++) {
764 const key = pairs[order[i]!]!
765 kept.push(key)
766 const top = key >> 12
767 const bot = key & 4095
768 const a = byTop.get(top)
769 if (a) a.push(bot)
770 else byTop.set(top, [bot])
771 const b = byBot.get(bot)
772 if (b) b.push(top)
773 else byBot.set(bot, [top])
774 }
775 const remap = new Map<number, number>()
776 for (let i = PAIR_BUDGET; i < order.length; i++) {
777 const key = pairs[order[i]!]!
778 const top = key >> 12
779 const bot = key & 4095
780 let best = kept[0]!
781 let bestD = Infinity
782 const sameTop = byTop.get(top)
783 const sameBot = byBot.get(bot)
784 if (sameTop) {
785 for (const b of sameTop) {
786 const d = dist12(bot, b)
787 if (d < bestD) {
788 bestD = d
789 best = (top << 12) | b
790 }
791 }
792 }
793 if (sameBot) {
794 for (const t of sameBot) {
795 const d = dist12(top, t)
796 if (d < bestD) {
797 bestD = d
798 best = (t << 12) | bot
799 }
800 }
801 }
802 if (bestD === Infinity) {
803 for (const kk of kept) {
804 const d = dist12(top, kk >> 12) + dist12(bot, kk & 4095)
805 if (d < bestD) {
806 bestD = d
807 best = kk
808 }
809 }
810 }
811 remap.set(key, best)
812 }
813 for (let i = 0; i < n; i++) {
814 const to = remap.get(keys[i]!)
815 if (to !== undefined) keys[i] = to
816 }
817 }
818}
819hooks/scene.ts 521 lines1// Turns the game's state into draw calls, and draws the score and banners on top.
2
3import { drawCentered, drawText, textWidth } from './font'
4import { EDGE, LOG_TOP, PAD_TOP, SPAN, groundY } from './game'
5import type { DeathKind, Game, Row } from './game'
6import {
7 BIT,
8 BUGS,
9 CHARACTERS,
10 CURSOR,
11 FLOWERS,
12 LILY_PAD,
13 LOGS,
14 PROPS,
15 RACK_LEDS,
16 SIGNAL,
17 TOKEN,
18 TRAIN,
19 TRAIN_CAR,
20 TUFT,
21} from './models'
22import type { BugKind, Character } from './models'
23import {
24 FLAG_EMISSIVE,
25 FLAG_GROUND,
26 FLAG_WATER,
27 RIGHT,
28 ROW_RISE,
29 Renderer,
30 TILE_ACROSS,
31 UP,
32 VIEW,
33 compose,
34} from './render'
35import type { Mat, Model } from './render'
36
37// ---- ground ----
38
39const HALF = 1 / 2
40const FAR = 14
41
42function boxes(list: number[][]): Model {
43 const out = new Float32Array(list.length * 8)
44 list.forEach((b, i) => out.set(b, i * 8))
45 return out
46}
47
48const GRASS = [0xaee665, 0x9cd352]
49const GRASS_ROWS = GRASS.map(c => boxes([[-FAR, -0.7, -HALF, FAR, 0, HALF, c, FLAG_GROUND]]))
50
51const ROAD = boxes([[-FAR, -0.7, -HALF, FAR, groundY('road'), HALF, 0x3b3f4a, FLAG_GROUND]])
52// lane markings on the line between two roads
53const DASHES = boxes(
54 Array.from({ length: FAR * 2 }, (_, i) => {
55 const x = -FAR + i + 0.25
56 return [x, groundY('road'), HALF - 0.035, x + 0.5, groundY('road') + 0.008, HALF + 0.035, 0x8b93a7, FLAG_GROUND]
57 }),
58)
59// the curb along a road's edge, where it meets something else
60const CURB = boxes([[-FAR, groundY('road'), -HALF, FAR, groundY('road') + 0.02, -HALF + 0.06, 0x595f6e, FLAG_GROUND]])
61
62const RAIL_Y = groundY('rail')
63const RAIL = boxes([
64 [-FAR, -0.7, -HALF, FAR, RAIL_Y, HALF, 0x8d7f73, FLAG_GROUND],
65 ...Array.from({ length: FAR * 4 }, (_, i) => {
66 const x = -FAR + i * 0.5 + 0.16
67 return [x, RAIL_Y, -0.36, x + 0.18, RAIL_Y + 0.04, 0.36, 0x6d4c33, FLAG_GROUND]
68 }),
69 [-FAR, RAIL_Y + 0.04, -0.26, FAR, RAIL_Y + 0.1, -0.2, 0xc8d0d8],
70 [-FAR, RAIL_Y + 0.04, 0.2, FAR, RAIL_Y + 0.1, 0.26, 0xc8d0d8],
71])
72
73const WATER_Y = groundY('water') - 0.05
74const WATER = boxes([[-FAR, -0.9, -HALF, FAR, WATER_Y, HALF, 0x5ec8f2, FLAG_GROUND | FLAG_WATER]])
75
76// tokens, lamps and rack lights that glow
77const GLOW = (c: number, x0: number, y0: number, z0: number, x1: number, y1: number, z1: number) =>
78 boxes([[x0, y0, z0, x1, y1, z1, c, FLAG_EMISSIVE]])
79
80const U = 1 / 16
81const LAMP_RED = GLOW(0xff3b30, -2.2 * U, 20 * U, -1.8 * U, -0.6 * U, 23 * U, -1.5 * U)
82const LAMP_RED_B = GLOW(0xff3b30, 0.6 * U, 20 * U, -1.8 * U, 2.2 * U, 23 * U, -1.5 * U)
83const LAMP_GREEN = GLOW(0x2ecc71, -2.2 * U, 20 * U, -1.8 * U, -0.6 * U, 23 * U, -1.5 * U)
84const LAMP_DARK = boxes([
85 [-2.2 * U, 20 * U, -1.8 * U, -0.6 * U, 23 * U, -1.5 * U, 0x3a2526, 0],
86 [0.6 * U, 20 * U, -1.8 * U, 2.2 * U, 23 * U, -1.5 * U, 0x3a2526, 0],
87])
88const LAMP_DARK_B = boxes([[0.6 * U, 20 * U, -1.8 * U, 2.2 * U, 23 * U, -1.5 * U, 0x3a2526, 0]])
89
90const LED_COLORS = [0x2ecc71, 0x2ecc71, 0x2ecc71, 0xf1c40f, 0x3498db]
91const LEDS = LED_COLORS.map(c =>
92 boxes(RACK_LEDS.map(([x, y]) => [x * U, y * U, -6.4 * U, (x + 1) * U, (y + 1) * U, -6.2 * U, c, FLAG_EMISSIVE])),
93)
94const LED_ONE = LED_COLORS.map(c => boxes([[0, 0, -6.4 * U, U, U, -6.2 * U, c, FLAG_EMISSIVE]]))
95
96const TOP_LINE = boxes(
97 Array.from({ length: 18 }, (_, i) => {
98 const x = -EDGE - 0.5 + i * 0.5
99 return [x + 0.06, 0.005, -0.05, x + 0.38, 0.03, 0.05, i % 2 ? 0xffffff : 0xf9ca24, FLAG_GROUND | FLAG_EMISSIVE]
100 }),
101)
102
103const SPARK = boxes([[-U, -U, -U, U, U, U, 0xffffff, FLAG_EMISSIVE]])
104
105// A matrix that turns a flat sprite to face the camera, its x along the screen's right and y up.
106function billboard(x: number, y: number, z: number, s: number): Mat {
107 const m = new Float64Array(12)
108 m[0] = RIGHT[0] * s
109 m[1] = UP[0] * s
110 m[2] = -VIEW[0] * s
111 m[3] = x
112 m[4] = RIGHT[1] * s
113 m[5] = UP[1] * s
114 m[6] = -VIEW[1] * s
115 m[7] = y
116 m[8] = RIGHT[2] * s
117 m[9] = UP[2] * s
118 m[10] = -VIEW[2] * s
119 m[11] = z
120 return m
121}
122
123function hash(a: number, b: number): number {
124 let h = Math.imul(a | 0, 374761393) ^ Math.imul(b | 0, 668265263)
125 h = Math.imul(h ^ (h >>> 13), 1274126177)
126 return ((h ^ (h >>> 16)) >>> 0) / 4294967296
127}
128
129// ---- the camera ----
130
131export type View = { scale: number; lookAhead: number; halfX: number; halfRows: number }
132
133export function viewFor(width: number, height: number): View {
134 // at least 7.5 tiles across (the camera pans for the rest) and 9 rows up the screen
135 const scale = Math.min(width / (7.5 * TILE_ACROSS), height / (9 * ROW_RISE))
136 const halfRows = height / 2 / (scale * ROW_RISE)
137 return {
138 scale,
139 lookAhead: Math.max(0.6, Math.min(3.2, halfRows - 3.6)),
140 halfX: width / 2 / (scale * TILE_ACROSS),
141 halfRows,
142 }
143}
144
145// ---- the world ----
146
147export function drawWorld(r: Renderer, g: Game, who: Character, view: View) {
148 g.viewHalfX = view.halfX
149 g.viewHalfRows = view.halfRows
150 const t = g.time
151 const shake = g.shake * g.shake * 0.12
152 const target: [number, number, number] = [
153 g.camX + Math.sin(t * 61) * shake,
154 Math.cos(t * 53) * shake * 0.5,
155 g.camZ + view.lookAhead + Math.sin(t * 47 + 1) * shake,
156 ]
157 r.begin(target, view.scale, t)
158 const top = Math.ceil(target[2] + view.halfRows + 2)
159 const bottom = Math.floor(target[2] - view.halfRows - 3)
160 const left = target[0] - view.halfX - 1.5
161 const right = target[0] + view.halfX + 1.5
162 r.flowBase = bottom
163 r.flowRows.fill(0)
164
165 for (let z = bottom; z <= top; z++) {
166 const row = g.row(z)
167 if (!row) continue
168 drawRow(r, g, row, left, right, t)
169 }
170
171 // a line across the world where the last top score was
172 if (g.best > 0 && g.best >= bottom && g.best <= top) {
173 const row = g.row(g.best)
174 r.draw(TOP_LINE, compose(0, row ? groundY(row.kind) : 0, g.best + HALF - 0.05))
175 }
176
177 drawCharacter(r, g, who)
178
179 const c = g.cursor
180 if (c) r.draw(CURSOR, billboard(c.x - 0.05, c.y + 0.1, c.z, 0.7))
181
182 for (const p of g.particles) {
183 const fade = p.life / p.max
184 const s = (p.size / 0.0625) * (fade > 0.7 ? (1 - fade) / 0.3 : 1)
185 if (s <= 0.01) continue
186 const m = compose(p.x, p.y, p.z, p.spin * p.life, s, s, s, p.spin * p.life * 0.7)
187 if (p.emissive) r.draw(SPARK, m, p.color, 1)
188 else r.draw(BIT, m, p.color, 1)
189 }
190 r.render()
191}
192
193function drawRow(r: Renderer, g: Game, row: Row, left: number, right: number, t: number) {
194 const z = row.z
195 const at = compose(0, 0, z)
196 switch (row.kind) {
197 case 'grass': {
198 r.draw(GRASS_ROWS[z & 1]!, at)
199 for (const d of row.decor) {
200 if (d.x < left || d.x > right) continue
201 const model = d.kind === 3 ? TUFT : d.kind === 0 ? TUFT : FLOWERS[d.kind - 1]!
202 r.draw(model, compose(d.x, 0, z + d.dz, d.x * 7))
203 }
204 const x0 = Math.max(-SPAN, Math.floor(left))
205 const x1 = Math.min(SPAN, Math.ceil(right))
206 for (let x = x0; x <= x1; x++) {
207 const kind = row.props[x + SPAN]
208 if (!kind) continue
209 const m = compose(x, 0, z)
210 r.draw(PROPS[kind], m)
211 if (kind === 'rack') drawLeds(r, m, x, z, t)
212 }
213 break
214 }
215 case 'road': {
216 r.draw(ROAD, at)
217 const next = g.row(z + 1)
218 if (next?.kind === 'road') r.draw(DASHES, at)
219 const prev = g.row(z - 1)
220 if (prev?.kind !== 'road') r.draw(CURB, at)
221 const bug = BUGS[row.bug]
222 const yaw = row.dir > 0 ? Math.PI / 2 : -Math.PI / 2
223 const step = Math.floor(t * row.speed * 5) & 1
224 for (const m of row.movers) {
225 if (m.x + m.length / 2 < left || m.x - m.length / 2 > right) continue
226 const mat = compose(m.x, groundY('road'), z, yaw)
227 r.draw(bug.body, mat)
228 r.draw(step ? bug.legsA : bug.legsB, mat)
229 }
230 break
231 }
232 case 'rail': {
233 r.draw(RAIL, at)
234 const rail = row.rail!
235 const post = compose(-EDGE - 0.75, 0, z - 0.38)
236 r.draw(SIGNAL, compose(-EDGE - 0.75, RAIL_Y, z - 0.38))
237 if (rail.state === 'idle') {
238 r.draw(LAMP_GREEN, post)
239 r.draw(LAMP_DARK_B, post)
240 } else {
241 const on = Math.floor(t / 0.2) & 1
242 r.draw(on ? LAMP_RED : LAMP_RED_B, post)
243 r.draw(on ? LAMP_DARK_B : LAMP_DARK, post)
244 }
245 if (rail.state === 'pass') {
246 const yaw = row.dir > 0 ? Math.PI / 2 : -Math.PI / 2
247 rail.cars.forEach((car, i) => {
248 const x = rail.x - row.dir * (i + 0.5) * TRAIN_CAR
249 if (x + TRAIN_CAR < left || x - TRAIN_CAR > right) return
250 r.draw(TRAIN[car], compose(x, RAIL_Y + 0.04, z, yaw))
251 })
252 }
253 break
254 }
255 case 'water': {
256 r.draw(WATER, at)
257 r.flowRows[(z - r.flowBase) & 63] = row.pads.length > 0 ? 0.15 : row.dir * row.speed
258 for (const m of row.movers) {
259 if (m.x + m.length / 2 < left || m.x - m.length / 2 > right) continue
260 const bob = Math.sin(t * 2.2 + m.x * 0.3 + z) * 0.012
261 r.draw(LOGS[m.kind]!, compose(m.x, LOG_TOP - 0.25 - m.sink * 0.06 + bob, z))
262 }
263 row.pads.forEach((x, i) => {
264 const sink = row.padSink[i]! * 0.05
265 r.draw(LILY_PAD, compose(x, PAD_TOP - 0.035 - sink, z, (x * 1.7 + z * 0.9) % (Math.PI * 2)))
266 })
267 break
268 }
269 }
270 if (row.token !== null) {
271 const bob = Math.sin(t * 3 + row.token) * 0.06
272 r.draw(TOKEN, compose(row.token, groundY(row.kind) + 0.42 + bob, z, t * 3))
273 }
274}
275
276function drawLeds(r: Renderer, m: Mat, x: number, z: number, t: number) {
277 // most lights hold steady; one per rack blinks like disk activity
278 r.draw(LEDS[Math.floor(hash(x, z) * 3)]!, m)
279 const blink = Math.floor(t * 6 + hash(z, x) * 20)
280 if (blink & 1) {
281 const [lx, ly] = RACK_LEDS[blink % RACK_LEDS.length]!
282 const color = LED_ONE[3 + (blink & 2 ? 1 : 0)]!
283 const mm = new Float64Array(m)
284 mm[3]! += (lx + 1.5) * U
285 mm[7]! += (ly - 0.5) * U
286 r.draw(color, mm)
287 }
288}
289
290function drawCharacter(r: Renderer, g: Game, who: Character) {
291 const death = g.death
292 if (death?.kind === 'train') return
293 if ((death?.kind === 'drown' || death?.kind === 'edge') && g.y < -1) return
294 const [sx, sy, sz] = g.pose()
295 const m = compose(g.x, g.y, g.z, g.yaw, sx, sy, sz)
296 r.draw(who.model, m, 0, 0, who.alpha ?? 1)
297}
298
299// ---- words ----
300
301const BUG_NAMES: Record<BugKind, string> = {
302 ladybug: 'ladybug',
303 beetle: 'beetle',
304 roach: 'cockroach',
305 centipede: 'centipede',
306}
307
308export function deathBanner(kind: DeathKind): string {
309 switch (kind) {
310 case 'squash':
311 return 'SEGFAULT'
312 case 'sideswipe':
313 return 'RACE CONDITION'
314 case 'train':
315 return 'MERGE CONFLICT'
316 case 'drown':
317 return '/DEV/NULL'
318 case 'edge':
319 return 'OUT OF BOUNDS'
320 case 'cursor':
321 return 'SIGKILL'
322 }
323}
324
325export function deathLine(kind: DeathKind, by: BugKind | null): string {
326 const bug = by ? BUG_NAMES[by] : 'bug'
327 switch (kind) {
328 case 'squash':
329 return `Segmentation fault: squashed by a ${bug}.`
330 case 'sideswipe':
331 return `Race condition: hopped right into a ${bug}.`
332 case 'train':
333 return 'Run over by the merge train.'
334 case 'drown':
335 return 'Piped to /dev/null.'
336 case 'edge':
337 return 'Index out of range: a log carried you off the screen.'
338 case 'cursor':
339 return 'Killed for idling: the cursor came for you.'
340 }
341}
342
343// ---- the overlay ----
344
345const INK = 0x1b1b1f
346const GOLD = 0xf9ca24
347const ORANGE = 0xd97757
348
349export type Hud = {
350 paused: boolean
351 // frame rate and timing, shown when asked for
352 stats?: string[]
353 // seconds left to flash "NEW TOP!"
354 newBest: number
355}
356
357function coin(r: Renderer, x: number, y: number) {
358 const art = ['.ooo.', 'oo*oo', 'o***o', 'oo*oo', '.ooo.']
359 art.forEach((line, j) =>
360 [...line].forEach((ch, i) => {
361 if (ch === '.') return
362 r.pixel(x + i, y + j, ch === 'o' ? GOLD : ORANGE)
363 }),
364 )
365 for (const [dx, dy] of [
366 [1, -1],
367 [2, -1],
368 [3, -1],
369 [-1, 1],
370 [-1, 2],
371 [-1, 3],
372 [5, 1],
373 [5, 2],
374 [5, 3],
375 [1, 5],
376 [2, 5],
377 [3, 5],
378 ] as const)
379 r.pixel(x + dx, y + dy, INK)
380}
381
382export function drawHud(r: Renderer, g: Game, hud: Hud) {
383 const w = r.width
384 const big = w >= 64 ? 2 : 1
385 const t = g.time
386
387 // floating "+1"s over picked-up tokens
388 for (const p of g.popups) {
389 const [px, py] = r.project(p.x, p.y, p.z)
390 drawCentered(r, p.text, Math.round(px), Math.round(py), { color: GOLD, outline: INK, alpha: Math.min(1, 1.6 - p.t * 1.6) })
391 }
392
393 if (g.phase !== 'title') {
394 drawText(r, String(g.score), 2, 2, { scale: big, color: 0xffffff, outline: INK })
395 const best = Math.max(g.best, g.score)
396 drawText(r, `TOP ${best}`, 2, 3 + 7 * big + 2, { color: 0xffffff, outline: INK })
397 if (hud.newBest > 0 && Math.floor(t * 6) % 2 === 0)
398 drawText(r, 'NEW TOP!', 2, 3 + 7 * big + 12, { color: GOLD, outline: INK })
399 }
400
401 const count = String(g.tokensTotal)
402 const tw = textWidth(count)
403 drawText(r, count, w - tw - 3, 3, { color: GOLD, outline: INK })
404 coin(r, w - tw - 11, 4)
405
406 if (hud.stats) {
407 const bottom = r.height - (g.phase === 'title' ? 18 : 9)
408 hud.stats.forEach((line, i) => drawText(r, line, 2, bottom - (hud.stats!.length - 1 - i) * 9, { color: 0xffffff, outline: INK }))
409 }
410 if (g.phase === 'title') drawTitle(r, t)
411 if (g.phase === 'over' && g.death) {
412 const appear = Math.min(1, g.phaseTime / 0.25)
413 drawBanner(r, deathBanner(g.death.kind), `SCORE ${g.score}`, appear)
414 } else if (hud.paused) {
415 drawBanner(r, 'PAUSED', 'P TO RESUME', 1)
416 }
417}
418
419function drawTitle(r: Renderer, t: number) {
420 const w = r.width
421 const scale = [3, 2, 1].find(s => textWidth('<CODE/>', s) + s * 2 + 4 <= w && 7 * s * 2 + 10 <= r.height * 0.45) ?? 1
422 const y = Math.max(3, Math.round(r.height * 0.1))
423 // a little bounce on the logo, like the game's own
424 const lift = Math.round(Math.abs(Math.sin(t * 2.4)) * scale)
425 drawCentered(r, 'CROSSY', w / 2, y - lift, {
426 scale,
427 color: 0xffffff,
428 shadow: 0x2d3436,
429 shadowDepth: scale,
430 outline: INK,
431 })
432 drawCentered(r, '<CODE/>', w / 2, y + 8 * scale + 2 - lift, {
433 scale,
434 color: ORANGE,
435 shadow: 0x7a3a24,
436 shadowDepth: scale,
437 outline: INK,
438 })
439 const hop = textWidth('PRESS W TO HOP') + 4 <= w ? 'PRESS W TO HOP' : 'W TO HOP'
440 if (Math.floor(t * 2) % 2 === 0) drawCentered(r, hop, w / 2, y + 16 * scale + 6, { color: 0xffffff, outline: INK })
441 drawCentered(r, textWidth('C: CHARACTERS') + 4 <= w ? 'C: CHARACTERS' : 'C: CHARS', w / 2, r.height - 9, { color: 0xdfe6e9, outline: INK })
442}
443
444function drawBanner(r: Renderer, title: string, sub: string, appear: number) {
445 const w = r.width
446 // as big as fits; a title too wide for one line goes on two
447 const scale = textWidth(title, 2) + 8 <= w ? 2 : 1
448 const lines = textWidth(title, scale) + 4 <= w ? [title] : title.split(' ')
449 const lineH = 8 * scale + 1
450 const bandH = lines.length * lineH + 20
451 const y = Math.round(r.height * 0.4 - bandH / 2 + (1 - appear) * 6)
452 r.rect(0, y, w, bandH, INK, 0.62 * appear)
453 r.rect(0, y, w, 1, ORANGE, appear)
454 r.rect(0, y + bandH - 1, w, 1, ORANGE, appear)
455 lines.forEach((line, i) =>
456 drawCentered(r, line, w / 2, y + 5 + i * lineH, { scale, color: 0xffffff, shadow: 0xc0392b, shadowDepth: scale, alpha: appear }),
457 )
458 drawCentered(r, sub, w / 2, y + bandH - 11, { color: GOLD, alpha: appear })
459}
460
461// ---- choosing a character ----
462
463export type Picker = { index: number; owned: Set<string>; selected: string; tokens: number; spin: number }
464
465export function drawPicker(r: Renderer, p: Picker, time: number) {
466 const w = r.width
467 const h = r.height
468 const big = w >= 90 && h >= 90 ? 2 : 1
469 // the models stand between the heading and the two lines under them
470 const top = 12
471 const bottom = h - (big === 2 ? 30 : 22)
472 const mid = (top + bottom) / 2
473 const scale = Math.max(4, Math.min(w / 5, (bottom - top) / 1.7))
474 const ty = 0.45 - (h / 2 - mid) / (UP[1] * scale)
475 r.begin([0, ty, 0], scale, time)
476 r.flowRows.fill(0)
477 const n = CHARACTERS.length
478 for (let i = -3; i <= 3; i++) {
479 const c = CHARACTERS[(((p.index + i) % n) + n) % n]!
480 const x = i * 1.6
481 r.draw(PEDESTAL[i === 0 ? 1 : 0]!, compose(x, -0.1, 0))
482 const spin = i === 0 ? Math.PI - 0.5 + p.spin : Math.PI * 0.85
483 const hop = i === 0 ? Math.abs(Math.sin(time * 4)) * 0.18 : 0
484 const s = i === 0 ? 1.15 : 0.85
485 const m = compose(x, hop, 0, spin, s, s * (1 + hop * 0.6), s)
486 if (p.owned.has(c.id)) r.draw(c.model, m, 0, 0, c.alpha ?? 1)
487 else r.draw(c.model, m, 0x2d3436, 0.88)
488 }
489 r.render()
490 const c = CHARACTERS[p.index]!
491 const owned = p.owned.has(c.id)
492 const name = textWidth(c.name.toUpperCase(), big) + 4 <= w ? c.name.toUpperCase() : c.name.toUpperCase().split(' ')[0]!
493 drawCentered(r, name, w / 2, bottom + 2, { scale: big, color: 0xffffff, outline: INK })
494 const line = owned ? (p.selected === c.id ? 'PLAYING' : 'OWNED') : `${c.price}`
495 const lw = textWidth(line) + (owned ? 0 : 8)
496 const lx = Math.round(w / 2 - lw / 2)
497 const ly = bottom + 7 * big + 5
498 if (!owned) coin(r, lx, ly + 1)
499 drawText(r, line, lx + (owned ? 0 : 8), ly, { color: owned ? 0xdfe6e9 : p.tokens >= c.price ? GOLD : 0xff7675, outline: INK })
500 const arrowY = Math.round(mid - 3)
501 drawText(r, '<', 2, arrowY, { color: 0xffffff, outline: INK })
502 drawText(r, '>', w - 7, arrowY, { color: 0xffffff, outline: INK })
503 const count = String(p.tokens)
504 const tw = textWidth(count)
505 drawText(r, count, w - tw - 3, 3, { color: GOLD, outline: INK })
506 coin(r, w - tw - 11, 4)
507 if (textWidth('CHARACTERS') + tw + 18 <= w) drawText(r, 'CHARACTERS', 2, 3, { color: 0xffffff, outline: INK })
508}
509
510const PEDESTAL = [
511 boxes([
512 [-0.55, -0.4, -0.55, 0.55, 0.1, 0.55, 0x9dd655, 0],
513 [-0.6, -0.6, -0.6, 0.6, -0.4, 0.6, 0x7a4f2a, 0],
514 ]),
515 boxes([
516 [-0.55, -0.4, -0.55, 0.55, 0.1, 0.55, 0xa8e05f, 0],
517 [-0.6, -0.6, -0.6, 0.6, -0.4, 0.6, ORANGE, 0],
518 ]),
519]
520
521hooks/keys.tsx 21 lines1// The status line under the picture. Clicked, it takes the arrow keys and h j k l.
2
3import type { ClientModule } from 'claude-code'
4
5type Props = { label: string }
6
7const Keys: ClientModule<Props, true> = (props, surface) => {
8 const { Box, Text } = surface.elements
9 if (!surface.state) {
10 surface.onKey(k => surface.post({ key: k.key }))
11 surface.setState(true)
12 }
13 return (
14 <Box width={surface.columns} height={1}>
15 <Text wrap="truncate-end">{props.label}</Text>
16 </Box>
17 )
18}
19
20export default Keys
21hooks/font.ts 137 lines1// A 5x7 pixel font for the score, banners and labels drawn into the picture.
2
3import type { Renderer } from './render'
4
5const GLYPHS: Record<string, string> = {
6 A: '01110 10001 10001 11111 10001 10001 10001',
7 B: '11110 10001 10001 11110 10001 10001 11110',
8 C: '01110 10001 10000 10000 10000 10001 01110',
9 D: '11110 10001 10001 10001 10001 10001 11110',
10 E: '11111 10000 10000 11110 10000 10000 11111',
11 F: '11111 10000 10000 11110 10000 10000 10000',
12 G: '01110 10001 10000 10111 10001 10001 01111',
13 H: '10001 10001 10001 11111 10001 10001 10001',
14 I: '01110 00100 00100 00100 00100 00100 01110',
15 J: '00111 00010 00010 00010 00010 10010 01100',
16 K: '10001 10010 10100 11000 10100 10010 10001',
17 L: '10000 10000 10000 10000 10000 10000 11111',
18 M: '10001 11011 10101 10101 10001 10001 10001',
19 N: '10001 10001 11001 10101 10011 10001 10001',
20 O: '01110 10001 10001 10001 10001 10001 01110',
21 P: '11110 10001 10001 11110 10000 10000 10000',
22 Q: '01110 10001 10001 10001 10101 10010 01101',
23 R: '11110 10001 10001 11110 10100 10010 10001',
24 S: '01111 10000 10000 01110 00001 00001 11110',
25 T: '11111 00100 00100 00100 00100 00100 00100',
26 U: '10001 10001 10001 10001 10001 10001 01110',
27 V: '10001 10001 10001 10001 10001 01010 00100',
28 W: '10001 10001 10001 10101 10101 10101 01010',
29 X: '10001 10001 01010 00100 01010 10001 10001',
30 Y: '10001 10001 01010 00100 00100 00100 00100',
31 Z: '11111 00001 00010 00100 01000 10000 11111',
32 '0': '01110 10001 10011 10101 11001 10001 01110',
33 '1': '00100 01100 00100 00100 00100 00100 01110',
34 '2': '01110 10001 00001 00010 00100 01000 11111',
35 '3': '11111 00010 00100 00010 00001 10001 01110',
36 '4': '00010 00110 01010 10010 11111 00010 00010',
37 '5': '11111 10000 11110 00001 00001 10001 01110',
38 '6': '00110 01000 10000 11110 10001 10001 01110',
39 '7': '11111 00001 00010 00100 01000 01000 01000',
40 '8': '01110 10001 10001 01110 10001 10001 01110',
41 '9': '01110 10001 10001 01111 00001 00010 01100',
42 ' ': '00000 00000 00000 00000 00000 00000 00000',
43 '.': '00000 00000 00000 00000 00000 01100 01100',
44 ',': '00000 00000 00000 00000 01100 00100 01000',
45 ':': '00000 01100 01100 00000 01100 01100 00000',
46 ';': '00000 01100 01100 00000 01100 00100 01000',
47 '!': '00100 00100 00100 00100 00100 00000 00100',
48 '?': '01110 10001 00001 00010 00100 00000 00100',
49 '-': '00000 00000 00000 11111 00000 00000 00000',
50 '+': '00000 00100 00100 11111 00100 00100 00000',
51 '=': '00000 00000 11111 00000 11111 00000 00000',
52 '/': '00001 00001 00010 00100 01000 10000 10000',
53 '(': '00010 00100 01000 01000 01000 00100 00010',
54 ')': '01000 00100 00010 00010 00010 00100 01000',
55 '{': '00110 00100 00100 01000 00100 00100 00110',
56 '}': '01100 00100 00100 00010 00100 00100 01100',
57 '<': '00010 00100 01000 10000 01000 00100 00010',
58 '>': '01000 00100 00010 00001 00010 00100 01000',
59 "'": '00100 00100 01000 00000 00000 00000 00000',
60 '#': '01010 01010 11111 01010 11111 01010 01010',
61 '*': '00000 00100 10101 01110 10101 00100 00000',
62 '_': '00000 00000 00000 00000 00000 00000 11111',
63 '%': '11000 11001 00010 00100 01000 10011 00011',
64 '$': '00100 01111 10100 01110 00101 11110 00100',
65 '^': '00100 01110 10101 00100 00100 00100 00100',
66 '~': '00000 00100 00010 11111 00010 00100 00000',
67 '`': '00000 00100 01000 11111 01000 00100 00000',
68 '|': '00100 00100 00100 10101 01110 00100 00000',
69}
70
71// ^ ~ ` | stand in for the up, right, left and down arrows.
72const ALIASES: Record<string, string> = { '↑': '^', '→': '~', '←': '`', '↓': '|' }
73
74const BITS = new Map<string, Uint8Array>()
75for (const [ch, rows] of Object.entries(GLYPHS)) {
76 BITS.set(ch, Uint8Array.from(rows.replace(/ /g, ''), c => (c === '1' ? 1 : 0)))
77}
78
79function glyph(ch: string): Uint8Array {
80 const key = ALIASES[ch] ?? ch.toUpperCase()
81 return BITS.get(key) ?? BITS.get('?')!
82}
83
84export function textWidth(text: string, scale = 1): number {
85 return text.length === 0 ? 0 : (text.length * 6 - 1) * scale
86}
87
88export type TextStyle = {
89 scale?: number
90 color?: number
91 // a 1px outline around each glyph, so text reads over any background
92 outline?: number
93 // a block shadow below and to the right, like the logo's
94 shadow?: number
95 shadowDepth?: number
96 alpha?: number
97}
98
99function stamp(r: Renderer, text: string, x: number, y: number, scale: number, color: number, alpha: number) {
100 let cx = x
101 for (const ch of text) {
102 const g = glyph(ch)
103 for (let gy = 0; gy < 7; gy++)
104 for (let gx = 0; gx < 5; gx++)
105 if (g[gy * 5 + gx]) r.rect(cx + gx * scale, y + gy * scale, scale, scale, color, alpha)
106 cx += 6 * scale
107 }
108}
109
110export function drawText(r: Renderer, text: string, x: number, y: number, style: TextStyle = {}) {
111 const scale = style.scale ?? 1
112 const color = style.color ?? 0xffffff
113 const alpha = style.alpha ?? 1
114 if (style.shadow !== undefined) {
115 const depth = style.shadowDepth ?? scale
116 for (let d = depth; d >= 1; d--) stamp(r, text, x + d, y + d, scale, style.shadow, alpha)
117 }
118 if (style.outline !== undefined) {
119 for (const [dx, dy] of [
120 [-1, 0],
121 [1, 0],
122 [0, -1],
123 [0, 1],
124 [-1, -1],
125 [1, -1],
126 [-1, 1],
127 [1, 1],
128 ] as const)
129 stamp(r, text, x + dx, y + dy, scale, style.outline, alpha)
130 }
131 stamp(r, text, x, y, scale, color, alpha)
132}
133
134export function drawCentered(r: Renderer, text: string, cx: number, y: number, style: TextStyle = {}) {
135 drawText(r, text, Math.round(cx - textWidth(text, style.scale ?? 1) / 2), y, style)
136}
137types/index.d.ts 23 lines1// What the pane's text rows show: the line under the picture and which screen is up.
2export type CrossyUi = {
3 screen: 'game' | 'picker'
4 status: string
5}
6
7// What is kept between sessions in $.store under "save".
8export type CrossySave = {
9 best: number
10 tokens: number
11 owned: string[]
12 selected: string
13 games: number
14 hops: number
15 deaths: Record<string, number>
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'crossy-code': { ui: CrossyUi }
21 }
22}
23