SLOPSHOPPER

brickfall

A brick breaker in a Claude Code pane, after Arkanoid: steer with the mouse, catch capsules, chain volt bricks, over a shimmering iridescent field. A real…

newpanecommandtimer
★ 3v0.1.0MITupdated 2026-10-16reporails/arcade/brickfall
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · brickfall
│ ┃ Brickfall ✕ › fix the failing auth test and add an audit log call │ ┃ 0000000 best 0000000 round 1 · First light │ ┃ ▣ Brickfall▣ client module ./pad.ts ⏺ 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 │ ┃ │ ┃ › /bricks │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Brickfall
0000000 best 0000000 round 1 · First light ●●● ◆ 0/12 ▣ Brickfall▣ client module ./pad.ts ▣ client module ./pad.ts a: ← a d: d → w: launch / fire p: pause n: new game
README

Brickfall

Brickfall docked beside a Claude Code session in kitty: round two, Staircase, mid-combo with COMBO X9 flying off the bricks, while Claude reports that all 11 tests passed (a recorded take, with a bot at the paddle)

A brick breaker in a Claude Code pane, after Arkanoid, made loud. A mod, shipped as a plugin: /bricks opens a field you play with the mouse while Claude works. Every hit lands: light bursts where the brick was, the brick swells and burns out, the walls flash its colour and a big number flies off it. None of it moves the field or stops the play, and the ball, the paddle and anything falling are always drawn on top, so what you need to catch stays in sight. Volt bricks blow up their neighbours in chains, thirteen kinds of capsule fall with powers in them, gems rain down toward a FEVER, and the whole field sits on a slow, shimmering iridescent backdrop that heats up as the combo grows. In kitty or Ghostty the field is a real picture; in any other terminal it is drawn in quadrant block characters, four pixels a cell.

It reads and changes nothing the model sees: claude plugin validate ./brickfall lists every event it hooks and every call it makes, before any of its code runs.

❯ ./register.ts hooks: session.start, command.run{command=bricks}, ui.render{component=Pane}, ui.message, ui.close, session.end
❯ ./register.ts calls: $.clock.after (via fitPane, focusSoon), $.clock.every (via startLoop), $.command.register, $.env.get, $.session.surfaces, $.store.get, $.store.set (via recordBest), $.ui.blit (via paint), $.ui.close, $.ui.invalidate (via openPane, refused, tick), $.ui.open (via fitPane, openPane), $.ui.panes (via refocus), $.ui.resolve
❯ ./register.ts env reads: BRICKFALL_SCREEN, TERM, TERM_PROGRAM
❯ ./register.ts surface modules: hooks/pad.ts

No prompt, tool or attachment hook, and no network call. The game runs inside the mod itself: no engine, no child process.

Install

In a Claude Code session:

/plugin marketplace add reporails/arcade
/plugin install brickfall@reporails-arcade

Or from your shell, then /reload-plugins in any session already open:

claude plugin marketplace add reporails/arcade
claude plugin install brickfall@reporails-arcade

Then start Claude Code in the fullscreen layout and run /bricks:

CLAUDE_CODE_NO_FLICKER=1 claude

To play from a checkout instead: claude --plugin-dir ./brickfall.

Mods need Claude Code 2.1.287 or later, where they are on by default. The fullscreen layout is the one where Claude Code reports the mouse, and the pane docks on the right beside the transcript. Without it the pane sits above the prompt and the game is played with the keys alone.

Commands

InputWhat it does
/bricksOpens the pane. A game in play comes back paused, as it was.
/bricks newDeals a new game.
/bricks quitCloses the pane.

/bricks is an immediate command, so it works while Claude is mid-turn.

Playing

InputWhat it does
Move the mouse over the fieldThe paddle follows it.
ClickLaunches the ball; with the laser, fires; after game over, deals a new game.
Move the mouse off the fieldPauses, so the ball is not lost while you read Claude's answer. Back on the field, play goes on.
a dMove the paddle a step.
w, or ReturnLaunch, fire.
pPause, and go on.
nNew game.
Arrow keys, spaceMove and launch, once a click has given the field the keyboard.
EscHands the keyboard back to the prompt. Close the pane with /bricks quit or ctrl+x x.

Where the ball lands on the paddle sets its angle: the middle sends it straight up, the ends send it off sideways. It speeds up as the round goes on. Breaking five bricks before the ball touches the paddle again doubles their points (COMBO X2), ten triples them, up to four times.

Broken bricks drop gems as well as capsules. Catch a gem for 500 points; catch twelve and FEVER starts: eight seconds of double points, a rainbow ball and walls, and a backdrop running wild. The line over the field counts the gems toward it.

A terminal tells a mod when a key goes down but never when it comes up, so a held key moves the paddle in steps, after your keyboard's repeat delay. The mouse is the way to play.

Bricks

BrickWhat it does
ColouredBreaks at one hit; worth 500 to 1200 by colour.
SilverTakes two hits (three from round 9), with a shine that sweeps across it.
GoldNever breaks. A round is cleared when everything else is.
VoltViolet, pulsing. Breaks at one hit and a moment later blows up the eight bricks around it; a volt among them blows up its own, so a line of them goes off in a chain. What a blast breaks flies off as one number.

Capsules

A broken brick sometimes drops a capsule. Catch it with the paddle; each is worth 2500 points.

CapsuleColourWhat it does
LaserredClick to fire two beams from the paddle.
WideblueA longer paddle.
CatchgreenThe ball sticks to the paddle; click to launch it (it goes by itself after a few seconds).
SloworangeSlows the ball.
SplitcyanEvery ball becomes three.
FirepinkFor eight seconds the ball burns through bricks without bouncing (gold still stops it).
1UPwhiteAn extra life.
GiantgoldFor ten seconds the ball is huge and breaks everything it touches.
BarriertealA shield of light under the paddle sends the ball back up three times.
ZapvioletLightning from the paddle strikes eight bricks at once.
Novadark grey, red bandThe next three hits explode, breaking the eight bricks around (gold excepted).
Magnethalf red, half blueFor twelve seconds falling capsules and gems are pulled to the paddle.
DoublerainbowDouble points for twelve seconds; it adds to the combo and to FEVER.

Laser, Wide and Catch are the paddle's: catching one replaces the other.

There are ten rounds: First light, Staircase, Spark, Gate, Invader, Diamond, Checker, Fortress, Rainbow and Core. After Core they come round again, faster, with harder silver. The best score is kept between sessions in the plugin's own store.

The screen

In kitty or Ghostty (found by TERM / TERM_PROGRAM) the field is an Image: RGBA pixels, about 58,000 a frame, painted about 30 times a second. The terminal stretches the picture to fill the pane and smooths it as it does, so the more pixels, the sharper. Anywhere else it is a Raster of quadrant block characters, two pixels a cell each way, painted up to 60 times a second; Claude Code keeps 4 bits a colour channel there.

On a busy machine it keeps the game responsive: when frames take too long to draw, the picture gives up a little sharpness (never below half its pixels, about 29,000) and wins it back once there is room; past that, the picture is painted every third or fourth tick and the cells every second or third. The game itself, and the paddle, move on every tick either way. If the terminal refuses the picture after all (inside tmux, say), the field switches to the cells by itself.

Set BRICKFALL_SCREEN=cells or BRICKFALL_SCREEN=image to choose instead.

The field is as tall as the pane allows and as wide as that height needs; a docked pane narrows to the field so the transcript keeps the rest. Inside tmux Claude Code draws 256 colours, which turns the backdrop grey; COLORTERM=truecolor with TMUX unset brings the colours back.

While nothing moves but the backdrop (before a launch, paused, game over) it paints less often, 15 frames a second. A pane hidden behind another pauses the game; a closed one stops the loop altogether.

Files

PathWhat it is
hooks/register.tsThe hooks module: the command, the pane, the loop that steps, draws and paints, the best score.
hooks/game.tsThe game: the field, the ball, bricks, capsules, particles, rounds. No drawing.
hooks/render.tsThe picture: draws a game into a canvas of light, then into RGBA bytes or quadrant cells.
hooks/pad.tsThe surface module laid over the field: the pointer and the keys.
hooks/lib.tsSizes, inputs, arguments, the best score and the line over the field.
tests/brickfall.test.tsThe rules, the picture's encodings and the pane, run by claude plugin test ./brickfall.

What has been verified

  • claude plugin validate --strict passes for the game and for the marketplace, the typecheck passes against the typings of 2.1.285 (the build CI uses), and claude plugin test passes 36 tests.
  • In a live 2.1.292 session in kitty 0.32.2 on Linux, the fullscreen layout, the picture: /bricks docks the pane, the pane takes the keyboard, w launches, a and d move the paddle, a hovering pointer steers it, the best score reads back from the store, and /bricks quit closes it.
  • Recorded beside a Claude Code session doing real work in kitty: the picture keeps moving while Claude reads, runs commands and answers.
  • A frame costs a few milliseconds inside the mod's own environment (measured under claude plugin test).
  • In a live 2.1.292 session on Linux, in tmux with truecolor, the cells (before the louder effects and the new capsules): /bricks docks the pane, the field animates, the paddle follows a hovering pointer, a click launches, leaving the field pauses, and /bricks quit closes it.
  • The picture has been played in kitty on Linux, through the louder effects, the new capsules and the sharpness fixes.

Not yet verified: Ghostty, the inline layout above the prompt, and macOS or Windows.

Known limits

  • No sound: Claude Code plays a mod's sounds on macOS only.
  • Keys move the paddle in steps (see Playing).
  • The picture's bytes cross to the terminal with every frame; on a slow link, BRICKFALL_SCREEN=cells is lighter.
  • While Claude Code draws a large tool result, such as a long file it has just written, the field stops for up to about three quarters of a second. Claude Code is busy drawing then, and the mod cannot paint.

Licence

MIT, as the rest of this repository.

