SLOPSHOPPER

crossy-code

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

newpanecommandtimer
v0.1.0MITupdated 2026-10-20samlevine03/crossy-code
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crossy-code
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /crossy-code ⎿ crossy-code: Crossy Code is open. Hop with w a s d. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

crossy-code

/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.

Clawd hopping across bug roads, merge trains and the stream to /dev/null in a Claude Code pane

Video. The run is played by a demo autopilot, see demo/README.md.

How it works

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.

Install

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.

Keys

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.

Characters

Tokens appear on grass and roads. They buy characters:

CharacterTokens
Clawdfree
Rubber Duck50
Ferris100
CI Bot100
Cold Brew150
Save Icon150
Kernel Hacker200
Zombie Process300

Top score, tokens, characters and death counts are saved to the plugin store and carry across sessions.

Rendering

The renderer redraws the whole world each frame, at about 30 frames per second:

  • shadows from a fixed sun
  • per-face lighting
  • water that ripples and flows with the logs
  • up to 3x supersampling, which drops a level when frames run slow

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.

Development

claude plugin validate .
claude plugin test .

Contributions: see CONTRIBUTING.md.

Disclaimer

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.

License

MIT, see LICENSE.

Source 8 files
hooks/register.tsx 419 lines
1// /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}
419
hooks/game.ts 868 lines
1// 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}
868
hooks/models.ts 523 lines
1// 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]])
523
hooks/render.ts 819 lines
1// 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}
819
hooks/scene.ts 521 lines
1// 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
521
hooks/keys.tsx 21 lines
1// 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
21
hooks/font.ts 137 lines
1// 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}
137
types/index.d.ts 23 lines
1// 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