Context usage as a pixel-art showdown: a hero laughs off a kamehameha that grows with the context window, strains harder and harder, and at 95% it's GAME OVER…

<img src="assets/demo.gif" alt="The fighter charges a kamehameha, the hero laughs it off, then braces and strains as it grows, falls at 95% into a GAME OVER screen, and gets back up with a senzu bean" width="560">
A Claude Code mod that turns context usage into a fight you can feel. At 5% the hero laughs. At 80% they're screaming. At 95% it's GAME OVER, so please /compact. Then a senzu bean puts them back on their feet.
Paste this at the prompt of a Claude Code terminal session:
/plugin install kamehameha --marketplace Aurumdev952/claude-code-kamehameha
Answer y, pick a scope, done. Then watch the whole story in about 30 seconds:
/kamehameha demo
The fighter charges up (KA… ME… HA… ME… HAAAA!!). After that the beam grows with your context window and the hero gets pushed back across the ground.
<img src="assets/stages.png" alt="Laughing, relaxed, bracing, straining, one knee down, the charge, the final surge, GAME OVER and the senzu bean" width="100%">
| Context | The hero | On screen | |:-:|---|---| | 0–24% | 😂 "HA HA HA! Is that all you've got?" | Hands on hips, head thrown back laughing, "HA HA!" popping up | | 25–49% | 😏 "Meh. Barely warm." | Arms crossed, smirking, scarf starting to flap | | 50–74% | 😬 "Ngh… okay, that's getting strong!" | Arms out, golden ki aura, sweat flying; the sky turns red and the ground cracks | | 75–89% | 😫 "AAARGH! Can't… hold… much longer!" | Crouched and trembling, a huge beam, debris lifting, embers falling | | 90–94% | 😱 "/compact… NOW…!" | One knee down, shoved backwards | | 95%+ | 💀 GAME OVER | A slow-motion surge and a white-out, then a shockwave, a smoking crater, and GAME OVER / PLEASE /COMPACT typing itself in, with a CONTINUE? 9… countdown | | after /compact | 🫘 SENZU BEAN! | A bean falls, the hero heals in a green ring, stands up and laughs, and the fighter charges again |
Every new stage lands with hit-stop, a camera kick, a flash and a shout. Past 90k tokens the scouter breaks: IT'S OVER 9000!!
| Terminal | Renderer | What you get |
|---|---|---|
| kitty, Ghostty | pixels (auto) | A real image at 2–3× resolution (smooth glow, fine sparks, fine ridgelines) with the gauge drawn into the picture |
| everything else (WezTerm, iTerm2, Alacritty, Windows Terminal…) | cells (auto) | Two pixels per character cell with the ▀ block in true color |
<table> <tr> <td align="center"><img src="assets/live-wezterm.png" width="400"><br><sub>Live in WezTerm: <code>cells</code></sub></td> <td align="center"><img src="assets/live-kitty.png" width="400"><br><sub>Live in kitty: <code>pixels</code></sub></td> </tr> </table>
| Command | Does |
|---|---|
/kamehameha | Open the showdown pane |
/kamehameha demo | Play the whole story: charge, fight, GAME OVER, senzu bean (about 30 s) |
/kamehameha 80 | Preview any percentage |
/kamehameha live | Go back to your real context usage |
/kamehameha pixels / cells | Switch renderer for this session |
/kamehameha fps 30 | Change the frame rate for this session |
/kamehameha stats | Live frame rate, draw time per frame, renderer and size |
You also get a three-color gauge (████████░░ 71% 142k / 200k) with a caption under the art (in pixels mode the gauge is part of the picture), a status line warning from 75%, and toasts when the hero falls, gets back up, or passes 9000.
Set these in /plugin (or under pluginConfigs.kamehameha in settings):
| Setting | Default | |
|---|---|---|
renderer | auto | auto, cells or pixels |
fps | 60 | 10 to 60. Lower it to save CPU: 30 looks smooth too. The pixels renderer is capped at 30 (see below). |
sound | false | Charge, beam, impact, explosion, heal and scouter sounds. macOS only: Claude Code has no audio player on Linux or Windows. |
Measured live in Claude Code 2.1.293 with a 79×31-cell docked pane, using /kamehameha stats:
| Terminal | Renderer | Calm (30%) | Peak (85–93%) | Draw time |
|---|---|---|---|---|
| WezTerm | cells | 60 fps | 50–57 fps | 1.3–3 ms |
| kitty | pixels | 30 fps | 30 fps | 6–13 ms |
cells dips at peak: the limit there is how many terminal cells change per frame, not the drawing itself.pixels is capped at 30: each frame sends a whole new image, and above about 30 a second kitty shows the gap between two images as a blank flash. Screen samples measured 13% blank at 60 fps, 1–2% at 40, and 0% at 30 or below. For the same reason, the pixel picture draws its own gauge, because redrawing the pane would also flash. If drawing runs long, the picture drops from 3× to 2× resolution automatically.fps to 30 if your machine is busy. With the pane closed, the fight keeps running ten times a second, with no drawing.session.measure ─► context % ─► director.update(dt) springs, stages, timelines, particles, events
│
self-pacing frame loop ($.clock.after)
▼
render.paint() sky · mountains · ground (cached at 4 Hz)
fighter · scarf · aura · hero (dither blend)
beam · charge · clash · particles · callouts · titles
vignette · shake · flash
▼
cells: '▀' fg/bg pairs ─► Raster pixels: RGBA ─► Image (kitty graphics)
▼
$.ui.blit() into the pane, at up to 60 fps
claude plugin validate lists every call:❯ calls: $.audio.play, $.clock.after, $.clock.now, $.command.register, $.env.get,
$.session.usage, $.ui.blit, $.ui.invalidate, $.ui.open, $.ui.resolve,
$.ui.status, $.ui.toast
❯ env reads: KITTY_WINDOW_ID, TERM_PROGRAM
git clone https://github.com/Aurumdev952/claude-code-kamehameha
claude --plugin-dir ./claude-code-kamehameha # run it from source
claude plugin test ./claude-code-kamehameha # 17 tests
bun tools/record.ts out --script demo && python3 tools/gif.py out gif demo.gif 4 # re-record the GIF
python3 tools/sounds.py # re-synthesize the sounds
| Path | What's inside |
|---|---|
hooks/register.tsx | The mod: context tracking, the frame loop, renderer choice, pane, command, toasts, sounds |
hooks/engine/director.ts | The fight as a simulation: spring, stages, hit-stop, the charge, game-over and recovery timelines, callouts |
hooks/engine/render.ts | Layout, the layers in order, camera, titles, both encoders |
hooks/engine/sprites.ts | Character maps, faces, auto outline, rim light and shadow, pose dissolve, aura |
hooks/engine/beam.ts · scenery.ts · particles.ts | The beam, the world, the particles |
hooks/engine/palette.ts · font.ts · rng.ts · canvas.ts | Palette and dithering, the 5×7 pixel font, seeded noise, the pixel buffer |
hooks/*.test.ts(x) | Stages, spring overshoot, frame-rate independence, hit-stop, charge, GAME OVER, senzu recovery, OVER 9000, beam growth, encoders, a 60 fps budget, the 1024-color-pair limit, the pane on terminal and desktop, a full session with sound |
tools/ | The headless recorder, GIF and contact-sheet maker, and sound synthesizer |
The engine has no Claude Code dependency: it's plain TypeScript, so the tests and the recorder drive the same code the pane runs.
An unofficial fan homage. It isn't affiliated with or endorsed by the owners of Dragon Ball. All sprites, sounds and the font are original, made for this mod.
hooks/register.tsx 384 lines1import type { EngineInterface, PluginOptions, Register, SessionContextUsage, Timer } from 'claude-code'
2
3import { CAPTIONS, createWorld, DEATH, drain, setTarget, stageOf, update } from './engine/director'
4import type { Event, World } from './engine/director'
5import { encodeCells, encodeRgba, HUD_H, layout, paint } from './engine/render'
6
7const PANE = 'kamehameha'
8const TITLE = 'Kamehameha'
9const KEY = 'scene'
10
11type Renderer = 'cells' | 'pixels'
12type Settings = { renderer: 'auto' | Renderer; fps: number; sound: boolean }
13
14/** `/kamehameha demo`: the whole fight in about half a minute, by real seconds. */
15const DEMO = { charge: 2.8, rise: 16, hold: 25, end: 31 } as const
16
17const SOUNDS: Partial<Record<Event, string>> = {
18 charge: 'sounds/charge.wav',
19 fire: 'sounds/fire.wav',
20 'stage-up': 'sounds/hit.wav',
21 boom: 'sounds/boom.wav',
22 heal: 'sounds/heal.wav',
23 over9000: 'sounds/scouter.wav',
24}
25
26const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
27
28const kilo = (n: number | undefined) =>
29 n === undefined ? '0' : n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
30
31const readSettings = (options: PluginOptions): Settings => {
32 const renderer = options.renderer
33 const fps = options.fps
34 return {
35 renderer: renderer === 'cells' || renderer === 'pixels' ? renderer : 'auto',
36 fps: typeof fps === 'number' && fps > 0 ? clamp(Math.round(fps), 10, 60) : 60,
37 sound: options.sound === true,
38 }
39}
40
41let settings: Settings = { renderer: 'auto', fps: 60, sound: false }
42let detected: Renderer = 'cells'
43let override: Renderer | undefined
44let world: World = createWorld()
45let live = 0
46let tokens: number | undefined
47let windowSize: number | undefined
48let pinned: number | undefined
49let demoAt: number | undefined
50let now = 0
51let last = 0
52let busy = false
53let timer: Timer | undefined
54let mounted: { renderer: Renderer; W: number; H: number } | undefined
55/** The world height the fight runs in: the pixel picture gives HUD_H rows to its gauge. */
56const sceneHeight = (site: { renderer: Renderer; H: number }) => (site.renderer === 'pixels' ? site.H - HUD_H : site.H)
57const hud = () => ({ percent: percent(), label: labelFor() })
58/** When the pane was last redrawn whole, so refusals and first mounts redraw once. */
59let settledAt = 0
60/**
61 * The picture's key. A redraw under a new key mounts a new picture instead of
62 * swapping the old one's source, which a terminal may have dropped.
63 */
64let generation = 0
65const pictureKey = () => `${KEY}-${generation}`
66let label = ''
67/** Real times of recent blits and how long each frame took to draw, for /kamehameha stats. */
68const frameTimes: number[] = []
69const drawTimes: number[] = []
70let refused = 0
71let lastRefusal = ''
72let shownStage = ''
73
74const renderer = (): Renderer => override ?? (settings.renderer === 'auto' ? detected : settings.renderer)
75
76/**
77 * Frames per second actually aimed for. A streamed image is replaced whole
78 * each frame, and kitty shows the gap between two as a blank flash past about
79 * 30 a second (measured: 60 fps blanked 13% of the time, 30 fps never).
80 */
81const PIXELS_MAX_FPS = 30
82const targetFps = () => (renderer() === 'pixels' ? Math.min(settings.fps, PIXELS_MAX_FPS) : settings.fps)
83
84/**
85 * Pixels per world pixel in an Image: 3 for smooth glow, dropping to 2 when
86 * this machine cannot draw that in time for the frame rate.
87 */
88let pixelScale = 3
89let drawCost = 0
90
91const adapt = (ms: number) => {
92 drawCost = drawCost === 0 ? ms : drawCost * 0.9 + ms * 0.1
93 if (pixelScale > 2 && drawCost > (1000 / targetFps()) * 0.6) {
94 pixelScale = 2
95 drawCost = 0
96 }
97}
98
99const demoPercent = () => {
100 const t = (now - (demoAt ?? now)) / 1000
101 if (t < DEMO.charge) return 4
102 if (t < DEMO.rise) return 4 + ((t - DEMO.charge) / (DEMO.rise - DEMO.charge)) * 96
103 if (t < DEMO.hold) return 100
104 return 22
105}
106
107const percent = () => (demoAt !== undefined ? demoPercent() : (pinned ?? live))
108
109const take = (context: SessionContextUsage) => {
110 tokens = context.tokens
111 windowSize = context.window
112 live =
113 context.percent ??
114 (context.tokens !== undefined && context.window > 0 ? Math.round((context.tokens / context.window) * 100) : 0)
115}
116
117const isReal = () => demoAt === undefined && pinned === undefined
118
119const labelFor = () => {
120 const p = Math.round(percent())
121 if (demoAt !== undefined) return `demo ${p}%`
122 if (pinned !== undefined) return `preview ${p}%`
123 return `${p}% ${kilo(tokens)} / ${kilo(windowSize)}`
124}
125
126const captionFor = (): { text: string; color: string } => {
127 if (world.phase === 'charge') return { text: '"Ka... me... ha... me..."', color: 'cyan' }
128 if (world.phase === 'over') return { text: '💀 GAME OVER. Please /compact', color: 'red' }
129 if (world.phase === 'recover') return { text: '🫘 Senzu bean! "Fully healed. HA HA!"', color: 'green' }
130 const stage = world.stage
131 const color = stage === 'knee' ? 'red' : stage === 'strain' || stage === 'brace' ? 'yellow' : 'cyan'
132 return { text: CAPTIONS[stage], color }
133}
134
135/** Toasts, status line and sounds for what the fight just did. */
136function react($: EngineInterface, events: readonly Event[]) {
137 for (const event of events) {
138 if (settings.sound) {
139 const asset = SOUNDS[event]
140 if (asset !== undefined) void $.audio.play({ asset }, { gain: 0.7 }).catch(() => undefined)
141 }
142 if (!isReal()) continue
143 if (event === 'gameover') $.ui.toast(`💀 GAME OVER: context at ${Math.round(percent())}%. Please /compact`)
144 if (event === 'heal') $.ui.toast('🫘 Senzu bean! The hero is back up. Context compacted.')
145 if (event === 'over9000') $.ui.toast("📟 Power level: IT'S OVER 9000!")
146 }
147
148 // The pixel picture carries its own gauge: redrawing its tree would place
149 // the image again, which kitty shows as a blank flash, so only the cell
150 // renderer's caption and gauge redraw.
151 const streaming = mounted?.renderer === 'pixels'
152 const stage = world.phase === 'over' ? 'dead' : world.stage
153 if (stage !== shownStage) {
154 shownStage = stage
155 const p = Math.round(percent())
156 $.ui.status(stage === 'dead' ? '💀 GAME OVER · /compact' : stage === 'knee' || stage === 'strain' ? `⚡ context ${p}%` : undefined)
157 if (!streaming) $.ui.invalidate('ui.render')
158 }
159 const next = `${labelFor()}|${world.phase}`
160 if (next !== label) {
161 label = next
162 if (!streaming) $.ui.invalidate('ui.render')
163 }
164}
165
166async function tick($: EngineInterface) {
167 if (busy) return
168 busy = true
169 try {
170 now = Date.now()
171 const dt = last === 0 ? 0 : (now - last) / 1000
172 last = now
173 if (demoAt !== undefined && (now - demoAt) / 1000 > DEMO.end) demoAt = undefined
174
175 const W = mounted?.W ?? 72
176 const H = mounted === undefined ? 40 : sceneHeight(mounted)
177 setTarget(world, percent(), tokens)
178 update(world, dt, layout(world, W, H))
179 react($, drain(world))
180
181 if (mounted === undefined) return
182 const site = mounted
183 const t0 = Date.now()
184 const picture = site.renderer === 'pixels' ? paint(world, site.W, site.H, pixelScale, hud()) : paint(world, site.W, site.H, 1)
185 const sent =
186 site.renderer === 'pixels'
187 ? $.ui.blit({ requestId: PANE, key: pictureKey(), source: { rgba: encodeRgba(picture), width: picture.w, height: picture.h } })
188 : $.ui.blit({ requestId: PANE, key: pictureKey(), cells: encodeCells(picture) })
189 drawTimes.push(Date.now() - t0)
190 if (site.renderer === 'pixels') adapt(Date.now() - t0)
191 // Awaited: the terminal, not the drawing, is the bottleneck, so the next
192 // frame waits until this one has been taken.
193 const done = await sent
194 if ('deny' in done && done.deny !== undefined) {
195 refused += 1
196 lastRefusal = String(done.deny)
197 if (mounted === site) mounted = undefined
198 // A refused frame (a resize, an image the terminal dropped): redraw the
199 // tree, at most once a second, and the stream picks up from there.
200 if (now - settledAt > 1000) {
201 settledAt = now
202 generation += 1
203 $.ui.invalidate('ui.render')
204 }
205 return
206 }
207 frameTimes.push(now)
208 if (frameTimes.length > 120) frameTimes.shift()
209 if (drawTimes.length > 120) drawTimes.shift()
210 } finally {
211 busy = false
212 }
213}
214
215/**
216 * The frame loop: draw, hand the frame over, then wait out whatever is left
217 * of the frame's time. Each frame schedules the next, so a slow terminal
218 * slows the loop down instead of piling frames up.
219 */
220let loopId = 0
221
222function start($: EngineInterface) {
223 timer?.cancel()
224 last = 0
225 const id = ++loopId
226 const frame = () => {
227 if (id !== loopId) return
228 const began = Date.now()
229 void tick($).finally(() => {
230 if (id !== loopId) return
231 // With nothing on screen the fight still runs, ten times a second.
232 const period = mounted === undefined ? 100 : 1000 / targetFps()
233 // The engine takes a few ms to deliver a timer; ask that much early.
234 const wait = Math.max(1, Math.round(period - (Date.now() - began) - 3))
235 timer = $.clock.after(wait, frame)
236 })
237 }
238 frame()
239}
240
241async function detect($: EngineInterface): Promise<Renderer> {
242 const program = ((await $.env.get('TERM_PROGRAM')) ?? '').toLowerCase()
243 const kitty = await $.env.get('KITTY_WINDOW_ID')
244 return program === 'ghostty' || program === 'kitty' || kitty !== undefined ? 'pixels' : 'cells'
245}
246
247function open($: EngineInterface) {
248 return $.ui.open({ id: PANE, title: TITLE })
249}
250
251export const register: Register = (on, options) => {
252 settings = readSettings(options)
253
254 on('session.start', async ($, e, next) => {
255 take((await $.session.usage()).context)
256 detected = await detect($)
257 world = createWorld({ seed: Math.floor((await $.clock.now()) % 100000), percent: live })
258 await $.command.register({
259 name: 'kamehameha',
260 description: 'The context showdown: demo, live, <percent>, cells or pixels',
261 })
262 start($)
263 void open($)
264
265 return next(e)
266 })
267
268 on('session.measure', async ($, e, next) => {
269 take(e.context)
270
271 return next(e)
272 })
273
274 on('command.run', { command: 'kamehameha' }, async ($, e) => {
275 const arg = e.args.trim().toLowerCase()
276
277 if (arg === 'demo') {
278 demoAt = Date.now()
279 pinned = undefined
280 world = createWorld({ seed: 11, percent: 4 })
281 } else if (arg === 'live') {
282 demoAt = undefined
283 pinned = undefined
284 } else if (arg === 'stats') {
285 const span = frameTimes.length > 1 ? (frameTimes[frameTimes.length - 1]! - frameTimes[0]!) / 1000 : 0
286 const fps = span > 0 ? (frameTimes.length - 1) / span : 0
287 const avg = drawTimes.length > 0 ? drawTimes.reduce((a, b) => a + b, 0) / drawTimes.length : 0
288 const size = mounted === undefined ? 'not shown' : `${mounted.W}x${mounted.H} world px${mounted.renderer === 'pixels' ? ` at ${pixelScale}x` : ''}`
289 return { text: `Kamehameha: ${fps.toFixed(1)} fps (target ${targetFps()}), ${avg.toFixed(1)} ms per frame, ${renderer()} renderer, ${size}, phase ${world.phase}, ${refused} frames refused${lastRefusal === '' ? '' : ` (last: ${lastRefusal})`}` }
290 } else if (/^fps \d+$/.test(arg)) {
291 settings = { ...settings, fps: clamp(parseInt(arg.slice(4), 10), 10, 60) }
292 start($)
293 return { text: `Kamehameha now aims for ${targetFps()} fps this session.` }
294 } else if (arg === 'cells' || arg === 'pixels') {
295 override = arg
296 $.ui.invalidate('ui.render')
297 return { text: `Kamehameha now draws with ${arg === 'pixels' ? 'real pixels (kitty, Ghostty)' : 'terminal cells'}.` }
298 } else if (/^\d+%?$/.test(arg)) {
299 demoAt = undefined
300 pinned = clamp(parseInt(arg, 10), 0, 100)
301 } else if (arg !== '') {
302 return { text: 'Usage: /kamehameha [demo | live | <percent> | cells | pixels | fps <n> | stats]' }
303 }
304
305 await open($)
306 const p = Math.round(percent())
307
308 return {
309 text:
310 arg === 'demo'
311 ? 'Demo: charge, the beam grows to GAME OVER, then a senzu bean (about 30s). /kamehameha live to stop.'
312 : `Context ${p}%: ${CAPTIONS[stageOf(p)]}`,
313 }
314 })
315
316 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
317 const table = $.ui.resolve(e)
318 const { Box, Text } = table
319 const columns = clamp(e.props.bodyColumns, 20, 160)
320 const rows = clamp(e.props.scroll.bodyRows - 2, 8, 34)
321 const W = columns
322 const H = rows * 2
323 const p = percent()
324 const caption = captionFor()
325
326 const barWidth = Math.max(8, columns - 26)
327 const full = Math.round((clamp(p, 0, 100) / 100) * barWidth)
328 const cut = (at: number) => Math.min(full, Math.round(barWidth * at))
329 const calm = cut(0.5)
330 const warm = cut(0.75) - calm
331 const hot = full - calm - warm
332 const gauge = (
333 <Text wrap="truncate-end">
334 <Text color="cyan">{'█'.repeat(calm)}</Text>
335 <Text color="yellow">{'█'.repeat(Math.max(0, warm))}</Text>
336 <Text color="red">{'█'.repeat(Math.max(0, hot))}</Text>
337 <Text dimColor>{'░'.repeat(barWidth - full)}</Text> <Text bold={p >= DEATH}>{labelFor()}</Text>
338 </Text>
339 )
340 const line = (
341 <Text color={caption.color} bold={world.phase !== 'fight'} wrap="truncate-end">
342 {caption.text}
343 </Text>
344 )
345
346 const want = renderer()
347 // The terminal's graphics support is probed as the session starts; a pane
348 // drawn before that settles is drawn once more a moment later.
349 if (settledAt === 0) {
350 settledAt = Date.now()
351 $.clock.after(1500, () => {
352 generation += 1
353 $.ui.invalidate('ui.render')
354 })
355 }
356 if (want === 'pixels' && 'Image' in table) {
357 const { Image } = table
358 const imageRows = rows + 2
359 mounted = { renderer: 'pixels', W, H: imageRows * 2 }
360 const picture = paint(world, W, imageRows * 2, pixelScale, hud())
361 return <Image key={pictureKey()} source={{ rgba: encodeRgba(picture), width: picture.w, height: picture.h }} columns={columns} rows={imageRows} alt={caption.text} />
362 }
363 if ('Raster' in table) {
364 const { Raster } = table
365 mounted = { renderer: 'cells', W, H }
366 return (
367 <Box flexDirection="column">
368 <Raster key={pictureKey()} columns={columns} rows={rows} cells={encodeCells(paint(world, W, H, 1))} />
369 {line}
370 {gauge}
371 </Box>
372 )
373 }
374
375 mounted = undefined
376 return (
377 <Box flexDirection="column">
378 {line}
379 {gauge}
380 </Box>
381 )
382 })
383}
384hooks/engine/director.ts 355 lines1import { C } from './palette'
2import { emitDebris, emitDust, emitEmbers, emitHeal, emitMote, emitSmoke, emitSparks, emitSweat, Particles } from './particles'
3import { Rng } from './rng'
4import type { Face, HeroPose } from './sprites'
5
6// The fight as a simulation over real time. `update` advances it by the
7// seconds since the last frame; `paint` (render.ts) draws whatever it holds.
8// Nothing here knows about terminals, so tests and the recorder drive it too.
9
10export type Stage = 'laugh' | 'relaxed' | 'brace' | 'strain' | 'knee' | 'dead'
11export const DEATH = 95
12export const STAGES: readonly Stage[] = ['laugh', 'relaxed', 'brace', 'strain', 'knee', 'dead']
13
14export const stageOf = (percent: number): Stage =>
15 percent >= DEATH ? 'dead' : percent >= 90 ? 'knee' : percent >= 75 ? 'strain' : percent >= 50 ? 'brace' : percent >= 25 ? 'relaxed' : 'laugh'
16
17export const CAPTIONS: Record<Stage, string> = {
18 laugh: '"HA HA HA! Is that all you\'ve got?"',
19 relaxed: '"Meh. Barely warm."',
20 brace: '"Ngh... okay, that\'s getting strong!"',
21 strain: '"AAARGH! Can\'t... hold... much longer!"',
22 knee: '"/compact... NOW...!"',
23 dead: 'GAME OVER. Please /compact',
24}
25
26const SHOUTS: Partial<Record<Stage, string>> = {
27 relaxed: 'HMPH.',
28 brace: 'NGH!',
29 strain: 'AAARGH!',
30 knee: 'NOOO!',
31}
32
33export type Phase = 'charge' | 'fight' | 'over' | 'recover'
34
35/** Something worth a sound, a toast or a status line. */
36export type Event = 'charge' | 'fire' | 'stage-up' | 'gameover' | 'boom' | 'heal' | 'over9000'
37
38export type Callout = {
39 text: string
40 at: number
41 dur: number
42 color: number
43 anchor: 'hero' | 'fighter' | 'center'
44 scale: number
45}
46
47export type World = {
48 /** Simulation seconds: frozen by hit-stop, slowed in slow-motion. */
49 time: number
50 phase: Phase
51 phaseAt: number
52 /** The real context percent, and the one on screen (a spring chasing it). */
53 target: number
54 shown: number
55 vel: number
56 tokens?: number
57 stage: Stage
58 pose: HeroPose
59 prevPose: HeroPose
60 poseAt: number
61 trauma: number
62 hitStop: number
63 flash: number
64 slow: number
65 particles: Particles
66 rng: Rng
67 callouts: Callout[]
68 over9000: boolean
69 nextLaugh: number
70 /** Fractional spawns carried between frames, per emitter. */
71 carry: Record<string, number>
72 /** Raised by `update`, kept until `drain` takes them. */
73 events: Event[]
74}
75
76export const POSE_OF: Record<Exclude<Stage, 'dead'>, HeroPose> = {
77 laugh: 'laugh',
78 relaxed: 'smug',
79 brace: 'brace',
80 strain: 'strain',
81 knee: 'knee',
82}
83
84export const CHARGE_TIME = 2.4
85const POSE_BLEND = 0.18
86
87export const createWorld = (opts: { seed?: number; percent?: number; charge?: boolean } = {}): World => {
88 const percent = opts.percent ?? 0
89 const stage = stageOf(Math.min(percent, DEATH - 0.01))
90 const pose = POSE_OF[stage === 'dead' ? 'knee' : stage]
91 const charge = opts.charge ?? true
92 return {
93 time: 0,
94 phase: charge ? 'charge' : 'fight',
95 phaseAt: 0,
96 target: percent,
97 shown: charge ? 0 : percent,
98 vel: 0,
99 stage: charge ? 'laugh' : stage,
100 pose: charge ? 'laugh' : pose,
101 prevPose: charge ? 'laugh' : pose,
102 poseAt: -1,
103 trauma: 0,
104 hitStop: 0,
105 flash: 0,
106 slow: 1,
107 particles: new Particles(),
108 rng: new Rng(opts.seed ?? 7),
109 callouts: [],
110 over9000: false,
111 nextLaugh: 0.6,
112 carry: {},
113 events: charge ? ['charge'] : [],
114 }
115}
116
117export const setTarget = (w: World, percent: number, tokens?: number) => {
118 w.target = Math.max(0, Math.min(100, percent))
119 if (tokens !== undefined) w.tokens = tokens
120}
121
122/** The scene's fixed points, in world pixels, for a world of `W` by `H`. */
123export type Layout = {
124 W: number
125 H: number
126 ground: number
127 fighterX: number
128 heroX: number
129 /** The fighter's hands and the hero's, where the beam starts and ends. */
130 from: [number, number]
131 to: [number, number]
132}
133
134export const power = (w: World) => Math.max(0, Math.min(1, w.shown / DEATH))
135
136const say = (w: World, text: string, anchor: Callout['anchor'], color: number, dur = 1.1, scale = 1) => {
137 w.callouts.push({ text, at: w.time, dur, color, anchor, scale })
138}
139
140const kick = (w: World, trauma: number, hitStop: number, flash: number) => {
141 w.trauma = Math.min(1, w.trauma + trauma)
142 w.hitStop = Math.max(w.hitStop, hitStop)
143 w.flash = Math.max(w.flash, flash)
144}
145
146const setPose = (w: World, pose: HeroPose) => {
147 if (pose === w.pose) return
148 w.prevPose = w.pose
149 w.pose = pose
150 w.poseAt = w.time
151}
152
153/** How far the new pose has dissolved in, 0..1. */
154export const poseBlend = (w: World) => Math.min(1, (w.time - w.poseAt) / POSE_BLEND)
155
156/** Calls `spawn` `rate` times a second on average, smoothly across frames. */
157const every = (w: World, key: string, rate: number, dt: number, spawn: () => void) => {
158 let n = (w.carry[key] ?? 0) + rate * dt
159 while (n >= 1) {
160 spawn()
161 n -= 1
162 }
163 w.carry[key] = n
164}
165
166export const phaseTime = (w: World) => w.time - w.phaseAt
167
168const enter = (w: World, phase: Phase) => {
169 w.phase = phase
170 w.phaseAt = w.time
171}
172
173/** The events raised since the last call, taken off the world. */
174export const drain = (w: World): Event[] => {
175 const events = w.events
176 w.events = []
177 return events
178}
179
180export const update = (w: World, dtReal: number, L: Layout) => {
181 const dt = Math.min(0.1, Math.max(0, dtReal))
182 w.flash = Math.max(0, w.flash - dt * 2.2)
183 w.trauma = Math.max(0, w.trauma - dt * 1.1)
184 if (w.hitStop > 0) {
185 w.hitStop -= dt
186 return
187 }
188 const sdt = dt * w.slow
189 w.time += sdt
190 w.callouts = w.callouts.filter(c => w.time - c.at < c.dur)
191
192 if (w.phase === 'charge') charge(w, sdt, L)
193 else if (w.phase === 'fight') fight(w, sdt, L)
194 else if (w.phase === 'over') over(w, sdt, L)
195 else recover(w, sdt, L)
196
197 w.particles.update(sdt)
198}
199
200const charge = (w: World, dt: number, L: Layout) => {
201 const t = phaseTime(w)
202 w.shown = 0
203 w.vel = 0
204 const words = ['KA...', 'ME...', 'HA...', 'ME...']
205 words.forEach((word, i) => {
206 const at = 0.1 + i * 0.45
207 if (t >= at && t - dt < at) say(w, word, 'fighter', C.cyan, 0.7)
208 })
209 every(w, 'mote', 40, dt, () => emitMote(w.particles, w.rng, L.from[0], L.from[1]))
210 setPose(w, 'laugh')
211 if (t >= CHARGE_TIME) {
212 say(w, 'HAAAA!!', 'fighter', C.white, 0.9, 2)
213 kick(w, 0.7, 0.12, 0.7)
214 w.events.push('fire')
215 w.nextLaugh = w.time + 1.4
216 w.shown = Math.min(w.target, 8)
217 enter(w, 'fight')
218 }
219}
220
221const fight = (w: World, dt: number, L: Layout) => {
222 // A slightly underdamped spring: the beam surges past the new size and settles.
223 const k = 22
224 const damping = 2 * Math.sqrt(k) * 0.55
225 w.vel += (k * (w.target - w.shown) - damping * w.vel) * dt
226 w.shown = Math.max(0, Math.min(100, w.shown + w.vel * dt))
227
228 const isDead = w.shown >= DEATH && w.target >= DEATH
229 const next = isDead ? 'dead' : stageOf(Math.min(w.shown, DEATH - 0.01))
230 const rank = (s: Stage) => STAGES.indexOf(s)
231 if (next !== w.stage) {
232 if (rank(next) > rank(w.stage) && next !== 'dead') {
233 kick(w, 0.45, 0.08, 0.18)
234 const shout = SHOUTS[next]
235 if (shout !== undefined) say(w, shout, 'hero', next === 'relaxed' ? C.cyan : C.gold, 1.1)
236 emitSparks(w.particles, w.rng, L.to[0], L.to[1], 18, power(w))
237 w.events.push('stage-up')
238 }
239 w.stage = next
240 }
241 if (next === 'dead') {
242 w.events.push('gameover')
243 kick(w, 1, 0.15, 0.5)
244 enter(w, 'over')
245 return
246 }
247 setPose(w, POSE_OF[next])
248
249 if ((w.tokens ?? 0) >= 90_000 && !w.over9000) {
250 w.over9000 = true
251 say(w, "IT'S OVER 9000!!", 'center', C.red, 2.4, 2)
252 kick(w, 0.3, 0.1, 0.15)
253 w.events.push('over9000')
254 }
255
256 if (w.stage === 'laugh' && w.time >= w.nextLaugh) {
257 say(w, 'HA HA!', 'hero', C.gold, 0.9)
258 w.nextLaugh = w.time + 2.4
259 }
260
261 const p = power(w)
262 every(w, 'spark', 6 + p * 70, dt, () => emitSparks(w.particles, w.rng, L.to[0], L.to[1], 1, p))
263 if (p > 0.5) every(w, 'debris', (p - 0.5) * 34, dt, () => emitDebris(w.particles, w.rng, L.heroX - 26, L.heroX + 10, L.ground))
264 if (p > 0.4) every(w, 'dust', p * 14, dt, () => emitDust(w.particles, w.rng, L.heroX + 6, L.ground, 1))
265 if (p > 0.5) every(w, 'sweat', 1.5 + p * 2, dt, () => emitSweat(w.particles, w.rng, L.heroX + 10, L.ground - 22 * (w.pose === 'knee' ? 0.7 : 1)))
266 if (p > 0.75) every(w, 'ember', (p - 0.75) * 30, dt, () => emitEmbers(w.particles, w.rng, L.W, 1))
267}
268
269/** The game-over cinematic, in seconds since the hero fell. */
270export const OVER = {
271 surge: 0.9,
272 whiteOut: 1.4,
273 title: 2.2,
274 please: 3.1,
275 countdown: 4.0,
276} as const
277
278const over = (w: World, dt: number, L: Layout) => {
279 const t = phaseTime(w)
280 w.shown = Math.max(w.shown, 100)
281 if (t < OVER.surge) {
282 w.slow = 0.4
283 w.trauma = 1
284 every(w, 'spark', 160, dt, () => emitSparks(w.particles, w.rng, L.to[0], L.to[1], 1, 1))
285 } else if (t < OVER.whiteOut) {
286 if (w.slow !== 1) {
287 w.events.push('boom')
288 w.particles.clear()
289 }
290 w.slow = 1
291 w.flash = 1
292 w.trauma = 1
293 } else {
294 w.slow = 1
295 every(w, 'smoke', 7, dt, () => emitSmoke(w.particles, w.rng, L.heroX + 9, L.ground - 2))
296 every(w, 'ember', 6, dt, () => emitEmbers(w.particles, w.rng, L.W, 1))
297 }
298 if (w.target < DEATH) {
299 w.events.push('heal')
300 w.slow = 1
301 enter(w, 'recover')
302 }
303}
304
305/** The recovery after /compact, in seconds. */
306export const RECOVER = {
307 bean: 0.9,
308 knee: 1.25,
309 stand: 1.6,
310 done: 3.0,
311} as const
312
313const recover = (w: World, dt: number, L: Layout) => {
314 const t = phaseTime(w)
315 if (t < RECOVER.bean) {
316 every(w, 'heal', 30, dt, () => emitHeal(w.particles, w.rng, L.heroX + 10, (L.ground - 4) * (t / RECOVER.bean)))
317 } else {
318 if (t - dt < RECOVER.bean) {
319 kick(w, 0.2, 0.06, 0.6)
320 say(w, 'SENZU BEAN!', 'center', C.cyan, 1.3, 1)
321 }
322 if (t < RECOVER.stand + 0.4) every(w, 'heal', 50, dt, () => emitHeal(w.particles, w.rng, L.heroX + 10, L.ground - 10))
323 }
324 if (t >= RECOVER.knee) setPose(w, t >= RECOVER.stand ? 'laugh' : 'knee')
325 if (t >= RECOVER.stand && t - dt < RECOVER.stand) {
326 w.shown = w.target
327 w.vel = 0
328 w.stage = stageOf(w.target)
329 say(w, 'FULLY HEALED! HA HA!', 'hero', C.gold, 1.4)
330 }
331 if (t >= RECOVER.done) {
332 w.events.push('charge')
333 enter(w, 'charge')
334 }
335}
336
337/** The hero's face for this moment. */
338export const heroFace = (w: World): { face: Face; lift: number } => {
339 const t = w.time
340 if (w.phase === 'charge') return { face: 'smug', lift: 0 }
341 if (w.phase === 'recover') return { face: phaseTime(w) > RECOVER.stand ? 'laugh' : 'pain', lift: 0 }
342 switch (w.stage) {
343 case 'laugh':
344 return { face: 'laugh', lift: Math.floor(t * 7) % 2 }
345 case 'relaxed':
346 return { face: 'smug', lift: Math.floor(t * 1.5) % 2 }
347 case 'brace':
348 return { face: 'grit', lift: 0 }
349 case 'strain':
350 return { face: Math.floor(t * 3) % 2 === 0 ? 'grit' : 'pain', lift: 0 }
351 default:
352 return { face: 'pain', lift: 0 }
353 }
354}
355hooks/engine/render.ts 365 lines1import { drawBeam, drawChargeBall, drawClash } from './beam'
2import { Canvas } from './canvas'
3import { CHARGE_TIME, DEATH, heroFace, OVER, phaseTime, poseBlend, power, RECOVER } from './director'
4import type { Layout, World } from './director'
5import { drawCentered, drawText, GLYPH_H, textWidth } from './font'
6import { bayer, C, mix } from './palette'
7import { noise1 } from './rng'
8import { drawGround, drawMountains, drawSky, flashOver } from './scenery'
9import type { Weather } from './scenery'
10import { blit, blitAura, blitBlend, FIGHTER_SIZE, fighterHands, fighterSprite, HERO_SIZE, heroAnchors, heroDownSprite, heroSprite } from './sprites'
11import type { Light } from './sprites'
12
13export const MIN_W = 52
14export const MIN_H = 34
15
16/** Where everything stands for a world of `W` by `H` world pixels, at the current power. */
17export const layout = (w: World, W: number, H: number): Layout => {
18 const ground = H - Math.max(3, Math.round(H * 0.1))
19 const p = power(w)
20 const pulse = w.phase === 'fight' ? Math.abs(Math.sin(w.time * 9)) * p * 1.2 : 0
21 const fighterX = 3 - pulse
22 const surge = w.phase === 'over' && phaseTime(w) < OVER.whiteOut ? 3 : 0
23 const heroX = W - HERO_SIZE.w - 9 + p * 6 + surge
24 const fy = ground - FIGHTER_SIZE.h + 1
25 const hy = ground - HERO_SIZE.h + 1
26 const pose = w.phase === 'charge' ? 'charge' : 'fire'
27 const [fhx, fhy] = fighterHands(pose)
28 const { hands } = heroAnchors(w.pose)
29 return {
30 W,
31 H,
32 ground,
33 fighterX,
34 heroX,
35 from: [fighterX + fhx, fy + fhy],
36 to: [heroX + hands[0] - 1, hy + hands[1]],
37 }
38}
39
40const beamRadius = (w: World, H: number) => {
41 const maxR = Math.max(3, Math.min(10, H * 0.12))
42 let r = 0.8 + Math.pow(power(w), 1.25) * maxR
43 if (w.phase === 'over') r *= 1 + Math.min(1, phaseTime(w) / OVER.surge) * 0.9
44 return r
45}
46
47const shake = (w: World) => {
48 // Only impacts shake the camera: a constant shake would repaint every
49 // terminal cell every frame. The hero's tremble carries the strain instead.
50 const k = w.trauma * w.trauma
51 const amp = k * 2.2
52 return {
53 x: Math.round((noise1(w.time * 23, 41) - 0.5) * 2 * amp),
54 y: Math.round((noise1(w.time * 19, 42) - 0.5) * 2 * amp * 0.6),
55 }
56}
57
58/** Moves the picture by whole canvas pixels; the uncovered edge goes dark. */
59const offset = (c: Canvas, dx: number, dy: number) => {
60 if (dx === 0 && dy === 0) return
61 const src = c.px.slice()
62 c.px.fill(C.ink)
63 for (let y = Math.max(0, dy); y < Math.min(c.h, c.h + dy); y++) {
64 const from = (y - dy) * c.w
65 const x0 = Math.max(0, dx)
66 const x1 = Math.min(c.w, c.w + dx)
67 c.px.set(src.subarray(from + x0 - dx, from + x1 - dx), y * c.w + x0)
68 }
69}
70
71/** The darkened corners, as the pixel indices they touch: computed once per size. */
72let vignetteCache: { w: number; h: number; at: Uint32Array } | undefined
73
74const vignette = (c: Canvas) => {
75 if (vignetteCache?.w !== c.w || vignetteCache.h !== c.h) {
76 const at: number[] = []
77 for (let y = 0; y < c.h; y++)
78 for (let x = 0; x < c.w; x++) {
79 const dx = (x / c.w - 0.5) * 2
80 const dy = (y / c.h - 0.5) * 2
81 const d = dx * dx * 0.6 + dy * dy * 0.4
82 if (d > 0.55 && Math.min(0.45, (d - 0.55) * 0.9) > bayer(x, y)) at.push(y * c.w + x)
83 }
84 vignetteCache = { w: c.w, h: c.h, at: Uint32Array.from(at) }
85 }
86 for (const i of vignetteCache.at) c.px[i] = mix(c.px[i]!, C.ink, 0.45)
87}
88
89/**
90 * Sky, mountains and ground change slowly: they are drawn four times a second
91 * at most, or when the light on them changes, and copied in between.
92 */
93let backdrop: { key: string; t: number; px: Uint32Array } | undefined
94
95const background = (c: Canvas, ground: number, weather: Weather, beamX0: number, beamX1: number) => {
96 const key = [c.w, c.h, weather.scale, ground, Math.round(weather.storm * 10), Math.round(weather.power * 10), Math.round(weather.cracks * 10), weather.crater, Math.round(weather.heroX / 2), Math.round(beamX0 / 2), Math.round(beamX1 / 2)].join('|')
97 if (backdrop !== undefined && backdrop.key === key && weather.t >= backdrop.t && weather.t - backdrop.t < 0.25) {
98 c.px.set(backdrop.px)
99 return
100 }
101 drawSky(c, ground, weather)
102 drawMountains(c, ground, weather)
103 drawGround(c, ground, weather, beamX0, beamX1)
104 backdrop = { key, t: weather.t, px: c.px.slice() }
105}
106
107const drawScarf = (c: Canvas, x: number, y: number, p: number, t: number, s: number) => {
108 const len = 6 + p * 8
109 const amp = 0.5 + p * 1.6
110 for (let i = 0; i < len; i++) {
111 const wx = x + i * 0.9
112 const wy = y + Math.sin(t * (6 + p * 8) - i * 0.7) * amp * (i / len) + i * 0.08
113 const color = i < len * 0.5 ? 0xd8283c : 0xa01830
114 c.rect(wx * s, wy * s, s, s * (i < len * 0.4 ? 2 : 1), color)
115 c.set(wx * s, (wy - 0.5) * s, C.ink)
116 }
117}
118
119const drawCallouts = (c: Canvas, w: World, L: Layout, s: number) => {
120 for (const call of w.callouts) {
121 const age = w.time - call.at
122 const pop = Math.min(1, age / 0.12)
123 const fade = age > call.dur - 0.15 && Math.floor(age * 20) % 2 === 0
124 if (fade) continue
125 const scale = (textWidth(call.text, call.scale * s) <= c.w - 4 * s ? call.scale : 1) * s
126 const lift = Math.round((1 - pop) * 4 + age * 3) * s
127 const cx = call.anchor === 'hero' ? (L.heroX + HERO_SIZE.w / 2 - 2) * s : call.anchor === 'fighter' ? (L.fighterX + 10) * s : (c.w / 2)
128 const top = call.anchor === 'center' ? Math.round(c.h * 0.22) : Math.round((L.ground - HERO_SIZE.h - GLYPH_H * call.scale - 3) * s)
129 const half = textWidth(call.text, scale) / 2
130 const x = Math.max(half + s, Math.min(c.w - half - s, cx))
131 drawCentered(c, call.text, x, Math.max(s, top - lift), { color: call.color, outline: C.ink, scale })
132 }
133}
134
135const drawTitles = (c: Canvas, w: World, s: number) => {
136 const t = phaseTime(w)
137 if (t < OVER.title) return
138 const big = c.w >= textWidth('GAME OVER', 2 * s) + 6 * s && c.h >= 50 * s ? 2 : 1
139 const scale = big * s
140 const shown = Math.floor((t - OVER.title) * 12)
141 const blink = shown > 9 && Math.floor(t * 3) % 2 === 0
142 const roomy = c.h >= 56 * s
143 const top = roomy ? Math.round(c.h * 0.12) : 3 * s
144 const gap = roomy ? 4 : 2
145 drawCentered(c, 'GAME OVER', c.w / 2, top, {
146 color: C.red,
147 outline: C.ink,
148 shadow: C.storm0,
149 scale,
150 shown,
151 lift: i => -Math.round(Math.max(0, 1 - (t - OVER.title - i / 12) / 0.25) * 8 * s),
152 ink: i => (blink ? (i % 2 ? C.gold : C.red) : C.red),
153 })
154 let y = top + (GLYPH_H + gap) * scale
155 if (t >= OVER.please) {
156 const lines = textWidth('PLEASE /COMPACT', s) <= c.w - 4 * s ? ['PLEASE /COMPACT'] : ['PLEASE', '/COMPACT']
157 for (const line of lines) {
158 drawCentered(c, line, c.w / 2, y, { color: C.cyan, outline: C.ink, scale: s, shown: Math.floor((t - OVER.please) * 20) })
159 y += (GLYPH_H + gap) * s
160 }
161 }
162 if (t >= OVER.countdown) {
163 const left = 9 - Math.floor(t - OVER.countdown)
164 const text = left >= 0 ? `CONTINUE? ${left}` : 'INSERT /COMPACT'
165 if (left >= 0 || Math.floor(t * 2) % 2 === 0) drawCentered(c, text, c.w / 2, y + s, { color: C.gold, outline: C.ink, scale: s })
166 }
167}
168
169/** The gauge drawn into the picture itself, for the pixel renderer. */
170export type Hud = { percent: number; label: string }
171
172/** World pixels the in-picture gauge takes at the bottom. */
173export const HUD_H = 9
174
175const drawHud = (c: Canvas, hud: Hud, top: number, s: number) => {
176 c.rect(0, top, c.w, c.h - top, 0x10131f)
177 const label = hud.label.toUpperCase()
178 // The label at finer pixels than the scene, so the bar keeps the room.
179 const ls = Math.max(1, s - 1)
180 const labelW = textWidth(label, ls)
181 const x0 = 3 * s
182 const x1 = Math.max(x0 + 8 * s, c.w - labelW - 6 * s)
183 const y = top + 3 * s
184 const full = Math.round(((x1 - x0) * Math.min(100, Math.max(0, hud.percent))) / 100)
185 for (let x = x0; x < x1; x++) {
186 const at = (x - x0) / (x1 - x0)
187 const ink = x - x0 >= full ? 0x2a2f45 : at < 0.5 ? 0x3ccaff : at < 0.75 ? C.gold : C.red
188 c.rect(x, y, 1, 3 * s, ink)
189 }
190 drawText(c, label, x1 + 3 * s, Math.round(top + (HUD_H * s - GLYPH_H * ls) / 2), { color: hud.percent >= DEATH ? C.red : 0xc8d0e8, scale: ls })
191}
192
193/**
194 * Draws the world at `W` by `H` world pixels, `scale` canvas pixels each:
195 * 1 for terminal cells, 2 or more for a real-pixel image. With `hud`, the
196 * bottom HUD_H rows hold the gauge instead of the scene.
197 */
198export const paint = (w: World, W: number, H: number, scale = 1, hud?: Hud): Canvas => {
199 if (hud === undefined) return paintScene(w, W, H, scale)
200 const scene = paintScene(w, W, H - HUD_H, scale)
201 const c = new Canvas(W * scale, H * scale)
202 c.px.set(scene.px)
203 drawHud(c, hud, (H - HUD_H) * scale, scale)
204 return c
205}
206
207const paintScene = (w: World, W: number, H: number, scale: number): Canvas => {
208 const s = scale
209 const c = new Canvas(W * s, H * s)
210 const L = layout(w, W, H)
211 const p = power(w)
212 const t = w.time
213 const phase = w.phase
214 const ot = phaseTime(w)
215 const aftermath = phase === 'over' && ot >= OVER.whiteOut
216 const heroDown = aftermath || (phase === 'recover' && ot < RECOVER.knee)
217 const beamOn = phase === 'fight' || (phase === 'over' && ot < OVER.whiteOut)
218
219 const weather: Weather = {
220 power: beamOn ? p : 0,
221 storm: aftermath || heroDown ? 1 : Math.max(0, Math.min(1, (w.shown - 45) / 45)),
222 flash: w.flash,
223 t,
224 scale: s,
225 heroX: L.heroX + HERO_SIZE.w / 2,
226 cracks: aftermath || heroDown ? 1 : Math.max(0, (p - 0.5) / 0.45),
227 crater: heroDown,
228 camX: 0,
229 }
230 background(c, L.ground, weather, beamOn ? Math.round(L.from[0]) : 0, beamOn ? Math.round(L.to[0]) : 0)
231
232 // Shockwave after the blast.
233 if (phase === 'over' && ot >= OVER.whiteOut && ot < OVER.whiteOut + 1.2) {
234 const k = (ot - OVER.whiteOut) / 1.2
235 c.ring(weather.heroX * s, (L.ground - 6) * s, k * W * 0.9 * s, (3 - k * 2) * s, C.fire4, 1 - k)
236 }
237
238 w.particles.draw(c, L.ground, s, 'back')
239
240 // The fighter.
241 const fighterPose = phase === 'charge' || !beamOn ? 'charge' : 'fire'
242 const fLight: Light = { from: 'right', color: C.beam3, k: phase === 'charge' ? Math.min(1, ot / CHARGE_TIME) * 0.6 : beamOn ? 0.2 + p * 0.5 : 0.1 }
243 const fy = L.ground - FIGHTER_SIZE.h + 1
244 blit(c, fighterSprite(fighterPose, beamOn || (phase === 'charge' && ot > CHARGE_TIME - 0.5), fLight), L.fighterX * s, fy * s, s)
245
246 // The hero.
247 const hy = L.ground - HERO_SIZE.h + 1
248 const hLight: Light = { from: 'left', color: C.beam3, k: beamOn ? 0.15 + p * 0.75 : 0.1 }
249 if (heroDown) {
250 const down = heroDownSprite({ ...hLight, k: 0.1 })
251 blit(c, down, (L.heroX - 2) * s, (L.ground - down.h + 2) * s, s)
252 } else {
253 const { neck } = heroAnchors(w.pose)
254 drawScarf(c, L.heroX + neck[0], hy + neck[1], beamOn ? p : 0.15, t, s)
255 const look = heroFace(w)
256 const now = heroSprite({ pose: w.pose, face: look.face, headLift: look.lift }, hLight)
257 const before = heroSprite({ pose: w.prevPose, face: look.face, headLift: 0 }, hLight)
258 const tremble = w.stage === 'strain' || w.stage === 'knee' ? Math.round((noise1(t * 40, 3) - 0.5) * 1.6) : 0
259 if (beamOn && p > 0.45) blitAura(c, now, (L.heroX + tremble) * s, hy * s, C.fire3, 0.35 + 0.25 * Math.sin(t * 28), s)
260 if (phase === 'recover') blitAura(c, now, L.heroX * s, hy * s, C.heal1, 0.6, s)
261 blitBlend(c, before, now, poseBlend(w), (L.heroX + tremble) * s, hy * s, s)
262 }
263
264 // The beam, its charge and the clash.
265 const r = beamRadius(w, H)
266 if (beamOn) {
267 drawBeam(c, { x0: L.from[0], y0: L.from[1], x1: L.to[0], y1: L.to[1], r, power: p, t, scale: s })
268 drawChargeBall(c, L.from[0], L.from[1], 1.5 + r * 0.55, t, s)
269 drawClash(c, L.to[0], L.to[1], r, p, t, s)
270 } else if (phase === 'charge') {
271 drawChargeBall(c, L.from[0], L.from[1], Math.min(1, ot / CHARGE_TIME) * 3.5, t, s)
272 }
273
274 w.particles.draw(c, L.ground, s, 'front')
275
276 // The senzu bean falls on the fallen hero.
277 if (phase === 'recover' && ot < RECOVER.bean) {
278 const by = (L.ground - 3) * (ot / RECOVER.bean) ** 2
279 c.disc((weather.heroX + 1) * s, by * s, 1.4 * s, [C.heal2, C.heal1, C.heal0])
280 }
281 if (phase === 'recover' && ot >= RECOVER.bean && ot < RECOVER.stand + 0.5) {
282 const k = (ot - RECOVER.bean) / (RECOVER.stand + 0.5 - RECOVER.bean)
283 c.ring(weather.heroX * s, (L.ground - 10) * s, (4 + k * 22) * s, 2 * s, C.heal2, 1 - k)
284 }
285
286 drawCallouts(c, w, L, s)
287 if (aftermath) drawTitles(c, w, s)
288
289 vignette(c)
290 const sh = shake(w)
291 offset(c, sh.x * s, sh.y * s)
292 flashOver(c, w.flash, phase === 'recover' ? C.heal2 : C.white)
293 return c
294}
295
296// ── encoders ───────────────────────────────────────────────────────────────
297
298const B64 = new TextEncoder().encode('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/')
299const PAD = 61
300const ascii = new TextDecoder()
301
302const base64js = (bytes: Uint8Array): string => {
303 const n = bytes.length
304 const out = new Uint8Array(Math.ceil(n / 3) * 4)
305 let o = 0
306 let i = 0
307 for (; i + 2 < n; i += 3) {
308 const v = (bytes[i]! << 16) | (bytes[i + 1]! << 8) | bytes[i + 2]!
309 out[o++] = B64[(v >> 18) & 63]!
310 out[o++] = B64[(v >> 12) & 63]!
311 out[o++] = B64[(v >> 6) & 63]!
312 out[o++] = B64[v & 63]!
313 }
314 if (i < n) {
315 const a = bytes[i]!
316 const b = i + 1 < n ? bytes[i + 1]! : 0
317 const v = (a << 16) | (b << 8)
318 out[o++] = B64[(v >> 18) & 63]!
319 out[o++] = B64[(v >> 12) & 63]!
320 out[o++] = i + 1 < n ? B64[(v >> 6) & 63]! : PAD
321 out[o++] = PAD
322 }
323 return ascii.decode(out)
324}
325
326type Native = Uint8Array & { toBase64?: () => string }
327
328/** Base64 of `bytes`: the runtime's own encoder where it has one. */
329export const base64 = (bytes: Uint8Array): string => {
330 const native = (bytes as Native).toBase64
331 return typeof native === 'function' ? native.call(bytes) : base64js(bytes)
332}
333
334const HALF_BLOCK = 0x2580
335
336/** A Raster's `cells`: '▀' per cell, the upper pixel as foreground, the lower as background. */
337export const encodeCells = (c: Canvas): string => {
338 const rows = c.h >> 1
339 const view = new DataView(new ArrayBuffer(c.w * rows * 12))
340 let at = 0
341 for (let r = 0; r < rows; r++)
342 for (let x = 0; x < c.w; x++) {
343 view.setUint32(at, HALF_BLOCK, true)
344 view.setUint32(at + 4, c.px[2 * r * c.w + x]!, true)
345 view.setUint32(at + 8, c.px[(2 * r + 1) * c.w + x]!, true)
346 at += 12
347 }
348 return base64(new Uint8Array(view.buffer))
349}
350
351/** An Image's `rgba` source. */
352export const encodeRgba = (c: Canvas): string => {
353 const bytes = new Uint8Array(c.w * c.h * 4)
354 for (let i = 0; i < c.px.length; i++) {
355 const v = c.px[i]!
356 bytes[i * 4] = (v >> 16) & 0xff
357 bytes[i * 4 + 1] = (v >> 8) & 0xff
358 bytes[i * 4 + 2] = v & 0xff
359 bytes[i * 4 + 3] = 0xff
360 }
361 return base64(bytes)
362}
363
364export { DEATH }
365hooks/engine/palette.ts 95 lines1// One curated palette for the whole scene. Gradients step between its colors
2// with ordered dithering instead of blending into mud, which keeps the retro
3// look and keeps a frame well under the terminal's 1024 color pairs.
4
5export const C = {
6 ink: 0x0c0a14,
7 black: 0x000000,
8 white: 0xffffff,
9
10 // Sky, calm night to storm to white-out.
11 night0: 0x070818,
12 night1: 0x0f1230,
13 night2: 0x1b2150,
14 night3: 0x2c3a78,
15 storm0: 0x16081c,
16 storm1: 0x3a0c2c,
17 storm2: 0x6e1630,
18 storm3: 0xa8282c,
19 star: 0xdfe6ff,
20 starDim: 0x6a76a8,
21
22 // Mountains and ground.
23 far0: 0x141838,
24 far1: 0x1f2550,
25 near0: 0x1a1426,
26 near1: 0x2a1f36,
27 soil0: 0x2a1c14,
28 soil1: 0x3d2a1c,
29 soil2: 0x5a3e26,
30 soil3: 0x7c5734,
31 crack: 0xffb347,
32
33 // Beam: deep blue to white.
34 beam0: 0x0e2a8a,
35 beam1: 0x1a5cff,
36 beam2: 0x3ccaff,
37 beam3: 0x8ef0ff,
38 beam4: 0xd6fbff,
39
40 // Fire, ki and sparks.
41 fire0: 0x7a1a12,
42 fire1: 0xe0451f,
43 fire2: 0xff9a2a,
44 fire3: 0xffd23f,
45 fire4: 0xfff4a8,
46
47 // Smoke and dust.
48 smoke0: 0x2a2433,
49 smoke1: 0x4a4458,
50 smoke2: 0x7a7488,
51
52 // Healing.
53 heal0: 0x1f7a3a,
54 heal1: 0x4ee06a,
55 heal2: 0xb8ffb0,
56
57 // UI text.
58 red: 0xff3b3b,
59 gold: 0xffd23f,
60 cyan: 0x9ff3ff,
61} as const
62
63export const BEAM_RAMP = [C.beam0, C.beam1, C.beam2, C.beam3, C.beam4, C.white] as const
64export const FIRE_RAMP = [C.fire0, C.fire1, C.fire2, C.fire3, C.fire4, C.white] as const
65export const SMOKE_RAMP = [C.smoke0, C.smoke1, C.smoke2] as const
66export const HEAL_RAMP = [C.heal0, C.heal1, C.heal2, C.white] as const
67
68const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v)
69
70/** Linear blend of two 0xRRGGBB colors. */
71export const mix = (a: number, b: number, k: number): number => {
72 const t = clamp01(k)
73 const r = ((a >> 16) & 0xff) + ((((b >> 16) & 0xff) - ((a >> 16) & 0xff)) * t)
74 const g = ((a >> 8) & 0xff) + ((((b >> 8) & 0xff) - ((a >> 8) & 0xff)) * t)
75 const bl = (a & 0xff) + (((b & 0xff) - (a & 0xff)) * t)
76 return (Math.round(r) << 16) | (Math.round(g) << 8) | Math.round(bl)
77}
78
79const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5] as const
80
81/** The ordered-dither threshold of a pixel, in (0, 1). */
82export const bayer = (x: number, y: number): number => (BAYER4[((y & 3) << 2) | (x & 3)]! + 0.5) / 16
83
84/** `a` or `b`, dithered so that a fraction `k` of the pixels pick `b`. */
85export const dither = (a: number, b: number, k: number, x: number, y: number): number =>
86 k > bayer(x, y) ? b : a
87
88/** A position `t` in [0, 1] along a ramp, dithered between its two nearest steps. */
89export const ramp = (colors: readonly number[], t: number, x: number, y: number): number => {
90 const f = clamp01(t) * (colors.length - 1)
91 const i = Math.floor(f)
92 if (i >= colors.length - 1) return colors[colors.length - 1]!
93 return dither(colors[i]!, colors[i + 1]!, f - i, x, y)
94}
95hooks/engine/particles.ts 158 lines1import type { Canvas } from './canvas'
2import { BEAM_RAMP, C, FIRE_RAMP, HEAL_RAMP, mix, SMOKE_RAMP } from './palette'
3import type { Rng } from './rng'
4
5export type Kind = 'spark' | 'debris' | 'mote' | 'dust' | 'smoke' | 'ember' | 'heal' | 'sweat'
6
7export type Particle = {
8 kind: Kind
9 x: number
10 y: number
11 vx: number
12 vy: number
13 /** Seconds lived and seconds to live. */
14 age: number
15 life: number
16 size: number
17 /** For motes: the point they are pulled toward. */
18 tx?: number
19 ty?: number
20}
21
22const MAX = 420
23
24const GRAVITY: Record<Kind, number> = {
25 spark: 60,
26 debris: -18,
27 mote: 0,
28 dust: -6,
29 smoke: -10,
30 ember: 22,
31 heal: -14,
32 sweat: 90,
33}
34
35const DRAG: Record<Kind, number> = {
36 spark: 1.6,
37 debris: 0.4,
38 mote: 0,
39 dust: 2.2,
40 smoke: 1.2,
41 ember: 0.8,
42 heal: 1.5,
43 sweat: 0.2,
44}
45
46export class Particles {
47 readonly list: Particle[] = []
48
49 add(p: Omit<Particle, 'age'>) {
50 if (this.list.length >= MAX) this.list.shift()
51 this.list.push({ ...p, age: 0 })
52 }
53
54 clear() {
55 this.list.length = 0
56 }
57
58 update(dt: number) {
59 for (const p of this.list) {
60 p.age += dt
61 if (p.kind === 'mote' && p.tx !== undefined && p.ty !== undefined) {
62 // Spiral in toward the charge.
63 const dx = p.tx - p.x
64 const dy = p.ty - p.y
65 const d = Math.max(1, Math.hypot(dx, dy))
66 p.vx += ((dx / d) * 160 - (dy / d) * 40) * dt
67 p.vy += ((dy / d) * 160 + (dx / d) * 40) * dt
68 if (d < 2) p.age = p.life
69 }
70 p.vy += GRAVITY[p.kind] * dt
71 const drag = Math.max(0, 1 - DRAG[p.kind] * dt)
72 p.vx *= drag
73 p.vy *= drag
74 p.x += p.vx * dt
75 p.y += p.vy * dt
76 }
77 let w = 0
78 for (const p of this.list) if (p.age < p.life) this.list[w++] = p
79 this.list.length = w
80 }
81
82 /** `back`: dust, smoke and debris behind the beam; `front`: the light. */
83 draw(c: Canvas, ground: number, scale = 1, layer: 'back' | 'front' = 'front') {
84 for (const p of this.list) {
85 const isBack = p.kind === 'debris' || p.kind === 'dust' || p.kind === 'smoke'
86 if (isBack !== (layer === 'back')) continue
87 const t = p.age / p.life
88 const x = p.x * scale
89 const y = p.y * scale
90 if (p.kind === 'smoke' || p.kind === 'dust') {
91 const r = (p.size * (0.6 + t)) * scale
92 const color = p.kind === 'smoke' ? SMOKE_RAMP[Math.min(2, Math.floor((1 - t) * 3))]! : C.soil3
93 c.halo(x, y, r + 0.5, color, (1 - t) * (p.kind === 'smoke' ? 0.9 : 0.6))
94 continue
95 }
96 if (p.kind === 'debris') {
97 const color = t < 0.5 ? C.soil2 : C.soil1
98 c.rect(x, y, p.size * scale, p.size * scale, color)
99 c.set(x, y, C.soil3)
100 continue
101 }
102 const ramp = p.kind === 'heal' ? HEAL_RAMP : p.kind === 'mote' ? BEAM_RAMP : p.kind === 'sweat' ? [0x5ab8ff, 0xbfe8ff] : FIRE_RAMP
103 const color = ramp[Math.max(0, Math.min(ramp.length - 1, Math.floor((1 - t) * ramp.length)))]!
104 if (y / scale > ground && p.kind !== 'ember') continue
105 // A short streak along the velocity, so fast sparks read as motion.
106 const len = p.kind === 'spark' ? Math.min(3, Math.hypot(p.vx, p.vy) / 40) : 0
107 c.set(x, y, color)
108 if (len > 0.5) {
109 const n = Math.hypot(p.vx, p.vy)
110 c.set(x - (p.vx / n) * len * scale, y - (p.vy / n) * len * scale, mix(color, C.fire1, 0.5))
111 }
112 if (scale > 1) c.rect(x, y, scale, scale, color)
113 }
114 }
115}
116
117// ── emitters ───────────────────────────────────────────────────────────────
118
119export const emitSparks = (ps: Particles, rng: Rng, x: number, y: number, count: number, power: number) => {
120 for (let i = 0; i < count; i++) {
121 const a = Math.PI + rng.range(-1.2, 1.2)
122 const speed = rng.range(30, 90) * (0.6 + power)
123 ps.add({ kind: 'spark', x, y: y + rng.range(-2, 2), vx: Math.cos(a) * speed, vy: Math.sin(a) * speed - 20, life: rng.range(0.25, 0.7), size: 1 })
124 }
125}
126
127export const emitDebris = (ps: Particles, rng: Rng, x0: number, x1: number, ground: number) => {
128 ps.add({ kind: 'debris', x: rng.range(x0, x1), y: ground - 1, vx: rng.range(-4, 4), vy: rng.range(-14, -4), life: rng.range(1.5, 3), size: rng.chance(0.3) ? 2 : 1 })
129}
130
131export const emitMote = (ps: Particles, rng: Rng, tx: number, ty: number) => {
132 const a = rng.range(0, Math.PI * 2)
133 const r = rng.range(8, 16)
134 ps.add({ kind: 'mote', x: tx + Math.cos(a) * r, y: ty + Math.sin(a) * r, vx: 0, vy: 0, life: 1.2, size: 1, tx, ty })
135}
136
137export const emitDust = (ps: Particles, rng: Rng, x: number, ground: number, dir: number) => {
138 ps.add({ kind: 'dust', x: x + rng.range(-2, 2), y: ground - 1, vx: dir * rng.range(8, 24), vy: rng.range(-6, -1), life: rng.range(0.4, 0.9), size: rng.range(0.8, 1.6) })
139}
140
141export const emitSmoke = (ps: Particles, rng: Rng, x: number, y: number) => {
142 ps.add({ kind: 'smoke', x: x + rng.range(-3, 3), y, vx: rng.range(-3, 3), vy: rng.range(-10, -4), life: rng.range(1.2, 2.4), size: rng.range(1, 2.2) })
143}
144
145export const emitEmbers = (ps: Particles, rng: Rng, w: number, count: number) => {
146 for (let i = 0; i < count; i++)
147 ps.add({ kind: 'ember', x: rng.range(0, w), y: rng.range(-4, 0), vx: rng.range(-6, 6), vy: rng.range(4, 14), life: rng.range(1.5, 3), size: 1 })
148}
149
150export const emitHeal = (ps: Particles, rng: Rng, x: number, y: number) => {
151 const a = rng.range(0, Math.PI * 2)
152 ps.add({ kind: 'heal', x: x + Math.cos(a) * rng.range(2, 9), y: y + Math.sin(a) * rng.range(1, 6), vx: 0, vy: rng.range(-12, -4), life: rng.range(0.5, 1.1), size: 1 })
153}
154
155export const emitSweat = (ps: Particles, rng: Rng, x: number, y: number) => {
156 ps.add({ kind: 'sweat', x, y, vx: rng.range(4, 14), vy: rng.range(-14, -6), life: 0.6, size: 1 })
157}
158hooks/engine/rng.ts 61 lines1// Deterministic randomness: every frame is a pure function of its inputs, so
2// tests and the README recorder see exactly what the pane shows.
3
4/** A stable pseudo-random value in [0, 1) for up to three integer keys. */
5export const hash = (a: number, b = 0, c = 0): number => {
6 let h = Math.imul(a | 0, 374761393) ^ Math.imul(b | 0, 668265263) ^ Math.imul(c | 0, 1440662683)
7 h = Math.imul(h ^ (h >>> 13), 1274126177)
8 h ^= h >>> 16
9 return (h >>> 0) / 4294967296
10}
11
12/** mulberry32: a small, fast seeded generator for particle spawns. */
13export class Rng {
14 private s: number
15
16 constructor(seed: number) {
17 this.s = seed >>> 0
18 }
19
20 next(): number {
21 this.s = (this.s + 0x6d2b79f5) >>> 0
22 let t = this.s
23 t = Math.imul(t ^ (t >>> 15), t | 1)
24 t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
25 return ((t ^ (t >>> 14)) >>> 0) / 4294967296
26 }
27
28 range(lo: number, hi: number): number {
29 return lo + (hi - lo) * this.next()
30 }
31
32 chance(p: number): boolean {
33 return this.next() < p
34 }
35}
36
37const smooth = (f: number) => f * f * (3 - 2 * f)
38
39/** Smooth 1D value noise in [0, 1). */
40export const noise1 = (x: number, seed = 0): number => {
41 const i = Math.floor(x)
42 const u = smooth(x - i)
43 return hash(i, seed) * (1 - u) + hash(i + 1, seed) * u
44}
45
46/** Smooth 2D value noise in [0, 1). */
47export const noise2 = (x: number, y: number, seed = 0): number => {
48 const ix = Math.floor(x)
49 const iy = Math.floor(y)
50 const u = smooth(x - ix)
51 const v = smooth(y - iy)
52 const a = hash(ix, iy, seed)
53 const b = hash(ix + 1, iy, seed)
54 const c = hash(ix, iy + 1, seed)
55 const d = hash(ix + 1, iy + 1, seed)
56 return (a * (1 - u) + b * u) * (1 - v) + (c * (1 - u) + d * u) * v
57}
58
59/** Two octaves of noise1, for ridgelines and flicker. */
60export const fbm1 = (x: number, seed = 0): number => noise1(x, seed) * 0.65 + noise1(x * 2.3, seed + 7) * 0.35
61hooks/engine/sprites.ts 467 lines1import { CLEAR } from './canvas'
2import type { Canvas } from './canvas'
3import { bayer, C, mix } from './palette'
4
5// Characters are drawn as flat-color maps; `bake` adds the polish: a 1px ink
6// outline, a rim light on the side the beam's glow falls on, and a shadow on
7// the far side. Faces are overlays, so every pose can wear every expression.
8
9export type Sprite = { w: number; h: number; px: Uint32Array }
10
11type Legend = Record<string, number>
12
13const HERO_LEGEND: Legend = {
14 H: 0x8a3cff,
15 h: 0x5a1fb0,
16 G: 0xc49aff,
17 K: 0x8fd0ff,
18 k: 0x5aa0d8,
19 L: 0xd6f4ff,
20 W: 0xf2f2f8,
21 w: 0xb0b0cc,
22 P: 0x5a2a9a,
23 p: 0x3a1a6a,
24 b: 0x24163a,
25 B: 0x4a3a6a,
26 y: 0xffd23f,
27}
28
29const FIGHTER_LEGEND: Legend = {
30 H: 0x16121e,
31 h: 0x3a3450,
32 S: 0xf2c08a,
33 s: 0xc8905a,
34 O: 0xf07a1a,
35 o: 0xb8501a,
36 U: 0x2a4fbf,
37 u: 0x1a2f80,
38 B: 0x2a4fbf,
39 b: 0x16205a,
40}
41
42const FACE_LEGEND: Legend = {
43 i: C.ink,
44 w: 0xffffff,
45 r: 0xa0142a,
46 t: 0xffffff,
47}
48
49/** Pads every row to `w` so maps can be written without counting dots. */
50const norm = (rows: readonly string[], w: number) => rows.map(r => r.padEnd(w, '.').slice(0, w))
51
52// ── hero (faces left, toward the beam) ─────────────────────────────────────
53
54const HW = 18
55const HH = 26
56
57const HERO_HEAD = norm(
58 [
59 '......HHG.HH.H',
60 '.....HHHHHHHHHHH',
61 '....HHGHHHHHHHHHHH',
62 '....HHHHHHHHHHhh',
63 '....KKKKKKHHHhh',
64 '...KKKKKKKKHhh',
65 '...KKKKKKKKKh',
66 '....KKKKKKKK',
67 '.....kKKKKk',
68 '......kKKk',
69 ],
70 HW,
71)
72
73const LEGS_STAND = [
74 '.....PPPPPPPP',
75 '.....PPp..pPP',
76 '....PPp....pPP',
77 '....PP......PP',
78 '....PP......PP',
79 '....pP......Pp',
80 '...bBBb....bBBb',
81 '..bbbbb....bbbbb',
82]
83
84export type HeroPose = 'laugh' | 'smug' | 'brace' | 'strain' | 'knee'
85
86type PoseDef = {
87 head: { dx: number; dy: number }
88 body: readonly string[]
89 /** Where the scarf ties on, in the sprite's own pixels. */
90 neck: [number, number]
91 /** Where the beam meets the hero. */
92 hands: [number, number]
93}
94
95const POSES: Record<HeroPose, PoseDef> = {
96 laugh: {
97 head: { dx: 0, dy: 0 },
98 body: [
99 '....wWWWWWWWWw',
100 '...wWWWWWWWWWWw',
101 '...KwWWWWWWWWwK',
102 '..KK.wWWWWWWw.KK',
103 '..KK.WWWWWWWW.KK',
104 '...KKwWWWWWWwKK',
105 '....KyyyyyyyyK',
106 '.....PPPPPPPP',
107 ...LEGS_STAND,
108 ],
109 neck: [9, 9],
110 hands: [2, 14],
111 },
112 smug: {
113 head: { dx: 0, dy: 0 },
114 body: [
115 '....wWWWWWWWWw',
116 '...wWWWWWWWWWWw',
117 '...wWWWWWWWWWWw',
118 '...kKKKKKKKKKKw',
119 '...wKKKKKKKKKkw',
120 '....wWWWWWWWWw',
121 '....yyyyyyyyyy',
122 '.....PPPPPPPP',
123 ...LEGS_STAND,
124 ],
125 neck: [9, 9],
126 hands: [3, 14],
127 },
128 brace: {
129 head: { dx: -1, dy: 0 },
130 body: [
131 '....wWWWWWWWWw',
132 'KKKKwWWWWWWWWWw',
133 'LKKKKKWWWWWWWWw',
134 'KK..kwWWWWWWWw',
135 '.....wWWWWWWWw',
136 '.....wWWWWWWw',
137 '.....yyyyyyyy',
138 '....PPPPPPPPPP',
139 '...PPPp....pPPP',
140 '..PPp.......pPP',
141 '..PP.........PP',
142 '.PP...........PP',
143 '.PP...........PP',
144 '.pP...........Pp',
145 'bBBb.........bBBb',
146 'bbbbb.......bbbbbb',
147 ],
148 neck: [8, 9],
149 hands: [0, 12],
150 },
151 strain: {
152 head: { dx: -2, dy: 3 },
153 body: [
154 'KKKwWWWWWWWWWw',
155 'LKKKKWWWWWWWWWw',
156 'KK..wWWWWWWWWWw',
157 '....wWWWWWWWWw',
158 '....yyyyyyyyyy',
159 '...PPPPPPPPPPPP',
160 '..PPPp......pPPP',
161 '.PPp..........pPP',
162 '.PP............PP',
163 'PPp............pP',
164 'pP..............Pp',
165 'bBBb..........bBBb',
166 'bbbbb.........bbbb',
167 ],
168 neck: [7, 12],
169 hands: [0, 14],
170 },
171 knee: {
172 head: { dx: -1, dy: 6 },
173 body: [
174 'KKKwWWWWWWWWw',
175 'LKKKWWWWWWWWWw',
176 '....wWWWWWWWWw',
177 '....wWWWWWWWwK',
178 '....yyyyyyyyyK',
179 '...PPPPPPPPPPK',
180 '..PPPp...PPPPPP',
181 '.PPp.....PPPPPPPP',
182 'bBBb......ppppbbb',
183 'bbbbb.....pppppbbb',
184 ],
185 neck: [8, 15],
186 hands: [0, 17],
187 },
188}
189
190export type Face = 'normal' | 'laugh' | 'smug' | 'grit' | 'pain'
191
192/** 7x5 overlays at the head's (3, 4): brows, eyes, then the mouth. */
193const FACES: Record<Face, readonly string[]> = {
194 normal: ['.......', '.wi.wi.', '.......', '..iii..', '.......'],
195 laugh: ['.......', '.ii.ii.', '.......', 'rrrr...', 'rttr...'],
196 smug: ['ii.....', '.wi.wi.', '.......', '...ii..', '..i....'],
197 grit: ['.ii.ii.', '.wi.wi.', '.......', 'twtw...', '.......'],
198 pain: ['i.i.i..', '.ii.ii.', '.......', 'rrrr...', 'twtw...'],
199}
200
201const HERO_DOWN = norm(
202 [
203 '.HHG.....wWWWw',
204 'HHHHKKKKwWWWWWWyPPPPPPPbb',
205 'HHHKKKKKWWWWWWWyPPPPPPPbbB',
206 '.HhKKKkwwWWWWwwyppp.pppbb',
207 '..h..kk..www.....pp..pp',
208 ],
209 26,
210)
211const DEAD_EYES = ['i.i.i.i', '.i...i.', 'i.i.i.i']
212
213// ── fighter (faces right, toward the hero) ─────────────────────────────────
214
215const FW = 16
216const FIGHTER_HEAD = [
217 '.H..H.H',
218 'HHHHHHHH',
219 '.HHHHHHHHH',
220 'HHHHHhHHHH',
221 '.HHHSSSSSS',
222 '..HHSSSSSS',
223 '...HSSSSS',
224 '....sSSs',
225]
226
227const FIGHTER_LEGS = [
228 '..oOOO..OOO',
229 '..oOO....OOO',
230 '.oOO......OOO',
231 '.oO........OO',
232 '.oO........oO',
233 '.UU........UU',
234 '.BBb.......BBb',
235 'BBBb.......BBBb',
236 'bbbb.......bbbb',
237]
238
239export type FighterPose = 'charge' | 'fire'
240
241const FIGHTER: Record<FighterPose, { rows: readonly string[]; hands: [number, number] }> = {
242 fire: {
243 rows: norm(
244 [
245 ...FIGHTER_HEAD,
246 '...UoOOOOU',
247 '..oOOOOOOOSSSSS',
248 '..oOOOUOOUSSSSSS',
249 '..oOOOOOOO.SSSS',
250 '...oOOOOOO',
251 '...UUUUUUU',
252 '...oOOOOOO',
253 ...FIGHTER_LEGS,
254 ],
255 FW,
256 ),
257 hands: [15, 10],
258 },
259 charge: {
260 rows: norm(
261 [
262 ...FIGHTER_HEAD,
263 '....UoOOOOU',
264 '.SSSoOOOOOOo',
265 'SSSSUOOOOOOo',
266 '.SS.OOOOOOOo',
267 '....oOOOOOo',
268 '....UUUUUUU',
269 '....oOOOOOo',
270 ...FIGHTER_LEGS.map(r => `.${r}`),
271 ],
272 FW,
273 ),
274 hands: [0, 10],
275 },
276}
277
278const FIGHTER_FACES = {
279 charge: ['.....', '.i.i.', '..ii.'],
280 fire: ['.ii.i', '.i.i.', '.rrr.'],
281} as const
282
283// ── baking ─────────────────────────────────────────────────────────────────
284
285const paintMap = (out: Uint32Array, w: number, rows: readonly string[], legend: Legend, ox: number, oy: number) => {
286 rows.forEach((row, y) => {
287 for (let x = 0; x < row.length; x++) {
288 const color = legend[row[x]!]
289 if (color === undefined) continue
290 const tx = ox + x
291 const ty = oy + y
292 if (tx < 0 || tx >= w || ty < 0) continue
293 out[ty * w + tx] = color
294 }
295 })
296}
297
298export type Light = {
299 /** Which side the glow comes from. */
300 from: 'left' | 'right'
301 color: number
302 /** 0 (none) to 1 (blown out). */
303 k: number
304}
305
306/** Outline, rim light and shadow around a flat map: the sprite grows by 1px each side. */
307const bake = (flat: Uint32Array, w: number, h: number, light: Light): Sprite => {
308 const W = w + 2
309 const H = h + 2
310 const out = new Uint32Array(W * H).fill(CLEAR)
311 const at = (x: number, y: number) => (x < 0 || y < 0 || x >= w || y >= h ? CLEAR : flat[y * w + x]!)
312 const toward = light.from === 'left' ? -1 : 1
313 for (let y = 0; y < h; y++)
314 for (let x = 0; x < w; x++) {
315 const base = at(x, y)
316 if (base === CLEAR) continue
317 let color = base
318 const lit = at(x + toward, y) === CLEAR || at(x + toward, y - 1) === CLEAR
319 const dark = at(x - toward, y) === CLEAR
320 if (lit) color = mix(color, light.color, 0.25 + light.k * 0.5)
321 else if (dark) color = mix(color, C.ink, 0.35)
322 if (at(x, y - 1) === CLEAR && !lit) color = mix(color, 0xffffff, 0.12)
323 out[(y + 1) * W + x + 1] = color
324 }
325 for (let y = 0; y < H; y++)
326 for (let x = 0; x < W; x++) {
327 if (out[y * W + x] !== CLEAR) continue
328 const near = (dx: number, dy: number) => {
329 const v = at(x - 1 + dx, y - 1 + dy)
330 return v !== CLEAR
331 }
332 if (near(-1, 0) || near(1, 0) || near(0, -1) || near(0, 1)) out[y * W + x] = C.ink
333 }
334 return { w: W, h: H, px: out }
335}
336
337const cache = new Map<string, Sprite>()
338const cached = (key: string, make: () => Sprite) => {
339 let s = cache.get(key)
340 if (s === undefined) {
341 if (cache.size > 400) cache.clear()
342 s = make()
343 cache.set(key, s)
344 }
345 return s
346}
347
348const lightKey = (l: Light) => `${l.from}${l.color}${Math.round(l.k * 8)}`
349
350export type HeroLook = {
351 pose: HeroPose
352 face: Face
353 /** Head bob in pixels (laughing, breathing). */
354 headLift: number
355}
356
357export const heroSprite = (look: HeroLook, light: Light): Sprite =>
358 cached(`h${look.pose}${look.face}${look.headLift}${lightKey(light)}`, () => {
359 const def = POSES[look.pose]
360 const flat = new Uint32Array(HW * HH).fill(CLEAR)
361 const bodyY = HH - def.body.length
362 paintMap(flat, HW, norm(def.body, HW), HERO_LEGEND, 0, bodyY)
363 const hx = def.head.dx
364 const hy = def.head.dy - look.headLift
365 paintMap(flat, HW, HERO_HEAD, HERO_LEGEND, hx, hy)
366 paintMap(flat, HW, FACES[look.face], FACE_LEGEND, hx + 3, hy + 4)
367 return bake(flat, HW, HH, light)
368 })
369
370export const heroDownSprite = (light: Light): Sprite =>
371 cached(`hd${lightKey(light)}`, () => {
372 const flat = new Uint32Array(26 * 5).fill(CLEAR)
373 paintMap(flat, 26, HERO_DOWN, HERO_LEGEND, 0, 0)
374 paintMap(flat, 26, DEAD_EYES, FACE_LEGEND, 1, 1)
375 return bake(flat, 26, 5, light)
376 })
377
378export const heroAnchors = (pose: HeroPose) => {
379 const def = POSES[pose]
380 // +1 for the outline the bake adds.
381 return { neck: [def.neck[0] + 1, def.neck[1] + 1] as const, hands: [def.hands[0] + 1, def.hands[1] + 1] as const }
382}
383
384export const fighterSprite = (pose: FighterPose, shouting: boolean, light: Light): Sprite =>
385 cached(`f${pose}${shouting}${lightKey(light)}`, () => {
386 const def = FIGHTER[pose]
387 const h = def.rows.length
388 const flat = new Uint32Array(FW * h).fill(CLEAR)
389 paintMap(flat, FW, def.rows, FIGHTER_LEGEND, 0, 0)
390 paintMap(flat, FW, FIGHTER_FACES[shouting ? 'fire' : 'charge'], FACE_LEGEND, 5, 4)
391 return bake(flat, FW, h, light)
392 })
393
394export const fighterHands = (pose: FighterPose) => {
395 const [x, y] = FIGHTER[pose].hands
396 return [x + 1, y + 1] as const
397}
398
399export const HERO_SIZE = { w: HW + 2, h: HH + 2 }
400export const FIGHTER_SIZE = { w: FW + 2, h: 24 + 2 }
401
402// ── drawing ────────────────────────────────────────────────────────────────
403
404/** Draws `s` with its top-left at (x, y), scaled by `scale`. */
405export const blit = (c: Canvas, s: Sprite, x: number, y: number, scale = 1, tint?: number) => {
406 const x0 = Math.round(x)
407 const y0 = Math.round(y)
408 for (let sy = 0; sy < s.h; sy++)
409 for (let sx = 0; sx < s.w; sx++) {
410 const v = s.px[sy * s.w + sx]!
411 if (v === CLEAR) continue
412 const color = tint ?? v
413 if (scale === 1) c.set(x0 + sx, y0 + sy, color)
414 else c.rect(x0 + sx * scale, y0 + sy * scale, scale, scale, color)
415 }
416}
417
418/** Dissolves from sprite `a` to `b` (same size) with an ordered-dither mask. */
419export const blitBlend = (c: Canvas, a: Sprite, b: Sprite, k: number, x: number, y: number, scale = 1) => {
420 if (k >= 1 || a.w !== b.w || a.h !== b.h) return blit(c, b, x, y, scale)
421 if (k <= 0) return blit(c, a, x, y, scale)
422 const x0 = Math.round(x)
423 const y0 = Math.round(y)
424 for (let sy = 0; sy < b.h; sy++)
425 for (let sx = 0; sx < b.w; sx++) {
426 const pick = k > bayer(sx, sy) ? b : a
427 const v = pick.px[sy * pick.w + sx]!
428 if (v === CLEAR) continue
429 if (scale === 1) c.set(x0 + sx, y0 + sy, v)
430 else c.rect(x0 + sx * scale, y0 + sy * scale, scale, scale, v)
431 }
432}
433
434/** The pixels just outside a sprite's silhouette, worked out once per sprite. */
435const auraCache = new WeakMap<Sprite, Uint32Array>()
436
437const auraOf = (s: Sprite) => {
438 let ring = auraCache.get(s)
439 if (ring === undefined) {
440 const at: number[] = []
441 const W = s.w + 2
442 const solid = (x: number, y: number) => x >= 0 && y >= 0 && x < s.w && y < s.h && s.px[y * s.w + x] !== CLEAR
443 for (let y = -1; y <= s.h; y++)
444 for (let x = -1; x <= s.w; x++) {
445 if (solid(x, y)) continue
446 if (solid(x - 1, y) || solid(x + 1, y) || solid(x, y - 1) || solid(x, y + 1) || solid(x - 1, y + 1) || solid(x + 1, y + 1))
447 at.push((y + 1) * W + x + 1)
448 }
449 ring = Uint32Array.from(at)
450 auraCache.set(s, ring)
451 }
452 return ring
453}
454
455/** A glowing ring hugging the silhouette: the ki aura. */
456export const blitAura = (c: Canvas, s: Sprite, x: number, y: number, color: number, k: number, scale = 1) => {
457 const W = s.w + 2
458 const x0 = Math.round(x) - scale
459 const y0 = Math.round(y) - scale
460 for (const i of auraOf(s)) {
461 const px = x0 + (i % W) * scale
462 const py = y0 + Math.floor(i / W) * scale
463 if (scale === 1) c.glow(px, py, color, k)
464 else for (let iy = 0; iy < scale; iy++) for (let ix = 0; ix < scale; ix++) c.glow(px + ix, py + iy, color, k)
465 }
466}
467hooks/engine/beam.ts 112 lines1import type { Canvas } from './canvas'
2import { BEAM_RAMP, C, FIRE_RAMP, ramp } from './palette'
3import { hash, noise1, noise2 } from './rng'
4
5/** Energy texture, precomputed: a 256x64 tile of smooth noise the beam scrolls through. */
6const TEX_W = 256
7const TEX_H = 64
8const TEX = (() => {
9 const t = new Float32Array(TEX_W * TEX_H)
10 for (let y = 0; y < TEX_H; y++)
11 for (let x = 0; x < TEX_W; x++) {
12 // Wraps horizontally: blend the tile's end into its start.
13 const a = noise2(x * 0.35, y * 0.6, 3)
14 const b = noise2((x - TEX_W) * 0.35, y * 0.6, 3)
15 const k = x / TEX_W
16 t[y * TEX_W + x] = a * (1 - k) + b * k
17 }
18 return t
19})()
20
21export type BeamSpec = {
22 /** From the fighter's hands to the clash point, in world pixels. */
23 x0: number
24 y0: number
25 x1: number
26 y1: number
27 /** Half-height of the beam's body. */
28 r: number
29 power: number
30 t: number
31 scale: number
32}
33
34/**
35 * The kamehameha: a body textured with scrolling energy, rings of light
36 * racing toward the hero, a flickering white core and a soft blue glow.
37 */
38export const drawBeam = (c: Canvas, b: BeamSpec) => {
39 const s = b.scale
40 const span = Math.max(1, b.x1 - b.x0)
41 for (let X = Math.floor(b.x0 * s); X <= b.x1 * s; X++) {
42 const wx = X / s
43 const u = wx - b.x0
44 const cy = b.y0 + ((b.y1 - b.y0) * u) / span
45 const taper = Math.min(1, (u + 1.5) / 5)
46 const phase = (u - b.t * 38) / 11
47 const f = phase - Math.floor(phase)
48 const bulge = f < 0.2 ? (1 - f / 0.2) * 0.3 : 0
49 const wobble = (noise1(u * 0.18 - b.t * 7, 2) - 0.5) * 0.24
50 const rx = Math.max(0.6, b.r * taper * (1 + wobble + bulge))
51 const reach = rx * 2
52 const flicker = noise1(b.t * 30, 4) * 0.15
53 for (let Y = Math.floor((cy - reach) * s); Y <= (cy + reach) * s; Y++) {
54 const wy = Y / s
55 const d = Math.abs(wy - cy) / rx
56 if (d < 1) {
57 const tx = ((Math.floor(u - b.t * 40) % TEX_W) + TEX_W) % TEX_W
58 const ty = Math.min(TEX_H - 1, Math.max(0, Math.floor(wy - cy + TEX_H / 2)))
59 const tex = TEX[ty * TEX_W + tx]!
60 const k = 1 - d * d * 0.9 + (tex - 0.5) * 0.45 + bulge * 0.9 + flicker
61 c.set(X, Y, ramp(BEAM_RAMP, k, X, Y))
62 } else if (d < 2) {
63 const g = (1 - (d - 1)) ** 2
64 c.glow(X, Y, d < 1.4 ? C.beam2 : C.beam1, g * (0.35 + 0.4 * b.power))
65 }
66 }
67 }
68}
69
70/** The ball of ki between the fighter's hands, swirling. */
71export const drawChargeBall = (c: Canvas, x: number, y: number, r: number, t: number, scale: number) => {
72 if (r <= 0) return
73 const s = scale
74 c.halo(x * s, y * s, (r * 1.8 + 1) * s, C.beam1, 0.55)
75 for (let Y = Math.floor((y - r - 1) * s); Y <= (y + r + 1) * s; Y++)
76 for (let X = Math.floor((x - r - 1) * s); X <= (x + r + 1) * s; X++) {
77 const dx = X / s - x
78 const dy = Y / s - y
79 const d = Math.hypot(dx, dy) / r
80 if (d >= 1) continue
81 const swirl = noise2(Math.atan2(dy, dx) * 1.6 + t * 9, d * 3 - t * 4, 6)
82 c.set(X, Y, ramp(BEAM_RAMP, 1.05 - d * 0.9 + (swirl - 0.5) * 0.6, X, Y))
83 }
84}
85
86/** Where the beam meets the hero's ki: a hot core, a shield and flying rays. */
87export const drawClash = (c: Canvas, x: number, y: number, r: number, power: number, t: number, scale: number) => {
88 const s = scale
89 const X = x * s
90 const Y = y * s
91 c.halo(X, Y, (r * 1.2 + 2.5) * s, C.fire3, 0.3 + power * 0.3)
92 // The hero's shield: an arc of gold in front of their hands.
93 if (power > 0.45) {
94 const sr = (r * 1.25 + 1.5) * s
95 for (let a = -1.25; a <= 1.25; a += 0.04 / Math.max(1, sr / 8)) {
96 const wob = noise1(a * 4 + t * 10, 12) * 1.2 * s
97 c.set(X - Math.cos(a) * (sr + wob) * 0.45, Y + Math.sin(a) * (sr + wob), hash(Math.floor(a * 50), Math.floor(t * 30)) < 0.5 ? C.fire3 : C.fire4)
98 }
99 }
100 const core = (r * 0.5 + 1 + noise1(t * 25, 13) * 0.9) * s
101 c.disc(X, Y, core, [C.white, C.white, C.fire4, C.fire3, C.fire2])
102 const rays = 5 + Math.floor(power * 10)
103 const tick = Math.floor(t * 24)
104 for (let i = 0; i < rays; i++) {
105 const a = Math.PI + (hash(i, tick) - 0.5) * Math.PI * 1.1 - 0.25
106 const len = core + (1.5 + hash(i, tick, 1) * (2 + r * 0.8)) * s
107 const x1 = X + Math.cos(a) * len
108 const y1 = Y + Math.sin(a) * len
109 c.line(X + Math.cos(a) * core * 0.8, Y + Math.sin(a) * core * 0.8, x1, y1, ramp(FIRE_RAMP, 0.6 + hash(i, tick, 2) * 0.4, Math.floor(x1), Math.floor(y1)))
110 }
111}
112hooks/engine/canvas.ts 87 lines1import { bayer, mix } from './palette'
2
3/** An RGB pixel buffer; every drawing call clips at the edges. */
4export class Canvas {
5 readonly px: Uint32Array
6
7 constructor(
8 readonly w: number,
9 readonly h: number,
10 ) {
11 this.px = new Uint32Array(w * h)
12 }
13
14 set(x: number, y: number, color: number) {
15 const ix = Math.floor(x)
16 const iy = Math.floor(y)
17 if (ix < 0 || iy < 0 || ix >= this.w || iy >= this.h) return
18 this.px[iy * this.w + ix] = color
19 }
20
21 get(x: number, y: number): number {
22 const ix = x < 0 ? 0 : x >= this.w ? this.w - 1 : Math.floor(x)
23 const iy = y < 0 ? 0 : y >= this.h ? this.h - 1 : Math.floor(y)
24 return this.px[iy * this.w + ix]!
25 }
26
27 /** Blends `color` over the pixel by `k`, dithered to four steps. */
28 glow(x: number, y: number, color: number, k: number) {
29 if (k <= 0) return
30 const ix = Math.floor(x)
31 const iy = Math.floor(y)
32 if (ix < 0 || iy < 0 || ix >= this.w || iy >= this.h) return
33 const q = Math.min(1, Math.floor(k * 4 + bayer(ix, iy)) / 4)
34 if (q <= 0) return
35 const at = iy * this.w + ix
36 this.px[at] = mix(this.px[at]!, color, q)
37 }
38
39 fill(color: number) {
40 this.px.fill(color)
41 }
42
43 rect(x0: number, y0: number, w: number, h: number, color: number) {
44 for (let y = Math.max(0, Math.floor(y0)); y < Math.min(this.h, y0 + h); y++)
45 for (let x = Math.max(0, Math.floor(x0)); x < Math.min(this.w, x0 + w); x++) this.px[y * this.w + x] = color
46 }
47
48 /** A filled disc colored by distance from its center, `inks[0]` at the center. */
49 disc(cx: number, cy: number, r: number, inks: readonly number[]) {
50 if (r <= 0) return
51 for (let y = Math.floor(cy - r - 1); y <= cy + r + 1; y++)
52 for (let x = Math.floor(cx - r - 1); x <= cx + r + 1; x++) {
53 const d = Math.hypot(x + 0.5 - cx, y + 0.5 - cy) / r
54 if (d >= 1) continue
55 const f = d * inks.length
56 const i = Math.min(inks.length - 1, Math.floor(f + (bayer(x, y) - 0.5) * 0.9))
57 this.set(x, y, inks[Math.max(0, i)]!)
58 }
59 }
60
61 /** A ring of `thickness` pixels at radius `r`. */
62 ring(cx: number, cy: number, r: number, thickness: number, color: number, k = 1) {
63 for (let y = Math.floor(cy - r - thickness - 1); y <= cy + r + thickness + 1; y++)
64 for (let x = Math.floor(cx - r - thickness - 1); x <= cx + r + thickness + 1; x++) {
65 const d = Math.abs(Math.hypot(x + 0.5 - cx, y + 0.5 - cy) - r)
66 if (d < thickness) this.glow(x, y, color, k * (1 - d / thickness))
67 }
68 }
69
70 /** A soft round glow: `k` at the center fading to nothing at `r`. */
71 halo(cx: number, cy: number, r: number, color: number, k: number) {
72 for (let y = Math.floor(cy - r); y <= cy + r; y++)
73 for (let x = Math.floor(cx - r); x <= cx + r; x++) {
74 const d = Math.hypot(x + 0.5 - cx, y + 0.5 - cy) / r
75 if (d < 1) this.glow(x, y, color, k * (1 - d) * (1 - d))
76 }
77 }
78
79 line(x0: number, y0: number, x1: number, y1: number, color: number) {
80 const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))))
81 for (let i = 0; i <= n; i++) this.set(x0 + ((x1 - x0) * i) / n, y0 + ((y1 - y0) * i) / n, color)
82 }
83}
84
85/** Sentinel for a see-through sprite pixel; real colors are 24-bit. */
86export const CLEAR = 0xff000000
87hooks/engine/font.ts 107 lines1import type { Canvas } from './canvas'
2
3// A 5x7 bitmap font: titles, callouts and the countdown.
4const GLYPHS: Record<string, readonly string[]> = {
5 A: ['.###.', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
6 B: ['####.', '#...#', '#...#', '####.', '#...#', '#...#', '####.'],
7 C: ['.###.', '#...#', '#....', '#....', '#....', '#...#', '.###.'],
8 D: ['####.', '#...#', '#...#', '#...#', '#...#', '#...#', '####.'],
9 E: ['#####', '#....', '#....', '####.', '#....', '#....', '#####'],
10 F: ['#####', '#....', '#....', '####.', '#....', '#....', '#....'],
11 G: ['.###.', '#...#', '#....', '#.###', '#...#', '#...#', '.####'],
12 H: ['#...#', '#...#', '#...#', '#####', '#...#', '#...#', '#...#'],
13 I: ['###', '.#.', '.#.', '.#.', '.#.', '.#.', '###'],
14 J: ['..###', '...#.', '...#.', '...#.', '#..#.', '#..#.', '.##..'],
15 K: ['#...#', '#..#.', '#.#..', '##...', '#.#..', '#..#.', '#...#'],
16 L: ['#....', '#....', '#....', '#....', '#....', '#....', '#####'],
17 M: ['#...#', '##.##', '#.#.#', '#.#.#', '#...#', '#...#', '#...#'],
18 N: ['#...#', '##..#', '#.#.#', '#..##', '#...#', '#...#', '#...#'],
19 O: ['.###.', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
20 P: ['####.', '#...#', '#...#', '####.', '#....', '#....', '#....'],
21 Q: ['.###.', '#...#', '#...#', '#...#', '#.#.#', '#..#.', '.##.#'],
22 R: ['####.', '#...#', '#...#', '####.', '#.#..', '#..#.', '#...#'],
23 S: ['.####', '#....', '#....', '.###.', '....#', '....#', '####.'],
24 T: ['#####', '..#..', '..#..', '..#..', '..#..', '..#..', '..#..'],
25 U: ['#...#', '#...#', '#...#', '#...#', '#...#', '#...#', '.###.'],
26 V: ['#...#', '#...#', '#...#', '#...#', '#...#', '.#.#.', '..#..'],
27 W: ['#...#', '#...#', '#...#', '#.#.#', '#.#.#', '##.##', '#...#'],
28 X: ['#...#', '#...#', '.#.#.', '..#..', '.#.#.', '#...#', '#...#'],
29 Y: ['#...#', '#...#', '.#.#.', '..#..', '..#..', '..#..', '..#..'],
30 Z: ['#####', '....#', '...#.', '..#..', '.#...', '#....', '#####'],
31 '0': ['.###.', '#...#', '#..##', '#.#.#', '##..#', '#...#', '.###.'],
32 '1': ['.#.', '##.', '.#.', '.#.', '.#.', '.#.', '###'],
33 '2': ['.###.', '#...#', '....#', '...#.', '..#..', '.#...', '#####'],
34 '3': ['####.', '....#', '....#', '.###.', '....#', '....#', '####.'],
35 '4': ['...#.', '..##.', '.#.#.', '#..#.', '#####', '...#.', '...#.'],
36 '5': ['#####', '#....', '####.', '....#', '....#', '#...#', '.###.'],
37 '6': ['.###.', '#....', '#....', '####.', '#...#', '#...#', '.###.'],
38 '7': ['#####', '....#', '...#.', '..#..', '.#...', '.#...', '.#...'],
39 '8': ['.###.', '#...#', '#...#', '.###.', '#...#', '#...#', '.###.'],
40 '9': ['.###.', '#...#', '#...#', '.####', '....#', '....#', '.###.'],
41 '!': ['#', '#', '#', '#', '#', '.', '#'],
42 '?': ['.###.', '#...#', '....#', '...#.', '..#..', '.....', '..#..'],
43 '.': ['.', '.', '.', '.', '.', '.', '#'],
44 ',': ['..', '..', '..', '..', '..', '.#', '#.'],
45 ':': ['.', '#', '.', '.', '.', '#', '.'],
46 "'": ['#', '#', '.', '.', '.', '.', '.'],
47 '-': ['....', '....', '....', '####', '....', '....', '....'],
48 '/': ['....#', '....#', '...#.', '..#..', '.#...', '#....', '#....'],
49 '%': ['##...', '##..#', '...#.', '..#..', '.#...', '#..##', '...##'],
50 ' ': ['...', '...', '...', '...', '...', '...', '...'],
51}
52
53export const GLYPH_H = 7
54
55const glyph = (ch: string) => GLYPHS[ch] ?? GLYPHS[ch.toUpperCase()] ?? GLYPHS['?']!
56
57export const textWidth = (text: string, scale = 1): number =>
58 [...text].reduce((w, ch) => w + (glyph(ch)[0]!.length + 1) * scale, 0) - scale
59
60export type TextStyle = {
61 color: number
62 /** The 1px outline drawn all around each stroke. */
63 outline?: number
64 /** A drop shadow below and right. */
65 shadow?: number
66 scale?: number
67 /** How many characters show, for typewriter wipes; all when absent. */
68 shown?: number
69 /** Per-character vertical offset, for bounces and waves. */
70 lift?: (index: number) => number
71 /** Per-character color, overriding `color`. */
72 ink?: (index: number) => number
73}
74
75const stamp = (c: Canvas, text: string, x0: number, y0: number, s: number, style: TextStyle, pass: 'shadow' | 'outline' | 'fill') => {
76 let x = x0
77 const shown = style.shown ?? Infinity
78 ;[...text].forEach((ch, i) => {
79 const g = glyph(ch)
80 if (i < shown) {
81 const lift = style.lift?.(i) ?? 0
82 const color = pass === 'fill' ? (style.ink?.(i) ?? style.color) : pass === 'shadow' ? style.shadow! : style.outline!
83 g.forEach((row, gy) => {
84 for (let gx = 0; gx < row.length; gx++) {
85 if (row[gx] !== '#') continue
86 const px = x + gx * s
87 const py = y0 + gy * s + lift
88 if (pass === 'fill') c.rect(px, py, s, s, color)
89 else if (pass === 'shadow') c.rect(px + s, py + s, s, s, color)
90 else for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1], [-1, -1], [1, 1], [-1, 1], [1, -1]] as const) c.rect(px + dx, py + dy, s, s, color)
91 }
92 })
93 }
94 x += (g[0]!.length + 1) * s
95 })
96}
97
98export const drawText = (c: Canvas, text: string, x: number, y: number, style: TextStyle) => {
99 const s = style.scale ?? 1
100 if (style.shadow !== undefined) stamp(c, text, x, y, s, style, 'shadow')
101 if (style.outline !== undefined) stamp(c, text, x, y, s, style, 'outline')
102 stamp(c, text, x, y, s, style, 'fill')
103}
104
105export const drawCentered = (c: Canvas, text: string, cx: number, y: number, style: TextStyle) =>
106 drawText(c, text, Math.round(cx - textWidth(text, style.scale ?? 1) / 2), y, style)
107hooks/engine/scenery.ts 155 lines1import type { Canvas } from './canvas'
2import { C, dither, mix, ramp } from './palette'
3import { fbm1, hash, noise1, noise2 } from './rng'
4
5// The world behind the fight. Everything is computed per canvas pixel from
6// world coordinates (pixel / scale), so pixel mode gets finer gradients and
7// ridgelines for free while sprites stay crisp at their own scale.
8
9export type Weather = {
10 /** 0..1: how hard the beam is pushing. */
11 power: number
12 /** 0..1: calm night to blood-red storm. */
13 storm: number
14 /** 0..1: lightning or explosion flash. */
15 flash: number
16 t: number
17 /** The canvas scale: canvas pixels per world pixel. */
18 scale: number
19 /** World x of the hero, where cracks and the crater center. */
20 heroX: number
21 /** 0..1: how far the ground has split. */
22 cracks: number
23 crater: boolean
24 /** World-pixel camera offset, for parallax. */
25 camX: number
26}
27
28const CALM = [C.night0, C.night1, C.night2, C.night3] as const
29const STORM = [C.storm0, C.storm1, C.storm2, C.storm3] as const
30
31export const drawSky = (c: Canvas, groundY: number, w: Weather) => {
32 const gy = groundY * w.scale
33 // One ramp blended from calm to storm, with in-between steps, so the
34 // dither only ever steps between neighbouring shades.
35 const steps: number[] = []
36 for (let i = 0; i < CALM.length; i++) {
37 const here = mix(CALM[i]!, STORM[i]!, w.storm)
38 if (i > 0) steps.push(mix(steps[steps.length - 1]!, here, 0.5))
39 steps.push(here)
40 }
41 for (let y = 0; y < Math.min(c.h, gy); y++) {
42 const v = y / gy
43 for (let x = 0; x < c.w; x++) c.px[y * c.w + x] = ramp(steps, v, x, y)
44 }
45
46 // Stars fade out as the storm comes in.
47 const stars = Math.floor((c.w / w.scale) * groundY * 0.03)
48 for (let i = 0; i < stars; i++) {
49 const sx = hash(i, 11) * c.w
50 const sy = hash(i, 12) * gy * 0.8
51 const tw = noise1(w.t * 2.5 + i * 7.3, 5)
52 if (hash(i, 13) < w.storm * 1.2) continue
53 c.set(sx - w.camX * 0.1 * w.scale, sy, tw > 0.6 ? C.star : C.starDim)
54 }
55
56 // Two cloud bands drifting at different speeds.
57 for (const [layer, speed, top, depth] of [[0, 2, 0.08, 0.18], [1, 5, 0.22, 0.16]] as const) {
58 const y0 = Math.floor(gy * top)
59 const y1 = Math.floor(gy * (top + depth))
60 for (let y = y0; y < y1; y++) {
61 const band = 1 - Math.abs((y - y0) / (y1 - y0) - 0.5) * 2
62 for (let x = 0; x < c.w; x++) {
63 const n = noise2((x / w.scale + w.t * speed - w.camX * (0.2 + layer * 0.2)) * 0.05, (y / w.scale) * 0.25, 20 + layer)
64 const k = (n - 0.55) * 3 * band
65 if (k <= 0) continue
66 const ink = dither(layer === 0 ? C.night2 : C.night3, layer === 0 ? C.storm1 : C.storm2, w.storm, x, y)
67 c.glow(x, y, ink, Math.min(0.8, k))
68 }
69 }
70 }
71}
72
73const ridge = (wx: number, seed: number, base: number, amp: number) => base - fbm1(wx * 0.045, seed) * amp
74
75export const drawMountains = (c: Canvas, groundY: number, w: Weather) => {
76 const s = w.scale
77 const H = groundY
78 for (let x = 0; x < c.w; x++) {
79 const wx = x / s
80 const far = ridge(wx + w.camX * 0.3, 31, H - H * 0.3, H * 0.35)
81 const near = ridge(wx * 1.4 + 40 + w.camX * 0.6, 47, H - H * 0.1, H * 0.25)
82 for (let y = Math.max(0, Math.floor(far * s)); y < groundY * s; y++) {
83 const wy = y / s
84 const isNear = wy >= near
85 const edge = isNear ? wy - near : wy - far
86 let color = isNear ? dither(C.near0, C.near1, edge < 2 ? 0.7 : 0.15, x, y) : dither(C.far0, C.far1, edge < 1.5 ? 0.8 : 0.2, x, y)
87 // The beam lights the ridges.
88 if (edge < 1.2) color = mix(color, C.beam2, w.power * 0.45)
89 if (w.storm > 0) color = mix(color, C.storm1, w.storm * 0.3)
90 c.px[y * c.w + x] = color
91 }
92 }
93}
94
95export const drawGround = (c: Canvas, groundY: number, w: Weather, beamX0: number, beamX1: number) => {
96 const s = w.scale
97 const gy = groundY * s
98 for (let y = gy; y < c.h; y++) {
99 const depth = (y - gy) / Math.max(1, c.h - gy)
100 for (let x = 0; x < c.w; x++) {
101 const n = noise2(x / s / 3, y / s / 2, 9)
102 let color = y < gy + s ? C.soil3 : ramp([C.soil2, C.soil1, C.soil0], depth + (n - 0.5) * 0.5, x, y)
103 if (n > 0.78 && y > gy + s) color = C.soil2
104 const wx = x / s
105 if (wx > beamX0 && wx < beamX1) color = mix(color, C.beam2, w.power * 0.35 * (1 - depth))
106 c.px[y * c.w + x] = color
107 }
108 }
109
110 if (w.cracks > 0) drawCracks(c, groundY, w)
111 if (w.crater) drawCrater(c, groundY, w)
112}
113
114const drawCracks = (c: Canvas, groundY: number, w: Weather) => {
115 const s = w.scale
116 for (let k = 0; k < 6; k++) {
117 let x = w.heroX + (hash(k, 3) - 0.5) * 8
118 let y = groundY + 0.5
119 const dir = k % 2 === 0 ? -1 : 1
120 const len = 6 + hash(k, 4) * 16
121 const reach = len * w.cracks
122 for (let i = 0; i < reach; i++) {
123 x += dir * (0.8 + hash(k, i) * 0.6)
124 y += (hash(k, i, 2) - 0.35) * 0.9
125 if (y < groundY) y = groundY + 0.3
126 const glow = 0.5 + 0.5 * noise1(w.t * 6 + k * 3 + i * 0.2, 8)
127 c.rect(x * s, y * s, Math.max(1, s), Math.max(1, s), mix(C.soil0, C.crack, glow * Math.min(1, w.cracks * 1.5)))
128 }
129 }
130}
131
132const drawCrater = (c: Canvas, groundY: number, w: Weather) => {
133 const s = w.scale
134 const cx = w.heroX * s
135 const rx = 13 * s
136 const ry = 2.5 * s
137 for (let y = Math.floor(groundY * s - ry * 0.4); y < groundY * s + ry; y++)
138 for (let x = Math.floor(cx - rx - s); x < cx + rx + s; x++) {
139 const d = Math.hypot((x - cx) / rx, (y - groundY * s) / ry)
140 if (d < 1) c.set(x, y, d > 0.82 ? C.soil3 : dither(C.soil0, C.ink, 0.6 - d * 0.4, x, y))
141 }
142}
143
144/** The whole screen washed toward white: lightning, impacts, the white-out. */
145export const flashOver = (c: Canvas, k: number, color: number = C.white) => {
146 if (k <= 0.02) return
147 if (k >= 0.98) {
148 c.px.fill(color)
149 return
150 }
151 // A smooth wash, not a dither: flashes are brief and a pattern reads as noise.
152 const q = Math.round(k * 12) / 12
153 for (let i = 0; i < c.px.length; i++) c.px[i] = mix(c.px[i]!, color, q)
154}
155