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…


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.
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.
| Input | What it does |
|---|---|
/bricks | Opens the pane. A game in play comes back paused, as it was. |
/bricks new | Deals a new game. |
/bricks quit | Closes the pane. |
/bricks is an immediate command, so it works while Claude is mid-turn.
| Input | What it does |
|---|---|
| Move the mouse over the field | The paddle follows it. |
| Click | Launches the ball; with the laser, fires; after game over, deals a new game. |
| Move the mouse off the field | Pauses, so the ball is not lost while you read Claude's answer. Back on the field, play goes on. |
a d | Move the paddle a step. |
w, or Return | Launch, fire. |
p | Pause, and go on. |
n | New game. |
| Arrow keys, space | Move and launch, once a click has given the field the keyboard. |
Esc | Hands 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.
| Brick | What it does |
|---|---|
| Coloured | Breaks at one hit; worth 500 to 1200 by colour. |
| Silver | Takes two hits (three from round 9), with a shine that sweeps across it. |
| Gold | Never breaks. A round is cleared when everything else is. |
| Volt | Violet, 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. |
A broken brick sometimes drops a capsule. Catch it with the paddle; each is worth 2500 points.
| Capsule | Colour | What it does |
|---|---|---|
| Laser | red | Click to fire two beams from the paddle. |
| Wide | blue | A longer paddle. |
| Catch | green | The ball sticks to the paddle; click to launch it (it goes by itself after a few seconds). |
| Slow | orange | Slows the ball. |
| Split | cyan | Every ball becomes three. |
| Fire | pink | For eight seconds the ball burns through bricks without bouncing (gold still stops it). |
| 1UP | white | An extra life. |
| Giant | gold | For ten seconds the ball is huge and breaks everything it touches. |
| Barrier | teal | A shield of light under the paddle sends the ball back up three times. |
| Zap | violet | Lightning from the paddle strikes eight bricks at once. |
| Nova | dark grey, red band | The next three hits explode, breaking the eight bricks around (gold excepted). |
| Magnet | half red, half blue | For twelve seconds falling capsules and gems are pulled to the paddle. |
| Double | rainbow | Double 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.
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.
| Path | What it is |
|---|---|
hooks/register.ts | The hooks module: the command, the pane, the loop that steps, draws and paints, the best score. |
hooks/game.ts | The game: the field, the ball, bricks, capsules, particles, rounds. No drawing. |
hooks/render.ts | The picture: draws a game into a canvas of light, then into RGBA bytes or quadrant cells. |
hooks/pad.ts | The surface module laid over the field: the pointer and the keys. |
hooks/lib.ts | Sizes, inputs, arguments, the best score and the line over the field. |
tests/brickfall.test.ts | The rules, the picture's encodings and the pane, run by claude plugin test ./brickfall. |
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./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.claude plugin test)./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.Not yet verified: Ghostty, the inline layout above the prompt, and macOS or Windows.
BRICKFALL_SCREEN=cells is lighter.MIT, as the rest of this repository.
hooks/register.ts 502 lines1// 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}
502hooks/game.ts 1229 lines1// 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 lines1// 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}
282hooks/pad.ts 120 lines1// 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
120hooks/render.ts 1069 lines1// 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