SLOPSHOPPER

kamehameha

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…

newpanecommandtoaststatustimer
v0.2.1MITupdated 2026-10-07Aurumdev952/claude-code-kamehameha
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kamehameha
│ ┃ Kamehameha ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ "Ka... me... ha... me..." │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /kamehameha │ ⎿ kamehameha: Context 49%: "Meh. Barely warm." │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Kamehameha
▣ "Ka... me... ha... me..."
README

⚡ KAMEHAMEHA ⚡

Your Claude Code context window, as a pixel-art beam struggle.

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

Claude Code Mod FPS Tokens License


🎮 Install

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 fight

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!!

✨ What makes it tick

  • Up to 60 fps, driven by real time. A self-pacing frame loop and a simulation that runs on elapsed seconds, so the motion looks the same at any frame rate.
  • Game feel.
  • The beam size follows a spring, so it surges past a new value and settles.
  • Impacts get hit-stop and decaying screen shake.
  • The game-over surge plays in slow motion.
  • Pose changes dissolve with a dither instead of snapping.
  • A particle system. Clash sparks with motion streaks, rocks lifting off the ground, ki motes spiralling into the charge, dust, sweat, smoke, embers and healing sparkles.
  • A layered beam. Scrolling energy texture, rings of light racing toward the hero, a flickering white core, a soft glow, the swirling charge ball, and a clash point where the hero's golden ki shield throws rays.
  • Real pixel art.
  • Hand-drawn characters, with outlines, rim light from the beam and shadows added automatically.
  • Swappable facial expressions.
  • A scarf blown back by the beam.
  • One curated palette with ordered dithering.
  • A world. A sky going from calm night to blood-red storm, drifting clouds, two layers of mountains, ground that cracks open, and a crater.
  • Two renderers.
TerminalRendererWhat you get
kitty, Ghosttypixels (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>

⌨️ Commands

CommandDoes
/kamehamehaOpen the showdown pane
/kamehameha demoPlay the whole story: charge, fight, GAME OVER, senzu bean (about 30 s)
/kamehameha 80Preview any percentage
/kamehameha liveGo back to your real context usage
/kamehameha pixels / cellsSwitch renderer for this session
/kamehameha fps 30Change the frame rate for this session
/kamehameha statsLive 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.

⚙️ Settings

Set these in /plugin (or under pluginConfigs.kamehameha in settings):

SettingDefault
rendererautoauto, cells or pixels
fps6010 to 60. Lower it to save CPU: 30 looks smooth too. The pixels renderer is capped at 30 (see below).
soundfalseCharge, beam, impact, explosion, heal and scouter sounds. macOS only: Claude Code has no audio player on Linux or Windows.

📊 Performance

Measured live in Claude Code 2.1.293 with a 79×31-cell docked pane, using /kamehameha stats:

TerminalRendererCalm (30%)Peak (85–93%)Draw time
WezTermcells60 fps50–57 fps1.3–3 ms
kittypixels30 fps30 fps6–13 ms
  • Why cells dips at peak: the limit there is how many terminal cells change per frame, not the drawing itself.
  • Why 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.
  • CPU: an animated pane costs real CPU, so set fps to 30 if your machine is busy. With the pane closed, the fight keeps running ten times a second, with no drawing.

🔬 How it works

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
  • Zero tokens. It reads the status line's context figures and never calls the model.
  • Small footprint. No network, no processes and no file writes. Mods run with your permissions, so check that claim: 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

🛠️ Hack on it

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
PathWhat's inside
hooks/register.tsxThe mod: context tracking, the frame loop, renderer choice, pane, command, toasts, sounds
hooks/engine/director.tsThe fight as a simulation: spring, stages, hit-stop, the charge, game-over and recovery timelines, callouts
hooks/engine/render.tsLayout, the layers in order, camera, titles, both encoders
hooks/engine/sprites.tsCharacter maps, faces, auto outline, rim light and shadow, pose dissolve, aura
hooks/engine/beam.ts · scenery.ts · particles.tsThe beam, the world, the particles
hooks/engine/palette.ts · font.ts · rng.ts · canvas.tsPalette 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.

⚖️ Disclaimer

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.

📄 License

MIT

Source 11 files
hooks/register.tsx 384 lines
1import 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}
384
hooks/engine/director.ts 355 lines
1import { 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}
355
hooks/engine/render.ts 365 lines
1import { 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 }
365
hooks/engine/palette.ts 95 lines
1// 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}
95
hooks/engine/particles.ts 158 lines
1import 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}
158
hooks/engine/rng.ts 61 lines
1// 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
61
hooks/engine/sprites.ts 467 lines
1import { 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}
467
hooks/engine/beam.ts 112 lines
1import 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}
112
hooks/engine/canvas.ts 87 lines
1import { 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
87
hooks/engine/font.ts 107 lines
1import 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)
107
hooks/engine/scenery.ts 155 lines
1import 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