Source 5 files
hooks/register.ts 502 lines
1// brickfall: a brick breaker in a pane.
2//
3// The game runs here, in the hooks module: a timer steps it (game.ts), draws
4// each frame (render.ts) and blits it onto the screen, a picture in kitty and
5// Ghostty, quadrant cells elsewhere. The overlay (pad.ts) hands it the
6// pointer and the keys. The best score is stored. No hook touches what the
7// model reads: there is no prompt, tool or attachment hook in this mod.
8//
9// The host reads on(...) and $.noun.method(...) from source, so they are
10// spelled literally, and helpers that take $ are top-level functions here.
11
12import type { EngineInterface, ImageSource, Register, Timer } from 'claude-code'
13
14import { aim, drainEvents, newGame, nudge, press, step, togglePause, W } from './game'
15import type { Game } from './game'
16import { BUTTONS, betterBest, bestOf, drawsImages, frameTicks, freshActs, hintFor, hudOf, hudText, nextPace, padOf, paneRequest, parseArgs, pixelsOf, rollScore, screenSize } from './lib'
17import type { Act, Best, Pace, ScreenMode, Size } from './lib'
18import type { PadProps } from './pad'
19import { cellsOf, draw, makeCanvas, toCells, toRgba, base64 } from './render'
20import type { Canvas } from './render'
21
22const PANE = 'brickfall'
23const TITLE = 'Brickfall'
24const SCREEN = 'screen'
25const STRIP = 'strip'
26const LOOK = 'look'
27const NO_SCREEN = 'Brickfall draws its field in the terminal only. In VS Code, run claude in the integrated terminal.'
28
29const TICK_MS = 16
30// Ticks between painted frames: a picture's bytes cross to the terminal with
31// every frame, cells are small. Idle (nothing moving but the backdrop), fewer.
32const IDLE_FRAME_TICKS = 4
33const HUD_MS = 150
34const AWAY_MS = 600
35const FIT_SLACK = 2
36const IMAGE_REFUSALS = 10
37const HIDDEN_REFUSALS = 6
38
39// A pane takes the keyboard only over an empty prompt, and a command's own
40// text stays in the prompt while the command runs: the waits after /bricks
41// returns before asking for the keyboard again, each tried only while the
42// pane is open and still without it.
43const FOCUS_RETRIES_MS = [250, 500, 1000] as const
44
45// The game as the hooks know it. In memory; the best score is also stored.
46const state: {
47  game: Game | null
48  best: Best
49  mode: ScreenMode
50  size: Size
51  canvas: Canvas | null
52  words: Uint32Array | null
53  rgba: Uint8Array | null
54  cells: string | null
55  source: ImageSource | null
56  drawnFor: string
57  // How long a frame has lately taken to draw (ms), and the picture's
58  // quality step with how long frames have been slow or quick at it.
59  cost: number
60  pace: Pace
61  timer: Timer | null
62  last: number
63  ticks: number
64  t0: number
65  isPainting: boolean
66  refusals: number
67  seen: Record<PadProps['role'], number>
68  awaySince: number | null
69  isAutoPaused: boolean
70  hud: string
71  hudAt: number
72  shown: number
73  isFullscreen: boolean
74  fittedTo: number | null
75} = {
76  game: null,
77  best: null,
78  mode: 'cells',
79  size: { columns: 56, rows: 32 },
80  canvas: null,
81  words: null,
82  rgba: null,
83  cells: null,
84  source: null,
85  drawnFor: '',
86  cost: 0,
87  pace: { quality: 0, slow: 0, quick: 0 },
88  timer: null,
89  last: 0,
90  ticks: 0,
91  t0: 0,
92  isPainting: false,
93  refusals: 0,
94  seen: unseen(),
95  awaySince: null,
96  isAutoPaused: false,
97  hud: '',
98  hudAt: 0,
99  shown: 0,
100  isFullscreen: true,
101  fittedTo: null,
102}
103
104export const register: Register = (on) => {
105  on('session.start', async ($, e, next) => {
106    const result = await next(e)
107    try {
108      state.best = bestOf(await $.store.get('best'))
109    } catch {
110      // Not readable now; a game's end reads the store again before writing.
111    }
112    try {
113      await $.command.register({
114        name: 'bricks',
115        description: 'Play Brickfall, a brick breaker, in a pane',
116        argumentHint: '[new|quit]',
117        immediate: true,
118      })
119    } catch {
120      // The name is taken; nothing else opens the pane.
121    }
122    return result
123  })
124
125  on('command.run', { command: 'bricks' }, async ($, e) => {
126    const asked = parseArgs(e.args)
127    if ('error' in asked) return { text: asked.error }
128    if (asked.action === 'quit') {
129      await closeGame($)
130      try {
131        await $.ui.close({ id: PANE })
132      } catch {
133        // Not open.
134      }
135      return { text: 'Brickfall has closed.' }
136    }
137    const surfaces = await $.session.surfaces()
138    if (!surfaces.includes('terminal')) return { text: NO_SCREEN }
139    state.isFullscreen = e.presentation.isFullscreen
140    const [term, termProgram, prefer] = await Promise.all([
141      $.env.get('TERM').catch(() => undefined),
142      $.env.get('TERM_PROGRAM').catch(() => undefined),
143      $.env.get('BRICKFALL_SCREEN').catch(() => undefined),
144    ])
145    state.mode = drawsImages(term, termProgram, prefer) ? 'image' : 'cells'
146    state.drawnFor = ''
147    if (asked.action === 'new' && state.game !== null) await recordBest($)
148    if (state.game === null || asked.action === 'new') deal()
149    await openPane($)
150    focusSoon($, 0)
151    startLoop($)
152    return {}
153  })
154
155  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
156    if (e.requestId !== PANE) return next(e)
157    const elements = $.ui.resolve(e)
158    // The surface decides; the `in` checks only tell the types what it decided.
159    if (e.surface !== 'terminal' || !('Raster' in elements) || !('Image' in elements) || !('Client' in elements)) {
160      return elements.Text({ children: [NO_SCREEN] })
161    }
162    const { Box, Text, Button, Raster, Image, Client } = elements
163    if (e.viewport?.isFullscreen !== undefined) state.isFullscreen = e.viewport.isFullscreen
164    const size = screenSize(e.props.bodyColumns, e.props.scroll.bodyRows, state.mode)
165    if (size.columns !== state.size.columns || size.rows !== state.size.rows) state.size = size
166    // A dock wider than the field the height allows: ask for the width the
167    // field needs, once per size, so the transcript keeps the rest.
168    if (e.props.placement === 'dock' && e.props.bodyColumns > size.columns + FIT_SLACK && state.fittedTo !== size.columns) {
169      state.fittedTo = size.columns
170      fitPane($, size.columns)
171    }
172    const g = state.game ?? deal()
173    drawFrame(g, Date.now())
174    const hud = hudOf(g, state.best, state.shown)
175    state.hud = hudText(hud)
176    const buttons = BUTTONS.map((b) =>
177      Button({
178        key: 'act-' + b.hotkey,
179        label: b.label,
180        hotkey: b.hotkey,
181        plain: true,
182        dimColor: b.act === 'left' || b.act === 'right',
183        ...(b.autoFocus ? { autoFocus: true } : {}),
184        onPress: () => {
185          act($, b.act)
186        },
187      }),
188    )
189    const screen =
190      state.mode === 'image' && state.source !== null
191        ? Image({ key: SCREEN, source: state.source, columns: size.columns, rows: size.rows, alt: TITLE })
192        : Raster({ key: SCREEN, columns: size.columns, rows: size.rows, cells: state.cells ?? '' })
193    return Box({
194      flexDirection: 'column',
195      children: [
196        Box({ flexDirection: 'row', children: hud.map((part) => Text({ color: part.color, wrap: 'truncate-end', children: [part.text] })) }),
197        // The field, and over it the pointer's layer, which draws nothing.
198        Box({
199          width: size.columns,
200          height: size.rows,
201          children: [
202            screen,
203            Box({
204              position: 'absolute',
205              top: 0,
206              left: 0,
207              width: size.columns,
208              height: size.rows,
209              children: [Client({ key: LOOK, module: './pad.ts', props: { role: 'look' } satisfies PadProps, width: size.columns, height: size.rows })],
210            }),
211          ],
212        }),
213        Client({ key: STRIP, module: './pad.ts', props: { role: 'strip', hint: hintFor(state.isFullscreen) } satisfies PadProps }),
214        Box({ flexDirection: 'row', columnGap: 2, children: buttons }),
215      ],
216    })
217  })
218
219  // The overlay posts the pointer and the acts it took.
220  on('ui.message', async ($, e, next) => {
221    const result = await next(e)
222    if (e.requestId !== PANE || (e.element !== LOOK && e.element !== STRIP)) return result
223    const pad = padOf(e.data)
224    const g = state.game
225    if (pad === null || g === null) return result
226    // Each instance numbers its own acts.
227    const fresh = freshActs(pad, state.seen[e.element])
228    state.seen[e.element] = fresh.seen
229    if (e.element === LOOK) {
230      if (pad.x !== null && pad.isInside !== false) aim(g, pad.x * W)
231      if (pad.isInside === false) state.awaySince ??= Date.now()
232      if (pad.isInside === true) {
233        state.awaySince = null
234        if (state.isAutoPaused) {
235          state.isAutoPaused = false
236          g.isPaused = false
237        }
238      }
239    }
240    for (const a of fresh.acts) act($, a)
241    return result
242  })
243
244  // A pane closes whatever this hook does: should it fail, the close goes on.
245  on('ui.close', async ($, e, next) => {
246    const result = await next(e)
247    if (e.id === PANE) await closeGame($)
248    return result
249  }).catch((_$, e, next) => next(e))
250
251  on('session.end', async ($, e, next) => {
252    stopLoop()
253    if (state.game !== null) await recordBest($)
254    return next(e)
255  })
256}
257
258// No act seen yet from either overlay instance: each numbers its own.
259function unseen(): Record<PadProps['role'], number> {
260  return { look: 0, strip: 0 }
261}
262
263// A new game, its first round served.
264function deal(): Game {
265  const g = newGame(Date.now() & 0x7fffffff)
266  state.game = g
267  state.shown = 0
268  state.isAutoPaused = false
269  state.awaySince = null
270  return g
271}
272
273function act($: EngineInterface, a: Act): void {
274  const g = state.game
275  if (g === null) return
276  switch (a) {
277    case 'left':
278      nudge(g, -1)
279      return
280    case 'right':
281      nudge(g, 1)
282      return
283    case 'fire':
284      if (g.phase === 'over') {
285        void recordBest($)
286        deal()
287        return
288      }
289      if (g.isPaused) state.isAutoPaused = false
290      press(g)
291      return
292    case 'pause':
293      state.isAutoPaused = false
294      togglePause(g)
295      return
296    case 'new':
297      void recordBest($)
298      deal()
299      return
300    default:
301      a satisfies never
302  }
303}
304
305// Draw the game into the canvas for the screen's size and keep the result as
306// the screen's next cells or picture.
307function drawFrame(g: Game, now: number): void {
308  const size = state.size
309  const key = `${state.mode}:${size.columns}x${size.rows}:${state.pace.quality}`
310  if (state.drawnFor !== key || state.canvas === null) {
311    const pixels = pixelsOf(size, state.mode, state.pace.quality)
312    state.canvas = makeCanvas(pixels.width, pixels.height)
313    state.words = null
314    state.rgba = null
315    state.drawnFor = key
316  }
317  const canvas = state.canvas
318  draw(canvas, g, (now - state.t0) / 1000)
319  if (state.mode === 'image') {
320    state.rgba = toRgba(canvas, state.rgba ?? undefined)
321    state.source = { rgba: base64(state.rgba), width: canvas.w, height: canvas.h }
322  } else {
323    state.words = toCells(canvas, state.words ?? undefined)
324    state.cells = cellsOf(state.words)
325  }
326}
327
328function startLoop($: EngineInterface): void {
329  if (state.timer !== null) return
330  const now = Date.now()
331  state.last = now
332  if (state.t0 === 0) state.t0 = now
333  try {
334    state.timer = $.clock.every(TICK_MS, () => tick($))
335  } catch {
336    // No timer: the field stays as drawn.
337  }
338}
339
340function stopLoop(): void {
341  if (state.timer !== null) state.timer.cancel()
342  state.timer = null
343  state.isPainting = false
344}
345
346// One step of the loop: move the game on by the time since the last, then
347// paint a frame when the screen is free for one.
348function tick($: EngineInterface): void {
349  const g = state.game
350  if (g === null) return
351  const now = Date.now()
352  const dt = Math.max(0, (now - state.last) / 1000)
353  state.last = now
354  if (state.awaySince !== null && now - state.awaySince > AWAY_MS && g.phase === 'play' && !g.isPaused) {
355    g.isPaused = true
356    state.isAutoPaused = true
357  }
358  step(g, dt)
359  const events = drainEvents(g)
360  if (events.includes('over') || events.includes('level')) void recordBest($)
361  const isIdle = (g.isPaused || g.phase === 'serve' || g.phase === 'over') && g.particles.length === 0
362  state.ticks += 1
363  const every = isIdle ? Math.max(IDLE_FRAME_TICKS, frameTicks(state.mode, state.cost)) : frameTicks(state.mode, state.cost)
364  if (!state.isPainting && state.ticks % every === 0) paint($, g, now)
365  if (now - state.hudAt >= HUD_MS) {
366    state.shown = rollScore(state.shown, g.score)
367    const hud = hudText(hudOf(g, state.best, state.shown))
368    if (hud !== state.hud) {
369      state.hud = hud
370      state.hudAt = now
371      $.ui.invalidate('ui.render')
372    }
373  }
374}
375
376// Paint the next frame. A blit resolves once Claude Code has painted it, so
377// one is in flight at a time and the frames in between are never drawn. The
378// strip (pad.ts) keeps Claude Code drawing frames.
379function paint($: EngineInterface, g: Game, now: number): void {
380  drawFrame(g, now)
381  measure(Date.now() - now)
382  const size = state.size
383  const isImage = state.mode === 'image'
384  const source = state.source
385  const cells = state.cells
386  if (isImage ? source === null : cells === null) return
387  state.isPainting = true
388  const blit =
389    isImage && source !== null
390      ? $.ui.blit({ requestId: PANE, key: SCREEN, source, columns: size.columns, rows: size.rows })
391      : $.ui.blit({ requestId: PANE, key: SCREEN, cells: cells ?? '', columns: size.columns, rows: size.rows })
392  blit
393    .then((result) => refused($, g, 'deny' in result && result.deny !== undefined, isImage))
394    .catch(() => undefined)
395    .finally(() => {
396      state.isPainting = false
397    })
398}
399
400// Keep the recent cost of a frame, and let the picture's quality follow it.
401function measure(ms: number): void {
402  state.cost = state.cost === 0 ? ms : state.cost * 0.8 + ms * 0.2
403  if (state.mode === 'image') state.pace = nextPace(state.pace, state.cost)
404}
405
406// Refused blit after blit: a picture the terminal does not draw after all
407// goes back to the cells; a field nobody can see (the pane hidden behind
408// another) pauses the game.
409function refused($: EngineInterface, g: Game, isRefused: boolean, isImage: boolean): void {
410  state.refusals = isRefused ? state.refusals + 1 : 0
411  if (state.refusals >= HIDDEN_REFUSALS && g.phase === 'play' && !g.isPaused) g.isPaused = true
412  if (isImage && state.refusals >= IMAGE_REFUSALS && state.mode === 'image') {
413    state.mode = 'cells'
414    state.source = null
415    state.drawnFor = ''
416    state.refusals = 0
417    $.ui.invalidate('ui.render')
418  }
419}
420
421// Keep the game's score when it beats the record. The store is read again
422// first, so a read that failed at session start never costs a stored record;
423// when it cannot be read now, the score stands for this session only.
424async function recordBest($: EngineInterface): Promise<void> {
425  const g = state.game
426  if (g === null) return
427  const best = betterBest(state.best, g.score, g.level)
428  if (best === state.best) return
429  state.best = best
430  try {
431    const stored = bestOf(await $.store.get('best'))
432    const merged = stored !== null && best !== null && stored.score >= best.score ? stored : best
433    state.best = merged
434    await $.store.set('best', merged)
435  } catch {
436    // Not stored; the score still stands for this session.
437  }
438}
439
440// The pane closed (or /bricks quit): stop the loop, keep the game paused for
441// /bricks to bring back, and keep its score if it is a record.
442async function closeGame($: EngineInterface): Promise<void> {
443  stopLoop()
444  const g = state.game
445  if (g === null) return
446  if (g.phase === 'play') g.isPaused = true
447  state.seen = unseen()
448  state.fittedTo = null
449  await recordBest($)
450}
451
452// Open the pane, or bring the open one forward, with the keyboard if it can;
453// at the width it was fitted to, once it has been.
454async function openPane($: EngineInterface): Promise<void> {
455  const size = paneRequest()
456  try {
457    // No closeOnEscape: Escape at an empty prompt would close the pane and
458    // end the round. /bricks quit or ctrl+x x close it.
459    await $.ui.open({ id: PANE, title: TITLE, focus: true, rows: size.rows, columns: state.fittedTo ?? size.columns })
460  } catch {
461    // Refused; the pane stays as it was.
462  }
463  $.ui.invalidate('ui.render')
464}
465
466function fitPane($: EngineInterface, columns: number): void {
467  try {
468    $.clock.after(0, async () => {
469      try {
470        await $.ui.open({ id: PANE, title: TITLE, columns })
471      } catch {
472        // Refused; the pane keeps its width.
473      }
474    })
475  } catch {
476    // No timer; the pane keeps its width.
477  }
478}
479
480// Ask for the keyboard again once /bricks has returned, while the pane is
481// open and still without it; never reopen a pane the person has closed.
482function focusSoon($: EngineInterface, attempt: number): void {
483  const wait = FOCUS_RETRIES_MS[attempt]
484  if (wait === undefined) return
485  try {
486    $.clock.after(wait, () => void refocus($, attempt))
487  } catch {
488    // No timer; a click on the pane gives it the keyboard.
489  }
490}
491
492async function refocus($: EngineInterface, attempt: number): Promise<void> {
493  try {
494    const pane = (await $.ui.panes()).find((p) => p.id === PANE)
495    if (pane === undefined || pane.isFocused) return
496    await openPane($)
497    focusSoon($, attempt + 1)
498  } catch {
499    // The panes cannot be listed; a click on the pane gives it the keyboard.
500  }
501}
502
hooks/game.ts 1229 lines
1// brickfall: the game itself, with no drawing and no $. A field of world
2// units (W by H) the renderer scales to the pane; the hooks module steps it
3// on a timer and hands it the pointer and the keys. Everything random comes
4// from the game's own seed, so the tests replay it exactly.
5
6export type Rgb = readonly [number, number, number]
7export type Point = { x: number; y: number }
8
9export type BrickKind = 'color' | 'silver' | 'gold' | 'volt'
10export type Brick = { kind: BrickKind; color: Rgb; hits: number; points: number; flash: number }
11export const CAPSULE_KINDS = ['laser', 'enlarge', 'catch', 'slow', 'split', 'fire', 'life', 'giant', 'barrier', 'zap', 'nova', 'magnet', 'double'] as const
12export type CapsuleKind = (typeof CAPSULE_KINDS)[number]
13// The capsules that change the paddle itself, and the paddle without one.
14export type PaddleMode = 'normal' | Extract<CapsuleKind, 'laser' | 'catch' | 'enlarge'>
15// Powers that last a while (FEVER comes from gems, not a capsule), and powers
16// that last so many hits.
17export const TIMED = ['fire', 'giant', 'magnet', 'double', 'fever'] as const
18export type Timed = (typeof TIMED)[number]
19export const CHARGED = ['barrier', 'nova'] as const
20export type Charged = (typeof CHARGED)[number]
21// What a step reports to the hooks module.
22export type GameEvent = 'paddle' | 'barrier' | 'clink' | 'break' | 'combo' | 'volt' | 'nova' | 'zap' | 'clear' | 'lose' | 'over' | 'level' | 'capsule' | 'gem' | 'fever' | 'laser'
23export type Ball = { x: number; y: number; vx: number; vy: number; isStuck: boolean; offset: number; stuckFor: number; trail: Point[] }
24export type Capsule = { x: number; y: number; vx: number; kind: CapsuleKind; age: number }
25export type Gem = { x: number; y: number; vx: number; vy: number; color: Rgb; age: number }
26export type Laser = { x: number; y: number }
27export type ParticleKind = 'spark' | 'shard' | 'ring'
28export type Particle = { x: number; y: number; vx: number; vy: number; life: number; max: number; color: Rgb; size: number; kind: ParticleKind; gravity: number }
29export type Floater = { x: number; y: number; text: string; life: number; max: number; color: Rgb; size: number }
30// A burst of light where something hit, a broken brick's last flash, a bolt
31// of lightning: effects that only age.
32export type Light = { x: number; y: number; color: Rgb; radius: number; life: number; max: number }
33export type Ghost = { col: number; row: number; color: Rgb; life: number; max: number }
34export type Bolt = { x0: number; y0: number; x1: number; y1: number; life: number; max: number; seed: number }
35export type Phase = 'serve' | 'play' | 'lost' | 'clear' | 'over'
36export type Blast = { col: number; row: number; at: number }
37
38export type Game = {
39  seed: number
40  level: number
41  lives: number
42  score: number
43  phase: Phase
44  phaseTime: number
45  // Seconds of play, paused time excluded: what particles and flashes age by.
46  time: number
47  isPaused: boolean
48  paddle: { x: number; width: number; target: number; mode: PaddleMode; glow: number; cooldown: number; dip: number }
49  balls: Ball[]
50  speed: number
51  bricks: (Brick | null)[]
52  rows: number
53  capsules: Capsule[]
54  gems: Gem[]
55  lasers: Laser[]
56  particles: Particle[]
57  floaters: Floater[]
58  lights: Light[]
59  ghosts: Ghost[]
60  bolts: Bolt[]
61  blasts: Blast[]
62  flash: number
63  // The walls light up in the colour of what just broke.
64  pulse: number
65  pulseColor: Rgb
66  // Bricks broken since the ball last touched the paddle: the combo.
67  chain: number
68  // Seconds left of each timed power, hits left of each charged one.
69  timers: Record<Timed, number>
70  charges: Record<Charged, number>
71  // Gems caught toward the next fever, and in all.
72  gemMeter: number
73  gemsTotal: number
74  sincePaddle: number
75  trailClock: number
76  // While a zap or a blast breaks many bricks at once, their points add up
77  // here, to fly off as one number.
78  batch: { points: number; x: number; y: number; n: number } | null
79  // What happened this step, for the hooks module (a best score, a sound).
80  events: GameEvent[]
81}
82
83// The field, in world units: Arkanoid's own 224 by 256, walls on three sides.
84export const W = 224
85export const H = 256
86export const WALL = 8
87export const COLS = 13
88export const BRICK_W = 16
89export const BRICK_H = 8
90export const BRICK_TOP = 32
91export const PADDLE_Y = 236
92export const PADDLE_H = 6
93export const BALL_R = 2.5
94export const GIANT_R = 6
95export const BARRIER_Y = 250
96export const PADDLE_WIDTH = { normal: 32, enlarge: 48 } as const
97export const FEVER_GEMS = 12
98
99const STEP = 1 / 240
100const MAX_DT = 0.05
101const MAX_BALLS = 9
102const MAX_PARTICLES = 1400
103export const MAX_LIVES = 9
104export const START_LIVES = 3
105const CAPSULE_SPEED = 46
106const CAPSULE_CHANCE = 0.2
107const GEM_CHANCE = 0.4
108export const GEM_POINTS = 500
109export const CAPSULE_POINTS = 2500
110const LASER_SPEED = 420
111const LASER_COOLDOWN = 0.2
112const CATCH_RELEASE = 2.5
113const LOST_SECONDS = 1.6
114const CLEAR_SECONDS = 2.4
115const VOLT_DELAY = 0.07
116const NUDGE = 14
117const MAX_ANGLE = (58 * Math.PI) / 180
118const MIN_VY = 0.3
119export const SECONDS: Readonly<Record<Timed, number>> = { fire: 8, giant: 10, magnet: 12, double: 12, fever: 8 }
120export const CHARGES: Readonly<Record<Charged, number>> = { barrier: 3, nova: 3 }
121export const ZAP_TARGETS = 8
122// How far in from the paddle's ends its laser guns sit.
123export const LASER_INSET = 3
124// The combo: every COMBO_STEP bricks before the paddle add one to the
125// multiplier, up to MAX_MULT; COMBO_FULL bricks reach it.
126export const COMBO_STEP = 5
127export const MAX_MULT = 4
128export const COMBO_FULL = (MAX_MULT - 1) * COMBO_STEP
129// Half the size of a falling capsule and of a gem, and how far past the
130// paddle's ends each is still caught.
131export const CAPSULE_HALF = { w: 6.5, h: 3.2 } as const
132export const GEM_HALF = { w: 3, h: 4 } as const
133const CATCH_MARGIN = { capsule: 4.5, gem: 6 } as const
134// A floater this size or more is a headline (COMBO, FEVER): it lasts longer
135// and rises slower.
136export const BIG_FLOATER = 3.5
137export const FEVER_COLOR: Rgb = [255, 120, 220]
138
139// Points: a brick's worth is ten times Arkanoid's, so the numbers that fly
140// off it are big ones.
141export const COLORS = {
142  W: { color: [242, 242, 255], points: 500 },
143  O: { color: [255, 138, 42], points: 600 },
144  C: { color: [46, 230, 255], points: 700 },
145  G: { color: [61, 255, 122], points: 800 },
146  R: { color: [255, 59, 92], points: 900 },
147  B: { color: [59, 108, 255], points: 1000 },
148  P: { color: [255, 79, 216], points: 1100 },
149  Y: { color: [255, 225, 74], points: 1200 },
150  // Claude's own coral, for the starburst round.
151  K: { color: [232, 121, 79], points: 1000 },
152} as const satisfies Readonly<Record<string, { color: Rgb; points: number }>>
153// The letters of the other bricks.
154const SPECIAL = { S: 'silver', X: 'gold', V: 'volt' } as const satisfies Readonly<Record<string, BrickKind>>
155
156// Whether `ch` stands for a brick in a round's rows.
157export function isBrickLetter(ch: string): boolean {
158  return isColorLetter(ch) || isSpecialLetter(ch)
159}
160
161function isColorLetter(ch: string): ch is keyof typeof COLORS {
162  return Object.hasOwn(COLORS, ch)
163}
164
165function isSpecialLetter(ch: string): ch is keyof typeof SPECIAL {
166  return Object.hasOwn(SPECIAL, ch)
167}
168export const SILVER: Rgb = [196, 204, 222]
169export const GOLD: Rgb = [255, 196, 64]
170export const VOLT: Rgb = [140, 90, 255]
171
172// Each capsule: its letter, body colour, the band across it, what it says
173// when caught, and how often it falls.
174export const CAPSULES: Readonly<Record<CapsuleKind, { letter: string; color: Rgb; band: Rgb; label: string; weight: number }>> = {
175  laser: { letter: 'L', color: [255, 60, 60], band: [255, 200, 200], label: 'LASER', weight: 12 },
176  enlarge: { letter: 'E', color: [60, 120, 255], band: [200, 220, 255], label: 'WIDE', weight: 12 },
177  catch: { letter: 'C', color: [60, 255, 120], band: [210, 255, 220], label: 'CATCH', weight: 9 },
178  slow: { letter: 'S', color: [255, 160, 40], band: [255, 230, 180], label: 'SLOW', weight: 9 },
179  split: { letter: 'D', color: [40, 230, 255], band: [210, 250, 255], label: 'SPLIT', weight: 12 },
180  fire: { letter: 'F', color: [255, 80, 220], band: [255, 210, 120], label: 'FIRE', weight: 10 },
181  life: { letter: 'P', color: [220, 220, 235], band: [255, 90, 120], label: '1UP', weight: 3 },
182  giant: { letter: 'G', color: [255, 210, 40], band: [255, 120, 20], label: 'GIANT', weight: 10 },
183  barrier: { letter: 'B', color: [40, 255, 200], band: [255, 255, 255], label: 'BARRIER', weight: 10 },
184  zap: { letter: 'Z', color: [170, 110, 255], band: [255, 250, 140], label: 'ZAP', weight: 9 },
185  nova: { letter: 'N', color: [90, 90, 104], band: [255, 70, 40], label: 'NOVA', weight: 10 },
186  magnet: { letter: 'M', color: [255, 60, 70], band: [70, 110, 255], label: 'MAGNET', weight: 8 },
187  double: { letter: '2', color: [255, 255, 255], band: [255, 255, 255], label: 'DOUBLE', weight: 9 },
188}
189
190// The rounds, thirteen bricks a row: a letter is a color (COLORS), S silver,
191// X gold (never breaks), V volt (blows up its neighbours), `.` nothing.
192// After the last the rounds come round again, faster and with harder silver.
193export type Level = { name: string; rows: readonly string[] }
194export type Wall = { bricks: (Brick | null)[]; rows: number }
195
196export const LEVELS: readonly [Level, ...Level[]] = [
197  {
198    name: 'First light',
199    rows: ['.............', 'SSSSSSSSSSSSS', 'RRRRRRRRRRRRR', 'YYYYYYYYYYYYY', 'BBBBBBBBBBBBB', 'PPPPPPPPPPPPP', 'GGGGGGGGGGGGG'],
200  },
201  {
202    name: 'Staircase',
203    rows: [
204      'W............',
205      'WO...........',
206      'WOC..........',
207      'WOCG.........',
208      'WOCGR........',
209      'WOCGRB.......',
210      'WOCGRBP......',
211      'WOCGRBPY.....',
212      'WOCGRBPYW....',
213      'WOCGRBPYWO...',
214      'WOCGRBPYWOC..',
215      'WOCGRBPYWOCG.',
216      'SSSSSSSSSSSSR',
217    ],
218  },
219  {
220    name: 'Spark',
221    rows: [
222      '......K......',
223      '..K...K...K..',
224      '...K..K..K...',
225      '....K.K.K....',
226      '.....KKK.....',
227      'KKKKKKVKKKKKK',
228      '.....KKK.....',
229      '....K.K.K....',
230      '...K..K..K...',
231      '..K...K...K..',
232      '......K......',
233    ],
234  },
235  {
236    name: 'Gate',
237    rows: ['.............', 'GGGGGGGGGGGGG', 'GGVGGGGGGGVGG', 'GGGGGGGGGGGGG', '.............', 'XX.XXXXXXX.XX', '.............', 'BBBBBBBBBBBBB', 'BBBBBBVBBBBBB', 'BBBBBBBBBBBBB'],
238  },
239  {
240    name: 'Invader',
241    rows: ['...C.....C...', '....C...C....', '...CCCCCCC...', '..CCVCCCVCC..', '.CCCCCCCCCCC.', '.C.CCCCCCC.C.', '.C.C.....C.C.', '....CC.CC....', '.............', 'SSS.SSSSS.SSS'],
242  },
243  {
244    name: 'Diamond',
245    rows: [
246      '......W......',
247      '.....WOW.....',
248      '....WOCOW....',
249      '...WOCGCOW...',
250      '..WOCGRGCOW..',
251      '.WOCGRVRGCOW.',
252      '..WOCGRGCOW..',
253      '...WOCGCOW...',
254      '....WOCOW....',
255      '.....WOW.....',
256      '......W......',
257      '.............',
258      'XXXX.....XXXX',
259    ],
260  },
261  {
262    name: 'Checker',
263    rows: ['S.S.S.S.S.S.S', '.R.R.R.R.R.R.', 'S.S.S.S.S.S.S', '.Y.Y.Y.V.Y.Y.', 'S.S.S.S.S.S.S', '.B.B.B.B.B.B.', 'S.S.S.S.S.S.S'],
264  },
265  {
266    name: 'Fortress',
267    rows: ['.............', '.XXXXXXXXXXX.', '.XSSSSSSSSSX.', '.XSPPPPPPPSX.', '.XSPVPVPVPSX.', '.XSPPPPPPPSX.', '.XSSSSSSSSSX.', '.X.........X.'],
268  },
269  {
270    name: 'Rainbow',
271    rows: [
272      'R............',
273      'RO...........',
274      '.OY..........',
275      '..YG.........',
276      '...GC........',
277      '....CB.......',
278      '.....BP......',
279      '......PR.....',
280      '.......RO....',
281      '........OY...',
282      '.........YG..',
283      '..........GC.',
284      '...........CB',
285    ],
286  },
287  {
288    name: 'Core',
289    rows: [
290      'SSSSSSSSSSSSS',
291      'SRRRRRRRRRRRS',
292      'SRYYYYYYYYYRS',
293      'SRYVGGGGGVYRS',
294      'SRYGCCCCCGYRS',
295      'SRYGCXXXCGYRS',
296      'SRYGCCCCCGYRS',
297      'SRYVGGGGGVYRS',
298      'SRYYYYYYYYYRS',
299      'SRRRRRRRRRRRS',
300      'SSSSSSSSSSSSS',
301    ],
302  },
303]
304
305// A small, fast generator whose whole state is the game's `seed`.
306export function random(g: Game): number {
307  g.seed = (g.seed + 0x6d2b79f5) | 0
308  let t = g.seed
309  t = Math.imul(t ^ (t >>> 15), t | 1)
310  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
311  return ((t ^ (t >>> 14)) >>> 0) / 4294967296
312}
313
314function range(g: Game, lo: number, hi: number): number {
315  return lo + (hi - lo) * random(g)
316}
317
318export function levelOf(level: number): Level {
319  const index = (((level - 1) % LEVELS.length) + LEVELS.length) % LEVELS.length
320  return LEVELS[index] ?? LEVELS[0]
321}
322
323export function silverHits(level: number): number {
324  return 2 + Math.floor((level - 1) / 8)
325}
326
327// Speed in world units a second: where a round starts, and as fast as it goes.
328export function baseSpeed(level: number): number {
329  return Math.min(190, 118 + (level - 1) * 6)
330}
331
332export function maxSpeed(level: number): number {
333  return Math.min(300, 236 + (level - 1) * 5)
334}
335
336export function bricksOf(level: number): Wall {
337  return bricksFrom(levelOf(level).rows, level)
338}
339
340// A wall of bricks from a round's rows, as round `level` makes them.
341export function bricksFrom(lines: readonly string[], level: number): Wall {
342  const rows = lines.length
343  const bricks: (Brick | null)[] = []
344  for (const line of lines) {
345    for (let col = 0; col < COLS; col++) bricks.push(brickOf(line[col] ?? '.', level))
346  }
347  return { bricks, rows }
348}
349
350function brickOf(ch: string, level: number): Brick | null {
351  if (isColorLetter(ch)) {
352    const c = COLORS[ch]
353    return { kind: 'color', color: c.color, hits: 1, points: c.points, flash: 0 }
354  }
355  if (!isSpecialLetter(ch)) return null
356  const kind = SPECIAL[ch]
357  switch (kind) {
358    case 'silver':
359      return { kind, color: SILVER, hits: silverHits(level), points: 500 * level, flash: 0 }
360    case 'gold':
361      return { kind, color: GOLD, hits: Infinity, points: 0, flash: 0 }
362    case 'volt':
363      return { kind, color: VOLT, hits: 1, points: 1500, flash: 0 }
364  }
365}
366
367// Gold never breaks; everything else does, in its own number of hits.
368export function isBreakable(b: Brick): boolean {
369  return b.kind !== 'gold'
370}
371
372export function newGame(seed: number, level = 1): Game {
373  const { bricks, rows } = bricksOf(level)
374  const g: Game = {
375    seed: seed | 0,
376    level,
377    lives: START_LIVES,
378    score: 0,
379    phase: 'serve',
380    phaseTime: 0,
381    time: 0,
382    isPaused: false,
383    paddle: { x: W / 2, width: PADDLE_WIDTH.normal, target: W / 2, mode: 'normal', glow: 0, cooldown: 0, dip: 0 },
384    balls: [],
385    speed: baseSpeed(level),
386    bricks,
387    rows,
388    capsules: [],
389    gems: [],
390    lasers: [],
391    particles: [],
392    floaters: [],
393    lights: [],
394    ghosts: [],
395    bolts: [],
396    blasts: [],
397    flash: 0,
398    pulse: 0,
399    pulseColor: [255, 255, 255],
400    chain: 0,
401    timers: noTimers(),
402    charges: { barrier: 0, nova: 0 },
403    gemMeter: 0,
404    gemsTotal: 0,
405    sincePaddle: 0,
406    trailClock: 0,
407    batch: null,
408    events: [],
409  }
410  serve(g)
411  return g
412}
413
414// A fresh ball sitting on the paddle, a round's name over the field. The
415// barrier and the gems caught so far stay; the timed powers go.
416function serve(g: Game): void {
417  g.phase = 'serve'
418  g.phaseTime = 0
419  g.speed = baseSpeed(g.level)
420  g.timers = noTimers()
421  g.charges.nova = 0
422  g.chain = 0
423  g.capsules = []
424  g.gems = []
425  g.lasers = []
426  g.paddle.mode = 'normal'
427  g.balls = [ballAt(g.paddle.x, PADDLE_Y - ballRadius(g), 0, 0, true)]
428}
429
430function noTimers(): Record<Timed, number> {
431  return { fire: 0, giant: 0, magnet: 0, double: 0, fever: 0 }
432}
433
434export function ballAt(x: number, y: number, vx = 0, vy = 0, isStuck = false): Ball {
435  return { x, y, vx, vy, isStuck, offset: 0, stuckFor: 0, trail: [] }
436}
437
438export function isOn(g: Game, power: Timed | Charged): boolean {
439  return isTimed(power) ? g.timers[power] > 0 : g.charges[power] > 0
440}
441
442function isTimed(power: CapsuleKind | Timed | Charged): power is Timed {
443  return TIMED.some((p) => p === power)
444}
445
446function isCharged(power: CapsuleKind | Timed | Charged): power is Charged {
447  return CHARGED.some((p) => p === power)
448}
449
450// The brick cell a world point is in (off the wall's grid, out of range).
451export function cellOf(x: number, y: number): { col: number; row: number } {
452  return { col: Math.floor((x - WALL) / BRICK_W), row: Math.floor((y - BRICK_TOP) / BRICK_H) }
453}
454
455// Where a brick's cell sits in the game's flat list of bricks.
456export function brickIndex(col: number, row: number): number {
457  return row * COLS + col
458}
459
460// The middle of a brick's place on the wall.
461export function brickCenter(col: number, row: number): Point {
462  const r = brickRect(col, row)
463  return { x: (r.x0 + r.x1) / 2, y: (r.y0 + r.y1) / 2 }
464}
465
466// A velocity turned by `a` radians.
467function rotate(vx: number, vy: number, a: number): { vx: number; vy: number } {
468  const cos = Math.cos(a)
469  const sin = Math.sin(a)
470  return { vx: vx * cos - vy * sin, vy: vx * sin + vy * cos }
471}
472
473export function breakableLeft(g: Game): number {
474  let n = 0
475  for (const b of g.bricks) if (b !== null && isBreakable(b)) n++
476  return n
477}
478
479export function multiplier(chain: number): number {
480  return Math.min(MAX_MULT, 1 + Math.floor(chain / COMBO_STEP))
481}
482
483// What points are worth right now: the combo, doubled by DOUBLE and by fever.
484export function bonus(g: Game): number {
485  return multiplier(g.chain) * doubling(g)
486}
487
488// DOUBLE and FEVER each double points, a brick's or a gem's.
489function doubling(g: Game): number {
490  return (isOn(g, 'double') ? 2 : 1) * (isOn(g, 'fever') ? 2 : 1)
491}
492
493export function ballRadius(g: Game): number {
494  return isOn(g, 'giant') ? GIANT_R : BALL_R
495}
496
497// ---- input ----------------------------------------------------------------
498
499// The pointer is over the field at world x: the paddle follows it.
500export function aim(g: Game, x: number): void {
501  g.paddle.target = clampPaddle(g, x)
502}
503
504// A key moved the paddle a step.
505export function nudge(g: Game, dir: -1 | 1): void {
506  g.paddle.target = clampPaddle(g, g.paddle.target + dir * NUDGE)
507}
508
509// Click, space or the launch button: launch a held ball, fire the laser, or
510// go on after a pause.
511export function press(g: Game): void {
512  if (g.isPaused) {
513    g.isPaused = false
514    return
515  }
516  if (g.phase === 'over') return
517  let launched = false
518  for (const ball of g.balls) {
519    if (ball.isStuck) {
520      launch(g, ball)
521      launched = true
522    }
523  }
524  if (launched) {
525    if (g.phase === 'serve') {
526      g.phase = 'play'
527      g.phaseTime = 0
528    }
529    return
530  }
531  if (g.paddle.mode === 'laser' && g.phase === 'play' && g.paddle.cooldown <= 0) {
532    const half = g.paddle.width / 2 - LASER_INSET
533    g.lasers.push({ x: g.paddle.x - half, y: PADDLE_Y }, { x: g.paddle.x + half, y: PADDLE_Y })
534    g.paddle.cooldown = LASER_COOLDOWN
535    g.events.push('laser')
536    light(g, g.paddle.x - half, PADDLE_Y - 3, [255, 80, 80], 12, 0.12)
537    light(g, g.paddle.x + half, PADDLE_Y - 3, [255, 80, 80], 12, 0.12)
538  }
539}
540
541export function togglePause(g: Game): void {
542  if (g.phase === 'over') return
543  g.isPaused = !g.isPaused
544}
545
546function clampPaddle(g: Game, x: number): number {
547  const half = g.paddle.width / 2
548  return Math.max(WALL + half, Math.min(W - WALL - half, x))
549}
550
551// A held ball goes off at the angle its place on the paddle gives it, never
552// quite straight up.
553function launch(g: Game, ball: Ball): void {
554  aimBall(ball, ball.offset / (g.paddle.width / 2), g.speed, 0.17)
555  ball.isStuck = false
556  ball.stuckFor = 0
557}
558
559// Send a ball up off the paddle: `rel` is where it is along the paddle, -1 at
560// the left end to 1 at the right, and sets the angle from straight up. An
561// angle under `least` (radians) is pushed out to a sure one.
562function aimBall(ball: Ball, rel: number, speed: number, least = 0): void {
563  let angle = Math.max(-1, Math.min(1, rel)) * MAX_ANGLE
564  if (Math.abs(angle) < least) angle = (angle < 0 ? -1 : 1) * 0.32
565  ball.vx = speed * Math.sin(angle)
566  ball.vy = -speed * Math.cos(angle)
567}
568
569// ---- stepping -------------------------------------------------------------
570
571// Move the game on by `dt` seconds of real time.
572export function step(g: Game, dt: number): void {
573  const real = Math.min(dt, MAX_DT)
574  if (g.isPaused || g.phase === 'over') {
575    ageEffects(g, g.phase === 'over' ? real : 0)
576    return
577  }
578  let left = real
579  while (left > 1e-9) {
580    const h = Math.min(STEP, left)
581    tick(g, h)
582    left -= h
583  }
584}
585
586function tick(g: Game, h: number): void {
587  g.time += h
588  g.phaseTime += h
589  g.sincePaddle += h
590  movePaddle(g, h)
591  if (g.paddle.cooldown > 0) g.paddle.cooldown -= h
592  for (const power of TIMED) g.timers[power] = Math.max(0, g.timers[power] - h)
593
594  if (g.phase === 'lost' && g.phaseTime >= LOST_SECONDS) {
595    if (g.lives > 0) serve(g)
596    else {
597      g.phase = 'over'
598      g.phaseTime = 0
599      g.events.push('over')
600    }
601  } else if (g.phase === 'clear' && g.phaseTime >= CLEAR_SECONDS) {
602    g.level += 1
603    const next = bricksOf(g.level)
604    g.bricks = next.bricks
605    g.rows = next.rows
606    g.particles = []
607    serve(g)
608    g.events.push('level')
609  }
610
611  for (const ball of g.balls) moveBall(g, ball, h)
612  const r = ballRadius(g)
613  g.balls = g.balls.filter((b) => b.y - r <= H + 4)
614  if (g.balls.length === 0 && (g.phase === 'play' || g.phase === 'serve')) loseLife(g)
615
616  moveLasers(g, h)
617  moveCapsules(g, h)
618  moveGems(g, h)
619  runBlasts(g)
620  ageEffects(g, h)
621
622  // Trails: one point every sixtieth of a second, the last ten kept; a
623  // burning or charged ball sheds embers.
624  g.trailClock += h
625  if (g.trailClock >= 1 / 60) {
626    g.trailClock = 0
627    for (const ball of g.balls) {
628      ball.trail.push({ x: ball.x, y: ball.y })
629      if (ball.trail.length > 10) ball.trail.shift()
630      if (ball.isStuck) continue
631      if (isOn(g, 'fire')) ember(g, ball, random(g) < 0.5 ? [255, 120, 40] : [255, 70, 200])
632      if (isOn(g, 'nova')) ember(g, ball, random(g) < 0.5 ? [255, 70, 40] : [255, 220, 120])
633      if (isOn(g, 'fever')) ember(g, ball, hueOf(g.time * 2 + random(g)))
634    }
635  }
636}
637
638function ember(g: Game, ball: Ball, color: Rgb): void {
639  const life = 0.3 + random(g) * 0.3
640  addParticle(g, { x: ball.x, y: ball.y, vx: range(g, -25, 25), vy: range(g, -40, 5), life, max: life, color, size: 1.2, kind: 'spark', gravity: -30 })
641}
642
643function movePaddle(g: Game, h: number): void {
644  const p = g.paddle
645  const want = p.mode === 'enlarge' ? PADDLE_WIDTH.enlarge : PADDLE_WIDTH.normal
646  p.width += (want - p.width) * Math.min(1, h * 10)
647  p.target = clampPaddle(g, p.target)
648  p.x += (p.target - p.x) * Math.min(1, h * 60)
649  p.x = clampPaddle(g, p.x)
650  if (p.glow > 0) p.glow = Math.max(0, p.glow - h * 3)
651  if (p.dip > 0) p.dip = Math.max(0, p.dip - h * 12)
652  for (const ball of g.balls) {
653    if (!ball.isStuck) continue
654    ball.x = p.x + ball.offset
655    ball.y = PADDLE_Y - ballRadius(g)
656    ball.stuckFor += h
657    if (g.phase === 'play' && ball.stuckFor >= CATCH_RELEASE) launch(g, ball)
658  }
659}
660
661function moveBall(g: Game, ball: Ball, h: number): void {
662  if (ball.isStuck) return
663  const r = ballRadius(g)
664  ball.x += ball.vx * h
665  ball.y += ball.vy * h
666
667  // Walls.
668  if (ball.x < WALL + r) {
669    ball.x = WALL + r
670    ball.vx = Math.abs(ball.vx)
671    wallBounce(g, ball)
672  } else if (ball.x > W - WALL - r) {
673    ball.x = W - WALL - r
674    ball.vx = -Math.abs(ball.vx)
675    wallBounce(g, ball)
676  }
677  if (ball.y < WALL + r) {
678    ball.y = WALL + r
679    ball.vy = Math.abs(ball.vy)
680    wallBounce(g, ball)
681  }
682
683  // The paddle: where it lands on it decides the angle.
684  const p = g.paddle
685  if (ball.vy > 0 && ball.y + r >= PADDLE_Y && ball.y - r <= PADDLE_Y + PADDLE_H && g.phase === 'play') {
686    const half = p.width / 2
687    if (ball.x >= p.x - half - r && ball.x <= p.x + half + r) {
688      g.speed = Math.min(maxSpeed(g.level), g.speed + 2.5)
689      aimBall(ball, (ball.x - p.x) / half, g.speed)
690      ball.y = PADDLE_Y - r
691      p.glow = 1
692      p.dip = 1
693      g.chain = 0
694      g.sincePaddle = 0
695      g.events.push('paddle')
696      sparks(g, ball.x, PADDLE_Y, [120, 220, 255], 10, 70)
697      light(g, ball.x, PADDLE_Y, [110, 210, 255], 22, 0.18)
698      if (p.mode === 'catch') {
699        ball.isStuck = true
700        ball.offset = ball.x - p.x
701        ball.stuckFor = 0
702        ball.vx = 0
703        ball.vy = 0
704      }
705    }
706  }
707
708  // The barrier under the paddle sends a ball back up, three times.
709  if (isOn(g, 'barrier') && ball.vy > 0 && ball.y + r >= BARRIER_Y) {
710    ball.y = BARRIER_Y - r
711    ball.vy = -Math.abs(ball.vy)
712    g.charges.barrier -= 1
713    g.events.push('barrier')
714    sparks(g, ball.x, BARRIER_Y, CAPSULES.barrier.color, 24, 120)
715    light(g, ball.x, BARRIER_Y, CAPSULES.barrier.color, 40, 0.3)
716    floater(g, ball.x, BARRIER_Y - 14, isOn(g, 'barrier') ? 'SAVED' : 'LAST SAVE', CAPSULES.barrier.color, 2.2)
717  }
718
719  hitBricks(g, ball, r)
720}
721
722// Keep the ball off flat, endless sideways runs, and nudge it out of loops
723// between bricks that never break.
724function wallBounce(g: Game, ball: Ball): void {
725  const speed = Math.hypot(ball.vx, ball.vy) || g.speed
726  if (Math.abs(ball.vy) < speed * MIN_VY) {
727    ball.vy = (ball.vy < 0 ? -1 : 1) * speed * MIN_VY
728    ball.vx = Math.sign(ball.vx || 1) * Math.sqrt(speed * speed - ball.vy * ball.vy)
729  }
730  if (g.sincePaddle > 20) {
731    Object.assign(ball, rotate(ball.vx, ball.vy, range(g, -0.2, 0.2)))
732  }
733}
734
735function brickAt(g: Game, col: number, row: number): Brick | null {
736  if (col < 0 || col >= COLS || row < 0 || row >= g.rows) return null
737  return g.bricks[brickIndex(col, row)] ?? null
738}
739
740export function brickRect(col: number, row: number): { x0: number; y0: number; x1: number; y1: number } {
741  const x0 = WALL + col * BRICK_W
742  const y0 = BRICK_TOP + row * BRICK_H
743  return { x0, y0, x1: x0 + BRICK_W, y1: y0 + BRICK_H }
744}
745
746function hitBricks(g: Game, ball: Ball, r: number): void {
747  const { col: c0, row: r0 } = cellOf(ball.x - r, ball.y - r)
748  const { col: c1, row: r1 } = cellOf(ball.x + r, ball.y + r)
749  let best: { col: number; row: number; dx: number; dy: number; d2: number; cx: number; cy: number } | null = null
750  const touched: { col: number; row: number }[] = []
751  for (let row = r0; row <= r1; row++) {
752    for (let col = c0; col <= c1; col++) {
753      if (brickAt(g, col, row) === null) continue
754      const rect = brickRect(col, row)
755      const cx = Math.max(rect.x0, Math.min(rect.x1, ball.x))
756      const cy = Math.max(rect.y0, Math.min(rect.y1, ball.y))
757      const dx = ball.x - cx
758      const dy = ball.y - cy
759      const d2 = dx * dx + dy * dy
760      if (d2 >= r * r) continue
761      touched.push({ col, row })
762      if (best === null || d2 < best.d2) best = { col, row, dx, dy, d2, cx, cy }
763    }
764  }
765  if (best === null) return
766  const brick = brickAt(g, best.col, best.row)
767  if (brick === null) return
768  const isPierce = isOn(g, 'fire') && isBreakable(brick)
769  // A giant ball breaks everything it touches; any other, the nearest.
770  const hits = isOn(g, 'giant') ? touched : [{ col: best.col, row: best.row }]
771  for (const t of hits) damage(g, t.col, t.row, isPierce ? 99 : 1)
772  if (isOn(g, 'nova')) {
773    g.charges.nova -= 1
774    blast(g, best.col, best.row, 'nova')
775  }
776  if (isPierce) return
777
778  // Bounce off the face the ball came through.
779  let nx = best.dx
780  let ny = best.dy
781  if (best.d2 < 1e-9) {
782    // The centre went inside: back out the way it came.
783    nx = -ball.vx
784    ny = -ball.vy
785  }
786  if (Math.abs(nx) > Math.abs(ny)) {
787    ball.vx = Math.sign(nx) * Math.abs(ball.vx)
788    if (best.d2 >= 1e-9) ball.x = best.cx + Math.sign(nx) * r
789  } else {
790    ball.vy = Math.sign(ny || 1) * Math.abs(ball.vy)
791    if (best.d2 >= 1e-9) ball.y = best.cy + Math.sign(ny || 1) * r
792  }
793}
794
795// A brick takes `power` hits: gold rings and stays, silver counts down, the
796// rest break. A volt brick blows up its neighbours a moment later.
797export function damage(g: Game, col: number, row: number, power: number): void {
798  const brick = brickAt(g, col, row)
799  if (brick === null) return
800  const rect = brickRect(col, row)
801  const { x: cx, y: cy } = brickCenter(col, row)
802  brick.flash = 1
803  if (!isBreakable(brick) || brick.hits - power > 0) {
804    if (isBreakable(brick)) brick.hits -= power
805    g.events.push('clink')
806    sparks(g, cx, cy, brick.color, 10, 70)
807    light(g, cx, cy, brick.color, 18, 0.14)
808    return
809  }
810  g.bricks[brickIndex(col, row)] = null
811  g.chain += 1
812  const before = multiplier(g.chain - 1)
813  const points = brick.points * bonus(g)
814  g.score += points
815  g.speed = Math.min(maxSpeed(g.level), g.speed + 0.4)
816  for (const ball of g.balls) rescale(ball, g.speed)
817  g.events.push('break')
818  // The loud part: a burst of light, the brick's ghost and the walls
819  // flashing its colour, shards and a ring, and the points. Nothing that
820  // moves the field or stops the play: the ball must stay easy to follow.
821  g.pulse = 1
822  g.pulseColor = brick.color
823  light(g, cx, cy, brick.color, 34, 0.26)
824  g.ghosts.push({ col, row, color: brick.color, life: 0.2, max: 0.2 })
825  if (g.ghosts.length > 40) g.ghosts.shift()
826  shatter(g, rect.x0, rect.y0, brick.color)
827  ring(g, cx, cy, brick.color, 16)
828  if (g.batch !== null) {
829    g.batch.points += points
830    g.batch.n += 1
831  } else {
832    floater(g, cx, cy, '+' + points, brick.color, 2.4)
833  }
834  const mult = multiplier(g.chain)
835  if (mult > before) {
836    floater(g, W / 2, H / 2 + 6, 'COMBO X' + mult, [255, 214, 90], 4)
837    g.flash = Math.min(1, g.flash + 0.25)
838    g.events.push('combo')
839  }
840  if (brick.kind === 'volt') {
841    g.blasts.push({ col, row, at: g.time + VOLT_DELAY })
842  } else if (g.phase === 'play') {
843    if (random(g) < CAPSULE_CHANCE && g.capsules.length < 3) g.capsules.push({ x: cx, y: cy, vx: 0, kind: pickCapsule(g), age: 0 })
844    else if (random(g) < GEM_CHANCE) g.gems.push({ x: cx, y: cy, vx: range(g, -30, 30), vy: range(g, -60, -20), color: brick.color, age: 0 })
845  }
846  if (breakableLeft(g) === 0 && g.phase === 'play') clearLevel(g)
847}
848
849function rescale(ball: Ball, speed: number): void {
850  if (ball.isStuck) return
851  const now = Math.hypot(ball.vx, ball.vy)
852  if (now < 1e-6) return
853  ball.vx *= speed / now
854  ball.vy *= speed / now
855}
856
857function runBlasts(g: Game): void {
858  if (g.blasts.length === 0) return
859  const due = g.blasts.filter((b) => b.at <= g.time)
860  if (due.length === 0) return
861  g.blasts = g.blasts.filter((b) => b.at > g.time)
862  for (const b of due) blast(g, b.col, b.row, 'volt')
863}
864
865// How each kind of explosion looks: its ring and light, its two sprays of
866// sparks, and the colour its added-up points fly off in.
867const BLASTS: Readonly<Record<'volt' | 'nova', { color: Rgb; sparks: readonly [Rgb, Rgb]; points: Rgb }>> = {
868  volt: { color: VOLT, sparks: [[190, 160, 255], [120, 240, 255]], points: [200, 170, 255] },
869  nova: { color: CAPSULES.nova.band, sparks: [[255, 160, 60], [255, 240, 180]], points: [255, 190, 90] },
870}
871
872// An explosion on a brick: the eight around it break (gold excepted), a volt
873// among them goes off in turn. A volt's own brick has already broken; a nova
874// takes the brick it hit too.
875function blast(g: Game, col: number, row: number, kind: 'volt' | 'nova'): void {
876  const { x: cx, y: cy } = brickCenter(col, row)
877  const look = BLASTS[kind]
878  const color = look.color
879  g.flash = Math.min(1, g.flash + 0.5)
880  g.events.push(kind)
881  ring(g, cx, cy, color, 34)
882  ring(g, cx, cy, [255, 255, 255], 22)
883  light(g, cx, cy, color, 64, 0.4)
884  sparks(g, cx, cy, look.sparks[0], 34, 160)
885  sparks(g, cx, cy, look.sparks[1], 18, 120)
886  const outer = batchStart(g)
887  for (let dr = -1; dr <= 1; dr++) {
888    for (let dc = -1; dc <= 1; dc++) {
889      const n = brickAt(g, col + dc, row + dr)
890      if (n === null || !isBreakable(n)) continue
891      damage(g, col + dc, row + dr, 99)
892    }
893  }
894  batchEnd(g, outer, cx, cy, look.points)
895}
896
897function clearLevel(g: Game): void {
898  g.phase = 'clear'
899  g.phaseTime = 0
900  g.capsules = []
901  g.lasers = []
902  for (const ball of g.balls) {
903    ball.isStuck = true
904    ball.offset = 0
905    ball.vx = 0
906    ball.vy = 0
907  }
908  g.balls = g.balls.slice(0, 1)
909  g.events.push('clear')
910  g.flash = 1
911  for (let i = 0; i < 8; i++) {
912    const x = range(g, 40, W - 40)
913    const y = range(g, 50, 160)
914    const hue = hueOf(i / 8)
915    ring(g, x, y, hue, 40)
916    light(g, x, y, hue, 50, 0.6)
917    sparks(g, x, y, hue, 30, 140)
918  }
919}
920
921function loseLife(g: Game): void {
922  g.lives -= 1
923  g.phase = 'lost'
924  g.phaseTime = 0
925  g.flash = 0.6
926  g.capsules = []
927  g.lasers = []
928  g.timers.fire = 0
929  g.events.push('lose')
930  sparks(g, g.paddle.x, PADDLE_Y, [255, 120, 80], 50, 160)
931  sparks(g, g.paddle.x, PADDLE_Y, [255, 230, 160], 26, 100)
932  ring(g, g.paddle.x, PADDLE_Y, [255, 90, 60], 44)
933  light(g, g.paddle.x, PADDLE_Y, [255, 90, 60], 70, 0.6)
934}
935
936function pickCapsule(g: Game): CapsuleKind {
937  const kinds = CAPSULE_KINDS.filter((k) => !(k === 'split' && g.balls.length > 3))
938  let total = 0
939  for (const k of kinds) total += CAPSULES[k].weight
940  let roll = random(g) * total
941  for (const k of kinds) {
942    roll -= CAPSULES[k].weight
943    if (roll <= 0) return k
944  }
945  return 'enlarge'
946}
947
948// Where a magnet steers a falling drop: its sideways speed eases toward one
949// that closes on the paddle, faster the further off it is, so it homes in
950// without swinging past.
951function steer(g: Game, x: number, vx: number, h: number): number {
952  if (!isOn(g, 'magnet')) return vx * (1 - Math.min(1, h * 1.5))
953  const want = Math.max(-220, Math.min(220, (g.paddle.x - x) * 4))
954  return vx + (want - vx) * Math.min(1, h * 10)
955}
956
957// Whether a drop `half` in size at (x, y) touches the paddle, counting
958// `margin` past its ends.
959function isOnPaddle(g: Game, x: number, y: number, half: { w: number; h: number }, margin: number): boolean {
960  return y + half.h >= PADDLE_Y && y - half.h <= PADDLE_Y + PADDLE_H && Math.abs(x - g.paddle.x) <= g.paddle.width / 2 + half.w + margin
961}
962
963function moveCapsules(g: Game, h: number): void {
964  const kept: Capsule[] = []
965  for (const c of g.capsules) {
966    c.vx = steer(g, c.x, c.vx, h)
967    c.x = Math.max(WALL + CAPSULE_HALF.w, Math.min(W - WALL - CAPSULE_HALF.w, c.x + c.vx * h))
968    c.y += CAPSULE_SPEED * (isOn(g, 'magnet') ? 1.4 : 1) * h
969    c.age += h
970    if (g.phase === 'play' && isOnPaddle(g, c.x, c.y, CAPSULE_HALF, CATCH_MARGIN.capsule)) {
971      apply(g, c.kind)
972      continue
973    }
974    if (c.y - CAPSULE_HALF.h < H) kept.push(c)
975  }
976  g.capsules = kept
977}
978
979function moveGems(g: Game, h: number): void {
980  const kept: Gem[] = []
981  for (const gem of g.gems) {
982    gem.vx = steer(g, gem.x, gem.vx, h)
983    gem.vy = Math.min(80, gem.vy + 130 * h)
984    gem.x += gem.vx * h
985    gem.y += gem.vy * h
986    gem.age += h
987    if (gem.x < WALL + GEM_HALF.w) {
988      gem.x = WALL + GEM_HALF.w
989      gem.vx = Math.abs(gem.vx)
990    } else if (gem.x > W - WALL - GEM_HALF.w) {
991      gem.x = W - WALL - GEM_HALF.w
992      gem.vx = -Math.abs(gem.vx)
993    }
994    if (g.phase === 'play' && isOnPaddle(g, gem.x, gem.y, GEM_HALF, CATCH_MARGIN.gem)) {
995      collect(g, gem)
996      continue
997    }
998    if (gem.y - GEM_HALF.h < H) kept.push(gem)
999  }
1000  g.gems = kept
1001}
1002
1003// A gem caught: points, a sparkle, and a step toward fever.
1004function collect(g: Game, gem: Gem): void {
1005  const points = GEM_POINTS * doubling(g)
1006  g.score += points
1007  g.gemsTotal += 1
1008  g.events.push('gem')
1009  sparks(g, gem.x, PADDLE_Y - 2, [255, 255, 255], 8, 60)
1010  sparks(g, gem.x, PADDLE_Y - 2, gem.color, 8, 80)
1011  light(g, gem.x, PADDLE_Y - 2, gem.color, 20, 0.2)
1012  floater(g, gem.x, PADDLE_Y - 16, '+' + points, gem.color, 1.8)
1013  if (isOn(g, 'fever')) return
1014  g.gemMeter += 1
1015  if (g.gemMeter >= FEVER_GEMS) {
1016    g.gemMeter = 0
1017    g.timers.fever = SECONDS.fever
1018    g.flash = 1
1019    g.events.push('fever')
1020    floater(g, W / 2, H / 2 - 10, 'FEVER!', FEVER_COLOR, 5)
1021    for (let i = 0; i < 6; i++) ring(g, W / 2, H / 2, hueOf(i / 6), 30 + i * 10)
1022  }
1023}
1024
1025export function apply(g: Game, kind: CapsuleKind): void {
1026  const info = CAPSULES[kind]
1027  g.score += CAPSULE_POINTS
1028  g.events.push('capsule')
1029  floater(g, g.paddle.x, PADDLE_Y - 70, info.label + '!', info.color, 2.6)
1030  sparks(g, g.paddle.x, PADDLE_Y, info.color, 26, 110)
1031  ring(g, g.paddle.x, PADDLE_Y, info.color, 26)
1032  light(g, g.paddle.x, PADDLE_Y, info.color, 44, 0.35)
1033  if (isTimed(kind)) {
1034    g.timers[kind] = SECONDS[kind]
1035    return
1036  }
1037  if (isCharged(kind)) {
1038    g.charges[kind] = CHARGES[kind]
1039    return
1040  }
1041  const p = g.paddle
1042  switch (kind) {
1043    case 'laser':
1044    case 'catch':
1045    case 'enlarge':
1046      if (p.mode === 'catch' && kind !== 'catch') releaseAll(g)
1047      p.mode = kind
1048      return
1049    case 'slow':
1050      g.speed = Math.max(baseSpeed(g.level) * 0.75, g.speed * 0.65)
1051      for (const ball of g.balls) rescale(ball, g.speed)
1052      return
1053    case 'split': {
1054      releaseAll(g)
1055      const extra: Ball[] = []
1056      for (const ball of g.balls) {
1057        for (const a of [-0.45, 0.45]) {
1058          if (g.balls.length + extra.length >= MAX_BALLS) break
1059          const v = rotate(ball.vx, ball.vy, a)
1060          extra.push(ballAt(ball.x, ball.y, v.vx, v.vy))
1061        }
1062      }
1063      g.balls.push(...extra)
1064      return
1065    }
1066    case 'life':
1067      g.lives = Math.min(MAX_LIVES, g.lives + 1)
1068      return
1069    case 'zap':
1070      zap(g)
1071      return
1072    default:
1073      kind satisfies never
1074  }
1075}
1076
1077// Lightning from the paddle to the bricks: up to eight of them, the nearest
1078// first, each struck down at once.
1079function zap(g: Game): void {
1080  const targets: { col: number; row: number; d: number }[] = []
1081  for (let row = 0; row < g.rows; row++) {
1082    for (let col = 0; col < COLS; col++) {
1083      const b = brickAt(g, col, row)
1084      if (b === null || !isBreakable(b)) continue
1085      targets.push({ col, row, d: Math.abs(brickCenter(col, row).x - g.paddle.x) + random(g) * 60 })
1086    }
1087  }
1088  targets.sort((a, b) => a.d - b.d)
1089  g.flash = Math.min(1, g.flash + 0.45)
1090  g.events.push('zap')
1091  const chosen = targets.slice(0, ZAP_TARGETS)
1092  const outer = batchStart(g)
1093  chosen.forEach((t, i) => {
1094    const { x, y } = brickCenter(t.col, t.row)
1095    // Fanned out across the paddle, so each bolt reads on its own.
1096    const from = g.paddle.x + (chosen.length > 1 ? (i / (chosen.length - 1) - 0.5) : 0) * g.paddle.width
1097    g.bolts.push({ x0: from, y0: PADDLE_Y - 2, x1: x, y1: y, life: 0.4, max: 0.4, seed: Math.floor(random(g) * 1e6) })
1098    light(g, x, y, CAPSULES.zap.color, 30, 0.35)
1099    damage(g, t.col, t.row, 99)
1100  })
1101  batchEnd(g, outer, W / 2, H / 2 + 40, CAPSULES.zap.band)
1102}
1103
1104// Start adding up the points of many breaks; the batch already open, if a
1105// blast went off inside another, carries on.
1106function batchStart(g: Game): boolean {
1107  if (g.batch !== null) return false
1108  g.batch = { points: 0, x: 0, y: 0, n: 0 }
1109  return true
1110}
1111
1112// The added-up points fly off as one big number, bigger the more there are.
1113function batchEnd(g: Game, isOuter: boolean, x: number, y: number, color: Rgb): void {
1114  if (!isOuter || g.batch === null) return
1115  const { points, n } = g.batch
1116  g.batch = null
1117  if (n === 0) return
1118  floater(g, x, y, '+' + points, color, Math.min(5, 2.6 + n * 0.25))
1119}
1120
1121function releaseAll(g: Game): void {
1122  for (const ball of g.balls) if (ball.isStuck) launch(g, ball)
1123}
1124
1125function moveLasers(g: Game, h: number): void {
1126  const kept: Laser[] = []
1127  for (const l of g.lasers) {
1128    l.y -= LASER_SPEED * h
1129    if (l.y < WALL) continue
1130    const { col, row } = cellOf(l.x, l.y)
1131    if (brickAt(g, col, row) !== null) {
1132      damage(g, col, row, 1)
1133      continue
1134    }
1135    kept.push(l)
1136  }
1137  g.lasers = kept
1138}
1139
1140// ---- effects --------------------------------------------------------------
1141
1142function ageEffects(g: Game, h: number): void {
1143  if (h <= 0) return
1144  for (const p of g.particles) {
1145    p.vy += p.gravity * h
1146    p.vx *= 1 - Math.min(1, h * 1.2)
1147    p.x += p.vx * h
1148    p.y += p.vy * h
1149    p.life -= h
1150  }
1151  g.particles = g.particles.filter((p) => p.life > 0)
1152  for (const b of g.bricks) if (b !== null && b.flash > 0) b.flash = Math.max(0, b.flash - h * 4)
1153  g.flash = Math.max(0, g.flash - h * 2.5)
1154  for (const f of g.floaters) {
1155    f.y -= (f.size >= BIG_FLOATER ? 6 : 16) * h
1156    f.life -= h
1157  }
1158  g.floaters = g.floaters.filter((f) => f.life > 0)
1159  for (const l of g.lights) l.life -= h
1160  g.lights = g.lights.filter((l) => l.life > 0)
1161  for (const gh of g.ghosts) gh.life -= h
1162  g.ghosts = g.ghosts.filter((gh) => gh.life > 0)
1163  for (const b of g.bolts) b.life -= h
1164  g.bolts = g.bolts.filter((b) => b.life > 0)
1165  g.pulse = Math.max(0, g.pulse - h * 4)
1166}
1167
1168function addParticle(g: Game, p: Particle): void {
1169  if (g.particles.length >= MAX_PARTICLES) g.particles.shift()
1170  g.particles.push(p)
1171}
1172
1173export function sparks(g: Game, x: number, y: number, color: Rgb, n: number, speed: number): void {
1174  for (let i = 0; i < n; i++) {
1175    const a = random(g) * Math.PI * 2
1176    const v = speed * (0.3 + random(g) * 0.7)
1177    const life = 0.35 + random(g) * 0.5
1178    addParticle(g, { x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, life, max: life, color, size: 0.8 + random(g) * 0.9, kind: 'spark', gravity: 90 })
1179  }
1180}
1181
1182// A broken brick: a spray of shards in its colour, falling, and a puff of
1183// white-hot sparks.
1184function shatter(g: Game, x0: number, y0: number, color: Rgb): void {
1185  for (let i = 0; i < 16; i++) {
1186    const x = x0 + 2 + random(g) * (BRICK_W - 4)
1187    const y = y0 + 1 + random(g) * (BRICK_H - 2)
1188    const life = 0.6 + random(g) * 0.7
1189    addParticle(g, { x, y, vx: range(g, -90, 90), vy: range(g, -110, 10), life, max: life, color, size: 1.3 + random(g) * 1.6, kind: 'shard', gravity: 260 })
1190  }
1191  sparks(g, x0 + BRICK_W / 2, y0 + BRICK_H / 2, mix(color, [255, 255, 255], 0.6), 16, 130)
1192}
1193
1194function ring(g: Game, x: number, y: number, color: Rgb, size: number): void {
1195  addParticle(g, { x, y, vx: 0, vy: 0, life: 0.4, max: 0.4, color, size, kind: 'ring', gravity: 0 })
1196}
1197
1198function light(g: Game, x: number, y: number, color: Rgb, radius: number, life: number): void {
1199  g.lights.push({ x, y, color, radius, life, max: life })
1200  if (g.lights.length > 24) g.lights.shift()
hooks/lib.ts 282 lines
1// Pure functions for the brickfall mod: how big the screen is drawn, what the
2// overlay's posts and the command's arguments say, the best score and the
3// line over the field. Nothing here takes $, so the tests import it directly.
4
5import type { ClientKeyEvent } from 'claude-code'
6
7import { CAPSULES, CHARGED, FEVER_COLOR, FEVER_GEMS, H, MAX_LIVES, TIMED, W, isOn, levelOf } from './game'
8import type { CapsuleKind, Game, Rgb } from './game'
9
10export type Size = { columns: number; rows: number }
11export type Pixels = { width: number; height: number }
12export const ACTS = ['left', 'right', 'fire', 'pause', 'new'] as const
13export type Act = (typeof ACTS)[number]
14// How the field reaches the terminal: a picture (kitty, Ghostty) or cells.
15export type ScreenMode = 'cells' | 'image'
16export type Args = { action: 'play' } | { action: 'quit' } | { action: 'new' } | { error: string }
17export type Best = { score: number; level: number } | null
18// What the overlay posts (pad.ts builds it, padOf reads it back): its last
19// few acts, numbered, where the pointer is across the field (0 to 1), and
20// whether it is over the field; null where it has not said.
21export type Numbered = { n: number; act: Act }
22export type Pad = { acts: Numbered[]; x: number | null; isInside: boolean | null }
23export type PadPost = { type: 'pad' } & Pad
24export type Button = { label: string; hotkey: string; act: Act; autoFocus?: true }
25
26// Rows the pane spends on things other than the screen: the score line over
27// it, the overlay's hint line and the buttons under it.
28export const CHROME_ROWS = 3
29
30// The field is 224 by 256 world units; a terminal cell is about twice as
31// tall as it is wide, so the screen is 1.75 times as many columns as rows.
32const ASPECT = (W / H) * 2
33const MIN_ROWS = 12
34const MAX_COLUMNS = { cells: 512, image: 255 } as const
35const MAX_ROWS = { cells: 256, image: 255 } as const
36
37// The screen's size in cells for a pane body of `bodyColumns` by `bodyRows`:
38// as tall as the body allows, as wide as that height needs.
39export function screenSize(bodyColumns: number | undefined, bodyRows: number | undefined, mode: ScreenMode): Size {
40  const width = Math.min(MAX_COLUMNS[mode], Math.max(Math.round(MIN_ROWS * ASPECT), Math.floor(bodyColumns ?? 80)))
41  let rows = Math.min(MAX_ROWS[mode], Math.max(MIN_ROWS, Math.floor(bodyRows ?? 40) - CHROME_ROWS))
42  let columns = Math.round(rows * ASPECT)
43  if (columns > width) {
44    columns = width
45    rows = Math.max(MIN_ROWS, Math.floor(columns / ASPECT))
46  }
47  return { columns, rows }
48}
49
50// The canvas behind a screen: two pixels a cell each way for the quadrant
51// cells; for a picture, the budget of pixels a frame (each frame's bytes cross
52// to the terminal, which smooths what it stretches) times the share of it the
53// current `quality` step keeps, four a column at most.
54export const IMAGE_PIXELS = 36000
55export const QUALITY = [1.6, 1.25, 1, 0.8] as const
56
57export function pixelsOf(size: Size, mode: ScreenMode, quality = 0): Pixels {
58  if (mode === 'cells') return { width: size.columns * 2, height: size.rows * 2 }
59  const share = QUALITY[Math.max(0, Math.min(QUALITY.length - 1, quality))] ?? 1
60  const scale = Math.min(4, Math.sqrt((IMAGE_PIXELS * share) / (size.columns * size.rows * 2)))
61  return { width: Math.max(1, Math.round(size.columns * scale)), height: Math.max(1, Math.round(size.rows * 2 * scale)) }
62}
63
64// Keeping the game responsive on a busy machine. `cost` is the recent time a
65// frame took to draw, in milliseconds. A picture gives up a little sharpness
66// (never below QUALITY's last step: fewer pixels than that blurs) only when
67// frames stay slow for a while, so a burst of fireworks never costs it; it
68// steps back up as soon as the sharper step would still draw in time. Past
69// that, both kinds of screen are painted every so many ticks instead. The
70// game itself steps every tick either way.
71export const FRAME_BUDGET_MS = 18
72export const PACE_FRAMES = { down: 45, up: 30 } as const
73export type Pace = { quality: number; slow: number; quick: number }
74
75export function nextPace(pace: Pace, cost: number): Pace {
76  const here = QUALITY[pace.quality] ?? 1
77  const sharper = pace.quality > 0 ? (QUALITY[pace.quality - 1] ?? here) : null
78  // Drawing cost follows the pixels drawn.
79  const fits = sharper !== null && (cost * sharper) / here < FRAME_BUDGET_MS * 0.75
80  const slow = cost > FRAME_BUDGET_MS ? pace.slow + 1 : 0
81  const quick = fits ? pace.quick + 1 : 0
82  if (slow >= PACE_FRAMES.down && pace.quality < QUALITY.length - 1) return { quality: pace.quality + 1, slow: 0, quick: 0 }
83  if (quick >= PACE_FRAMES.up) return { quality: pace.quality - 1, slow: 0, quick: 0 }
84  return { quality: pace.quality, slow, quick }
85}
86
87// Ticks between painted frames: cells every tick and a picture every other
88// when frames are quick, up to one in three or four when they are slow.
89export function frameTicks(mode: ScreenMode, cost: number): number {
90  const need = Math.ceil(cost / 12)
91  return mode === 'cells' ? Math.max(1, Math.min(3, need)) : Math.max(2, Math.min(4, need))
92}
93
94// The pane the command asks for: docked, a width the render then fits to
95// the screen the pane's height allows; inline above the prompt, tall enough
96// for a playable field.
97export function paneRequest(): Size {
98  return { columns: 64, rows: 28 + CHROME_ROWS }
99}
100
101// Whether the terminal draws pictures (kitty's graphics protocol): kitty and
102// Ghostty say so in TERM or TERM_PROGRAM. `BRICKFALL_SCREEN` set to `cells`
103// or `image` decides instead. A terminal that refuses the picture anyway
104// still gets the cells: refused blits switch back.
105export function drawsImages(term: string | undefined, termProgram: string | undefined, prefer: string | undefined): boolean {
106  if (prefer === 'cells') return false
107  if (prefer === 'image') return true
108  return /kitty|ghostty/i.test(String(term ?? '')) || /ghostty/i.test(String(termProgram ?? ''))
109}
110
111// The buttons the hooks module draws under the screen, one an act. A pane's
112// hotkeys reach only buttons the hooks module draws, and work before any
113// click; a hotkey is one letter or digit, so space and the arrows cannot be
114// one. Return presses the button holding the focus ring: `fire`'s.
115const BUTTON: Readonly<Record<Act, Omit<Button, 'act'>>> = {
116  left: { label: '← a', hotkey: 'a' },
117  right: { label: 'd →', hotkey: 'd' },
118  fire: { label: 'launch / fire', hotkey: 'w', autoFocus: true },
119  pause: { label: 'pause', hotkey: 'p' },
120  new: { label: 'new game', hotkey: 'n' },
121}
122
123export const BUTTONS: readonly Button[] = ACTS.map((act) => ({ act, ...BUTTON[act] }))
124
125export function hotkeyOf(act: Act): string {
126  return BUTTON[act].hotkey
127}
128
129// The act a key stands for once a click has given the overlay the keyboard:
130// every button's hotkey, and the keys no button can have.
131const KEYS: Readonly<Record<string, Act>> = {
132  ...Object.fromEntries(ACTS.map((act) => [hotkeyOf(act), act])),
133  left: 'left',
134  right: 'right',
135  ' ': 'fire',
136  space: 'fire',
137  up: 'fire',
138  return: 'fire',
139  enter: 'fire',
140}
141
142// The line under the field: how to play on the screen there is.
143export function hintFor(isFullscreen: boolean): string {
144  const key = hotkeyOf
145  if (isFullscreen) return `the mouse steers · click launches and fires · leave the field to pause · ${key('pause')} pause · ${key('new')} new game`
146  return `keys: ${key('left')} ${key('right')} steer · ${key('fire')} launches and fires · ${key('pause')} pause · /tui fullscreen adds the mouse`
147}
148
149export function actOf(e: ClientKeyEvent | null | undefined): Act | null {
150  if (!e || typeof e.key !== 'string' || e.ctrl || e.meta) return null
151  const key = e.key.length === 1 ? e.key.toLowerCase() : e.key
152  return Object.hasOwn(KEYS, key) ? (KEYS[key] ?? null) : null
153}
154
155function isAct(value: unknown): value is Act {
156  return typeof value === 'string' && ACTS.some((act) => act === value)
157}
158
159// The overlay's post, checked: its numbered acts, where the pointer is
160// across the field (0 to 1, null when it has said nothing), and whether the
161// pointer is over the field (null when it has not said).
162export function padOf(message: unknown): Pad | null {
163  if (!isRecord(message) || message['type'] !== 'pad') return null
164  const acts: Numbered[] = []
165  if (Array.isArray(message['acts'])) {
166    for (const a of message['acts']) {
167      if (!isRecord(a)) continue
168      const n = a['n']
169      const act = a['act']
170      if (typeof n !== 'number' || !isAct(act)) continue
171      acts.push({ n, act })
172    }
173  }
174  const x = message['x']
175  const inside = message['isInside']
176  return {
177    acts,
178    x: typeof x === 'number' && Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : null,
179    isInside: typeof inside === 'boolean' ? inside : null,
180  }
181}
182
183// A post replaces one not yet delivered, so each carries its last few acts,
184// numbered: keep the ones newer than `seen`.
185export function freshActs(pad: Pad, seen: number): { seen: number; acts: Act[] } {
186  let last = seen
187  const acts: Act[] = []
188  for (const a of pad.acts) {
189    if (a.n <= seen) continue
190    acts.push(a.act)
191    last = Math.max(last, a.n)
192  }
193  return { seen: last, acts }
194}
195
196function isRecord(value: unknown): value is Record<string, unknown> {
197  return typeof value === 'object' && value !== null && !Array.isArray(value)
198}
199
200// `/bricks` alone opens the game; `/bricks new` deals a new one; `/bricks
201// quit` closes it.
202export function parseArgs(args: string | undefined): Args {
203  const word = (args ?? '').trim().toLowerCase()
204  if (word === '') return { action: 'play' }
205  if (word === 'new' || word === 'restart') return { action: 'new' }
206  if ('quit'.startsWith(word) || word === 'stop') return { action: 'quit' }
207  return { error: `Unknown argument "${word}". Use /bricks, /bricks new or /bricks quit.` }
208}
209
210// The stored best, checked: anything else is no best at all.
211export function bestOf(value: unknown): Best {
212  if (!isRecord(value)) return null
213  const score = value['score']
214  const level = value['level']
215  if (typeof score !== 'number' || !Number.isFinite(score) || score < 0) return null
216  if (typeof level !== 'number' || !Number.isFinite(level) || level < 1) return null
217  return { score: Math.floor(score), level: Math.floor(level) }
218}
219
220// The better of a stored best and a game's result: the same object when the
221// game did not beat it.
222export function betterBest(best: Best, score: number, level: number): Best {
223  if (score <= 0) return best
224  if (best !== null && best.score >= score) return best
225  return { score, level }
226}
227
228export type HudPart = { text: string; color: string }
229
230// The line over the field: score (`shown`, rolling up to the game's), best,
231// round, lives, the gems toward a fever, and every power on.
232export function hudOf(g: Game, best: Best, shown: number = g.score): HudPart[] {
233  const parts: HudPart[] = [
234    { text: String(Math.floor(shown)).padStart(7, '0'), color: '#ffe14a' },
235    { text: ' best ' + String(Math.max(best?.score ?? 0, g.score)).padStart(7, '0'), color: '#8a8fb0' },
236    { text: `  round ${g.level} · ${levelOf(g.level).name}`, color: '#9fe8ff' },
237    { text: '  ' + '●'.repeat(Math.max(0, Math.min(MAX_LIVES, g.lives))), color: '#ff5c7a' },
238    isOn(g, 'fever') ? { text: `  FEVER ${Math.ceil(g.timers.fever)}s`, color: hexOf(FEVER_COLOR) } : { text: `  ◆ ${g.gemMeter}/${FEVER_GEMS}`, color: '#7fe6ff' },
239  ]
240  return [...parts, ...powersOf(g)]
241}
242
243// Every power on: the timed ones with their seconds left (FEVER has its own
244// slot), the charged ones with their hits left, and the paddle's.
245function powersOf(g: Game): HudPart[] {
246  const parts: HudPart[] = []
247  for (const power of TIMED) {
248    if (power === 'fever' || !isOn(g, power)) continue
249    parts.push({ text: `  ${CAPSULES[power].label} ${Math.ceil(g.timers[power])}s`, color: hudColor(power) })
250  }
251  for (const power of CHARGED) if (isOn(g, power)) parts.push({ text: `  ${CAPSULES[power].label} x${g.charges[power]}`, color: hudColor(power) })
252  const mode = g.paddle.mode
253  if (mode !== 'normal') parts.push({ text: '  ' + CAPSULES[mode].label, color: hudColor(mode) })
254  return parts
255}
256
257// A capsule's colour for the dark line over the field: the brighter of its
258// body and its band.
259function hudColor(kind: CapsuleKind): string {
260  const { color, band } = CAPSULES[kind]
261  return hexOf(luma(band) > luma(color) ? band : color)
262}
263
264function luma([r, g, b]: Rgb): number {
265  return r * 0.3 + g * 0.59 + b * 0.11
266}
267
268function hexOf(color: Rgb): string {
269  return '#' + color.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')
270}
271
272// The score a line over the field shows, a step closer to the game's: a
273// quarter of the way each time, so a big hit rolls up.
274export function rollScore(shown: number, score: number): number {
275  if (shown >= score) return score
276  return Math.min(score, shown + Math.max(10, Math.ceil((score - shown) / 4)))
277}
278
279export function hudText(parts: readonly HudPart[]): string {
280  return parts.map((p) => p.text).join('')
281}
282
hooks/pad.ts 120 lines
1// brickfall: the surface module taking the pointer and the keys. Two
2// instances: `look`, laid over the screen and drawing nothing there, so the
3// paddle follows the pointer across the field itself (a surface module
4// cannot draw the screen, so it lies on top of it); and `strip`, the hint
5// line under it. A click on either gives it the keyboard; what it takes goes
6// to the hooks module, which plays it.
7
8import type { ClientModule, ClientPointerEvent, ClientSurface } from 'claude-code'
9
10import { actOf } from './lib'
11import type { Act, Pad, PadPost } from './lib'
12
13// What the hooks module hands each instance: the layer over the screen needs
14// nothing; the strip shows the hint.
15export type PadProps = { role: 'look' } | { role: 'strip'; hint: string }
16
17type PadState = { beat: 0 | 1 }
18
19// How many recent acts each post carries. A post replaces one not yet
20// delivered, so each carries the last few, numbered, and the hooks module
21// keeps the ones it has not seen.
22const RECENT = 16
23
24// Claude Code paints a blit at its next frame, and with nothing else moving
25// it draws a frame only a few times a second. The strip redraws itself
26// PUMP_MS apart (a blank that alternates between two blank characters) so
27// every frame the game sends gets painted.
28const PUMP_MS = 30
29const BEATS = [' ', '⠀'] as const
30
31// What an instance has to say: the post it sends, and its count of acts.
32type Keys = Pad & { n: number }
33
34// Each instance's act count, recent acts and pointer. Not state: they redraw
35// nothing by themselves.
36const pads = new WeakMap<ClientSurface<PadState>, Keys>()
37
38function keysOf(surface: ClientSurface<PadState>): Keys {
39  let keys = pads.get(surface)
40  if (keys === undefined) {
41    keys = { n: 0, acts: [], x: null, isInside: null }
42    pads.set(surface, keys)
43  }
44  return keys
45}
46
47function send(surface: ClientSurface<PadState>, keys: Keys): void {
48  const post: PadPost = { type: 'pad', acts: keys.acts, x: keys.x, isInside: keys.isInside }
49  surface.post(post)
50}
51
52function acted(surface: ClientSurface<PadState>, act: Act): void {
53  const keys = keysOf(surface)
54  keys.n += 1
55  keys.acts.push({ n: keys.n, act })
56  if (keys.acts.length > RECENT) keys.acts.shift()
57  send(surface, keys)
58}
59
60// Over the field: a move steers, a press launches or fires, and crossing the
61// edge says whether the pointer is on the field at all.
62function pointed(surface: ClientSurface<PadState>, e: ClientPointerEvent, isLook: boolean): void {
63  const keys = keysOf(surface)
64  if (e.type === 'down') {
65    if (isLook) keys.x = across(surface, e)
66    acted(surface, 'fire')
67    return
68  }
69  if (!isLook) return
70  if (e.type === 'move') {
71    keys.x = across(surface, e)
72    keys.isInside = true
73  } else if (e.type === 'enter') {
74    keys.isInside = true
75  } else if (e.type === 'leave') {
76    keys.isInside = false
77  } else {
78    return
79  }
80  send(surface, keys)
81}
82
83// Where the pointer is across the region, 0 at its left edge and 1 at its
84// right, to the fraction of a cell where the terminal says.
85function across(surface: ClientSurface<PadState>, e: ClientPointerEvent): number {
86  const columns = surface.columns > 0 ? surface.columns : 1
87  const x = e.fine ? e.fine.x : e.x + 0.5
88  return Math.max(0, Math.min(1, x / columns))
89}
90
91function beat(surface: ClientSurface<PadState>): void {
92  const state = surface.state ?? { beat: 0 }
93  surface.setState({ beat: state.beat === 0 ? 1 : 0 })
94}
95
96const PadModule: ClientModule<PadProps, PadState> = (props, surface) => {
97  const { Box, Text } = surface.elements
98  const isLook = props.role === 'look'
99  if (surface.state === undefined) {
100    surface.setState({ beat: 0 })
101    // One heartbeat is enough: the strip's.
102    if (!isLook) surface.every(PUMP_MS, () => beat(surface))
103  }
104  surface.onKey((e) => {
105    const act = actOf(e)
106    if (act !== null) acted(surface, act)
107  })
108  surface.onPointer((e) => pointed(surface, e, isLook))
109  // Over the screen: nothing drawn, so the picture shows through.
110  if (props.role === 'look') return Box({ flexDirection: 'column' })
111
112  const state = surface.state ?? { beat: 0 }
113  return Box({
114    flexDirection: 'row',
115    children: [Text({ children: [BEATS[state.beat]] }), Text({ color: '#9fe8ff', wrap: 'truncate-end', children: [props.hint] })],
116  })
117}
118
119export default PadModule
120
hooks/render.ts 1069 lines
1// brickfall: the picture. Draws a Game into a canvas of light (floats, so
2// glows add up past white), then hands it on as RGBA bytes for an Image or as
3// quadrant-block cells for a Raster. Nothing here takes $.
4
5import { BARRIER_Y, BRICK_H, BRICK_W, CAPSULES, CAPSULE_HALF, COLS, COMBO_FULL, GEM_HALF, H, LASER_INSET, PADDLE_H, PADDLE_Y, W, WALL, ballRadius, brickCenter, brickIndex, brickRect, hueOf, isOn, levelOf } from './game'
6import type { Bolt, Brick, Capsule, Game, Gem, Ghost, PaddleMode, Point, Rgb } from './game'
7import { hotkeyOf } from './lib'
8
9// Light, 0 to 1 a channel and past 1 where glows add up: what the canvas holds.
10// Branded, so a game colour (0 to 255) is never drawn as light by mistake;
11// tone() makes one, toneOf() turns a game colour into one.
12export type Tint = readonly [number, number, number] & { readonly unit: 'light' }
13
14export function tone(r: number, g: number, b: number): Tint {
15  return Object.assign([r, g, b] as const, { unit: 'light' as const })
16}
17
18export function toneOf(color: Rgb, k = 1): Tint {
19  return tone((color[0] / 255) * k, (color[1] / 255) * k, (color[2] / 255) * k)
20}
21
22export type Canvas = {
23  w: number
24  h: number
25  // Pixels per world unit, across and down, and world units per pixel.
26  sx: number
27  sy: number
28  isx: number
29  isy: number
30  px: Float32Array
31  colA: Float32Array
32  rowB: Float32Array
33  rowD: Int32Array
34  diag: Float32Array
35  colE: Float32Array
36  rowE: Float32Array
37  rowL: Float32Array
38  colV: Float32Array
39  lut: Float32Array
40  sheen: Float32Array
41  // What never changes between frames of one size, worked out once: the
42  // walls' metal and seam, and each kind of brick's shading.
43  walls: WallCache | null
44  tiles: Map<string, Float32Array>
45}
46
47type WallCache = { idx: Int32Array; k: Float32Array; seam: Float32Array; spill: Int32Array; spillK: Float32Array }
48
49const LUT = 256
50const SHEEN = 512
51
52export function makeCanvas(w: number, h: number): Canvas {
53  return {
54    w,
55    h,
56    sx: w / W,
57    sy: h / H,
58    isx: W / w,
59    isy: H / h,
60    px: new Float32Array(w * h * 3),
61    colA: new Float32Array(w),
62    rowB: new Float32Array(h),
63    rowD: new Int32Array(h),
64    diag: new Float32Array(w * 2 + 2),
65    colE: new Float32Array(w),
66    rowE: new Float32Array(h),
67    rowL: new Float32Array(h),
68    colV: new Float32Array(w),
69    lut: new Float32Array(LUT * 3),
70    sheen: new Float32Array(SHEEN),
71    walls: null,
72    tiles: new Map(),
73  }
74}
75
76// Draw the whole frame at time `t` (seconds, the wall clock: the background
77// moves on while the game is paused).
78export function draw(c: Canvas, g: Game, t: number): void {
79  // The backdrop heats up with the combo, and runs wild in a fever.
80  const heat = Math.min(1, g.chain / COMBO_FULL) * 0.8 + (isOn(g, 'fever') ? 1 : 0)
81  background(c, t, heat, isOn(g, 'fever'))
82  twinkles(c, t)
83  walls(c, g, t)
84  for (let row = 0; row < g.rows; row++) {
85    for (let col = 0; col < COLS; col++) {
86      const b = g.bricks[brickIndex(col, row)]
87      if (b) shadow(c, col, row)
88    }
89  }
90  for (let row = 0; row < g.rows; row++) {
91    for (let col = 0; col < COLS; col++) {
92      const b = g.bricks[brickIndex(col, row)]
93      if (b) brick(c, b, col, row, t)
94    }
95  }
96  for (const gh of g.ghosts) ghost(c, gh)
97  for (const l of g.lights) {
98    const f = l.life / l.max
99    glow(c, l.x, l.y, Math.min(32, l.radius) * (1.2 - 0.2 * f), toneOf(l.color), 0.8 * f * f)
100  }
101  // The numbers and words go under what is played with, so a ball or a
102  // capsule never hides behind them.
103  floaters(c, g)
104  overlay(c, g, t)
105  if (isOn(g, 'barrier')) barrier(c, g, t)
106  for (const cap of g.capsules) capsule(c, cap, t)
107  for (const gem of g.gems) jewel(c, gem, t)
108  for (const l of g.lasers) laser(c, l.x, l.y)
109  if (g.phase !== 'lost' && g.phase !== 'over') paddle(c, g, t)
110  for (const b of g.bolts) bolt(c, b, t)
111  const style: BallStyle = isOn(g, 'fire') ? 'fire' : isOn(g, 'nova') ? 'nova' : isOn(g, 'fever') ? 'fever' : 'normal'
112  const radius = ballRadius(g)
113  for (const ball of g.balls) {
114    trail(c, ball.trail, style, radius, t)
115    orb(c, ball.x, ball.y, style, radius, t)
116  }
117  particles(c, g)
118  if (g.flash > 0) wash(c, g.flash * 0.22)
119}
120
121type BallStyle = 'normal' | 'fire' | 'nova' | 'fever'
122
123// ---- background -----------------------------------------------------------
124
125// A slow iridescent sheet: hues drift in bands that fold over each other, a
126// bright sheen sweeps across now and then, the edges dimmed. Separable terms
127// worked out at half resolution (the sheet is smooth), so no sine runs per
128// pixel and each one lights a 2 by 2 block.
129function background(c: Canvas, t: number, heat: number, isFever: boolean): void {
130  const { w, h, px, colA, rowB, rowD, diag, colE, rowE, rowL, colV, lut, sheen } = c
131  const hw = (w + 1) >> 1
132  const hh = (h + 1) >> 1
133  for (let x = 0; x < hw; x++) {
134    const u = (x * 2 + 1) / w
135    colA[x] = 0.33 * Math.sin(u * 5.3 + t * 0.31) + 0.18 * Math.sin(u * 11.7 - t * 0.19)
136    colE[x] = u * 0.62
137    const cu = u * 2 - 1
138    colV[x] = 1 - 0.3 * cu * cu * cu * cu
139  }
140  for (let y = 0; y < hh; y++) {
141    const v = (y * 2 + 1) / h
142    rowB[y] = 0.3 * Math.sin(v * 4.1 - t * 0.23) + 0.16 * Math.sin(v * 9.5 + t * 0.29)
143    rowD[y] = Math.round((y * hw) / hh)
144    rowE[y] = -v * 0.9
145    const cv = v * 2 - 1
146    // Lighter at the top, darker where the paddle plays, dim at the edges.
147    rowL[y] = (0.55 + 0.75 * Math.pow(1 - v, 1.4)) * (1 - 0.25 * cv * cv * cv * cv)
148  }
149  for (let i = 0; i < hw * 2 + 2; i++) diag[i] = 0.42 * Math.sin((i / hw) * 3.4 + t * 0.43)
150  for (let i = 0; i < LUT; i++) {
151    const hue = i / LUT
152    const [r, gg, b] = hueColor(hue)
153    // Pastel, then dimmed onto a deep violet ground.
154    const k = 1 + heat * 0.9
155    lut[i * 3] = 0.025 + (r * 0.75 + 0.25) * 0.19 * k
156    lut[i * 3 + 1] = 0.015 + (gg * 0.75 + 0.25) * 0.16 * k
157    lut[i * 3 + 2] = 0.06 + (b * 0.75 + 0.25) * 0.22 * k
158  }
159  const center = ((t * 0.085) % 2.6) - 1.05
160  for (let i = 0; i < SHEEN; i++) {
161    const e = (i / SHEEN) * 2 - 1
162    const a = (e - center) / 0.055
163    const b = (e - center - 0.11) / 0.025
164    sheen[i] = (0.3 * Math.exp(-a * a) + 0.14 * Math.exp(-b * b)) * (1 + heat * 0.6)
165  }
166  const drift = t * (isFever ? 0.35 : 0.025)
167  const row3 = w * 3
168  for (let y = 0; y < hh; y++) {
169    const by = rowB[y] ?? 0
170    const dy = rowD[y] ?? 0
171    const ey = rowE[y] ?? 0
172    const ly = rowL[y] ?? 1
173    const top = y * 2 * row3
174    const hasBelow = y * 2 + 1 < h
175    for (let x = 0; x < hw; x++) {
176      let s = (colA[x] ?? 0) + by + (diag[x + dy] ?? 0) + drift
177      s -= Math.floor(s)
178      const k = ((s * LUT) | 0) * 3
179      const sh = sheen[((((colE[x] ?? 0) + ey + 1) * 0.5 * SHEEN) | 0) & (SHEEN - 1)] ?? 0
180      const l = ly * (colV[x] ?? 1)
181      const lr = lut[k] ?? 0
182      const lg = lut[k + 1] ?? 0
183      const lb = lut[k + 2] ?? 0
184      const r = (lr + sh * (lr * 2.2 + 0.25)) * l
185      const g = (lg + sh * (lg * 2.2 + 0.25)) * l
186      const b = (lb + sh * (lb * 2.2 + 0.3)) * l
187      let i = top + x * 6
188      const hasRight = x * 2 + 1 < w
189      px[i] = r
190      px[i + 1] = g
191      px[i + 2] = b
192      if (hasRight) {
193        px[i + 3] = r
194        px[i + 4] = g
195        px[i + 5] = b
196      }
197      if (hasBelow) {
198        i += row3
199        px[i] = r
200        px[i + 1] = g
201        px[i + 2] = b
202        if (hasRight) {
203          px[i + 3] = r
204          px[i + 4] = g
205          px[i + 5] = b
206        }
207      }
208    }
209  }
210}
211
212// Points of light that come and go behind the field.
213function twinkles(c: Canvas, t: number): void {
214  for (let n = 0; n < 34; n++) {
215    const hx = hash(n * 3 + 1)
216    const hy = hash(n * 3 + 2)
217    const hr = hash(n * 3 + 3)
218    const x = WALL + hx * (W - WALL * 2)
219    const y = WALL + hy * (H - WALL - 30)
220    const k = Math.pow(Math.max(0, Math.sin(t * (0.7 + hr * 1.6) + hr * 40)), 10)
221    if (k < 0.02) continue
222    glow(c, x, y, 2.6, tone(0.8, 0.85, 1), k * 0.9)
223  }
224}
225
226function hash(n: number): number {
227  const s = Math.sin(n * 127.1 + 311.7) * 43758.5453
228  return s - Math.floor(s)
229}
230
231// ---- the frame ------------------------------------------------------------
232
233// The walls' neon seam: drifting slowly through the hues, flashing the colour
234// of what just broke, racing round the wheel in a fever. The metal and the
235// seam's shape are worked out once a size; a frame only colours them.
236function walls(c: Canvas, g: Game, t: number): void {
237  const base = isOn(g, 'fever') ? hueColor(t * 1.5) : hueColor(t * 0.05)
238  const hit = toneOf(g.pulseColor)
239  const p = g.pulse
240  const boost = 1 + p * 1.8 + (isOn(g, 'fever') ? 0.6 : 0)
241  const nr = (base[0] + (hit[0] - base[0]) * p) * boost
242  const ng = (base[1] + (hit[1] - base[1]) * p) * boost
243  const nb = (base[2] + (hit[2] - base[2]) * p) * boost
244  const cache = c.walls ?? (c.walls = wallCache(c))
245  const { px } = c
246  const { idx, k, seam, spill, spillK } = cache
247  for (let n = 0; n < idx.length; n++) {
248    const i = idx[n] ?? 0
249    const kk = k[n] ?? 0
250    const s = (seam[n] ?? 0) * 1.1
251    px[i] = 0.52 * kk + nr * s
252    px[i + 1] = 0.58 * kk + ng * s
253    px[i + 2] = 0.74 * kk + nb * s
254  }
255  for (let n = 0; n < spill.length; n++) {
256    const i = spill[n] ?? 0
257    const s = spillK[n] ?? 0
258    px[i] = (px[i] ?? 0) + nr * s
259    px[i + 1] = (px[i + 1] ?? 0) + ng * s
260    px[i + 2] = (px[i + 2] ?? 0) + nb * s
261  }
262}
263
264// Every wall pixel: its gunmetal shade (rounded across the wall, rivets every
265// so often) and how much of the seam it carries; and the pixels of the field
266// the seam's light spills onto, with how much.
267function wallCache(c: Canvas): WallCache {
268  const idx: number[] = []
269  const k: number[] = []
270  const seam: number[] = []
271  const spill: number[] = []
272  const spillK: number[] = []
273  const reach = 6
274  for (let py = 0; py < c.h; py++) {
275    const wy = toWorldY(c, py)
276    for (let pxx = 0; pxx < c.w; pxx++) {
277      const wx = toWorldX(c, pxx)
278      const i = (py * c.w + pxx) * 3
279      const isSide = wx < WALL || wx > W - WALL
280      const isTop = !isSide && wy < WALL
281      if (isSide || isTop) {
282        // Across the wall: 0 at its outer edge, 1 where it meets the field.
283        const across = Math.max(0, Math.min(1, isSide ? (wx < WALL ? wx : W - wx) / WALL : wy / WALL))
284        const along = isSide ? wy : wx
285        const rivet = Math.abs(((along + 16) % 32) - 16) < 1.6 && Math.abs(across - 0.5) < 0.2
286        idx.push(i)
287        k.push(0.1 + 0.32 * Math.sin(across * Math.PI) + (rivet ? 0.35 : 0))
288        seam.push(Math.max(0, 1 - (1 - across) * WALL * 0.9))
289        continue
290      }
291      const d = Math.min(wx - WALL, W - WALL - wx, wy - WALL)
292      if (d >= 0 && d < reach) {
293        spill.push(i)
294        spillK.push(Math.pow(1 - d / reach, 2) * 0.22)
295      }
296    }
297  }
298  return { idx: Int32Array.from(idx), k: Float32Array.from(k), seam: Float32Array.from(seam), spill: Int32Array.from(spill), spillK: Float32Array.from(spillK) }
299}
300
301function shadow(c: Canvas, col: number, row: number): void {
302  const r = brickRect(col, row)
303  const { px, w } = c
304  const pr = pixelRect(c, r.x0 + 1.5, r.y0 + 2, r.x1 + 1.5, r.y1 + 2)
305  for (let py = pr.py0; py < pr.py1; py++) {
306    for (let pxx = pr.px0; pxx < pr.px1; pxx++) {
307      const i = (py * w + pxx) * 3
308      px[i] = (px[i] ?? 0) * 0.45
309      px[i + 1] = (px[i + 1] ?? 0) * 0.45
310      px[i + 2] = (px[i + 2] ?? 0) * 0.45
311    }
312  }
313}
314
315// A brick: lit from above, bevelled, a gloss band, and a shine that sweeps
316// across the whole wall of bricks every few seconds (strongest on metal). The
317// shading is the same for every brick of a kind and size, so it is worked out
318// once (tileOf) and each frame adds only the shine and a hit's flash.
319function brick(c: Canvas, b: Brick, col: number, row: number, t: number): void {
320  const r = brickRect(col, row)
321  const pr = pixelRect(c, r.x0, r.y0, r.x1 - 0.6, r.y1 - 0.6)
322  const tw = pr.px1 - pr.px0
323  const th = pr.py1 - pr.py0
324  if (tw <= 0 || th <= 0) return
325  if (b.kind === 'volt') {
326    volt(c, b, col, row, pr, t)
327    return
328  }
329  const tile = tileOf(c, b, tw, th)
330  const { px, w } = c
331  const sweep = ((t * 75) % 560) - 90
332  const metal = b.kind === 'silver' || b.kind === 'gold' ? 0.75 : 0.375
333  const { isx, isy } = c
334  let k = 0
335  for (let py = pr.py0; py < pr.py1; py++) {
336    const wy = (py + 0.5) * isy
337    let i = (py * w + pr.px0) * 3
338    for (let pxx = pr.px0; pxx < pr.px1; pxx++, i += 3, k += 3) {
339      const d = ((pxx + 0.5) * isx + wy * 0.6 - sweep) / 9
340      const d2 = d * d
341      const shine = d2 < 1 ? (1 - d2) * (1 - d2) * metal : 0
342      px[i] = (tile[k] ?? 0) + shine
343      px[i + 1] = (tile[k + 1] ?? 0) + shine
344      px[i + 2] = (tile[k + 2] ?? 0) + shine
345    }
346  }
347  flashRect(c, pr, b.flash)
348  if (b.kind === 'gold') {
349    const s = Math.pow(Math.max(0, Math.sin(t * 2.3 + col * 2.1 + row)), 30)
350    if (s > 0.05) glow(c, r.x0 + 4 + ((col * 5) % 8), r.y0 + 2, 3, tone(1, 0.95, 0.7), s * 1.4)
351  }
352}
353
354// One kind of brick's shading at `tw` by `th` pixels, kept for the canvas.
355function tileOf(c: Canvas, b: Brick, tw: number, th: number): Float32Array {
356  const key = `${b.kind}:${b.color.join(',')}:${tw}x${th}`
357  const kept = c.tiles.get(key)
358  if (kept) return kept
359  const tile = new Float32Array(tw * th * 3)
360  let n = 0
361  for (let y = 0; y < th; y++) {
362    const fy = (y + 0.5) / th
363    for (let x = 0; x < tw; x++, n += 3) {
364      const fx = (x + 0.5) / tw
365      let k = 0.72 + 0.5 * (1 - fy)
366      let add = 0
367      if (fy < 0.16) add += 0.38
368      else if (fy > 0.8) k *= 0.5
369      if (fx < 0.06) add += 0.18
370      else if (fx > 0.93) k *= 0.65
371      if (fy > 0.24 && fy < 0.42) add += 0.14
372      if (b.kind === 'silver') add += 0.08 * Math.sin((fx * BRICK_W + fy * BRICK_H) * 1.7)
373      const [r, gg, bb] = toneOf(b.color, k)
374      tile[n] = r + add
375      tile[n + 1] = gg + add
376      tile[n + 2] = bb + add
377    }
378  }
379  c.tiles.set(key, tile)
380  return tile
381}
382
383// A volt brick: dark, a core that throbs and flickers, a violet glow.
384function volt(c: Canvas, b: Brick, col: number, row: number, pr: PixelRect, t: number): void {
385  const r = brickRect(col, row)
386  const { px, w } = c
387  const pulse = 0.5 + 0.5 * Math.sin(t * 9 + col * 1.3 + row * 0.7)
388  const flicker = hash(Math.floor(t * 20) + col * 7 + row * 13) > 0.85 ? 0.6 : 0
389  for (let py = pr.py0; py < pr.py1; py++) {
390    const fy = (toWorldY(c, py) - r.y0) / BRICK_H
391    for (let pxx = pr.px0; pxx < pr.px1; pxx++) {
392      const fx = (toWorldX(c, pxx) - r.x0) / BRICK_W
393      const edge = Math.min(fx, 1 - fx, fy * 2, (1 - fy) * 2)
394      const core = Math.max(0, 1 - edge * 5)
395      put(px, (py * w + pxx) * 3, 0.14 + core * (0.5 + pulse * 0.6) + flicker * 0.3, 0.06 + core * (0.32 + pulse * 0.5) + flicker * 0.6, 0.32 + core * (0.9 + pulse * 0.4) + flicker * 0.7)
396    }
397  }
398  flashRect(c, pr, b.flash)
399  const mid = brickCenter(col, row)
400  glow(c, mid.x, mid.y, 11, tone(0.55, 0.35, 1), 0.18 + pulse * 0.2 + flicker * 0.4)
401}
402
403// A capsule: a pill in its colour with a band across it (MAGNET's halves are
404// red and blue, DOUBLE's run through the rainbow), turning as it falls.
405function capsule(c: Canvas, cap: Capsule, t: number): void {
406  const info = CAPSULES[cap.kind]
407  const { px, w } = c
408  const x = cap.x
409  const y = cap.y
410  const hw = CAPSULE_HALF.w
411  const hh = CAPSULE_HALF.h
412  const base = toneOf(info.color)
413  const band = toneOf(info.band)
414  glow(c, x, y, 11, cap.kind === 'double' ? hueColor(t) : base, 0.4 + 0.15 * Math.sin(t * 8))
415  const pr = pixelRect(c, x - hw, y - hh, x + hw, y + hh)
416  const spin = cap.age * 6
417  for (let py = pr.py0; py < pr.py1; py++) {
418    const wy = toWorldY(c, py)
419    const fy = (wy - y) / hh
420    for (let pxx = pr.px0; pxx < pr.px1; pxx++) {
421      const wx = toWorldX(c, pxx)
422      if (!inPill(wx - x, wy - y, hw, hh)) continue
423      const turn = 0.5 + 0.5 * Math.sin(fy * 2.2 + spin)
424      const k = 0.55 + 0.6 * (1 - Math.abs(fy)) + turn * 0.35
425      let col: Tint = base
426      if (cap.kind === 'magnet') col = wx < x ? base : band
427      else if (cap.kind === 'double') col = hueColor((wx - x) / 14 + t * 0.8)
428      else if (Math.abs(fy) < 0.3) col = band
429      put(px, (py * w + pxx) * 3, col[0] * k + 0.08, col[1] * k + 0.08, col[2] * k + 0.08)
430    }
431  }
432  // The letter, where there are pixels enough to read it.
433  if (c.sx >= 0.9) text(c, info.letter, x, y, 1, tone(0.1, 0.1, 0.14), 0.9, false)
434}
435
436// A gem: a cut diamond in its brick's colour, a glint running over it.
437function jewel(c: Canvas, gem: Gem, t: number): void {
438  const { px, w } = c
439  const color = toneOf(gem.color)
440  glow(c, gem.x, gem.y, 7, color, 0.45)
441  const rx = GEM_HALF.w
442  const ry = GEM_HALF.h
443  const pr = pixelRect(c, gem.x - rx, gem.y - ry, gem.x + rx, gem.y + ry)
444  for (let py = pr.py0; py < pr.py1; py++) {
445    const dy = (toWorldY(c, py) - gem.y) / ry
446    for (let pxx = pr.px0; pxx < pr.px1; pxx++) {
447      const dx = (toWorldX(c, pxx) - gem.x) / rx
448      if (Math.abs(dx) + Math.abs(dy) > 1) continue
449      // Four facets, the upper left lit.
450      const k = dx < 0 ? (dy < 0 ? 1.5 : 0.9) : dy < 0 ? 1.15 : 0.6
451      put(px, (py * w + pxx) * 3, color[0] * k + 0.15, color[1] * k + 0.15, color[2] * k + 0.15)
452    }
453  }
454  const glint = Math.pow(Math.max(0, Math.sin(t * 6 + gem.age * 3)), 12)
455  if (glint > 0.05) glow(c, gem.x - 1, gem.y - 1.5, 3.5, tone(1, 1, 1), glint * 1.5)
456}
457
458function laser(c: Canvas, x: number, y: number): void {
459  glow(c, x, y - 3, 5, tone(1, 0.2, 0.25), 0.5)
460  fillRect(c, x - 0.6, y - 6, x + 0.6, y, tone(1.4, 0.7, 0.75))
461}
462
463// The paddle: a silver capsule with coloured ends, its glow below it.
464function paddle(c: Canvas, g: Game, t: number): void {
465  const p = g.paddle
466  const { px, w } = c
467  const end = paddleEnd(p.mode)
468  const hw = p.width / 2
469  const hh = PADDLE_H / 2
470  // A hit pushes the paddle down a little; it springs back.
471  const top = PADDLE_Y + p.dip * 1.5
472  const cy = top + hh
473  const breathe = 0.5 + 0.5 * Math.sin(t * 3)
474  glow(c, p.x, cy + 1, hw + 8 + p.glow * 6, end, 0.16 + p.glow * 0.6 + breathe * 0.05)
475  const pr = pixelRect(c, p.x - hw, top, p.x + hw, top + PADDLE_H)
476  for (let py = pr.py0; py < pr.py1; py++) {
477    const wy = toWorldY(c, py)
478    const fy = (wy - cy) / hh
479    for (let pxx = pr.px0; pxx < pr.px1; pxx++) {
480      const wx = toWorldX(c, pxx)
481      if (!inPill(wx - p.x, wy - cy, hw, hh)) continue
482      const k = 0.5 + 0.75 * (1 - Math.abs(fy + 0.35)) + (fy < -0.45 ? 0.35 : 0)
483      const i = (py * w + pxx) * 3
484      if (Math.abs(wx - p.x) > hw - 5.5) put(px, i, end[0] * k + 0.1, end[1] * k + 0.1, end[2] * k + 0.1)
485      else put(px, i, 0.62 * k + p.glow * 0.4, 0.66 * k + p.glow * 0.45, 0.78 * k + p.glow * 0.55)
486    }
487  }
488  if (p.mode === 'laser') {
489    for (const gun of [p.x - hw + LASER_INSET, p.x + hw - LASER_INSET]) fillRect(c, gun - 1, PADDLE_Y - 2, gun + 1, PADDLE_Y, tone(1.2, 0.4, 0.4))
490  }
491}
492
493// The paddle's ends: the colour of the capsule that changed it, red without one.
494function paddleEnd(mode: PaddleMode): Tint {
495  return mode === 'normal' ? tone(1, 0.32, 0.38) : toneOf(CAPSULES[mode].color)
496}
497
498// Each kind of ball: its trail (point `k` of it), its halo and its core at
499// time `t`. A fire ball's halo flickers, a nova one throbs, a fever one runs
500// through the rainbow.
501type BallLook = { trail: (t: number, k: number) => Tint; halo: (t: number) => Tint; core: (t: number) => Tint }
502
503const BALL: Readonly<Record<BallStyle, BallLook>> = {
504  normal: { trail: () => tone(0.35, 0.75, 1), halo: () => tone(0.4, 0.85, 1), core: () => tone(1.5, 1.6, 1.7) },
505  fire: { trail: () => tone(1, 0.45, 0.2), halo: (t) => tone(1, 0.35 + 0.15 * Math.sin(t * 30), 0.6), core: () => tone(1.6, 1.2, 0.9) },
506  nova: { trail: () => tone(1, 0.3, 0.1), halo: () => tone(1, 0.35, 0.15), core: (t) => tone(1.7, 0.9 + throbOf('nova', t) * 0.5, 0.6) },
507  fever: { trail: (t, k) => hueColor(t * 2 - k * 0.07), halo: (t) => hueColor(t * 2), core: () => tone(1.5, 1.6, 1.7) },
508}
509
510function throbOf(style: BallStyle, t: number): number {
511  return style === 'nova' ? 0.5 + 0.5 * Math.sin(t * 18) : 0
512}
513
514function trail(c: Canvas, points: readonly Point[], style: BallStyle, radius: number, t: number): void {
515  const n = points.length
516  for (let k = 0; k < n; k++) {
517    const p = points[k]
518    if (!p) continue
519    const f = (k + 1) / (n + 1)
520    glow(c, p.x, p.y, radius * (0.8 + f * 1.4), BALL[style].trail(t, k), f * (style === 'normal' ? 0.35 : 0.6))
521  }
522}
523
524// The ball: a white-hot core in a halo; a giant one is bigger.
525function orb(c: Canvas, x: number, y: number, style: BallStyle, radius: number, t: number): void {
526  const look = BALL[style]
527  const throb = throbOf(style, t)
528  const isPlain = style === 'normal'
529  glow(c, x, y, radius * 3.6 + (isPlain ? 0 : 3) + throb * 4, look.halo(t), 0.55 + (isPlain ? 0 : 0.25) + throb * 0.3)
530  disk(c, x, y, radius, look.core(t))
531}
532
533function particles(c: Canvas, g: Game): void {
534  for (const p of g.particles) {
535    const f = Math.max(0, p.life / p.max)
536    switch (p.kind) {
537      case 'spark':
538        glow(c, p.x, p.y, p.size * 1.8, toneOf(p.color), f * 0.9)
539        break
540      case 'shard': {
541        const s = p.size * (0.4 + 0.6 * f)
542        fillRect(c, p.x - s / 2, p.y - s / 2, p.x + s / 2, p.y + s / 2, toneOf(p.color, 0.6 + f))
543        break
544      }
545      case 'ring':
546        ringAt(c, p.x, p.y, p.size * (1 - f) + 2, toneOf(p.color), f)
547        break
548      default:
549        p.kind satisfies never
550    }
551  }
552}
553
554// The numbers: big, shadowed, popping in larger than they settle.
555function floaters(c: Canvas, g: Game): void {
556  for (const f of g.floaters) {
557    const age = f.max - f.life
558    const a = Math.min(1, (f.life / f.max) * 2.4)
559    const pop = age < 0.1 ? 1.5 - (age / 0.1) * 0.5 : 1
560    text(c, f.text, f.x, f.y, f.size * pop, toneOf(f.color), a)
561  }
562}
563
564// What the round says over the field: its name before the serve, CLEAR,
565// GAME OVER, PAUSED.
566function overlay(c: Canvas, g: Game, t: number): void {
567  const mid = H / 2 + 20
568  if (g.isPaused) {
569    text(c, 'PAUSED', W / 2, mid, 4, tone(0.7, 0.9, 1), 1)
570    text(c, `CLICK OR ${hotkeyOf('pause').toUpperCase()} TO GO ON`, W / 2, mid + 26, 1.8, tone(0.8, 0.8, 0.9), 0.6 + 0.4 * Math.sin(t * 4))
571    return
572  }
573  if (g.phase === 'serve') {
574    const a = Math.min(1, Math.max(0, 2.2 - g.phaseTime))
575    if (a > 0) {
576      text(c, 'ROUND ' + g.level, W / 2, mid - 8, 4, tone(1, 0.85, 0.5), a)
577      text(c, levelOf(g.level).name.toUpperCase(), W / 2, mid + 16, 2.2, tone(0.75, 0.9, 1), a)
578    }
579    const blink = 0.55 + 0.45 * Math.sin(t * 5)
580    text(c, 'CLICK TO LAUNCH', W / 2, PADDLE_Y - 28, 1.8, tone(0.85, 0.9, 1), blink * Math.min(1, g.phaseTime * 2))
581  } else if (g.phase === 'clear') {
582    const pop = Math.min(1, g.phaseTime * 3)
583    text(c, 'CLEAR!', W / 2, mid, 2.5 + pop * 2.5, hueColor(t * 0.4), pop)
584  } else if (g.phase === 'over') {
585    text(c, 'GAME OVER', W / 2, mid, 4, tone(1, 0.35, 0.4), Math.min(1, g.phaseTime * 2))
586    text(c, 'CLICK FOR A NEW GAME', W / 2, mid + 26, 1.8, tone(0.85, 0.85, 0.95), (0.6 + 0.4 * Math.sin(t * 4)) * Math.min(1, g.phaseTime))
587  }
588}
589
590function wash(c: Canvas, k: number): void {
591  const { px } = c
592  for (let i = 0; i < px.length; i++) px[i] = (px[i] ?? 0) + k
593}
594
595// A broken brick's last frames: it swells and burns out white.
596function ghost(c: Canvas, gh: Ghost): void {
597  const f = gh.life / gh.max
598  const { x: cx, y: cy } = brickCenter(gh.col, gh.row)
599  const s = 1 + (1 - f) * 0.7
600  const hx = (BRICK_W / 2) * s
601  const hy = (BRICK_H / 2) * s
602  const k = f * f
603  const [r, g, b] = toneOf(gh.color, 0.7 * k)
604  addRect(c, cx - hx, cy - hy, cx + hx, cy + hy, tone(r + 0.6 * k, g + 0.6 * k, b + 0.6 * k))
605}
606
607// The barrier: a bright line under the paddle, flickering, dimmer with each
608// save it has used.
609function barrier(c: Canvas, g: Game, t: number): void {
610  const { px, w } = c
611  const [cr, cg, cb] = toneOf(CAPSULES.barrier.color)
612  const k = (0.45 + 0.25 * g.charges.barrier) * (0.85 + 0.15 * Math.sin(t * 23))
613  const reach = 4
614  const pr = pixelRect(c, WALL, BARRIER_Y - reach, W - WALL, BARRIER_Y + reach)
615  for (let py = pr.py0; py < pr.py1; py++) {
616    const d = Math.abs(toWorldY(c, py) - BARRIER_Y) / reach
617    if (d >= 1) continue
618    const f = (1 - d) * (1 - d) * k
619    const core = d < 0.25 ? 0.6 * k : 0
620    for (let pxx = pr.px0; pxx < pr.px1; pxx++) {
621      const wave = 0.75 + 0.25 * Math.sin(toWorldX(c, pxx) * 0.3 - t * 9)
622      const i = (py * w + pxx) * 3
623      add(px, i, cr * f * wave + core, cg * f * wave + core, cb * f * wave + core)
624    }
625  }
626}
627
628// A bolt of lightning: a jagged path that jumps about while it lasts, a
629// white core in a violet glow.
630function bolt(c: Canvas, b: Bolt, t: number): void {
631  const f = b.life / b.max
632  const n = 9
633  const jitter = Math.floor(t * 30)
634  const dx = b.x1 - b.x0
635  const dy = b.y1 - b.y0
636  const len = Math.hypot(dx, dy) || 1
637  const nx = -dy / len
638  const ny = dx / len
639  let px0 = b.x0
640  let py0 = b.y0
641  for (let i = 1; i <= n; i++) {
642    const s = i / n
643    const off = i === n ? 0 : (hash(b.seed + i * 7 + jitter * 131) - 0.5) * 14 * Math.sin(Math.PI * s)
644    const px1 = b.x0 + dx * s + nx * off
645    const py1 = b.y0 + dy * s + ny * off
646    const steps = Math.max(1, Math.ceil(Math.hypot(px1 - px0, py1 - py0) / 1.5))
647    for (let k = 0; k < steps; k++) {
648      const u = k / steps
649      const x = px0 + (px1 - px0) * u
650      const y = py0 + (py1 - py0) * u
651      glow(c, x, y, 2.4, tone(0.6, 0.4, 1), 0.35 * f)
652      glow(c, x, y, 0.9, tone(1, 1, 1), 0.8 * f)
653    }
654    px0 = px1
655    py0 = py1
656  }
657}
658
659// ---- primitives -----------------------------------------------------------
660
661function toWorldX(c: Canvas, x: number): number {
662  return (x + 0.5) * c.isx
663}
664
665function toWorldY(c: Canvas, y: number): number {
666  return (y + 0.5) * c.isy
667}
668
669// The pixels a world rectangle covers, clipped to the canvas.
670type PixelRect = { px0: number; py0: number; px1: number; py1: number }
671
672function pixelRect(c: Canvas, x0: number, y0: number, x1: number, y1: number): PixelRect {
673  return {
674    px0: Math.max(0, Math.floor(x0 * c.sx)),
675    py0: Math.max(0, Math.floor(y0 * c.sy)),
676    px1: Math.min(c.w, Math.ceil(x1 * c.sx)),
677    py1: Math.min(c.h, Math.ceil(y1 * c.sy)),
678  }
679}
680
681// Writing one pixel: set it, add light to it, or blend toward a colour. The
682// hot loops (the backdrop, glows, bricks, cells) write inline instead: a call
683// per pixel there costs frames on a busy machine.
684function put(px: Float32Array, i: number, r: number, g: number, b: number): void {
685  px[i] = r
686  px[i + 1] = g
687  px[i + 2] = b
688}
689
690function add(px: Float32Array, i: number, r: number, g: number, b: number): void {
691  px[i] = (px[i] ?? 0) + r
692  px[i + 1] = (px[i + 1] ?? 0) + g
693  px[i + 2] = (px[i + 2] ?? 0) + b
694}
695
696function blend(px: Float32Array, i: number, color: Tint, a: number): void {
697  px[i] = (px[i] ?? 0) * (1 - a) + color[0] * a
698  px[i + 1] = (px[i + 1] ?? 0) * (1 - a) + color[1] * a
699  px[i + 2] = (px[i + 2] ?? 0) * (1 - a) + color[2] * a
700}
701
702// Whether a point (`dx`, `dy` from the centre) is inside a pill `hw` by `hh`
703// half-units: a bar with round ends, as capsules and the paddle are; and how
704// far down it the point sits, -1 at the top to 1 at the bottom.
705function inPill(dx: number, dy: number, hw: number, hh: number): boolean {
706  const ex = Math.max(0, Math.abs(dx) - (hw - hh)) / hh
707  const fy = dy / hh
708  return ex * ex + fy * fy <= 1
709}
710
711// A hit brick's flash: its pixels pulled toward white-hot.
712function flashRect(c: Canvas, pr: PixelRect, flash: number): void {
713  if (flash <= 0) return
714  const { px, w } = c
715  for (let py = pr.py0; py < pr.py1; py++) {
716    for (let pxx = pr.px0; pxx < pr.px1; pxx++) blend(px, (py * w + pxx) * 3, WHITE_HOT, flash)
717  }
718}
719
720const WHITE_HOT = tone(1.4, 1.4, 1.4)
721
722// Light added around a point, falling off to nothing at `radius` units.
723export function glow(c: Canvas, x: number, y: number, radius: number, color: Tint, intensity: number): void {
724  if (intensity <= 0.003) return
725  const { px, w } = c
726  const pr = pixelRect(c, x - radius, y - radius, x + radius, y + radius)
727  const inv = 1 / (radius * radius)
728  const { isx, isy } = c
729  const ax = 0.5 * isx - x
730  const ay = 0.5 * isy - y
731  const [cr, cg, cb] = color
732  for (let py = pr.py0; py < pr.py1; py++) {
733    const dy = py * isy + ay
734    const dy2 = dy * dy * inv
735    if (dy2 >= 1) continue
736    let i = (py * w + pr.px0) * 3
737    for (let pxx = pr.px0; pxx < pr.px1; pxx++, i += 3) {
738      const dx = pxx * isx + ax
739      const f = 1 - dx * dx * inv - dy2
740      if (f <= 0) continue
741      const k = f * f * intensity
742      px[i] = (px[i] ?? 0) + cr * k
743      px[i + 1] = (px[i + 1] ?? 0) + cg * k
744      px[i + 2] = (px[i + 2] ?? 0) + cb * k
745    }
746  }
747}
748
749function disk(c: Canvas, x: number, y: number, radius: number, color: Tint): void {
750  const { px, w } = c
751  const pr = pixelRect(c, x - radius - 1, y - radius - 1, x + radius + 1, y + radius + 1)
752  for (let py = pr.py0; py < pr.py1; py++) {
753    const dy = toWorldY(c, py) - y
754    for (let pxx = pr.px0; pxx < pr.px1; pxx++) {
755      const dx = toWorldX(c, pxx) - x
756      const d = Math.sqrt(dx * dx + dy * dy)
757      const a = Math.max(0, Math.min(1, radius + 0.5 - d))
758      if (a <= 0) continue
759      blend(px, (py * w + pxx) * 3, color, a)
760    }
761  }
762}
763
764function ringAt(c: Canvas, x: number, y: number, radius: number, color: Tint, intensity: number): void {
765  const { px, w } = c
766  const width = 1.6
767  const pr = pixelRect(c, x - radius - width, y - radius - width, x + radius + width, y + radius + width)
768  for (let py = pr.py0; py < pr.py1; py++) {
769    const dy = toWorldY(c, py) - y
770    for (let pxx = pr.px0; pxx < pr.px1; pxx++) {
771      const dx = toWorldX(c, pxx) - x
772      const d = Math.abs(Math.sqrt(dx * dx + dy * dy) - radius) / width
773      if (d >= 1) continue
774      const k = (1 - d) * (1 - d) * intensity
775      add(px, (py * w + pxx) * 3, color[0] * k, color[1] * k, color[2] * k)
776    }
777  }
778}
779
780function fillRect(c: Canvas, x0: number, y0: number, x1: number, y1: number, color: Tint): void {
781  const { px, w } = c
782  const pr = pixelRect(c, x0, y0, x1, y1)
783  // Something too thin to cover a pixel still lights the one it sits in.
784  const px1 = Math.max(pr.px1, Math.min(c.w, pr.px0 + 1))
785  const py1 = Math.max(pr.py1, Math.min(c.h, pr.py0 + 1))
786  for (let py = pr.py0; py < py1; py++) {
787    for (let pxx = pr.px0; pxx < px1; pxx++) put(px, (py * w + pxx) * 3, color[0], color[1], color[2])
788  }
789}
790
791// Light added over a world rectangle.
792function addRect(c: Canvas, x0: number, y0: number, x1: number, y1: number, color: Tint): void {
793  const { px, w } = c
794  const pr = pixelRect(c, x0, y0, x1, y1)
795  for (let py = pr.py0; py < pr.py1; py++) {
796    for (let pxx = pr.px0; pxx < pr.px1; pxx++) add(px, (py * w + pxx) * 3, color[0], color[1], color[2])
797  }
798}
799
800// The game's rainbow (hueOf), as light.
801export function hueColor(h: number): Tint {
802  return toneOf(hueOf(h))
803}
804
805// ---- text -----------------------------------------------------------------
806
807// A 3 by 5 pixel font: each glyph five rows of three, `#` lit.
808const FONT: Readonly<Record<string, readonly string[]>> = {
809  A: ['.#.', '#.#', '###', '#.#', '#.#'],
810  B: ['##.', '#.#', '##.', '#.#', '##.'],
811  C: ['.##', '#..', '#..', '#..', '.##'],
812  D: ['##.', '#.#', '#.#', '#.#', '##.'],
813  E: ['###', '#..', '##.', '#..', '###'],
814  F: ['###', '#..', '##.', '#..', '#..'],
815  G: ['.##', '#..', '#.#', '#.#', '.##'],
816  H: ['#.#', '#.#', '###', '#.#', '#.#'],
817  I: ['###', '.#.', '.#.', '.#.', '###'],
818  J: ['..#', '..#', '..#', '#.#', '.#.'],
819  K: ['#.#', '#.#', '##.', '#.#', '#.#'],
820  L: ['#..', '#..', '#..', '#..', '###'],
821  M: ['#...#', '##.##', '#.#.#', '#...#', '#...#'],
822  N: ['#..#', '##.#', '#.##', '#..#', '#..#'],
823  O: ['.#.', '#.#', '#.#', '#.#', '.#.'],
824  P: ['##.', '#.#', '##.', '#..', '#..'],
825  Q: ['.#.', '#.#', '#.#', '##.', '.##'],
826  R: ['##.', '#.#', '##.', '#.#', '#.#'],
827  S: ['.##', '#..', '.#.', '..#', '##.'],
828  T: ['###', '.#.', '.#.', '.#.', '.#.'],
829  U: ['#.#', '#.#', '#.#', '#.#', '###'],
830  V: ['#.#', '#.#', '#.#', '#.#', '.#.'],
831  W: ['#...#', '#...#', '#.#.#', '##.##', '#...#'],
832  X: ['#.#', '#.#', '.#.', '#.#', '#.#'],
833  Y: ['#.#', '#.#', '.#.', '.#.', '.#.'],
834  Z: ['###', '..#', '.#.', '#..', '###'],
835  '0': ['###', '#.#', '#.#', '#.#', '###'],
836  '1': ['.#.', '##.', '.#.', '.#.', '###'],
837  '2': ['##.', '..#', '.#.', '#..', '###'],
838  '3': ['##.', '..#', '.#.', '..#', '##.'],
839  '4': ['#.#', '#.#', '###', '..#', '..#'],
840  '5': ['###', '#..', '##.', '..#', '##.'],
841  '6': ['.##', '#..', '###', '#.#', '###'],
842  '7': ['###', '..#', '.#.', '.#.', '.#.'],
843  '8': ['###', '#.#', '###', '#.#', '###'],
844  '9': ['###', '#.#', '###', '..#', '##.'],
845  '+': ['...', '.#.', '###', '.#.', '...'],
846  x: ['...', '#.#', '.#.', '#.#', '...'],
847  '!': ['.#.', '.#.', '.#.', '...', '.#.'],
848  '-': ['...', '...', '###', '...', '...'],
849  '.': ['...', '...', '...', '...', '.#.'],
850  ':': ['...', '.#.', '...', '.#.', '...'],
851  ' ': ['...', '...', '...', '...', '...'],
852}
853
854function glyphOf(ch: string): readonly string[] {
855  return FONT[ch] ?? FONT[ch.toUpperCase()] ?? FONT[' '] ?? []
856}
857
858// How many font pixels across a string runs: each glyph its width and a gap.
859function textWidth(s: string): number {
860  let n = 0
861  for (const ch of s) n += (glyphOf(ch)[0]?.length ?? 3) + 1
862  return Math.max(0, n - 1)
863}
864
865// Text centred on a world point, each font pixel `unit` world units square
866// (at least a screen pixel), a dark shadow under it so it reads over any
867// colour, a soft glow behind.
868export function text(c: Canvas, s: string, x: number, y: number, unit: number, color: Tint, alpha: number, hasShadow = true): void {
869  if (alpha <= 0.01) return
870  const fw = Math.max(1, Math.ceil(unit * c.sx - 0.15))
871  const fh = Math.max(1, Math.ceil(unit * c.sy - 0.15))
872  const totalW = textWidth(s) * fw
873  const totalH = fh * 5
874  const left = Math.round(x * c.sx - totalW / 2)
875  const top = Math.round(y * c.sy - totalH / 2)
876  const { px, w, h } = c
877  for (let pass = hasShadow ? 0 : 1; pass < 2; pass++) {
878    const ox = pass === 0 ? Math.max(1, Math.round(fw / 2)) : 0
879    const oy = pass === 0 ? Math.max(1, Math.round(fh / 2)) : 0
880    let at = left
881    for (const ch of s) {
882      const glyph = glyphOf(ch)
883      const gw = glyph[0]?.length ?? 3
884      for (let gy = 0; gy < 5; gy++) {
885        const line = glyph[gy] ?? ''
886        for (let gx = 0; gx < gw; gx++) {
887          if (line[gx] !== '#') continue
888          const x0 = at + gx * fw + ox
889          const y0 = top + gy * fh + oy
890          const shade = 0.8 + 0.2 * (1 - gy / 4)
891          const r = pass === 0 ? 0 : color[0] * shade + 0.35
892          const g = pass === 0 ? 0 : color[1] * shade + 0.35
893          const b = pass === 0 ? 0 : color[2] * shade + 0.35
894          const a = pass === 0 ? alpha * 0.8 : alpha
895          for (let py = y0; py < y0 + fh; py++) {
896            if (py < 0 || py >= h) continue
897            for (let pxx = x0; pxx < x0 + fw; pxx++) {
898              if (pxx < 0 || pxx >= w) continue
899              const i = (py * w + pxx) * 3
900              px[i] = (px[i] ?? 0) * (1 - a) + r * a
901              px[i + 1] = (px[i + 1] ?? 0) * (1 - a) + g * a
902              px[i + 2] = (px[i + 2] ?? 0) * (1 - a) + b * a
903            }
904          }
905        }
906      }
907      at += (gw + 1) * fw
908    }
909  }
910}
911
912// ---- output ---------------------------------------------------------------
913
914// The canvas as RGBA bytes, light past white clipped to white: written
915// through a clamped view, which rounds and clips each value itself.
916export function toRgba(c: Canvas, out?: Uint8Array): Uint8Array {
917  const n = c.w * c.h
918  const bytes = out && out.length === n * 4 ? out : new Uint8Array(n * 4)
919  const clamped = new Uint8ClampedArray(bytes.buffer, bytes.byteOffset, bytes.length)
920  const { px } = c
921  for (let i = 0, j = 0; i < n * 3; i += 3, j += 4) {
922    clamped[j] = (px[i] ?? 0) * 255
923    clamped[j + 1] = (px[i + 1] ?? 0) * 255
924    clamped[j + 2] = (px[i + 2] ?? 0) * 255
925    clamped[j + 3] = 255
926  }
927  return bytes
928}
929
930// The quadrant glyph for each 2 by 2 pattern of lit pixels: bit 1 top left,
931// 2 top right, 4 bottom left, 8 bottom right.
932const QUADRANTS = [0x2588, 0x2598, 0x259d, 0x2580, 0x2596, 0x258c, 0x259e, 0x259b, 0x2597, 0x259a, 0x2590, 0x259c, 0x2584, 0x2599, 0x259f, 0x2588] as const
933
934// The canvas (2 pixels a cell each way) as Raster cells: each cell's four
935// pixels split in two by the channel that varies most, each half its mean
936// colour, rounded to the 4 bits a channel the terminal keeps. A cell all one
937// colour is a full block, never a blank: blanks at a row's end show black.
938// Written flat, with no call per cell: it runs for every cell of every frame.
939export function toCells(c: Canvas, out?: Uint32Array): Uint32Array {
940  const columns = c.w >> 1
941  const rows = c.h >> 1
942  const words = out && out.length === columns * rows * 3 ? out : new Uint32Array(columns * rows * 3)
943  const { px, w } = c
944  const row3 = w * 3
945  let k = 0
946  for (let cy = 0; cy < rows; cy++) {
947    for (let cx = 0; cx < columns; cx++, k += 3) {
948      const i0 = cy * 2 * row3 + cx * 6
949      const i2 = i0 + row3
950      const v00 = px[i0] ?? 0
951      const v01 = px[i0 + 1] ?? 0
952      const v02 = px[i0 + 2] ?? 0
953      const v10 = px[i0 + 3] ?? 0
954      const v11 = px[i0 + 4] ?? 0
955      const v12 = px[i0 + 5] ?? 0
956      const v20 = px[i2] ?? 0
957      const v21 = px[i2 + 1] ?? 0
958      const v22 = px[i2 + 2] ?? 0
959      const v30 = px[i2 + 3] ?? 0
960      const v31 = px[i2 + 4] ?? 0
961      const v32 = px[i2 + 5] ?? 0
962      const r0 = v00 <= 0 ? 0 : v00 >= 1 ? 255 : v00 * 255
963      const g0 = v01 <= 0 ? 0 : v01 >= 1 ? 255 : v01 * 255
964      const b0 = v02 <= 0 ? 0 : v02 >= 1 ? 255 : v02 * 255
965      const r1 = v10 <= 0 ? 0 : v10 >= 1 ? 255 : v10 * 255
966      const g1 = v11 <= 0 ? 0 : v11 >= 1 ? 255 : v11 * 255
967      const b1 = v12 <= 0 ? 0 : v12 >= 1 ? 255 : v12 * 255
968      const r2 = v20 <= 0 ? 0 : v20 >= 1 ? 255 : v20 * 255
969      const g2 = v21 <= 0 ? 0 : v21 >= 1 ? 255 : v21 * 255
970      const b2 = v22 <= 0 ? 0 : v22 >= 1 ? 255 : v22 * 255
971      const r3 = v30 <= 0 ? 0 : v30 >= 1 ? 255 : v30 * 255
972      const g3 = v31 <= 0 ? 0 : v31 >= 1 ? 255 : v31 * 255
973      const b3 = v32 <= 0 ? 0 : v32 >= 1 ? 255 : v32 * 255
974      const rl = Math.min(r0, r1, r2, r3)
975      const rh = Math.max(r0, r1, r2, r3)
976      const gl = Math.min(g0, g1, g2, g3)
977      const gh = Math.max(g0, g1, g2, g3)
978      const bl = Math.min(b0, b1, b2, b3)
979      const bh = Math.max(b0, b1, b2, b3)
980      const rr = rh - rl
981      const gr = gh - gl
982      const br = bh - bl
983      if (rr < 18 && gr < 18 && br < 18) {
984        const color = pack((r0 + r1 + r2 + r3) / 4, (g0 + g1 + g2 + g3) / 4, (b0 + b1 + b2 + b3) / 4)
985        words[k] = 0x2588
986        words[k + 1] = color
987        words[k + 2] = color
988        continue
989      }
990      let mask: number
991      if (rr >= gr && rr >= br) {
992        const mid = (rl + rh) / 2
993        mask = (r0 > mid ? 1 : 0) | (r1 > mid ? 2 : 0) | (r2 > mid ? 4 : 0) | (r3 > mid ? 8 : 0)
994      } else if (gr >= br) {
995        const mid = (gl + gh) / 2
996        mask = (g0 > mid ? 1 : 0) | (g1 > mid ? 2 : 0) | (g2 > mid ? 4 : 0) | (g3 > mid ? 8 : 0)
997      } else {
998        const mid = (bl + bh) / 2
999        mask = (b0 > mid ? 1 : 0) | (b1 > mid ? 2 : 0) | (b2 > mid ? 4 : 0) | (b3 > mid ? 8 : 0)
1000      }
1001      let fr = 0
1002      let fg = 0
1003      let fb = 0
1004      let nr = 0
1005      let ng = 0
1006      let nb = 0
1007      let n = 0
1008      if (mask & 1) { fr += r0; fg += g0; fb += b0; n++ } else { nr += r0; ng += g0; nb += b0 }
1009      if (mask & 2) { fr += r1; fg += g1; fb += b1; n++ } else { nr += r1; ng += g1; nb += b1 }
1010      if (mask & 4) { fr += r2; fg += g2; fb += b2; n++ } else { nr += r2; ng += g2; nb += b2 }
1011      if (mask & 8) { fr += r3; fg += g3; fb += b3; n++ } else { nr += r3; ng += g3; nb += b3 }
1012      const m = 4 - n
1013      words[k] = QUADRANTS[mask] ?? 0x2588
1014      words[k + 1] = pack(fr / n, fg / n, fb / n)
1015      words[k + 2] = pack(nr / m, ng / m, nb / m)
1016    }
1017  }
1018  return words
1019}
1020
1021// A colour as the Raster takes it, each channel rounded to a multiple of 17.
1022function pack(r: number, g: number, b: number): number {
1023  return ((((r / 17 + 0.5) | 0) * 17) << 16) | ((((g / 17 + 0.5) | 0) * 17) << 8) | (((b / 17 + 0.5) | 0) * 17)
1024}
1025
1026// ---- base64 ---------------------------------------------------------------
1027
1028const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
1029const CODES = Array.from(ALPHABET, (ch) => ch.charCodeAt(0))
1030
1031// Standard padded base64 of `bytes`, built in chunks so no call takes too
1032// many arguments.
1033export function base64(bytes: Uint8Array): string {
1034  // Uint8Array.prototype.toBase64, where the environment has it.
1035  const native: unknown = Reflect.get(bytes, 'toBase64')
1036  if (typeof native === 'function') return String(Reflect.apply(native, bytes, []))
1037  const parts: string[] = []
1038  const chunk = new Array<number>(16384)
1039  let n = 0
1040  const flush = (): void => {
1041    parts.push(String.fromCharCode.apply(null, chunk.slice(0, n)))
1042    n = 0
1043  }
1044  const len = bytes.length
1045  let i = 0
1046  for (; i + 2 < len; i += 3) {
1047    const v = ((bytes[i] ?? 0) << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
1048    chunk[n++] = CODES[(v >> 18) & 63] ?? 65
1049    chunk[n++] = CODES[(v >> 12) & 63] ?? 65
1050    chunk[n++] = CODES[(v >> 6) & 63] ?? 65
1051    chunk[n++] = CODES[v & 63] ?? 65
1052    if (n >= 16380) flush()
1053  }
1054  const rest = len - i
1055  if (rest > 0) {
1056    const v = ((bytes[i] ?? 0) << 16) | (rest > 1 ? (bytes[i + 1] ?? 0) << 8 : 0)
1057    chunk[n++] = CODES[(v >> 18) & 63] ?? 65
1058    chunk[n++] = CODES[(v >> 12) & 63] ?? 65
1059    chunk[n++] = rest > 1 ? (CODES[(v >> 6) & 63] ?? 65) : 61
1060    chunk[n++] = 61
1061  }
1062  flush()
1063  return parts.join('')
1064}
1065
1066export function cellsOf(words: Uint32Array): string {
1067  return base64(new Uint8Array(words.buffer, words.byteOffset, words.byteLength))
1068}
1069