zx-kit 0.32.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,27 +68,6 @@ A white hero walking past a green plant stays white under the **default** render
68
68
 
69
69
  ---
70
70
 
71
- ## Examples
72
-
73
- **[Minefield — ZX Spectrum Minesweeper](https://zrebec.github.io/minefield/)** — live demo built entirely with zx-kit.
74
-
75
- The repository also includes small static examples that import `../../dist/index.js`
76
- directly, so each one doubles as a browser-checkable API recipe:
77
-
78
- | Example | Shows |
79
- |---------|-------|
80
- | `examples/ay-music/` | AY channels A/B/C plus beeper SFX as a four-voice Spectrum-style setup |
81
- | `examples/pixel-collision/` | AABB false positives vs `bitmapPixelMask()` / `masksOverlap()` |
82
- | `examples/particles/` | Allocation-free particle pools for sparks, smoke, and explosions |
83
- | `examples/i18n-runtime/` | Runtime language switching with `pickLocale()` and persisted preference |
84
- | `examples/bitmap-attrs/` | `Bitmap`, `AttrMap`, mirroring, colour clash, and `inkOnly` rendering |
85
- | `examples/save-slots/` | Save profiles, auto/manual slots, latest-slot restore, throttling, and delete |
86
-
87
- Build first with `npm run build`, then serve the repository root and open any
88
- example path in the browser.
89
-
90
- ---
91
-
92
71
  ## Installation
93
72
 
94
73
  ### From npm (recommended)
@@ -175,2749 +154,53 @@ requestAnimationFrame(loop)
175
154
 
176
155
  ---
177
156
 
178
- ## Getting Started — Build Your First Game
179
-
180
- This tutorial walks you through building a working game from scratch: a character you can move around the screen with arrow keys, animated walking frames, and a sound effect on every step.
181
-
182
- No prior game development experience needed. You need basic JavaScript/TypeScript knowledge (variables, functions, arrays).
183
-
184
- ---
185
-
186
- ### What you will need
187
-
188
- | Tool | Where to get it | Why |
189
- |------|----------------|-----|
190
- | **Node.js 22+** | [nodejs.org](https://nodejs.org) | Runs npm — the package manager we use to install zx-kit |
191
- | **A code editor** | [code.visualstudio.com](https://code.visualstudio.com) (free) | Edits your source files |
192
- | **A terminal** | Built into macOS/Linux; use PowerShell on Windows | Runs commands |
193
-
194
- ---
195
-
196
- ### Step 1 — Create the project
197
-
198
- Open a terminal and run:
199
-
200
- ```bash
201
- mkdir my-first-game
202
- cd my-first-game
203
- npm init -y
204
- ```
205
-
206
- `npm init -y` creates a `package.json` file — the project's identity card. The `-y` flag accepts all defaults so you don't have to answer questions.
207
-
208
- ---
209
-
210
- ### Step 2 — Install dependencies
211
-
212
- ```bash
213
- npm install zx-kit
214
- npm install --save-dev vite
215
- ```
216
-
217
- - **zx-kit** — the game engine you are building with
218
- - **vite** — a development server that reloads the browser whenever you save a file (installed as a dev tool, not part of your shipped game)
219
-
220
- ---
221
-
222
- ### Step 3 — Configure package.json
223
-
224
- Open `package.json` and replace it with the following. The two key additions are `"type": "module"` (enables modern JavaScript imports) and the `scripts` section (adds the `npm run dev` command):
225
-
226
- ```json
227
- {
228
- "name": "my-first-game",
229
- "version": "1.0.0",
230
- "type": "module",
231
- "scripts": {
232
- "dev": "vite",
233
- "build": "vite build"
234
- },
235
- "dependencies": {
236
- "zx-kit": "^0.31.1"
237
- },
238
- "devDependencies": {
239
- "vite": "^6.0.0"
240
- }
241
- }
242
- ```
243
-
244
- ---
245
-
246
- ### Step 4 — Create index.html
247
-
248
- Create a file called `index.html` in your project root:
249
-
250
- ```html
251
- <!DOCTYPE html>
252
- <html lang="en">
253
- <head>
254
- <meta charset="UTF-8" />
255
- <title>My First Game</title>
256
- <style>
257
- body {
258
- margin: 0;
259
- background: #000;
260
- display: flex;
261
- align-items: center;
262
- justify-content: center;
263
- height: 100vh;
264
- }
265
- </style>
266
- </head>
267
- <body>
268
- <canvas id="game"></canvas>
269
- <script type="module" src="/src/main.ts"></script>
270
- </body>
271
- </html>
272
- ```
273
-
274
- The `<canvas>` is the game screen. The `<script>` tag loads your game code.
275
-
276
- ---
277
-
278
- ### Step 5 — Create the game
279
-
280
- Create the folder `src/` and inside it a file called `main.ts`. We will build it one piece at a time.
281
-
282
- ---
283
-
284
- #### 5a — Canvas setup
285
-
286
- A canvas element is just a rectangle of pixels in the browser. `setupCanvas` configures it for pixel-perfect ZX Spectrum-style rendering and returns a drawing context you use to paint everything.
287
-
288
- ```ts
289
- import { setupCanvas, C, CELL } from 'zx-kit'
290
-
291
- const canvas = document.getElementById('game') as HTMLCanvasElement
292
- const ctx = setupCanvas(canvas, 4)
293
- // The screen is 256 × 192 game pixels, scaled up 4× in the browser.
294
- // From here on draw everything in game pixels — setupCanvas handles the scale.
295
- ```
296
-
297
- `C` is the color palette. `CELL` is the size of one sprite: 8 pixels.
298
-
299
- ---
300
-
301
- #### 5b — Define a sprite
302
-
303
- Every character and object in zx-kit is an 8×8 pixel bitmap. You define it as 8 numbers, one per row. Each number is 8 bits — one bit per pixel. Bit 7 is the leftmost pixel.
304
-
305
- The binary literal `0b00111100` is the same as the number `60`, but written out as ones and zeros so you can see the pixel pattern directly.
306
-
307
- We will use two walking frames so the character animates when it moves:
308
-
309
- ```ts
310
- // 76543210 ← bit position (7 = leftmost pixel)
311
- const WALK_A = new Uint8Array([
312
- 0b00111100, // ..####.. head
313
- 0b01111110, // .######.
314
- 0b00011000, // ...##... neck
315
- 0b01111110, // .######. arms + body
316
- 0b00011000, // ...##... waist
317
- 0b01011010, // .#.##.#. legs apart
318
- 0b01000010, // .#....#. feet
319
- 0b00000000, // ........
320
- ])
321
-
322
- const WALK_B = new Uint8Array([
323
- 0b00111100, // ..####.. head
324
- 0b01111110, // .######.
325
- 0b00011000, // ...##... neck
326
- 0b01111110, // .######. arms + body
327
- 0b00011000, // ...##... waist
328
- 0b00111100, // ..####.. legs together
329
- 0b00011000, // ...##...
330
- 0b00000000, // ........
331
- ])
332
-
333
- const FRAMES = [WALK_A, WALK_B] // frame 0 = legs apart, frame 1 = legs together
334
- ```
335
-
336
- ---
337
-
338
- #### 5c — Input
339
-
340
- `initInput()` attaches keyboard listeners. Call it once at startup, not inside the game loop.
341
-
342
- `isHeld(key)` returns `true` while a key is pressed. We use it to check whether an arrow key is being held down each frame.
343
-
344
- ```ts
345
- import { initInput, isHeld } from 'zx-kit'
346
-
347
- initInput()
348
- ```
349
-
350
- ---
351
-
352
- #### 5d — Audio
353
-
354
- Browsers will not play any sound until the user has interacted with the page (clicked, tapped, or pressed a key). This is a browser security rule — there is nothing we can do to bypass it. The pattern below waits for the first keydown and initialises audio then:
355
-
356
- ```ts
357
- import { initAudio, getAudioContext, resumeAudio, beep } from 'zx-kit'
358
-
359
- window.addEventListener('keydown', () => initAudio(0.3), { once: true })
360
- // initAudio(0.3) = master volume 30%. Called at most once thanks to { once: true }.
361
- ```
362
-
363
- ---
364
-
365
- #### 5e — Player state and animation
366
-
367
- ```ts
368
- import { createAnimation, tickAnimation } from 'zx-kit'
369
-
370
- let px = 120 // player x position in game pixels
371
- let py = 88 // player y position
372
- const SPEED = 60 // pixels per second
373
-
374
- // A 2-frame looping animation, 150ms per frame = one full step every 300ms
375
- const walkAnim = createAnimation(2, 150)
376
- let stepTimer = 0 // footstep sound timer
377
- ```
378
-
379
- ---
380
-
381
- #### 5f — The game loop
382
-
383
- Every frame the browser calls `loop`. Inside, we:
384
-
385
- 1. Calculate `dt` — how many milliseconds passed since last frame
386
- 2. Move the player based on held keys
387
- 3. Keep the player inside the screen
388
- 4. Pick the right animation frame
389
- 5. Play a footstep sound periodically while moving
390
- 6. Clear the screen and redraw everything
391
-
392
- ```ts
393
- import { drawSprite, drawText } from 'zx-kit'
394
-
395
- let lastTime = 0
396
-
397
- function loop(now: number): void {
398
- const dt = Math.min(now - lastTime, 100)
399
- // dt is milliseconds since the last frame (usually ~16ms at 60fps).
400
- // We cap at 100ms so a background tab returning doesn't cause a huge position jump.
401
- lastTime = now
402
-
403
- // ── Move player ────────────────────────────────────────────────────────────
404
- let moving = false
405
- if (isHeld('ArrowRight')) { px += SPEED * dt / 1000; moving = true }
406
- if (isHeld('ArrowLeft')) { px -= SPEED * dt / 1000; moving = true }
407
- if (isHeld('ArrowDown')) { py += SPEED * dt / 1000; moving = true }
408
- if (isHeld('ArrowUp')) { py -= SPEED * dt / 1000; moving = true }
409
- // SPEED (60) is pixels per second. dt is milliseconds. Divide by 1000 to convert.
410
-
411
- // Keep inside the 256×192 canvas
412
- px = Math.max(0, Math.min(256 - CELL, px))
413
- py = Math.max(0, Math.min(192 - CELL, py))
414
-
415
- // ── Animation ──────────────────────────────────────────────────────────────
416
- // tickAnimation advances the timer and returns the current frame index (0 or 1).
417
- // When standing still we always use frame 0.
418
- const frame = moving ? tickAnimation(walkAnim, dt) : 0
419
-
420
- // ── Footstep sound ─────────────────────────────────────────────────────────
421
- if (moving) {
422
- stepTimer -= dt
423
- if (stepTimer <= 0) {
424
- stepTimer = 250 // play a sound every 250ms while moving
425
- const audio = getAudioContext()
426
- if (audio) {
427
- resumeAudio() // un-suspend if the tab was hidden
428
- beep(220, 30, audio.currentTime) // 220 Hz, 30ms — a short thud
429
- }
430
- }
431
- } else {
432
- stepTimer = 0 // reset so next movement starts immediately
433
- }
434
-
435
- // ── Draw ───────────────────────────────────────────────────────────────────
436
- ctx.fillStyle = C.BLACK
437
- ctx.fillRect(0, 0, 256, 192) // clear the whole screen
438
-
439
- drawSprite(ctx, FRAMES[frame], Math.round(px), Math.round(py), C.B_CYAN, C.BLACK)
440
- // ↑ position ↑ ink color ↑ paper (background)
441
-
442
- drawText(ctx, 'ARROW KEYS = MOVE', 8, 184, C.WHITE)
443
- // drawText draws one ASCII character per 8px slot, left-to-right.
444
-
445
- requestAnimationFrame(loop) // ask the browser to call us again next frame
446
- }
447
-
448
- requestAnimationFrame(loop) // kick off the first frame
449
- ```
450
-
451
- ---
452
-
453
- ### Step 6 — Run the game
454
-
455
- ```bash
456
- npm run dev
457
- ```
458
-
459
- Open [http://localhost:5173](http://localhost:5173) in your browser. Press an arrow key. Your character walks.
460
-
461
- ---
462
-
463
- ### Complete file
464
-
465
- The full `src/main.ts` all in one place:
466
-
467
- ```ts
468
- import {
469
- setupCanvas, C, CELL,
470
- drawSprite, drawText,
471
- initInput, isHeld,
472
- initAudio, getAudioContext, resumeAudio, beep,
473
- createAnimation, tickAnimation,
474
- } from 'zx-kit'
475
-
476
- // ── Canvas ────────────────────────────────────────────────────────────────────
477
- const canvas = document.getElementById('game') as HTMLCanvasElement
478
- const ctx = setupCanvas(canvas, 4)
479
-
480
- // ── Sprites ───────────────────────────────────────────────────────────────────
481
- const WALK_A = new Uint8Array([
482
- 0b00111100, // ..####.. head
483
- 0b01111110, // .######.
484
- 0b00011000, // ...##... neck
485
- 0b01111110, // .######. arms + body
486
- 0b00011000, // ...##... waist
487
- 0b01011010, // .#.##.#. legs apart
488
- 0b01000010, // .#....#. feet
489
- 0b00000000, // ........
490
- ])
491
-
492
- const WALK_B = new Uint8Array([
493
- 0b00111100, // ..####.. head
494
- 0b01111110, // .######.
495
- 0b00011000, // ...##... neck
496
- 0b01111110, // .######. arms + body
497
- 0b00011000, // ...##... waist
498
- 0b00111100, // ..####.. legs together
499
- 0b00011000, // ...##...
500
- 0b00000000, // ........
501
- ])
502
-
503
- const FRAMES = [WALK_A, WALK_B]
504
-
505
- // ── Input ─────────────────────────────────────────────────────────────────────
506
- initInput()
507
-
508
- // ── Audio ─────────────────────────────────────────────────────────────────────
509
- window.addEventListener('keydown', () => initAudio(0.3), { once: true })
510
-
511
- // ── Player state ──────────────────────────────────────────────────────────────
512
- let px = 120
513
- let py = 88
514
- const SPEED = 60 // pixels per second
515
-
516
- const walkAnim = createAnimation(2, 150)
517
- let stepTimer = 0
518
-
519
- // ── Game loop ─────────────────────────────────────────────────────────────────
520
- let lastTime = 0
521
-
522
- function loop(now: number): void {
523
- const dt = Math.min(now - lastTime, 100)
524
- lastTime = now
525
-
526
- let moving = false
527
- if (isHeld('ArrowRight')) { px += SPEED * dt / 1000; moving = true }
528
- if (isHeld('ArrowLeft')) { px -= SPEED * dt / 1000; moving = true }
529
- if (isHeld('ArrowDown')) { py += SPEED * dt / 1000; moving = true }
530
- if (isHeld('ArrowUp')) { py -= SPEED * dt / 1000; moving = true }
531
-
532
- px = Math.max(0, Math.min(256 - CELL, px))
533
- py = Math.max(0, Math.min(192 - CELL, py))
534
-
535
- const frame = moving ? tickAnimation(walkAnim, dt) : 0
536
-
537
- if (moving) {
538
- stepTimer -= dt
539
- if (stepTimer <= 0) {
540
- stepTimer = 250
541
- const audio = getAudioContext()
542
- if (audio) { resumeAudio(); beep(220, 30, audio.currentTime) }
543
- }
544
- } else {
545
- stepTimer = 0
546
- }
547
-
548
- ctx.fillStyle = C.BLACK
549
- ctx.fillRect(0, 0, 256, 192)
550
-
551
- drawSprite(ctx, FRAMES[frame], Math.round(px), Math.round(py), C.B_CYAN, C.BLACK)
552
- drawText(ctx, 'ARROW KEYS = MOVE', 8, 184, C.WHITE)
553
-
554
- requestAnimationFrame(loop)
555
- }
556
-
557
- requestAnimationFrame(loop)
558
- ```
559
-
560
- ---
561
-
562
- ### What to try next
563
-
564
- **Change the sprite.** Edit the binary rows in `WALK_A` / `WALK_B` — each `1` is a pixel, each `0` is background. Draw a spaceship, a gem, or a face.
157
+ ## Documentation
565
158
 
566
- **Change the color.** Replace `C.B_CYAN` with any palette color: `C.B_GREEN`, `C.B_YELLOW`, `C.B_RED`, `C.B_MAGENTA`, `C.B_WHITE`. The full list is in the [palette reference](#palettets--color-constants).
159
+ | Guide | What's inside |
160
+ |-------|---------------|
161
+ | [Getting started](docs/getting-started.md) | Project setup + a complete first game, start to finish. |
162
+ | [Rendering](docs/rendering.md) | Canvas, sprites, bitmaps, attr/mono screens, cache, scrolling, lighting, palette, font. |
163
+ | [Audio](docs/audio.md) | Beeper vs AY, the AY-3-8912 emulator, note-name music. |
164
+ | [Collision](docs/collision.md) | AABB, rect-vs-tile, pixel-precise — when and how. |
165
+ | [Save / load](docs/save.md) | Typed `localStorage` saves with versioning and migration. |
166
+ | [API reference](docs/api.md) | Input, sprites, animation, camera, scenes, tilemap, UI, particles, RNG, i18n, presentation. |
167
+ | [Examples](docs/examples.md) | Runnable snippets + the flagship games. |
168
+ | [API stability](docs/api-stability.md) | What's Stable vs Experimental, the deprecation policy, and the road to 1.0. |
567
169
 
568
- **Add a second character.** Copy the player variables (`px2`, `py2`, `walkAnim2`) and add `W A S D` controls using `isHeld('w')` etc.
569
-
570
- **Add obstacles.** Use `createTileMap` to place solid wall tiles and `resolveX` / `resolveY` to stop the player at them.
571
-
572
- **Add chiptune music.** Call `playAY()` with a note array to play a three-channel melody — see [`ay.ts`](#ayts--ay-3-8912-melodik-audio).
573
-
574
- **Study a complete game.** [Minefield](https://github.com/zrebec/minefield) is built entirely with zx-kit. Every mechanic in this tutorial — sprites, input, animation, audio, tilemap — appears there in a production context.
575
-
576
- ---
170
+ Background: [retrospective](docs/retrospective.md) · [debug / telemetry analysis](docs/analyza-debug.md).
577
171
 
578
172
  ## Modules
579
173
 
580
- | Module | What it provides |
581
- |--------|-----------------|
582
- | [`ay.ts`](#ayts--ay-3-8912-melodik-audio) | AY chip emulator: 3-channel tone, LFSR noise, 16 envelope shapes |
583
- | [`renderer.ts`](#rendererts--canvas-renderer) | Canvas setup, 8x8 sprites, arbitrary-size bitmaps, attribute maps, text, scanlines, border flash |
584
- | [`audio.ts`](#audiots--beeper-audio) | 1-bit beeper: square-wave notes, patterns, volume control |
585
- | [`ui.ts`](#uits--ui-widgets) | Boxes, frames, panel titles, progress bars + instrumentation widgets (dotted grids, segmented bars, fluid tanks, dials, text compass) |
586
- | [`input.ts`](#inputts--keyboard--gamepad-input) | Keyboard/gamepad movement, key-repeat, action flags, state reset |
587
- | [`sprite.ts`](#spritets--free-roaming-sprites) | Sprites: position, velocity, gravity, flip, render |
588
- | [`collision.ts`](#collisionts--collision-detection) | AABB overlap + rect-based tile resolution, pixel-precise mask overlap and tile checks |
589
- | [`animation.ts`](#animationts--frame-timer--tween) | Frame-timer for sprite strips, position tween between two points |
590
- | [`camera.ts`](#camerats--scrolling-camera) | Viewport that follows a target with lerp + deadzone, world-bounds clamping |
591
- | [`scene.ts`](#scenets--scene-manager) | Stack-based scene manager with onEnter/onExit/onPause/onResume hooks |
592
- | [`save.ts`](#savets--typed-save--load) | Typed save/load via callbacks, versioning + migrations, slot enumeration, throttling, Result types |
593
- | [`tilemap.ts`](#tilemapts--tile-map-engine) | Scrollable maps, solid tiles, O(1) id-index, background swap |
594
- | [`tilescroll.ts`](#tilescrollts--pixel-smooth-scrolling) | Pixel-smooth tile-map rendering at any camera position (sub-tile scroll) |
595
- | [`particles.ts`](#particlests--particle-pool) | Allocation-free particle pool for pixel effects: sparks, dust, puffs |
596
- | [`rng.ts`](#rngts--seeded-rng) | Seeded deterministic PRNG (mulberry32): int/range/float/chance/pick/shuffle/fork |
597
- | [`palette.ts`](#palettets--color-constants) | 15 Spectrum colors, `SpectrumColor` type, `CELL`, `SCALE` |
598
- | [`font.ts`](#fontts--rom-bitmap-font) | 96-character ROM font, raw bitmap access |
599
- | [`i18n.ts`](#i18nts--runtime-locale-selection) | Type-safe runtime locale selection for translated string packs |
600
- | [`lighting.ts`](#lightingts--dithered-cave-darkness) | Dithered cave darkness: pre-baked level tiles + dirty-cell buffer, one blit/frame (no per-frame putImageData) |
601
- | [`cache.ts`](#cachets--offscreen-layer-cache) | Offscreen layer cache: render a static layer once, blit each frame, `dirty`-flag invalidation |
602
- | [`attrscreen.ts`](#attrscreents--attribute-clash-opt-in) | Opt-in authentic ZX colour clash: 1-bit pixels + 32×24 per-cell ink/paper, one `putImageData`/frame |
603
- | [`monoscreen.ts`](#monoscreents--monochrome-playfield) | Opt-in monochrome playfield (own size): 1-bit mask + one ink/paper, blitted at an offset — clash-proof |
604
- | [`music.ts`](#musicts--note-name-ay-music) | Write AY music by note name (`A5`, `C#4`) and loop it for background tracks |
605
-
606
- ---
607
-
608
- ## Audio architecture beeper vs AY
609
-
610
- zx-kit ships two independent audio modules — [`audio.ts`](#audiots--beeper-audio) (the **beeper**) and [`ay.ts`](#ayts--ay-3-8912-melodik-audio) (the **AY chip**). They are **not alternatives** — most ZX Spectrum 128K games used both at once, and so should yours.
611
-
612
- ### The history (so the choice makes sense)
613
-
614
- | Hardware | Beeper (1-bit) | AY-3-8912 (3 ch) |
615
- |----------|:--:|:--:|
616
- | Spectrum 48K | ✅ built-in | ❌ |
617
- | Spectrum 128K / +2 / +3 | ✅ built-in | ✅ built-in |
618
- | Melodik add-on (for 48K) | — | ✅ |
619
-
620
- - **48K games** (Manic Miner, Jet Set Willy, Atic Atac) had only the beeper — every blip, jump, footstep and title jingle was a square wave forced out of the 1-bit speaker by tight CPU loops.
621
- - **128K games** (Robocop, R-Type, Chase H.Q., Lord of the Rings) used the AY for **music** — proper 3-channel tunes with envelope shaping — while the beeper kept doing **sound effects** in parallel. AY hummed an orchestral score; the beeper still went *pew pew*.
622
-
623
- ### When to use which
624
-
625
- | Want to play… | Module | Function | Why |
626
- |---|---|---|---|
627
- | Short SFX (shot, jump, hit, beep) | `audio.ts` | `beep(freq, dur, t)` | Single square wave, punchy, era-correct for SFX |
628
- | A 3-channel jingle / chord | `ay.ts` | `playAY({ a, b, c })` | Needs ≥2 simultaneous voices |
629
- | Game-over fanfare / level music | `ay.ts` | `playAY(...)` | Envelope shaping + multiple voices |
630
- | Single-voice melody | `audio.ts` | `playPattern(notes)` | Lighter setup, no AY init needed |
631
- | Live, dynamically-changing tone (siren, engine) | `ay.ts` | `createAY()` then `tone()` | Persistent oscillator handle |
632
- | Title-screen music | `ay.ts` | `playAY(...)` | Authentic 128K title-music feel |
633
-
634
- **Rule of thumb:** if it needs to be *heard at the same time as something else*, you almost certainly want AY for at least one of the two.
635
-
636
- ### Authentic parallel pattern — the "Robocop" pattern
637
-
638
- This is how 128K games actually sounded:
639
-
640
- ```ts
641
- import { initAudio, beep, getAudioContext, resumeAudio } from 'zx-kit' // beeper
642
- import { playAY } from 'zx-kit' // AY
643
-
644
- // One-time setup (must be inside a user gesture — click, keydown — due to browser autoplay policy)
645
- window.addEventListener('keydown', () => { initAudio(); resumeAudio() }, { once: true })
646
-
647
- // Title screen: AY plays a multi-voice melody...
648
- playAY({
649
- a: [{ freq: 523, dur: 200 }, { freq: 659, dur: 200 }, { freq: 784, dur: 400, envShape: 12, envCycleDurMs: 200 }],
650
- b: [{ freq: 262, dur: 200 }, { freq: 330, dur: 200 }, { freq: 392, dur: 400 }],
651
- })
652
-
653
- // ...meanwhile in the game loop, beeper does the SFX:
654
- function onPlayerShoots() {
655
- const audio = getAudioContext()
656
- if (audio) beep(1200, 40, audio.currentTime) // sharp pew
657
- }
658
- function onPlayerHit() {
659
- const audio = getAudioContext()
660
- if (audio) beep(120, 200, audio.currentTime) // low thump
661
- }
662
- ```
663
-
664
- Both modules route through the **same master `GainNode`**, so `setMasterVolume(v)` controls both at once. They share state cleanly — no audio bus conflicts.
665
-
666
- ### Notes on accuracy
667
-
668
- - **Beeper** (`audio.ts`) is a faithful 1-bit-style square wave via Web Audio's `OscillatorNode`. Era-correct for SFX use.
669
- - **AY** (`ay.ts`) is a *good approximation* of the AY-3-8912 — hardware-accurate logarithmic amplitudes (16 levels, ≈ √2 ratio), all 16 envelope shapes, proper LFSR noise. **Not sample-accurate**: Web Audio's `OscillatorNode` is band-limited (no aliasing artefacts), real AY's raw squares have a buzzier, fuzzier character; envelopes are smooth ramps here vs the chip's 16-step ramps. For chip-tune purists wanting bit-exact AY emulation, a future AudioWorklet-based backend is on the roadmap. For game sound and most music, the current implementation is more than convincing.
670
-
671
- ---
672
-
673
- ## `ay.ts` — AY-3-8912 Melodik Audio
674
-
675
- The AY-3-8912 chip (sold as the *Melodik* add-on for ZX Spectrum 48K, built into the 128K) gave the Spectrum three independent square-wave channels, a shared LFSR noise generator, and a hardware envelope generator with 16 distinct shapes. This module emulates all of it via the Web Audio API with hardware-accurate logarithmic amplitude values.
676
-
677
- > **Pair with [`audio.ts`](#audiots--beeper-audio) (the beeper) for sound effects.** Use AY for music, beeper for SFX — see [Audio architecture — beeper vs AY](#audio-architecture--beeper-vs-ay) for the historical context and the parallel-use pattern. Both modules share the same master gain, so `setMasterVolume()` controls them together.
678
-
679
- Two usage modes:
680
-
681
- | Mode | Function | Use case |
682
- |------|----------|----------|
683
- | **Real-time** | `createAY()` | Persistent chip handle — set channels live (SFX, dynamic music) |
684
- | **Sequencer** | `playAY(pattern)` | Pre-scheduled, fire-and-forget (music tracks, jingles) |
685
-
686
- Both modes route through the zx-kit master `GainNode`, so `setMasterVolume()` works globally.
687
-
688
- ### `AY_CLOCK`
689
-
690
- ```ts
691
- export const AY_CLOCK = 1_773_400 // Hz — ZX Spectrum 128K / Melodik
692
- ```
693
-
694
- The AY-3-8912 master clock. Exported for use in frequency calculations:
695
- `f_Hz = AY_CLOCK / (16 × period_register)`.
696
-
697
- ### `AY_VOL`
698
-
699
- ```ts
700
- export const AY_VOL: readonly number[] = [
701
- 0, 0.0089, 0.0118, 0.0156, 0.0211, 0.0289, 0.0403, 0.0549,
702
- 0.0744, 0.1060, 0.1518, 0.2139, 0.2969, 0.4259, 0.6098, 1.0,
703
- ]
704
- ```
705
-
706
- Hardware-accurate logarithmic amplitude table. Each step ≈ √2 (3 dB), matching the real chip's resistor ladder. Index 0 = silence, index 15 = full amplitude.
707
-
708
- ### `AY_ENVELOPE_SHAPES`
709
-
710
- ```ts
711
- export const AY_ENVELOPE_SHAPES: readonly string[]
712
- ```
713
-
714
- Human-readable names for all 16 R13 envelope shapes — useful for documentation, tooling, and debugging.
715
-
716
- | R13 | Shape | Description |
717
- |-----|-------|-------------|
718
- | 0–3 | `\_ ` | One-shot decay, hold at zero |
719
- | 4–7 | `/_ ` | One-shot attack, hold at zero |
720
- | 8 | `\\\\` | Repeat decay (sawtooth down) |
721
- | 9 | `\_` | One-shot decay, hold at zero |
722
- | 10 | `\/\/` | Alternate down/up (triangle) |
723
- | 11 | `\‾` | One-shot decay, hold at maximum |
724
- | 12 | `//` | Repeat attack (sawtooth up) |
725
- | 13 | `/‾` | One-shot attack, hold at maximum |
726
- | 14 | `/\/\`| Alternate up/down (triangle) |
727
- | 15 | `/_` | One-shot attack, hold at zero |
728
-
729
- ### `AYChannel` type
730
-
731
- ```ts
732
- type AYChannel = 'A' | 'B' | 'C'
733
- ```
734
-
735
- ### `AYNote` interface
736
-
737
- ```ts
738
- interface AYNote {
739
- freq: number // Hz — 0 = rest
740
- dur: number // milliseconds
741
- vol?: number // 0–15 (default 15). Ignored when envShape is set.
742
- noise?: boolean // mix LFSR noise alongside tone (default false)
743
- noisePeriod?: number // 1–31 — higher = darker texture (default 8)
744
- envShape?: number // 0–15 (R13) — activates envelope, overrides vol
745
- envCycleDurMs?: number // ms for one ramp (15→0 or 0→15). Default = note duration.
746
- }
747
- ```
748
-
749
- ### `AYChip` interface
750
-
751
- The handle returned by `createAY()`.
752
-
753
- ```ts
754
- interface AYChip {
755
- tone(ch: AYChannel, freq: number, vol?: number): void
756
- enableNoise(ch: AYChannel, period?: number): void
757
- disableNoise(ch: AYChannel): void
758
- envelope(ch: AYChannel, shape: number, cycleDurMs: number): void
759
- mute(ch: AYChannel): void
760
- muteAll(): void
761
- stop(): void
762
- }
763
- ```
764
-
765
- ### `createAY(): AYChip`
766
-
767
- Creates three persistent AY channels wired to the master gain. Each channel has:
768
- - An independent square-wave oscillator (tone)
769
- - An LFSR noise path (shared 17-bit noise source, per-channel lowpass filter and gain)
770
- - `AudioParam` automation for envelope
771
-
772
- Must be called inside a user-gesture handler.
773
-
774
- ```ts
775
- button.addEventListener('click', () => {
776
- initAudio()
777
- const ay = createAY()
778
-
779
- // Simple tone
780
- ay.tone('A', 440, 12) // channel A: A4, amplitude level 12
781
-
782
- // Tone + noise mix
783
- ay.tone('B', 220, 10)
784
- ay.enableNoise('B', 16) // darker noise (higher period = lower cutoff)
785
-
786
- // Envelope — shape 10 = \/\/ triangle, 400ms cycle
787
- ay.tone('C', 110, 0) // oscillator active but tone gain is silent
788
- ay.envelope('C', 10, 400) // envelope drives the amplitude
789
-
790
- setTimeout(() => ay.muteAll(), 3000)
791
- setTimeout(() => ay.stop(), 3500)
792
- })
793
- ```
794
-
795
- #### `ay.tone(ch, freq, vol?)`
796
-
797
- Sets the channel oscillator frequency and amplitude. `freq ≤ 0` silences the tone generator (noise can still run). `vol` maps to `AY_VOL` (0–15, default 15). Cancels any running envelope on that channel.
798
-
799
- #### `ay.enableNoise(ch, period?)`
800
-
801
- Enables LFSR noise on a channel. `period` 1–31 maps to `AY_CLOCK / (16 × period)` Hz as a lowpass cutoff on the noise path. Default period 8 → ~13 kHz (bright, crispy). Period 28 → ~4 kHz (darker, rumble-like).
802
-
803
- #### `ay.disableNoise(ch)`
804
-
805
- Fades noise out on a channel with a 5ms release.
806
-
807
- #### `ay.envelope(ch, shape, cycleDurMs)`
808
-
809
- Applies an AY hardware envelope to a channel's amplitude. `shape` 0–15 corresponds to the 16 R13 values. `cycleDurMs` is the duration of one ramp (0→15 or 15→0). Repeating shapes (8, 10, 12, 14) are pre-scheduled for 32 cycles; call again to extend.
810
-
811
- ```ts
812
- // Explosion: channel C, shape 8 (repeat decay), 60ms per cycle
813
- ay.enableNoise('C', 5)
814
- ay.envelope('C', 8, 60)
815
-
816
- // Organ: shape 13 (/‾ fast attack, hold high), 20ms attack
817
- ay.tone('A', 523, 0)
818
- ay.envelope('A', 13, 20)
819
- ```
820
-
821
- #### `ay.mute(ch)` / `ay.muteAll()`
822
-
823
- Fade out one or all channels (5ms release). Cancels any pending envelope automation.
824
-
825
- #### `ay.stop()`
826
-
827
- Stops all oscillators and the noise source, disconnects all Web Audio nodes. Call when discarding the chip instance.
828
-
829
- ---
830
-
831
- ### `playAY(pattern, startDelay?): void`
832
-
833
- Pre-schedules up to three independent note arrays on the shared `AudioContext`. All channels start at the same wall-clock time. Fire-and-forget — no handle returned. Per-note noise and envelope are fully supported.
834
-
835
- ```ts
836
- // Three-channel chiptune jingle with envelope and noise
837
- playAY({
838
- a: [
839
- { freq: 523, dur: 300, envShape: 13, envCycleDurMs: 20 }, // C5, organ attack
840
- { freq: 659, dur: 300, envShape: 13, envCycleDurMs: 20 }, // E5
841
- { freq: 784, dur: 600, envShape: 12, envCycleDurMs: 100 }, // G5, sawtooth swell
842
- ],
843
- b: [
844
- { freq: 261, dur: 600, vol: 10 }, // C4 bass note
845
- { freq: 329, dur: 600, vol: 10 }, // E4
846
- ],
847
- c: [
848
- { freq: 0, dur: 100, noise: true, noisePeriod: 5, envShape: 8, envCycleDurMs: 40 }, // snare hit
849
- { freq: 0, dur: 1100 }, // silence
850
- ],
851
- })
852
-
853
- // With a 500ms startup delay
854
- playAY({ a: melody, b: bass }, 500)
855
- ```
856
-
857
- ---
858
-
859
- ## `renderer.ts` — Canvas Renderer
860
-
861
- All drawing functions operate in **game pixels**. `setupCanvas` applies `ctx.scale(scale, scale)` so every call uses the ZX Spectrum's native coordinate space. Every ink/paper parameter is `SpectrumColor` — the compiler enforces the palette.
862
-
863
- ### `setupCanvas(canvas, scale, width?, height?): CanvasRenderingContext2D`
864
-
865
- One-call canvas initialization. Sets dimensions, CSS size, disables smoothing, applies scale transform.
866
-
867
- - `scale` — CSS pixels per game pixel. `4` = standard ZX display (256×192 → 1024×768)
868
- - `width` — game pixels wide, default `256`
869
- - `height` — game pixels tall, default `192`
870
-
871
- ```ts
872
- const ctx = setupCanvas(canvas, 4) // standard 256×192
873
- const ctx = setupCanvas(canvas, 4, 256, 208) // +2 extra rows for status bar
874
- const ctx = setupCanvas(canvas, 3) // 768×576 CSS — smaller screen
875
- ```
876
-
877
- ### `mirrorSprite(src): Uint8Array`
878
-
879
- Flips an 8-byte sprite horizontally. Returns a new `Uint8Array` — the original is not modified. The result is cache-friendly: call once and store both orientations.
880
-
881
- ```ts
882
- export const PLAYER_RIGHT = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
883
- export const PLAYER_LEFT = mirrorSprite(PLAYER_RIGHT)
884
- ```
885
-
886
- ### `drawSprite(ctx, sprite, x, y, ink, paper): void`
887
-
888
- Draws an 8×8 bitmap at game coordinates. Always paints the `paper` background first. `ink` and `paper` must be `SpectrumColor` values.
889
-
890
- ```ts
891
- drawSprite(ctx, MINE_SPRITE, col * CELL, row * CELL, C.B_RED, C.BLACK)
892
- drawSprite(ctx, GEM_SPRITE, col * CELL, row * CELL, C.B_CYAN, C.BLACK)
893
- drawSprite(ctx, DOOR_SPRITE, col * CELL, row * CELL, C.YELLOW, C.B_BLUE)
894
- ```
895
-
896
- ### `drawChar(ctx, charCode, x, y, ink, paper?): void`
897
-
898
- Draws one ASCII character from the ROM font. Omit `paper` for a transparent background (only ink pixels are drawn).
899
-
900
- ```ts
901
- drawChar(ctx, 127, x, y, C.B_GREEN, C.BLACK) // solid block █
902
- drawChar(ctx, 'A'.charCodeAt(0), x, y, C.B_WHITE) // transparent bg
903
- ```
904
-
905
- ### `drawText(ctx, text, x, y, ink, paper?): void`
906
-
907
- Draws a string left-to-right, one character per `CELL`-wide slot.
908
-
909
- ```ts
910
- drawText(ctx, 'SCORE:00000', 0, statusY, C.B_WHITE, C.BLACK)
911
- drawText(ctx, 'PRESS ANY KEY', x, y, C.B_YELLOW) // transparent bg
912
- ```
913
-
914
- ### `drawTextCentered(ctx, text, y, cols, ink, paper?): void`
915
-
916
- Centers a string within `cols` character columns.
917
-
918
- ```ts
919
- // Bind the column count once to keep call sites clean
920
- const print = (text: string, y: number, ink: SpectrumColor) =>
921
- drawTextCentered(ctx, text, y, 32, ink)
922
-
923
- print('GAME OVER', 88, C.B_RED)
924
- print('PRESS ANY KEY', 104, C.B_WHITE)
925
- ```
926
-
927
- ### `flashBorder(color, times, intervalMs, resetColor?): void`
928
-
929
- Animates `document.body.style.backgroundColor`. Fire-and-forget — does not block. Each call cancels any in-flight flash (no overlapping intervals). Always resets to `resetColor` when the sequence completes.
930
-
931
- - `resetColor` defaults to `C.BLACK`
932
-
933
- ```ts
934
- flashBorder(C.B_RED, 3, 150) // 3 red flashes → black (explosion)
935
- flashBorder(C.B_GREEN, 2, 200) // level complete
936
- flashBorder(C.B_CYAN, 2, 120, C.B_BLUE) // flash → reset to blue border
937
- ```
938
-
939
- ### `drawScanlines(ctx, width?, height?, alpha?): void`
940
-
941
- Draws a CRT scanline overlay. Every even row gets a semi-transparent black stripe. Pass the same `width`/`height` as `setupCanvas`, or omit to use the defaults (256×192).
942
-
943
- ```ts
944
- // At the end of each frame, after all game content:
945
- drawScanlines(ctx) // standard 256×192, alpha=0.18
946
- drawScanlines(ctx, 256, 208) // taller canvas
947
- drawScanlines(ctx, 256, 192, 0.25) // darker scanlines
948
- ```
949
-
950
- ### `curveDisplay(ctx, width?, height?, strength?): void`
951
-
952
- Applies a CRT barrel-distortion warp to the canvas content using a temporary off-screen canvas and a `quadraticCurveTo` warp. Gives the display a subtle CRT monitor feel.
953
-
954
- ```ts
955
- // Last step, after drawScanlines:
956
- curveDisplay(ctx) // default strength
957
- curveDisplay(ctx, 256, 208, 6) // stronger warp
958
- ```
959
-
960
- ### `Bitmap` interface
961
-
962
- An arbitrary-size monochrome bitmap. Width must be a positive multiple of 8 so
963
- each row is byte-aligned. `data` is row-major; bit 7 is the leftmost pixel in
964
- each byte.
965
-
966
- ```ts
967
- interface Bitmap {
968
- data: Uint8Array
969
- width: number
970
- height: number
971
- }
972
- ```
973
-
974
- Use `Bitmap` for 16x16 enemies, 16x24 heroes, 32x32 bosses, tall objects, and
975
- anything that outgrows the classic 8x8 `drawSprite()` format.
976
-
977
- ### `createBitmap(data, width, height): Bitmap`
978
-
979
- Builds a `Bitmap` from packed bytes and validates the dimensions and byte
980
- count immediately. Throws if width is not byte-aligned, height is invalid, or
981
- the data length does not match `(width / 8) * height`.
982
-
983
- ```ts
984
- const HERO = createBitmap(new Uint8Array([
985
- 0x03, 0xC0,
986
- 0x07, 0xE0,
987
- // ...22 more 16px-wide rows
988
- ]), 16, 24)
989
- ```
990
-
991
- ### `createBitmapFromRows(rows): Bitmap`
992
-
993
- Builds an arbitrary-size `Bitmap` from readable pixel-art rows instead of
994
- hand-packed bytes. This is useful for sprites larger than 8x8 where hex arrays
995
- become hard to review.
996
-
997
- - `X` or `#` = solid pixel
998
- - `.` or space = transparent pixel
999
- - every row must have the same width
1000
- - width must be a positive multiple of 8
1001
-
1002
- ```ts
1003
- const TRUCK = createBitmapFromRows([
1004
- '....XXXXXXXX....',
1005
- '..XXXXXXXXXXXX..',
1006
- '.XXXX......XXXX.',
1007
- 'XXXXXXXXXXXXXXXX',
1008
- 'XX..XXXXXXXX..XX',
1009
- 'XX............XX',
1010
- '..XXX......XXX..',
1011
- '................',
1012
- ])
1013
-
1014
- drawBitmap(ctx, TRUCK, x, y, C.B_WHITE)
1015
- ```
1016
-
1017
- The returned object is the same `Bitmap` shape produced by `createBitmap()`, so
1018
- it works with `drawBitmap`, `drawBitmapAttrs`, `mirrorBitmap`, and collision
1019
- helpers such as `bitmapPixelMask`.
1020
-
1021
- ### `drawBitmap(ctx, bitmap, x, y, ink, paper?, inkOnly?): void`
1022
-
1023
- Draws an arbitrary-size `Bitmap`. The colour model has three modes, ordered by how much of the background they disturb:
1024
-
1025
- | Call | Paints | Touches the background? |
1026
- |------|--------|-------------------------|
1027
- | `drawBitmap(ctx, bmp, x, y, ink)` | only the set ink pixels | no — transparent overlay |
1028
- | `drawBitmap(ctx, bmp, x, y, ink, paper)` | a full `width×height` paper rectangle, then ink pixels on top | yes — the whole bounding box |
1029
- | `drawBitmap(ctx, bmp, x, y, ink, paper, true)` | only the set ink pixels; `paper` is ignored | no |
1030
-
1031
- `inkOnly` (last parameter, default `false`) **suppresses the paper rectangle even when a `paper` colour is supplied.** For `drawBitmap` this is functionally identical to omitting `paper` — its value is ergonomic: keep a sprite's configured `paper` and toggle the opaque box on or off with a boolean, instead of conditionally choosing whether to pass the argument at the call site.
1032
-
1033
- ### `mirrorBitmap(src): Bitmap`
1034
-
1035
- Returns a horizontally flipped copy of a `Bitmap`. The original is not
1036
- modified. Use it at module load time to derive left-facing sprites from one
1037
- right-facing definition.
1038
-
1039
- ```ts
1040
- const HERO_RIGHT = createBitmapFromRows([...])
1041
- const HERO_LEFT = mirrorBitmap(HERO_RIGHT)
1042
- ```
1043
-
1044
- ### `AttrMap` interface
1045
-
1046
- Per-8x8-cell ink and paper colours for a `Bitmap`, mirroring the ZX Spectrum
1047
- attribute buffer. `cols` must match `bitmap.width / 8`; `rows` must match
1048
- `bitmap.height / 8`.
1049
-
1050
- ```ts
1051
- interface AttrMap {
1052
- readonly cols: number
1053
- readonly rows: number
1054
- readonly inks: readonly SpectrumColor[]
1055
- readonly papers?: readonly SpectrumColor[]
1056
- }
1057
- ```
1058
-
1059
- Omit `papers` for transparent per-cell ink rendering, or provide papers for
1060
- the authentic colour-clash look.
1061
-
1062
- ### `createAttrMap(cols, rows, inks, papers?): AttrMap`
1063
-
1064
- Builds an `AttrMap` with validation. `inks` must contain `cols * rows` colours.
1065
- `papers` can be omitted, supplied as a matching per-cell array, or supplied as
1066
- one colour to fill every cell.
1067
-
1068
- ```ts
1069
- const HERO_ATTRS = createAttrMap(2, 3, [
1070
- C.B_YELLOW, C.B_YELLOW,
1071
- C.B_RED, C.B_MAGENTA,
1072
- C.B_CYAN, C.B_GREEN,
1073
- ], C.BLACK)
1074
- ```
1075
-
1076
- ### `drawBitmapAttrs(ctx, bitmap, attrs, x, y, inkOnly?): void`
1077
-
1078
- Renders a `Bitmap` with a per-cell `AttrMap` — each 8×8 cell carries its own `(ink, paper)`, the authentic Spectrum attribute model. Here `inkOnly` is **not** redundant: it keeps every per-cell *ink* colour but skips all per-cell *paper* fills. One fully-coloured `AttrMap` (with `papers` for the boxed look on a plain background) then renders two ways — flip `inkOnly` per frame, with no second paper-less map to build and keep in sync. Dimension validation still throws under `inkOnly`: the flag changes what is painted, never the contract.
1079
-
1080
- ```ts
1081
- // chaosbunny — a blue rabbit with a white belly, hopping through a dark cave:
1082
- drawBitmapAttrs(ctx, BUNNY, BUNNY_ATTRS, x, y, true)
1083
- // → per-cell blue/white inks preserved, but no black 8×8 blocks stamped onto
1084
- // the cave behind it. The rabbit reads by its own silhouette.
1085
- ```
1086
-
1087
- ### `mirrorAttrMap(attrs): AttrMap`
1088
-
1089
- Returns a horizontally flipped copy of an `AttrMap`, reversing each attribute
1090
- row. Pair it with `mirrorBitmap()` so a mirrored sprite keeps its colours on
1091
- the matching 8x8 cells.
1092
-
1093
- ```ts
1094
- const HERO_LEFT = mirrorBitmap(HERO_RIGHT)
1095
- const HERO_LEFT_ATTRS = mirrorAttrMap(HERO_RIGHT_ATTRS)
1096
- ```
1097
-
1098
- ### Why does `inkOnly` exist? (and why is it, honestly, a little bit of debt?)
1099
-
1100
- This is the kind of decision worth writing down, because the "obvious" answer is the wrong one.
1101
-
1102
- **Why was the box there in the first place?** Because that *is* the ZX Spectrum. The real machine had a 256×192 one-bit pixel bitmap and a *separate* 32×24 attribute map: one ink + one paper + bright + flash per 8×8 cell, nothing finer. zx-kit's `drawBitmapAttrs`, and the `paper` argument of `drawBitmap`, model exactly that constraint (added in v0.19.0, *"authentic Spectrum colour clash"*). Fill the cell's paper, draw the ink on top. That's the look, and removing it would make the engine less of a Spectrum, not more.
1103
-
1104
- **So why fight it?** Because the hardware had a *second* technique we had quietly skipped — the **masked sprite.** Cheap games stamped attributes and lived with "colour clash," the famous bleeding of one sprite's colours onto whatever 8×8 cell it touched. The games whose movement looked clean used a *mask*: a second bitmap ANDed into the screen to punch a hole in the exact shape of the sprite, then the sprite ORed in. Only the sprite's own pixels changed — no paper block, no bleed onto the neighbour. `inkOnly` is the modern per-pixel-canvas equivalent of a masked sprite, and on a canvas it comes almost for free: painting only the set pixels already leaves everything else untouched.
1105
-
1106
- **What broke without it?** The same character that broke collision. Picture a Dizzy-style sprite with `paper: C.BLACK` drawn next to a white leaf. The visible pixels never touch the leaf — but the paper rectangle (or the 8×8 paper cell) does, and it paints the leaf's edge black. The bounding box committed the crime; the sprite took the blame. We met this exact bounding-box sin once before, in collision (v0.21.0, *"the Dizzy problem"*): the AABB overlapped a platform the pixels didn't. Same Dizzy, same box, different subsystem — and rendering needed the same answer collision got: *stop trusting the box, trust the pixels.*
1107
-
1108
- **Then why call it debt?** Because the truly faithful fix is bigger than a boolean. A real masking layer would carry an explicit mask bitmap per sprite, or compose against an off-screen attribute buffer the way the hardware did. `inkOnly` is the ~12-line, zero-allocation, fully backwards-compatible shortcut to 90% of that value. For a hobby engine whose stated philosophy is *less is more*, that is the right call — but it is worth being honest that it is a shortcut, not the model. The day a game needs a paper silhouette that hugs the sprite *outline* (paper behind the shape, but not the box) is the day this flag stops being enough and the masking layer earns its place.
1109
-
1110
- ### Tested
1111
-
1112
- Both functions are covered by the renderer suite, including the new branch and its edges:
1113
-
1114
- - `inkOnly` suppresses the paper rectangle / per-cell paper blocks (zero fills for an all-zero sprite; ink-only fills for an all-ones sprite);
1115
- - per-cell ink colours survive `inkOnly`;
1116
- - the default (`false`) still fills paper — a regression guard so the flag can never silently change an existing game;
1117
- - `drawBitmap(..., inkOnly)` matches transparent rendering, and `drawBitmapAttrs(..., inkOnly)` matches a paper-less `AttrMap`, pixel-for-pixel;
1118
- - **exception path:** `drawBitmapAttrs` still throws on an `AttrMap`/`Bitmap` dimension mismatch with `inkOnly` set.
1119
-
1120
- ---
1121
-
1122
- ## `audio.ts` — Beeper Audio
1123
-
1124
- Single-channel 1-bit square-wave audio, faithful to the ZX Spectrum beeper. Use this for **sound effects** (shots, jumps, hits, beeps) and simple monophonic melodies.
1125
-
1126
- > **Pair with [`ay.ts`](#ayts--ay-3-8912-melodik-audio) for music.** This is how 128K Spectrum games actually sounded — see [Audio architecture — beeper vs AY](#audio-architecture--beeper-vs-ay) for the reasoning and the "Robocop" parallel-use pattern.
1127
-
1128
- All audio routes through a shared `AudioContext` and master `GainNode` — `setMasterVolume()` controls both modules at once. **`initAudio()` must be called inside a user-gesture handler** due to browser autoplay policy.
1129
-
1130
- ### `initAudio(volume?): void`
1131
-
1132
- Creates the `AudioContext` and master gain node. Idempotent — safe to call multiple times. `volume` is clamped to 0.0–1.0 (default `0.3`).
1133
-
1134
- ```ts
1135
- window.addEventListener('keydown', () => initAudio(), { once: true })
1136
- window.addEventListener('click', () => initAudio(), { once: true })
1137
- ```
1138
-
1139
- ### `resumeAudio(): void`
1140
-
1141
- Resumes a suspended `AudioContext`. Browsers suspend the context on tab hide or first load. Call before scheduling any audio in the game loop.
1142
-
1143
- ### `getAudioContext(): AudioContext | null`
1144
-
1145
- Returns the shared context, or `null` before `initAudio()`.
1146
-
1147
- ### `getMasterGain(): GainNode | null`
1148
-
1149
- Returns the master gain node. Connect custom oscillators here to participate in the global volume level.
1150
-
1151
- ### `getMasterVolume(): number`
1152
-
1153
- Returns the current master volume (0.0–1.0), or `0` before `initAudio()`.
1154
-
1155
- ### `setMasterVolume(volume): void`
1156
-
1157
- Sets master volume. Clamped to 0.0–1.0. No-op before `initAudio()`.
1158
-
1159
- ```ts
1160
- setMasterVolume(0.5) // 50%
1161
- setMasterVolume(0) // mute
1162
- setMasterVolume(1) // full
1163
- ```
1164
-
1165
- ### `increaseVolume() / decreaseVolume(): void`
1166
-
1167
- Adjusts master volume by ±0.1, clamped to 0.0–1.0.
1168
-
1169
- ### `Note` interface
1170
-
1171
- ```ts
1172
- interface Note {
1173
- freq: number // Hz — 0 = rest (silence, advances timeline)
1174
- dur: number // ms
1175
- }
1176
- ```
1177
-
1178
- ### `playPattern(notes, startDelay?): void`
1179
-
1180
- Schedules a note sequence on the shared `AudioContext`. `freq: 0` entries produce silence for their duration. `startDelay` offsets the entire pattern in milliseconds.
1181
-
1182
- ```ts
1183
- // Rising arpeggio
1184
- playPattern([
1185
- { freq: 262, dur: 80 }, // C4
1186
- { freq: 330, dur: 80 }, // E4
1187
- { freq: 392, dur: 80 }, // G4
1188
- { freq: 523, dur: 160 }, // C5
1189
- ])
1190
-
1191
- // With rest and startup delay
1192
- playPattern([
1193
- { freq: 880, dur: 100 },
1194
- { freq: 0, dur: 50 }, // rest
1195
- { freq: 880, dur: 100 },
1196
- ], 200)
1197
- ```
1198
-
1199
- ### `beep(freq, durationMs, startTime): void`
1200
-
1201
- Schedules a single square-wave note at an absolute `AudioContext.currentTime`. Uses a 5ms linear ramp on attack and release to avoid click artefacts. Use `playPattern` for sequences; use `beep` when you need algorithmic or sample-accurate timing.
1202
-
1203
- ```ts
1204
- const audio = getAudioContext()!
1205
- resumeAudio()
1206
- beep(440, 80, audio.currentTime)
1207
- beep(880, 80, audio.currentTime + 0.15) // 150ms later
1208
- ```
1209
-
1210
- ---
1211
-
1212
- ## `ui.ts` — UI Widgets
1213
-
1214
- High-level drawing helpers and a stateful widget system for HUD elements. All primitives operate in game pixels and enforce the Spectrum palette.
1215
-
1216
- ### Types
1217
-
1218
- #### `BorderOptions`
1219
-
1220
- | Field | Type | Default | Description |
1221
- |-------|------|---------|-------------|
1222
- | `enabled` | `boolean` | `true` | Set `false` to suppress border without removing the object |
1223
- | `thickness` | `number` | `1` | Border thickness in game pixels |
1224
- | `color` | `SpectrumColor` | parent ink | Overrides the parent function's foreground color |
1225
- | `style` | `'solid' \| 'dashed'` | `'solid'` | `'dashed'` = 2 px on / 2 px off |
1226
-
1227
- #### `DrawProgressBarOptions`
1228
-
1229
- | Field | Type | Default | Description |
1230
- |-------|------|---------|-------------|
1231
- | `id` | `string` | `"${x},${y}"` | Stable key for managed redraws |
1232
- | `x` | `number` | — | Left edge in game pixels |
1233
- | `y` | `number` | — | Top edge in game pixels |
1234
- | `width` | `number` | — | Total width (multiples of `CELL = 8` recommended) |
1235
- | `value` | `number` | — | Current value |
1236
- | `min` | `number` | `0` | Empty-edge value |
1237
- | `max` | `number` | `1` | Full-edge value |
1238
- | `ink` | `SpectrumColor` | `C.B_WHITE` | Filled-block color |
1239
- | `paper` | `SpectrumColor` | `C.BLACK` | Empty-block background |
1240
- | `border` | `BorderOptions` | — | Optional border |
1241
- | `visibilityLength` | `number` | `500` | Ms to stay visible after last call; `0` = permanent |
1242
-
1243
- ### Stateless primitives
1244
-
1245
- #### `drawBox(ctx, options): void`
1246
-
1247
- Fills a rectangle with `paper` and draws an optional border.
1248
-
1249
- ```ts
1250
- drawBox(ctx, {
1251
- x: 8, y: 8, width: 112, height: 40,
1252
- paper: C.BLACK, ink: C.B_WHITE,
1253
- border: { style: 'solid', thickness: 1 },
1254
- })
1255
- ```
1256
-
1257
- #### `drawFrame(ctx, options): void`
1258
-
1259
- Draws a border only — no background fill.
1260
-
1261
- ```ts
1262
- drawFrame(ctx, { x: 0, y: 0, width: 256, height: 176, color: C.B_CYAN })
1263
- drawFrame(ctx, { x: 16, y: 16, width: 64, height: 32, color: C.B_RED,
1264
- border: { style: 'dashed' } })
1265
- ```
1266
-
1267
- #### `drawPanelTitle(ctx, options): void`
1268
-
1269
- Renders a text strip (`CELL + padding * 2` height) with optional background fill. Does not draw a surrounding container — combine with `drawBox` or `drawFrame`.
1270
-
1271
- ```ts
1272
- drawBox(ctx, { x: 8, y: 24, width: 128, height: 56, paper: C.BLACK })
1273
- drawPanelTitle(ctx, {
1274
- text: 'OPTIONS', x: 8, y: 24,
1275
- ink: C.B_YELLOW, paper: C.BLACK,
1276
- centered: true, width: 128,
1277
- })
1278
- ```
1279
-
1280
- ### Instrumentation widgets (stateless)
1281
-
1282
- Five stateless primitives for HUDs, dashboards and tactical displays — gauges, bars, tanks, dials, compass. Each function takes a `ctx` plus an `options` object and renders immediately. The caller drives state on every frame (no built-in animation, no internal timers). Pair with `Animation` / `Tween` from `animation.ts` if you want smoothed transitions.
1283
-
1284
- #### `drawDottedGrid(ctx, options): void`
1285
-
1286
- Options type: `DrawDottedGridOptions`.
1287
-
1288
- Regularly-spaced dot pattern. Useful for radar / sonar screens, tactical scanner overlays, debug grids, stippled backgrounds, alien-invasion detection grids.
1289
-
1290
- ```ts
1291
- // Sonar background (submarine HUD)
1292
- drawDottedGrid(ctx, {
1293
- x: 8, y: 8, width: 64, height: 48,
1294
- spacing: 4, color: C.GREEN, paper: C.BLACK,
1295
- })
1296
-
1297
- // Chunky 2×2 dots for tactical map overlay
1298
- drawDottedGrid(ctx, {
1299
- x: 0, y: 0, width: 256, height: 192,
1300
- spacing: 8, dotSize: 2, color: C.B_WHITE,
1301
- })
1302
- ```
1303
-
1304
- | Option | Type | Default | Description |
1305
- |--------|------|---------|-------------|
1306
- | `x`, `y`, `width`, `height` | `number` | — | Area covered by the dot field |
1307
- | `spacing` | `number` | — | Distance between adjacent dot centres |
1308
- | `color` | `SpectrumColor` | — | Dot colour |
1309
- | `paper` | `SpectrumColor` | — | Optional background fill |
1310
- | `dotSize` | `number` | `1` | Dot size in pixels (use `2` for chunkier dots) |
1311
-
1312
- #### `drawSegmentedBar(ctx, options): void`
1313
-
1314
- Options type: `DrawSegmentedBarOptions`.
1315
-
1316
- Discrete segmented bar — health, ammo, shield, fuel, stamina, mana, battery, damage. Computes `round(value/max * segments)` filled segments.
1317
-
1318
- Two colouring strategies, mutually exclusive:
1319
-
1320
- - **Single colour** (`color`): every filled segment uses it. Classic Robocop health style.
1321
- - **Threshold gradient** (`colors: [low, mid, high]`): the widget picks one of three colours based on `value/max` (`< 1/3` → low, `< 2/3` → mid, else high). Classic oxygen / damage indicator.
1322
-
1323
- ```ts
1324
- // Robocop-style health (single colour)
1325
- drawSegmentedBar(ctx, {
1326
- x: 0, y: 0, segments: 10, value: 7, max: 10,
1327
- color: C.B_GREEN, paper: C.BLACK,
1328
- })
1329
-
1330
- // Oxygen with threshold gradient (red → yellow → green)
1331
- drawSegmentedBar(ctx, {
1332
- x: 0, y: 0, segments: 10, value: 8, max: 10,
1333
- colors: [C.B_RED, C.B_YELLOW, C.B_GREEN],
1334
- paper: C.BLACK,
1335
- })
1336
-
1337
- // Vertical bar (e.g. ammo column on the side of the HUD)
1338
- drawSegmentedBar(ctx, {
1339
- x: 0, y: 0, segments: 8, value: 5, max: 8,
1340
- orientation: 'vertical', color: C.B_GREEN,
1341
- })
1342
- ```
1343
-
1344
- | Option | Type | Default | Description |
1345
- |--------|------|---------|-------------|
1346
- | `x`, `y` | `number` | — | Top-left corner |
1347
- | `segments` | `number` | — | Total segment count |
1348
- | `value`, `max` | `number` | — | Filled = `round(value/max * segments)` |
1349
- | `segmentWidth` | `number` | `8` (CELL) | Width of one segment |
1350
- | `segmentHeight` | `number` | `8` (CELL) | Height of one segment |
1351
- | `gap` | `number` | `1` | Pixels between adjacent segments |
1352
- | `color` | `SpectrumColor` | — | Single fill colour (mutually exclusive with `colors`) |
1353
- | `colors` | `[low, mid, high]` | — | Three-stop threshold gradient |
1354
- | `paper` | `SpectrumColor` | — | Background for empty segments |
1355
- | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Layout direction |
1356
-
1357
- #### `drawTank(ctx, options): void`
1358
-
1359
- Options type: `DrawTankOptions`.
1360
-
1361
- Fluid container — ballast tanks, fuel gauges, water reservoirs, lava levels, oil drums, chemical canisters. Liquid fills from the bottom up.
1362
-
1363
- ```ts
1364
- // Submarine ballast tank (pill, cyan fluid)
1365
- drawTank(ctx, {
1366
- x: 8, y: 16, width: 16, height: 48,
1367
- fillPct: 0.66, shape: 'pill',
1368
- liquidColor: C.B_CYAN,
1369
- containerColor: C.WHITE,
1370
- emptyColor: C.BLACK,
1371
- })
1372
-
1373
- // Generic fuel gauge (rect, yellow fluid)
1374
- drawTank(ctx, {
1375
- x: 200, y: 8, width: 24, height: 32,
1376
- fillPct: 0.4, shape: 'rect',
1377
- liquidColor: C.B_YELLOW,
1378
- containerColor: C.WHITE,
1379
- emptyColor: C.BLACK,
1380
- })
1381
- ```
1382
-
1383
- | Option | Type | Default | Description |
1384
- |--------|------|---------|-------------|
1385
- | `x`, `y`, `width`, `height` | `number` | — | Container bounding box |
1386
- | `fillPct` | `number` | — | Fill level `0..1`, clamped |
1387
- | `shape` | `'pill' \| 'rect'` | `'pill'` | `'pill'` = rounded caps, `'rect'` = sharp corners |
1388
- | `liquidColor` | `SpectrumColor` | — | Fluid colour |
1389
- | `containerColor` | `SpectrumColor` | `liquidColor` | Outline colour |
1390
- | `emptyColor` | `SpectrumColor \| 'transparent'` | `C.BLACK` | Fill for the empty portion. Use `'transparent'` to leave it un-painted (so the underlying frame shows through) |
1391
-
1392
- #### `drawDial(ctx, options): void`
1393
-
1394
- Options type: `DrawDialOptions`.
1395
-
1396
- Circular analog gauge with movable needle — RPM, speedometer, fuel, temperature, volume knob. Decorations (face fill, rim outline, tick marks) are optional; the needle alone is the minimum visible output.
1397
-
1398
- ```ts
1399
- // Submarine motor RPM gauge (range 0–3000)
1400
- drawDial(ctx, {
1401
- cx: 128, cy: 100, radius: 24,
1402
- value: 1500, min: 0, max: 3000,
1403
- needleColor: C.B_RED,
1404
- rimColor: C.WHITE,
1405
- tickColor: C.WHITE,
1406
- ticks: 7,
1407
- })
1408
-
1409
- // Bare minimum: just the needle
1410
- drawDial(ctx, {
1411
- cx: 50, cy: 50, radius: 10,
1412
- value: 75, needleColor: C.B_GREEN,
1413
- })
1414
- ```
1415
-
1416
- | Option | Type | Default | Description |
1417
- |--------|------|---------|-------------|
1418
- | `cx`, `cy`, `radius` | `number` | — | Centre and radius |
1419
- | `value` | `number` | — | Mapped to needle angle |
1420
- | `min` | `number` | `0` | Minimum value |
1421
- | `max` | `number` | `100` | Maximum value |
1422
- | `startAngle` | `number` (rad) | `3π/4` | Needle angle at `min` (bottom-left default) |
1423
- | `endAngle` | `number` (rad) | `9π/4` | Needle angle at `max` (bottom-right, after sweeping CW through top) |
1424
- | `needleColor` | `SpectrumColor` | — | Needle colour |
1425
- | `faceColor` | `SpectrumColor` | — | Optional filled disc background |
1426
- | `rimColor` | `SpectrumColor` | — | Optional circle outline |
1427
- | `tickColor` | `SpectrumColor` | — | Optional tick mark colour (requires `ticks`) |
1428
- | `ticks` | `number` | `0` | Number of evenly-spaced tick marks |
1429
-
1430
- Angles use canvas convention: `0` = right, `π/2` = down, `π` = left, `3π/2` = up — angles increase **clockwise** because the canvas y-axis points down. Default sweep covers the typical 270° gauge arc through the top.
1431
-
1432
- #### `drawCompassText(ctx, options): void`
1433
-
1434
- Options type: `DrawCompassTextOptions`.
1435
-
1436
- Text-based heading indicator in the classic 80s tactical-display style `[W [NW] N [NE] E]` — current direction in the centre, highlighted, with two neighbouring directions on each side. Heading rounds to the nearest 45° step.
1437
-
1438
- ```ts
1439
- drawCompassText(ctx, {
1440
- x: 0, y: 168,
1441
- heading: 0, // N
1442
- color: C.WHITE,
1443
- highlightColor: C.B_YELLOW,
1444
- paper: C.BLACK,
1445
- })
1446
- // heading=0 → centre is N. Five labels: W, NW, N, NE, E
1447
- // → `W [NW] N [NE] E` — centre "N" in bright yellow, ±1 in brackets,
1448
- // outer ±2 ("W", "E") rendered plain.
1449
- ```
1450
-
1451
- | Option | Type | Default | Description |
1452
- |--------|------|---------|-------------|
1453
- | `x`, `y` | `number` | — | Top-left of the rendered string |
1454
- | `heading` | `number` (degrees) | — | `0`/`360` = N, `90` = E, `180` = S, `270` = W (wraps automatically) |
1455
- | `color` | `SpectrumColor` | — | Colour for non-current direction labels |
1456
- | `highlightColor` | `SpectrumColor` | `color` | Colour for current direction (centre label) |
1457
- | `paper` | `SpectrumColor` | — | Optional background behind labels |
1458
- | `brackets` | `boolean` | `true` | Wrap **only the ±1 (adjacent) directions** in `[…]`. The centre label and the outer ±2 directions are never bracketed. |
1459
-
1460
- ### Stateful widget — Progress Bar
1461
-
1462
- The progress bar is a **managed widget**: after a `drawProgressBar` call, the bar is automatically re-rendered on subsequent frames by `renderUI` until `visibilityLength` milliseconds have elapsed. Calling `drawProgressBar` again resets the timer.
1463
-
1464
- #### `drawProgressBar(ctx, options): void`
1465
-
1466
- Draws the bar immediately **and** registers it for managed redraws.
1467
-
1468
- ```ts
1469
- // Appears for 1.5 s after each volume change
1470
- drawProgressBar(ctx, {
1471
- id: 'volume', x: 88, y: 88, width: 80,
1472
- value: getMasterVolume(),
1473
- ink: C.B_GREEN, paper: C.BLACK,
1474
- border: { style: 'solid' },
1475
- visibilityLength: 1500,
1476
- })
1477
-
1478
- // Permanent HUD element (visibilityLength: 0)
1479
- drawProgressBar(ctx, {
1480
- id: 'health', x: 0, y: 184, width: 40,
1481
- value: lives, min: 0, max: 3,
1482
- ink: C.B_GREEN, paper: C.BLACK,
1483
- visibilityLength: 0,
1484
- })
1485
- ```
1486
-
1487
- #### `tickUI(dtMs): void`
1488
-
1489
- Advances all managed bar timers. Expired bars are removed. Call once per frame.
1490
-
1491
- #### `renderUI(ctx): void`
1492
-
1493
- Redraws all currently visible bars. Call every frame **after** the game world render.
1494
-
1495
- #### `resetUI(): void`
1496
-
1497
- Clears all managed widget state. Call alongside `resetInput()` on phase transitions.
1498
-
1499
- ```ts
1500
- // Typical game loop
1501
- renderFrame(ctx, state)
1502
- tickUI(dt)
1503
- renderUI(ctx)
1504
-
1505
- // Phase transition
1506
- resetInput()
1507
- resetUI()
1508
- appPhase = 'intro'
1509
- ```
1510
-
1511
- ---
1512
-
1513
- ## `input.ts` — Keyboard & Gamepad Input
1514
-
1515
- Handles directional movement with configurable keyboard repeat, transparent
1516
- gamepad polling, and single-consume flags for action buttons. Call
1517
- `initInput()` once at startup, then `tickMovement(dt)` every frame.
1518
-
1519
- ### `Direction` type
1520
-
1521
- ```ts
1522
- type Direction = 'up' | 'down' | 'left' | 'right'
1523
- ```
1524
-
1525
- ### `initInput(repeatDelay?, repeatInterval?): void`
1526
-
1527
- Attaches `keydown`/`keyup` listeners. Idempotent — safe to call multiple times; timing parameters are always updated but listeners are only registered once.
1528
-
1529
- Default key bindings: arrows = movement, `W A S D` = also movement, `F` = flag action, `P` = pause, `Ctrl+Shift+B` = debug toggle.
1530
-
1531
- Gamepad support is automatic. `tickMovement()` polls the first connected
1532
- gamepad via the browser Gamepad API: D-pad / left stick move, button 0 maps to
1533
- `consumeFlag()`, button 9 maps to `consumePause()`, button 3 maps to
1534
- `consumeDebug()`, and any button triggers `consumeAnyKey()`.
1535
-
1536
- ```ts
1537
- initInput() // default: 150ms initial delay, 80ms repeat
1538
- initInput(200, 60) // custom timing
1539
- ```
1540
-
1541
- ### `tickMovement(dtMs): Direction | null`
1542
-
1543
- Returns the active movement direction for this frame, or `null`. Handles the
1544
- keyboard delay/repeat state machine and gamepad polling internally. Call
1545
- exactly once per frame.
1546
-
1547
- ```ts
1548
- const dir = tickMovement(dt)
1549
- if (dir === 'left') player.x -= speed * dt
1550
- if (dir === 'right') player.x += speed * dt
1551
- if (dir === 'up') player.y -= speed * dt
1552
- if (dir === 'down') player.y += speed * dt
1553
- ```
1554
-
1555
- ### Consume flags
1556
-
1557
- Each function returns `true` exactly once per key press, then resets to `false`. Designed for single-fire events — menus, flags, pause, etc.
1558
-
1559
- | Function | Default key | Typical use |
1560
- |----------|-------------|-------------|
1561
- | `consumeFlag()` | `F` / gamepad button 0 | Flag / unflag a tile |
1562
- | `consumePause()` | `P` / gamepad button 9 | Pause / unpause |
1563
- | `consumeDebug()` | `Ctrl+Shift+B` / gamepad button 3 | Toggle debug overlay |
1564
- | `consumeAnyKey()` | Any key / any gamepad button | Dismiss overlays, start game |
1565
-
1566
- ```ts
1567
- if (consumeFlag()) toggleFlag(playerX, playerY)
1568
- if (consumePause()) appPhase = appPhase === 'paused' ? 'game' : 'paused'
1569
- if (consumeAnyKey()) appPhase = 'game' // dismiss title screen
1570
- ```
1571
-
1572
- ### `isHeld(key): boolean`
1573
-
1574
- Returns whether a key is currently held down. Argument is `KeyboardEvent.key`.
1575
-
1576
- ```ts
1577
- if (isHeld('ArrowUp') && isHeld('ArrowRight')) moveDiagonal()
1578
- ```
1579
-
1580
- ### `resetInput(): void`
1581
-
1582
- Clears all pending key state immediately — held keys, direction, all consume flags. Call on phase transitions to prevent stale inputs carrying over.
1583
-
1584
- ```ts
1585
- appPhase = 'gameover'
1586
- resetInput() // discard any queued keypresses from gameplay
1587
- ```
1588
-
1589
- ---
1590
-
1591
- ## `sprite.ts` — Free-Roaming Sprites
1592
-
1593
- Sprites are entities that move in continuous pixel space — not locked to the 8×8 tile grid. Use them for players, enemies, bullets, particles: anything with physics or sub-pixel movement. They integrate directly with `collision.ts` for tile-map wall resolution.
1594
-
1595
- ### `Sprite` interface
1596
-
1597
- | Field | Type | Default | Description |
1598
- |-------|------|---------|-------------|
1599
- | `x` | `number` | `0` | Horizontal position in game pixels (float allowed) |
1600
- | `y` | `number` | `0` | Vertical position in game pixels |
1601
- | `vx` | `number` | `0` | Horizontal velocity in px/ms |
1602
- | `vy` | `number` | `0` | Vertical velocity in px/ms |
1603
- | `bitmap` | `Uint8Array` | — | 8-byte sprite bitmap |
1604
- | `ink` | `SpectrumColor` | — | Foreground color |
1605
- | `paper` | `SpectrumColor \| null` | `null` | Background color, or `null` for transparent |
1606
- | `flipX` | `boolean` | `false` | Render mirrored horizontally (cached — no per-frame allocation) |
1607
- | `visible` | `boolean` | `true` | When `false`, `renderSprite` skips this entity |
1608
-
1609
- ### `createSprite(bitmap, ink, paper?): Sprite`
1610
-
1611
- Creates a `Sprite` at `(0, 0)` with zero velocity. `paper` defaults to `null` (transparent).
1612
-
1613
- ```ts
1614
- const PLAYER_BM = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
1615
- const BULLET_BM = new Uint8Array([0x00, 0x00, 0x18, 0x3C, 0x18, 0x00, 0x00, 0x00])
1616
-
1617
- const player = createSprite(PLAYER_BM, C.B_CYAN) // transparent bg
1618
- const bullet = createSprite(BULLET_BM, C.B_YELLOW, C.BLACK) // opaque bg
1619
- player.x = 16; player.y = 80
1620
- ```
1621
-
1622
- ### `moveSprite(sprite, dt): void`
1623
-
1624
- Advances `sprite.x` by `vx * dt` and `sprite.y` by `vy * dt`. Call once per frame, before collision resolution.
1625
-
1626
- ### `applyGravity(sprite, gravity, dt): void`
1627
-
1628
- Adds `gravity * dt` to `sprite.vy`. Call once per frame, before `moveSprite`.
1629
-
1630
- - `gravity` in px/ms² — typical values: `0.002`–`0.005` (platformer), `0.008` (debris)
1631
-
1632
- ```ts
1633
- applyGravity(player, 0.003, dt)
1634
- moveSprite(player, dt)
1635
- // then resolveX / resolveY...
1636
- ```
1637
-
1638
- ### `renderSprite(ctx, sprite): void`
1639
-
1640
- Draws the sprite at `(Math.round(x), Math.round(y))`. Skips if `visible === false`. Respects `flipX` (uses cached mirrored bitmap) and `paper: null` (transparent — only ink pixels painted).
1641
-
1642
- ```ts
1643
- player.flipX = player.vx < 0 // face the direction of movement
1644
- renderSprite(ctx, player)
1645
- ```
1646
-
1647
- ---
1648
-
1649
- ## `collision.ts` — Collision Detection
1650
-
1651
- Three tiers of collision detection, from broad-phase to pixel-precise:
1652
-
1653
- | Tier | Functions | Use case |
1654
- |------|-----------|----------|
1655
- | **AABB** | `rectsOverlap`, `spritesOverlap`, `spriteRect`, `bitmapRect` | Fast broad-phase hit tests |
1656
- | **Rect-vs-tile** | `isSolidAt`, `resolveRectX`, `resolveRectY`, `resolveX`, `resolveY` | Wall resolution for any sprite size |
1657
- | **Pixel-precise** | `bitmapPixelMask`, `masksOverlap`, `pixelSolidCount` | Exact opaque-pixel overlap tests |
1658
-
1659
- Pick the tier that matches your accuracy need. AABB is O(1) and correct for most cases. Pixel-precise costs O(opaque pixels) but eliminates false positives for non-rectangular sprites.
1660
-
1661
- ### `Rect` interface
1662
-
1663
- ```ts
1664
- interface Rect { x: number; y: number; w: number; h: number }
1665
- ```
1666
-
1667
- An axis-aligned bounding rectangle in game pixels.
1668
-
1669
- ---
1670
-
1671
- ### AABB overlap tests
1672
-
1673
- #### `spriteRect(sprite): Rect`
1674
-
1675
- Returns the `CELL × CELL` bounding box of a sprite at its current position.
1676
-
1677
- #### `bitmapRect(bitmap, x, y): Rect`
1678
-
1679
- Returns the bounding box for any `Bitmap` at `(x, y)`. Correct for bitmaps of any size — 16×24, 32×32, 96×128 — not just `CELL × CELL`.
1680
-
1681
- ```ts
1682
- const heroRect = bitmapRect(HERO_BMP, hero.x, hero.y)
1683
- const enemyRect = bitmapRect(ENEMY_BMP, enemy.x, enemy.y)
1684
- if (rectsOverlap(heroRect, enemyRect)) damage(hero)
1685
- ```
1686
-
1687
- #### `rectsOverlap(a, b): boolean`
1688
-
1689
- Returns `true` when two rectangles share at least one pixel. Touching edges (zero-area overlap) return `false`.
1690
-
1691
- ```ts
1692
- rectsOverlap(spriteRect(bullet), spriteRect(enemy)) // hit test
1693
- rectsOverlap(bitmapRect(HERO_BMP, hx, hy), bitmapRect(SWORD_BMP, sx, sy))
1694
- ```
1695
-
1696
- #### `spritesOverlap(a, b): boolean`
1697
-
1698
- Shorthand: `rectsOverlap(spriteRect(a), spriteRect(b))`.
1699
-
1700
- ```ts
1701
- if (spritesOverlap(player, coin)) collectCoin()
1702
- if (enemies.some(e => spritesOverlap(player, e))) loseLife()
1703
- ```
1704
-
1705
- ---
1706
-
1707
- ### Rect-vs-tile resolution
1708
-
1709
- #### `isSolidAt(map, px, py): boolean`
1710
-
1711
- Tests whether the game-pixel `(px, py)` falls inside a solid tile. Out-of-bounds pixels return `true` (implicit solid boundary). Converts to tile coords via `Math.floor(px / CELL)`.
1712
-
1713
- ```ts
1714
- if (isSolidAt(map, player.x, player.y + CELL)) { player.vy = 0 } // on floor
1715
- ```
1716
-
1717
- #### `resolveRectX(rect, map, newX): { x, hitLeft, hitRight }`
1718
-
1719
- Generic horizontal resolver for any axis-aligned rectangle. Checks every tile row the rectangle spans — so a 16×24 hero correctly detects walls in the middle rows, not just the top and bottom corners.
1720
-
1721
- Returns the clamped x and collision flags. On collision, the rectangle is placed flush against the wall.
1722
-
1723
- ```ts
1724
- const rect = bitmapRect(HERO_BMP, hero.x, hero.y)
1725
- const r = resolveRectX(rect, map, hero.x + dx)
1726
- hero.x = r.x
1727
- if (r.hitLeft || r.hitRight) hero.vx = 0
1728
- ```
1729
-
1730
- #### `resolveRectY(rect, map, newY): { y, hitTop, hitBottom }`
1731
-
1732
- Generic vertical resolver for any axis-aligned rectangle. Checks every tile column the rectangle spans — so a wide wagon detects the floor across its full footprint.
1733
-
1734
- ```ts
1735
- const rect = bitmapRect(HERO_BMP, hero.x, hero.y)
1736
- const r = resolveRectY(rect, map, hero.y + dy)
1737
- hero.y = r.y
1738
- if (r.hitBottom) { hero.vy = 0; onGround = true }
1739
- if (r.hitTop) { hero.vy = 0 }
1740
- ```
1741
-
1742
- #### `resolveX(sprite, map, newX): { x, hitLeft, hitRight }`
1743
-
1744
- Resolves a proposed horizontal move for an 8×8 sprite. Thin wrapper over `resolveRectX` — preserved for the common CELL-sized sprite case.
1745
-
1746
- ```ts
1747
- const { x, hitLeft, hitRight } = resolveX(player, map, player.x)
1748
- player.x = x
1749
- if (hitLeft || hitRight) player.vx = 0
1750
- ```
1751
-
1752
- #### `resolveY(sprite, map, newY): { y, hitTop, hitBottom }`
1753
-
1754
- Resolves a proposed vertical move for an 8×8 sprite. Thin wrapper over `resolveRectY`.
1755
-
1756
- - `hitBottom` — landed on a floor (use for jump ground detection)
1757
- - `hitTop` — bumped a ceiling
1758
-
1759
- ```ts
1760
- const { y, hitBottom, hitTop } = resolveY(player, map, player.y)
1761
- player.y = y
1762
- if (hitBottom) { player.vy = 0; onGround = true }
1763
- if (hitTop) { player.vy = 0 }
1764
- ```
1765
-
1766
- ---
1767
-
1768
- ### Pixel-precise collision
1769
-
1770
- AABB and rect-vs-tile use the full bounding box. This is almost always correct — but it fails for non-rectangular sprites in edge cases: a circular character standing on a ledge, a diamond-shaped projectile grazing a corner, a tall hero with narrow feet that shouldn't trigger floor detection when hanging over a gap.
1771
-
1772
- Pixel-precise collision solves this by working with the actual opaque pixels of a bitmap, not its enclosing rectangle.
1773
-
1774
- ```
1775
- AABB (16px wide): ████████████████ → fires when any part of the box overlaps
1776
- a tile, even if the sprite itself clears it
1777
- pixelSolidCount: ···██····██···· → only the real foot pixels are checked —
1778
- Dizzy hanging over a platform edge doesn't
1779
- feel magically glued to empty air
1780
- ```
1781
-
1782
- #### `PixelMask` interface
1783
-
1784
- ```ts
1785
- interface PixelMask {
1786
- readonly width: number
1787
- readonly height: number
1788
- readonly rows: readonly (readonly number[])[] // per-row sorted opaque column indices
1789
- readonly totalPixels: number
1790
- }
1791
- ```
1792
-
1793
- Pre-computed per-row opaque pixel data for a `Bitmap`. Build once with `bitmapPixelMask`; reuse every frame. Each `rows[r]` is a sorted array of column indices where that row has a set bit. Empty rows have zero-length arrays — never `undefined`.
1794
-
1795
- ```
1796
- // Example: 16×16 circular sprite
1797
- mask.rows[0] → [6, 7, 8, 9] // narrow top
1798
- mask.rows[7] → [0, 1, 2, ..., 15] // full-width middle
1799
- mask.rows[11] → [3, 4, 10, 11] // only feet
1800
- mask.rows[14] → [] // below feet, empty
1801
- ```
1802
-
1803
- The immutability guarantee matters: the mask is derived from immutable `Bitmap` data. Pre-compute once and store alongside the bitmap definition.
1804
-
1805
- #### `bitmapPixelMask(bitmap): PixelMask`
1806
-
1807
- Extracts a pixel mask from a `Bitmap`. Reads each row's bit data (bit 7 = leftmost pixel) and collects column indices of set (opaque) pixels into sorted arrays. Counts `totalPixels` for overlap severity calculations.
1808
-
1809
- ```ts
1810
- // Pre-compute at module load time — not inside the game loop
1811
- const HERO_MASK = bitmapPixelMask(HERO_BMP)
1812
- const ENEMY_MASK = bitmapPixelMask(ENEMY_BMP)
1813
- const BULLET_MASK = bitmapPixelMask(BULLET_BMP)
1814
- ```
1815
-
1816
- **Bitmap width must be a multiple of 8.** Bitmaps with width `w` require `w * height / 8` bytes of data — standard `createBitmap` enforces this.
1817
-
1818
- #### `masksOverlap(a, ax, ay, b, bx, by): number`
1819
-
1820
- Counts opaque pixels of mask `a` at `(ax, ay)` that overlap with opaque pixels of mask `b` at `(bx, by)`.
1821
-
1822
- Returns **0** when there is no pixel-level overlap. Any value **> 0** is a pixel-perfect collision. The count itself carries semantic meaning: use it for overlap severity — damage scaling, knock-back strength, or as a threshold to ignore grazing touches.
1823
-
1824
- Uses sorted-merge intersection per row: O(opaque pixels), no allocations per call. Safe to call every frame for multiple pairs.
1825
-
1826
- ```ts
1827
- // Simple hit test
1828
- if (masksOverlap(BULLET_MASK, bullet.x, bullet.y, ENEMY_MASK, enemy.x, enemy.y) > 0) {
1829
- destroyEnemy()
1830
- }
1831
-
1832
- // Severity — require a real overlap, not just a 1-pixel graze
1833
- const overlap = masksOverlap(SWORD_MASK, sx, sy, HERO_MASK, hx, hy)
1834
- if (overlap >= 3) {
1835
- takeDamage(Math.round(overlap / SWORD_MASK.totalPixels * 10))
1836
- }
1837
- ```
1838
-
1839
- Masks of different sizes work without any special handling — the overlap window is clipped to the intersection region automatically.
1840
-
1841
- #### `pixelSolidCount(mask, mx, my, map): number`
1842
-
1843
- Counts opaque pixels of a mask at `(mx, my)` that sit on solid tiles in a `TileMap`. Pixel-precise replacement for AABB-based ground / wall checks.
1844
-
1845
- Solves the "character standing on a platform edge" problem: a round sprite with narrow feet can hang over the edge — only the real foot pixels are checked against the tile map, not the full bounding box.
1846
-
1847
- ```ts
1848
- const HERO_MASK = bitmapPixelMask(HERO_BMP)
1849
-
1850
- // Check if standing — test 1 px below current foot position
1851
- const standing = pixelSolidCount(HERO_MASK, hero.x, hero.y + 1, map) > 0
1852
-
1853
- // Check wall to the right — test 1 px past right edge
1854
- const wallRight = pixelSolidCount(HERO_MASK, hero.x + 1, hero.y, map) > 0
1855
-
1856
- // How many foot pixels are actually on solid ground? Use as grip factor
1857
- const groundContact = pixelSolidCount(HERO_MASK, hero.x, hero.y + 1, map)
1858
- ```
1859
-
1860
- When `groundContact` is 0, a circle-shaped hero hanging over a tile edge won't trigger `hitBottom` in `resolveRectY` — the pixel-check and AABB-check intentionally disagree, and you pick which one to trust for each gameplay mechanic.
1861
-
1862
- ---
1863
-
1864
- ### Choosing the right tier
1865
-
1866
- | Situation | Recommended tier |
1867
- |-----------|-----------------|
1868
- | Player touches any part of a coin | `spritesOverlap` — AABB is exact when both sprites are `CELL × CELL` |
1869
- | Large hero (16×24) walks into a wall | `resolveRectX` / `resolveRectY` — checks all tile rows the sprite spans |
1870
- | Round sprite on a platform edge | `pixelSolidCount` — only real foot pixels count |
1871
- | Bullet vs. irregular boss sprite | `masksOverlap` — pixel-precise, returns overlap count for damage |
1872
- | Off-road detection for a truck with a bumpy silhouette | `pixelSolidCount` / custom mask loop — checks each opaque pixel against road boundary |
1873
-
1874
- For a step-by-step walkthrough of both tiers — including how to combine AABB and pixel-precise in one loop and how to handle non-tile boundaries — see **[docs/collision.md](docs/collision.md)**.
1875
-
1876
- ---
1877
-
1878
- ## `animation.ts` — Frame Timer & Tween
1879
-
1880
- Two small primitives for time-based animation:
1881
-
1882
- - **`Animation`** — counts time and reports the current frame index of an N-frame strip. Holds no bitmaps; index lookup into your sprite table is your job (so one timer can drive multi-direction sprites).
1883
- - **`Tween`** — interpolates a 2D position from `(fromX, fromY)` to `(toX, toY)` over a duration with optional easing. Useful for sliding a sprite between cells, dropping a mine in an arc, etc.
1884
-
1885
- Both are stateful objects you mutate via tick functions — same shape as `Sprite` + `moveSprite`. Neither uses module-level state, so multiple instances coexist freely.
1886
-
1887
- ### `Easing` type & `Easings`
1888
-
1889
- ```ts
1890
- type Easing = (t: number) => number // 0..1 → eased value
1891
-
1892
- Easings.linear // (t) => t — constant velocity
1893
- Easings.easeIn // (t) => t * t — quadratic in (slow start)
1894
- Easings.easeOut // (t) => 1 - (1-t) * (1-t) — quadratic out (slow end)
1895
- ```
1896
-
1897
- Pass any `(t: number) => number` to `createTween({ ease })` to roll your own.
1898
-
1899
- ### `Animation` interface
1900
-
1901
- ```ts
1902
- interface Animation {
1903
- frameCount: number // number of frames in cycle
1904
- frameMs: number // duration of each frame
1905
- loop: boolean // wrap, or stop on last frame
1906
- elapsed: number // accumulated time (internal)
1907
- done: boolean // true once non-looping anim reaches the end
1908
- onComplete?: () => void // fired exactly once (non-looping only)
1909
- }
1910
- ```
1911
-
1912
- ### `createAnimation(frameCount, frameMs, opts?): Animation`
1913
-
1914
- ```ts
1915
- const walkAnim = createAnimation(2, 60) // 2-frame walk cycle
1916
- const explosion = createAnimation(4, 50, {
1917
- loop: false,
1918
- onComplete: () => state.phase = 'gameover',
1919
- })
1920
- ```
1921
-
1922
- ### `tickAnimation(anim, dt): number`
1923
-
1924
- Advances by `dt` ms, returns the current frame index (`0..frameCount-1`). For non-looping animations, fires `onComplete` exactly once when the last frame ends.
1925
-
1926
- ```ts
1927
- const idx = tickAnimation(walkAnim, dt)
1928
- const sprite = PLAYER_FRAMES[playerDir][idx] // your own lookup table
1929
- drawSprite(ctx, sprite, x, y, C.B_WHITE, C.BLACK)
1930
- ```
1931
-
1932
- ### `getAnimationFrame(anim): number`
1933
-
1934
- Reads the current frame index without advancing time — useful when reading inside a renderer that runs after the tick.
1935
-
1936
- ### `resetAnimation(anim): void`
1937
-
1938
- Returns the animation to frame 0 and clears `done`. Use to restart a non-looping animation, or to begin a fresh loop from frame 0.
1939
-
1940
- ### `Tween` interface
1941
-
1942
- ```ts
1943
- interface Tween {
1944
- fromX, fromY, toX, toY: number
1945
- durationMs: number
1946
- elapsed: number // accumulated time (internal)
1947
- x, y: number // current interpolated position (read after tickTween)
1948
- ease: Easing
1949
- done: boolean
1950
- onComplete?: () => void
1951
- }
1952
- ```
1953
-
1954
- ### `createTween(fromX, fromY, toX, toY, durationMs, opts?): Tween`
1955
-
1956
- ```ts
1957
- // Slide player from one cell to the next over 120ms
1958
- state.walkTween = createTween(
1959
- state.playerCol * 8, state.playerRow * 8,
1960
- newCol * 8, newRow * 8,
1961
- 120,
1962
- { onComplete: () => commitMove(state) },
1963
- )
1964
- ```
1965
-
1966
- ### `tickTween(tween, dt): boolean`
1967
-
1968
- Advances by `dt` ms, updates `tween.x` / `tween.y`, returns `true` once the tween has reached its end. Fires `onComplete` exactly once. Subsequent calls after completion are no-ops.
1969
-
1970
- ```ts
1971
- if (state.walkTween) {
1972
- tickTween(state.walkTween, dt)
1973
- // renderer reads state.walkTween.x / .y
1974
- }
1975
- ```
1976
-
1977
- ### Combining Animation + Tween
1978
-
1979
- Typical walk-between-cells pattern: a looping `Animation` cycles the foot frames while a non-looping `Tween` slides the position. They tick independently — the tween decides *where*, the animation decides *which sprite*.
1980
-
1981
- ```ts
1982
- // On input:
1983
- state.walkTween = createTween(/* from cell, to cell, 120ms */, {
1984
- onComplete: () => commitMove(state),
1985
- })
1986
-
1987
- // In game loop:
1988
- if (state.walkTween) {
1989
- tickAnimation(state.walkAnim, dt)
1990
- tickTween(state.walkTween, dt)
1991
- }
1992
-
1993
- // In renderer:
1994
- const px = state.walkTween ? state.walkTween.x : state.playerCol * CELL
1995
- const py = state.walkTween ? state.walkTween.y : state.playerRow * CELL
1996
- const f = getAnimationFrame(state.walkAnim)
1997
- drawSprite(ctx, PLAYER_FRAMES[state.playerDir][f], Math.round(px), Math.round(py), ink, paper)
1998
- ```
1999
-
2000
- ### `Blinker` — on/off toggle timer
2001
-
2002
- A minimal boolean timer that flips its state every `intervalMs`. Use for blinking text ("PRESS ANY KEY"), flashing warnings, cursor visibility, aircraft alerts — any situation where a boolean needs to alternate on a fixed cadence.
2003
-
2004
- #### `Blinker` interface
2005
-
2006
- ```ts
2007
- interface Blinker {
2008
- intervalMs: number // toggle interval in ms
2009
- elapsed: number // internal: accumulated time since last toggle
2010
- state: boolean // current state — true = on, false = off
2011
- }
2012
- ```
2013
-
2014
- #### `createBlinker(intervalMs, opts?): Blinker`
2015
-
2016
- | Option | Type | Default | Description |
2017
- |--------|------|---------|-------------|
2018
- | `opts.initialState` | `boolean` | `true` | Starting state — `true` = visible |
2019
-
2020
- ```ts
2021
- const blinker = createBlinker(500) // toggle every 500 ms
2022
- const cursor = createBlinker(400, { initialState: false }) // starts hidden
2023
- ```
2024
-
2025
- #### `tickBlinker(blinker, dt): boolean`
2026
-
2027
- Advances the blinker by `dt` ms and returns the current state. Handles accumulated time correctly — if `dt` spans multiple intervals the state flips the appropriate number of times with the remainder carried over.
2028
-
2029
- ```ts
2030
- // Module-level setup (once):
2031
- const blinker = createBlinker(BLINK_INTERVAL_MS)
2032
-
2033
- // In game loop (replaces manual timer + toggle):
2034
- const blink = tickBlinker(blinker, dt)
2035
- renderIntro(ctx, blink)
2036
- state.blink = blink
2037
- ```
2038
-
2039
- ---
2040
-
2041
- ## `camera.ts` — Scrolling Camera
2042
-
2043
- A 2D viewport that maps a window of world space onto the screen. The camera follows a target point (typically the player) with frame-rate-independent smoothing and an optional deadzone, and clamps to world bounds so the viewport never sees outside the map.
2044
-
2045
- ```ts
2046
- import { createCamera, setCameraTarget, tickCamera, worldToScreen, isInView } from 'zx-kit'
2047
-
2048
- const cam = createCamera({
2049
- viewW: 256, viewH: 192, // game canvas size
2050
- worldW: 2048, worldH: 192, // a long horizontal level
2051
- lerp: 0.15, // smooth follow (15% of remaining distance per 16.67 ms)
2052
- deadzoneW: 64, deadzoneH: 0, // ±32 px horizontal slack before the camera scrolls
2053
- })
2054
-
2055
- // In your game loop:
2056
- setCameraTarget(cam, player.x, player.y)
2057
- tickCamera(cam, dt)
2058
-
2059
- // Render anything via worldToScreen — sprites stay aligned to the camera:
2060
- for (const e of enemies) {
2061
- if (!isInView(cam, e.x, e.y, 8, 8)) continue // cull off-screen
2062
- const s = worldToScreen(cam, e.x, e.y)
2063
- drawSprite(ctx, ENEMY, s.x, s.y, C.B_RED, C.BLACK)
2064
- }
2065
- ```
2066
-
2067
- ### `Camera` interface
2068
-
2069
- | Field | Description |
2070
- |-------|-------------|
2071
- | `x`, `y` | Current viewport top-left in world pixels (mutated by `tickCamera`) |
2072
- | `viewW`, `viewH` | Viewport size in pixels |
2073
- | `worldW`, `worldH` | World size in pixels — camera clamps so `x ∈ [0, worldW-viewW]` |
2074
- | `lerp` | `(0..1]` — fraction of remaining distance covered per 60 fps frame. `1` = snap. |
2075
- | `deadzoneW`, `deadzoneH` | Deadzone size — target may move ±`deadzoneW/2` from centre before scrolling |
2076
- | `targetX`, `targetY` | Current follow target (set via `setCameraTarget`) |
2077
-
2078
- ### `CameraOptions` interface
2079
-
2080
- Options passed to `createCamera()`. `viewW`, `viewH`, `worldW`, and `worldH`
2081
- are required; `lerp`, `deadzoneW`, and `deadzoneH` are optional.
2082
-
2083
- ```ts
2084
- interface CameraOptions {
2085
- viewW: number
2086
- viewH: number
2087
- worldW: number
2088
- worldH: number
2089
- lerp?: number
2090
- deadzoneW?: number
2091
- deadzoneH?: number
2092
- }
2093
- ```
2094
-
2095
- ### `createCamera(opts): Camera`
2096
-
2097
- Creates a camera at world origin `(0, 0)`. `lerp` defaults to `1` (snap), deadzones default to `0`.
2098
-
2099
- ### `setCameraTarget(cam, x, y): void`
2100
-
2101
- Sets the world-space follow target. Does not move the camera — `tickCamera` does that.
2102
-
2103
- ### `tickCamera(cam, dt): void`
2104
-
2105
- Advances the camera one frame:
2106
- 1. Computes the desired viewport position from the target, honouring the deadzone
2107
- 2. Eases `cam.x` / `cam.y` toward the desired position using `lerp` (frame-rate-corrected by `dt`)
2108
- 3. Clamps to world bounds so the viewport never sees outside the world
2109
-
2110
- The lerp is dt-independent: one `tickCamera(cam, 33.34)` call produces (within floating-point precision) the same result as two `tickCamera(cam, 16.67)` calls. If the world is smaller than the viewport the camera pins to `(0, 0)`.
2111
-
2112
- ### `worldToScreen(cam, wx, wy): { x, y }`
2113
-
2114
- Converts a world coordinate to a screen (viewport-relative) coordinate. Subtracts `cam.x` / `cam.y`.
2115
-
2116
- ### `isInView(cam, wx, wy, w?, h?): boolean`
2117
-
2118
- Returns `true` when a world rectangle of size `w × h` (default `0 × 0` for a point test) overlaps the camera viewport. Use to cull off-screen sprites before drawing.
2119
-
2120
- ---
2121
-
2122
- ## `scene.ts` — Scene Manager
2123
-
2124
- A stack-based scene manager with full lifecycle hooks. Replaces ad-hoc phase enums (`'intro' | 'playing' | 'gameover'`) with a clean push / pop / replace API. Only the **top** scene receives `update`, so pushing a pause overlay freezes everything beneath; **all** scenes receive `render` bottom-up, so the paused scene stays visible.
2125
-
2126
- ```ts
2127
- import { createSceneManager, pushScene, popScene, updateScenes, renderScenes, type Scene } from 'zx-kit'
2128
-
2129
- const gameplay: Scene = {
2130
- name: 'gameplay',
2131
- update(dt) { /* tick game */ },
2132
- render(ctx) { /* draw game */ },
2133
- onPause() { stopAmbientSound() },
2134
- onResume() { startAmbientSound() },
2135
- }
2136
-
2137
- const pauseOverlay: Scene = {
2138
- name: 'pause',
2139
- update(_dt) { if (keys.pressed('P')) popScene(mgr) },
2140
- render(ctx) { drawTextCentered(ctx, '** PAUSED **', ROWS/2 * CELL, C.B_WHITE, C.BLACK) },
2141
- }
2142
-
2143
- const mgr = createSceneManager()
2144
- pushScene(mgr, gameplay) // gameplay.onEnter(null)
2145
- // later, player presses P:
2146
- pushScene(mgr, pauseOverlay) // gameplay.onPause() → pauseOverlay.onEnter(gameplay)
2147
-
2148
- // Game loop:
2149
- updateScenes(mgr, dt) // only top scene ticks
2150
- renderScenes(mgr, ctx) // bottom-up: gameplay first, then pause overlay
2151
- ```
2152
-
2153
- ### `Scene` interface
2154
-
2155
- | Field | Description |
2156
- |-------|-------------|
2157
- | `name` | Human-readable identifier (for logging / debugging) |
2158
- | `update(dt)` | Called once per frame on the **top** scene only |
2159
- | `render(ctx)` | Called once per frame on **all** scenes, bottom-up |
2160
- | `onEnter?(prev)` | Fired when this scene becomes top (push / replace / initial). `prev` is the previously-top scene or `null`. |
2161
- | `onExit?(next)` | Fired when this scene is removed (pop / replace). `next` is what becomes top, or `null`. |
2162
- | `onPause?()` | Fired when another scene is pushed on top of this one. |
2163
- | `onResume?()` | Fired when the scene above this one is popped. |
2164
-
2165
- ### `createSceneManager(): SceneManager`
2166
-
2167
- Creates a manager with an empty stack.
2168
-
2169
- ### `pushScene(mgr, scene): void`
2170
-
2171
- Pushes a scene onto the stack. Lifecycle order: `prev.onPause()` → `scene.onEnter(prev)`. Use for modal overlays, dialogs, pause screens.
2172
-
2173
- ### `popScene(mgr): Scene | null`
2174
-
2175
- Pops the top scene and returns it (or `null` if the stack was empty). Lifecycle order: `top.onExit(below)` → `below.onResume()`.
2176
-
2177
- ### `replaceScene(mgr, scene): void`
2178
-
2179
- Swaps the top scene without affecting scenes beneath. Lifecycle order: `outgoing.onExit(scene)` → `scene.onEnter(outgoing)`. Does **not** fire `onPause` / `onResume` on the scene below — it was never paused by this call. Use for state transitions like `gameplay → gameOver` while keeping `intro` on the bottom.
2180
-
2181
- On an empty manager `replaceScene` behaves like `pushScene` (outgoing is `null`).
2182
-
2183
- ### `currentScene(mgr): Scene | null`
2184
-
2185
- Returns the top scene, or `null` if the stack is empty.
2186
-
2187
- ### `updateScenes(mgr, dt): void`
2188
-
2189
- Updates the top scene only. No-op on an empty manager. Scenes beneath the top stay frozen — this is what makes pause overlays work.
2190
-
2191
- ### `renderScenes(mgr, ctx): void`
2192
-
2193
- Renders every scene from bottom to top. No-op on an empty manager.
2194
-
2195
- ---
2196
-
2197
- ## `save.ts` — Typed Save / Load
2198
-
2199
- Persistent save / load via `localStorage` with versioning, schema migration, slot enumeration, and in-memory throttling. The game declares its state shape through `serialize` / `deserialize` callbacks; the kit handles storage, namespacing, error mapping, and throttle timing. Every operation returns a discriminated Result type — `quota`, `disabled`, `corrupt`, `version_unsupported` and the rest are distinct, so the game can react to each failure mode if it cares.
2200
-
2201
- ```ts
2202
- import {
2203
- createSaveProfile, writeSave, writeSaveThrottled,
2204
- readSave, readSaveLatest, saveExists, deleteSave, listSaves,
2205
- } from 'zx-kit'
2206
-
2207
- type MyGameSave = {
2208
- score: number
2209
- lives: number
2210
- probed: string[] // Set<string> serialized to array
2211
- }
2212
-
2213
- const save = createSaveProfile<MyGameSave>({
2214
- key: 'my-game',
2215
- version: 1,
2216
- serialize: () => ({
2217
- score: game.score,
2218
- lives: game.lives,
2219
- probed: [...game.probedCells],
2220
- }),
2221
- deserialize: (data) => {
2222
- game.score = data.score
2223
- game.lives = data.lives
2224
- game.probedCells = new Set(data.probed)
2225
- },
2226
- })
2227
-
2228
- writeSave(save, 'manual') // immediate write to 'manual'
2229
- writeSaveThrottled(save, 'auto', 5000) // skips if last 'auto' write < 5s ago
2230
- readSaveLatest(save) // load newest slot, calls deserialize
2231
- deleteSave(save, 'auto') // remove slot + clear its throttle entry
2232
- ```
2233
-
2234
- ### Why callbacks, not a "save the whole state" snapshot
2235
-
2236
- In emulators a full RAM dump round-trips losslessly because RAM is a byte array. JavaScript state is an object graph: `JSON.stringify(gameState)` silently corrupts `Set`, `Map`, class instances and circular references; a snapshot also persists transient runtime state (audio nodes, `requestAnimationFrame` IDs) that has no business surviving. Forcing the game to declare what's in a save via `serialize` keeps the kit state-agnostic and gives the game a place to convert non-JSON values back and forth.
2237
-
2238
- ### `SaveProfileConfig<T>` interface
2239
-
2240
- | Field | Description |
2241
- |-------|-------------|
2242
- | `key` | Game key — used as namespace in storage. Unique per game. |
2243
- | `version` | Current schema version. Increment when the shape of `T` changes. |
2244
- | `serialize` | Returns the current game state as a JSON-safe `T`. |
2245
- | `deserialize` | Applies a loaded `T` back to the game (side effect — the game owns the mutation). |
2246
- | `migrate?` | `(data: unknown, fromVersion: number) => T` — runs when the loaded envelope is older than `version`. If absent and `fromVersion < version`, load fails with `version_unsupported`. |
2247
-
2248
- ### `SaveResult` / `LoadResult`
2249
-
2250
- ```ts
2251
- type SaveResult =
2252
- | { ok: true }
2253
- | { ok: false, reason: 'quota' | 'disabled' | 'serialize_error' | 'throttled', error?: Error }
2254
-
2255
- type LoadResult =
2256
- | { ok: true, slot: string }
2257
- | { ok: false, reason: 'not_found' | 'corrupt' | 'version_unsupported' | 'parse_error' | 'disabled', error?: Error }
2258
- ```
2259
-
2260
- `throttled` is not a true failure — it means the throttle interval hadn't elapsed and the write was skipped. Surfaced as `ok: false` so callers can distinguish skipping from a real success, but typically ignored.
2261
-
2262
- ### `createSaveProfile<T>(config): SaveProfile<T>`
2263
-
2264
- Registers a save profile. Call once at startup and reuse the returned handle for every operation. The handle also carries in-memory throttle state (per-slot last-write timestamps).
2265
-
2266
- ### `writeSave(profile, slot?): SaveResult`
2267
-
2268
- Writes immediately. Calls `serialize`, wraps the result as `{ version, timestamp, data }` and stores under `zxkit:<key>:<slot>`. Default slot is `'default'`.
2269
-
2270
- ### `writeSaveThrottled(profile, slot, minIntervalMs): SaveResult`
2271
-
2272
- Writes only if at least `minIntervalMs` has elapsed since the last successful write to the same slot in this session. The first call to a given slot always proceeds — the throttle only applies once there's a prior write to compare against. Throttle state lives in memory; a page reload resets it.
2273
-
2274
- ### `readSave(profile, slot?): LoadResult`
2275
-
2276
- Reads a slot, runs `migrate` if the stored version is older than the profile version, then calls `deserialize` with the result. On `ok`, the game state has been restored.
2277
-
2278
- ### `readSaveLatest(profile): LoadResult`
2279
-
2280
- Enumerates every slot belonging to this profile's key and loads the one with the most recent `timestamp`. Returns `{ ok: false, reason: 'not_found' }` when no slots exist.
2281
-
2282
- ### `saveExists(profile, slot?): boolean`
2283
-
2284
- True iff the slot key exists in storage. Does not validate envelope shape — use `readSave` for that.
2285
-
2286
- ### `deleteSave(profile, slot?): boolean`
2287
-
2288
- Removes the slot. Also clears the slot's throttle entry, so the next `writeSaveThrottled` to that slot proceeds immediately. Returns `true` if a slot was actually removed.
2289
-
2290
- ### `listSaves(profile): SlotInfo[]`
2291
-
2292
- Returns `{ name, timestamp, version, sizeBytes }[]` for every slot belonging to this profile. Corrupt or mis-shaped entries are silently skipped — they will surface as `corrupt` if loaded explicitly via `readSave`.
2293
-
2294
- ### Versioning and migration
2295
-
2296
- Every saved payload carries `{ version, timestamp, data }`. On load, when the stored version is older than the profile's current version, `migrate` is called with the raw `data` and the version it was saved at. If the stored version is newer than the profile's, the load fails with `version_unsupported` — a downgrade cannot read forward.
2297
-
2298
- ```ts
2299
- createSaveProfile({
2300
- key: 'my-game',
2301
- version: 3,
2302
- migrate: (data, fromVersion) => {
2303
- let d = data as Record<string, unknown>
2304
- if (fromVersion < 2) d = { ...d, lives: 3 } // v1 → v2: gained 'lives'
2305
- if (fromVersion < 3) d = { ...d, probed: [] } // v2 → v3: gained 'probed'
2306
- return d as MyGameSave
2307
- },
2308
- deserialize: (data) => { /* always receives v3 shape */ },
2309
- })
2310
- ```
2311
-
2312
- If `migrate` throws, the read fails with `corrupt`.
2313
-
2314
- ### Slot naming — convention, not policy
2315
-
2316
- The kit places no policy on slot names. A useful pattern for retro-style games:
2317
-
2318
- - `'auto'` — written via `writeSaveThrottled` at meaningful game events (level complete, checkpoint, after a major state change).
2319
- - `'manual'` — written via `writeSave` when the player hits a save key.
2320
- - Load via `readSaveLatest` to pick whichever is newer.
2321
-
2322
- This composes a "your last meaningful checkpoint is always available" UX without a load-slot menu.
2323
-
2324
- ---
2325
-
2326
- ## `tilemap.ts` — Tile Map Engine
2327
-
2328
- A scrollable, queryable tile map backed by an O(1) id-index. Tiles use the same 8×8 sprite format as `drawSprite`. Supports solid-tile collision queries, viewport-clipped rendering, and smart seasonal background swapping.
2329
-
2330
- ### Types
2331
-
2332
- #### `Tile`
2333
-
2334
- | Field | Type | Description |
2335
- |-------|------|-------------|
2336
- | `sprite` | `Uint8Array` | 8-byte bitmap |
2337
- | `ink` | `SpectrumColor` | Foreground color |
2338
- | `paper` | `SpectrumColor` | Background color |
2339
- | `solid` | `boolean` | `true` = blocks movement |
2340
- | `id` | `string \| number` | Stable identifier for logic and swap operations |
2341
- | `metadata?` | `Record<string, unknown>` | Optional game payload (points, next level, …) |
2342
-
2343
- #### `Viewport`
2344
-
2345
- | Field | Type | Description |
2346
- |-------|------|-------------|
2347
- | `x` | `number` | First visible column (tile units) |
2348
- | `y` | `number` | First visible row (tile units) |
2349
- | `cols` | `number` | Number of columns to render |
2350
- | `rows` | `number` | Number of rows to render |
2351
-
2352
- ### `createTileMap(cols, rows): TileMap`
2353
-
2354
- Creates an empty `cols × rows` map — all cells start `null`.
2355
-
2356
- ### Method reference
2357
-
2358
- | Method | Description |
2359
- |--------|-------------|
2360
- | `setTile(x, y, tile)` | Store a shallow copy. Out-of-bounds = silent no-op. |
2361
- | `getTile(x, y)` | Return tile or `null`. Never throws. |
2362
- | `clearTile(x, y)` | Remove tile (collect gem, break wall). |
2363
- | `fill(tile)` | Fill every cell with independent shallow copies. |
2364
- | `fillRect(x, y, w, h, tile)` | Fill rectangle; clips to map bounds. |
2365
- | `setBackground(tile)` | Register or swap the background (see below). |
2366
- | `render(ctx, viewport?)` | Render map or viewport via `drawSprite`. Empty cells skipped. |
2367
- | `isSolid(x, y)` | `true` if tile is solid or position is out-of-bounds. |
2368
- | `findById(id)` | `{ x, y, tile }[]` for all tiles with given `id` — O(1). |
2369
-
2370
- ### Smart background swapping (`setBackground`)
2371
-
2372
- - **First call** — registers the tile as the current background. Map is not modified; call `fill` first to place tiles.
2373
- - **Subsequent calls** — replaces every cell whose `id` still matches the previous background with the new tile. Cells with any other `id` (player, gems, modified terrain) are untouched.
2374
-
2375
- ```ts
2376
- map.fill(TILE_GRASS)
2377
- map.setBackground(TILE_GRASS) // register
2378
-
2379
- map.setTile(5, 3, TILE_PLAYER) // player placed on grass
2380
-
2381
- map.setBackground(TILE_SNOW) // grass → snow; player tile untouched
2382
- map.setBackground(TILE_NIGHT) // snow → night; player still safe
2383
- ```
2384
-
2385
- ---
2386
-
2387
- ## `palette.ts` — Color Constants
2388
-
2389
- ### `SCALE`
2390
-
2391
- Default CSS-pixel scale factor: `4`. One game pixel = 4×4 CSS pixels at standard Spectrum resolution.
2392
-
2393
- ### `CELL`
2394
-
2395
- Tile and character grid size: `8` game pixels. Matches the ZX Spectrum's 8×8 character cell.
2396
-
2397
- ### `C` — Color object
2398
-
2399
- | Key | Hex | Key | Hex |
2400
- |-----|-----|-----|-----|
2401
- | `C.BLACK` | `#000000` | `C.B_BLACK` | `#000000` |
2402
- | `C.BLUE` | `#0000CD` | `C.B_BLUE` | `#0000FF` |
2403
- | `C.RED` | `#CD0000` | `C.B_RED` | `#FF0000` |
2404
- | `C.MAGENTA` | `#CD00CD` | `C.B_MAGENTA` | `#FF00FF` |
2405
- | `C.GREEN` | `#00CD00` | `C.B_GREEN` | `#00FF00` |
2406
- | `C.CYAN` | `#00CDCD` | `C.B_CYAN` | `#00FFFF` |
2407
- | `C.YELLOW` | `#CDCD00` | `C.B_YELLOW` | `#FFFF00` |
2408
- | `C.WHITE` | `#CDCDCD` | `C.B_WHITE` | `#FFFFFF` |
2409
-
2410
- ### `SpectrumColor` type
2411
-
2412
- ```ts
2413
- type SpectrumColor = typeof C[keyof typeof C]
2414
- ```
2415
-
2416
- A union of all hex string values in `C`. Enforces palette compliance at compile time — any function that accepts `SpectrumColor` will reject an arbitrary `string` at the type level.
2417
-
2418
- ---
2419
-
2420
- ## `font.ts` — ROM Bitmap Font
2421
-
2422
- 96 printable ASCII characters (codes 32–127), each 8×8 pixels, byte-for-byte faithful to the original ZX Spectrum ROM. Character 127 is a solid block `█`.
2423
-
2424
- ### `FONT`
2425
-
2426
- ```ts
2427
- const FONT: Uint8Array // 768 bytes: 96 chars × 8 rows
2428
- // Row bitmap: FONT[(charCode - 32) * 8 + row]
2429
- // Bit layout: bit 7 = leftmost pixel
2430
- ```
2431
-
2432
- ### `getCharRow(charCode, row): number`
2433
-
2434
- Returns the bitmap byte for one row of a character. `charCode` outside 32–127 returns `0`. `row` outside 0–7 returns `0`. In practice, use `drawChar`/`drawText` from `renderer.ts` — you only need `getCharRow` for custom pixel-level font rendering.
2435
-
2436
- ```ts
2437
- // Draw a character manually
2438
- for (let row = 0; row < 8; row++) {
2439
- const byte = getCharRow('A'.charCodeAt(0), row)
2440
- for (let bit = 0; bit < 8; bit++) {
2441
- if (byte & (0x80 >> bit)) ctx.fillRect(x + bit, y + row, 1, 1)
2442
- }
2443
- }
2444
- ```
2445
-
2446
- ---
2447
-
2448
- ## `i18n.ts` — Runtime Locale Selection
2449
-
2450
- A tiny helper for choosing a translated string pack at runtime. The original ZX Spectrum could not realistically swap whole languages while a game was running; zx-kit keeps the Spectrum presentation, but lets browser games offer modern language switching without a framework or dependency.
2451
-
2452
- ### `pickLocale(defaultLocale, locales, code): T`
2453
-
2454
- Selects a locale object by language code and falls back to the default locale when the code is missing, empty, or unknown. Matching is case-insensitive.
2455
-
2456
- ```ts
2457
- import { pickLocale } from 'zx-kit'
2458
- import * as en from './strings'
2459
- import * as sk from './strings.sk'
2460
- import * as ru from './strings.ru'
2461
-
2462
- let languageCode = localStorage.getItem('language') // e.g. 'sk'
2463
- let L = pickLocale(en, { sk, ru }, languageCode)
2464
-
2465
- drawText(ctx, L.STR_PRESS_START, 32, 88, C.B_WHITE, C.BLACK)
2466
-
2467
- function setLanguage(code: string): void {
2468
- languageCode = code
2469
- localStorage.setItem('language', code)
2470
- L = pickLocale(en, { sk, ru }, languageCode)
2471
- }
2472
- ```
2473
-
2474
- Every locale must have the same shape as the default locale. Because `pickLocale` is generic over that shape, missing keys and wrong function signatures are caught by TypeScript:
2475
-
2476
- ```ts
2477
- // strings.ts
2478
- export const STR_PRESS_START = 'PRESS START'
2479
- export const STR_DEPTH = (m: number) => `D:${m}M`
2480
-
2481
- // strings.sk.ts
2482
- export const STR_PRESS_START = 'STLAC START'
2483
- export const STR_DEPTH = (m: number) => `H:${m}M`
2484
- ```
2485
-
2486
- Selection rules:
2487
-
2488
- | Code | Result |
2489
- |------|--------|
2490
- | `null`, `undefined`, `''` | `defaultLocale` |
2491
- | `'sk'`, `'SK'`, `'Sk'` | `locales.sk` |
2492
- | unknown code | `defaultLocale` |
2493
- | `'en'` when `en` is not in `locales` | `defaultLocale` |
2494
-
2495
- The default language does not need its own `strings.en.ts` file. Keep the source language at `strings.ts`, put only additional translations in the locale map, and call `pickLocale` again whenever the player changes language.
2496
-
2497
- ---
2498
-
2499
- ## `tilescroll.ts` — Pixel-Smooth Scrolling
2500
-
2501
- [`tilemap.ts`](#tilemapts--tile-map-engine)'s `render(viewport)` takes a viewport in **whole tiles**, so a camera can only move in 8-pixel steps. That is perfect for grid games, but visibly steppy in a platformer where the player moves sub-pixel-smooth while jumping. `tilescroll.ts` renders the map at an **arbitrary pixel camera position** by drawing one overscan row/column and offsetting every tile by the camera remainder.
2502
-
2503
- Pair it with [`camera.ts`](#camerats--scrolling-camera): use `tileMapWorldSize` for the camera's `worldW` / `worldH`, then feed `cam.x` / `cam.y` straight into `drawTileMapAt`.
2504
-
2505
- ### `tileMapWorldSize(map): { width, height }`
2506
-
2507
- Returns the map's full size in pixels (`cols × CELL`, `rows × CELL`) — handy for the camera's world bounds.
2508
-
2509
- ```ts
2510
- import { createCamera } from 'zx-kit'
2511
- const cam = createCamera({ viewW: 256, viewH: 192, ...tileMapWorldSize(map) })
2512
- ```
2513
-
2514
- ### `drawTileMapAt(ctx, map, camX, camY, viewW?, viewH?): void`
2515
-
2516
- Renders `map` with the viewport's top-left at world pixel `(camX, camY)`. The camera position is rounded to whole pixels for crisp output; off-screen and empty cells are skipped. `viewW` (default `256`) and `viewH` (default `192`) must be positive — throws otherwise. The leading/trailing partial tiles are drawn and naturally clipped by the canvas bounds, so keep the play area at the canvas origin (or render the map first) to avoid spilling under a status bar.
2517
-
2518
- ```ts
2519
- // game loop
2520
- setCameraTarget(cam, player.x, player.y)
2521
- tickCamera(cam, dt)
2522
- drawTileMapAt(ctx, map, cam.x, cam.y) // smooth background
2523
- renderSprite(ctx, /* player drawn at (x - cam.x, y - cam.y) */)
2524
- ```
2525
-
2526
- ---
2527
-
2528
- ## `particles.ts` — Particle Pool
2529
-
2530
- An **allocation-free** particle pool for ZX-style pixel effects: carrot-shot sparks, landing dust, an enemy curling into a puff, glowing motes around a crystal. The pool is created once at startup with a fixed capacity; emitting and ticking never allocate, so it is safe to run every frame. Particles are plain coloured squares drawn in the Spectrum palette. Pass an `rng` for deterministic effects (replays, seeded worlds); otherwise it uses `Math.random`.
2531
-
2532
- ### `Particle` / `ParticleSystem` interfaces
2533
-
2534
- ```ts
2535
- interface Particle {
2536
- x: number; y: number // world pixels
2537
- vx: number; vy: number // px per ms
2538
- life: number; maxLife: number // ms (fade with life / maxLife)
2539
- color: SpectrumColor
2540
- size: number // square side in px
2541
- active: boolean // false slots are free for reuse
2542
- }
2543
-
2544
- interface ParticleSystem {
2545
- readonly particles: Particle[] // length === capacity
2546
- readonly capacity: number
2547
- activeCount: number
2548
- }
2549
- ```
2550
-
2551
- ### `Ranged` type
2552
-
2553
- Several emitter options accept either a fixed value or a `[min, max]` range.
2554
-
2555
- ```ts
2556
- type Ranged = number | readonly [number, number]
2557
- ```
2558
-
2559
- ### `createParticleSystem(capacity): ParticleSystem`
2560
-
2561
- Creates a pool of `capacity` particles, all inactive. Throws when `capacity` is not a positive integer.
2562
-
2563
- ### `emitParticles(ps, opts): number`
2564
-
2565
- Emits up to `opts.count` particles and returns the number actually emitted
2566
- (fewer when the pool is full). Throws when `count` is negative or non-integer.
2567
- The options object is exported as `EmitOptions`.
2568
-
2569
- | Option | Type | Default | Meaning |
2570
- |--------|------|---------|---------|
2571
- | `x`, `y` | `number` | — | spawn position (world px) |
2572
- | `count` | `number` | — | how many to emit |
2573
- | `color` | `SpectrumColor \| SpectrumColor[]` | — | single colour, or palette to pick per particle |
2574
- | `speed` | `number \| [min, max]` | `0.03` | px/ms |
2575
- | `angle` | `number` | `0` | base direction in radians (`-π/2` = up) |
2576
- | `spread` | `number` | `0` | angular jitter centred on `angle` |
2577
- | `life` | `number \| [min, max]` | `300` | lifetime in ms |
2578
- | `size` | `number` | `1` | square side in px |
2579
- | `rng` | `() => number` | `Math.random` | deterministic source |
2580
-
2581
- ```ts
2582
- const sparks = createParticleSystem(128)
2583
-
2584
- // on carrot impact — a fan of yellow/white sparks shooting upward
2585
- emitParticles(sparks, {
2586
- x: hit.x, y: hit.y, count: 12,
2587
- color: [C.B_YELLOW, C.B_WHITE],
2588
- speed: [0.02, 0.06], angle: -Math.PI / 2, spread: Math.PI,
2589
- life: [200, 400],
2590
- })
2591
- ```
2592
-
2593
- ### `tickParticles(ps, dtMs, gravity?): void`
2594
-
2595
- Advances every active particle by `dtMs`, applying optional `gravity` (px/ms², default `0`) to vertical velocity. Expired particles are deactivated and returned to the pool.
2596
-
2597
- ### `renderParticles(ctx, ps, offsetX?, offsetY?): void`
2598
-
2599
- Draws every active particle as a filled square, rounding world coordinates to whole pixels. Subtract the camera world position via `offsetX` / `offsetY` to convert world → screen.
2600
-
2601
- ### `clearParticles(ps): void`
2602
-
2603
- Deactivates all particles immediately (e.g. on room change).
2604
-
2605
- ```ts
2606
- // game loop
2607
- tickParticles(sparks, dt, 0.0004) // gentle gravity
2608
- renderParticles(ctx, sparks, cam.x, cam.y) // scrolled world
2609
- ```
2610
-
2611
- ---
2612
-
2613
- ## `rng.ts` — Seeded RNG
2614
-
2615
- A **seeded deterministic** pseudo-random generator: the same seed produces the same sequence on every machine and every run — exactly what procedural worlds need. Built on **mulberry32** (fast, allocation-free, good statistical quality for games). It is **not** cryptographically secure.
2616
-
2617
- The call order is part of the determinism contract: call the methods in the same order to reproduce a world.
2618
-
2619
- ### `createRng(seed): Rng`
2620
-
2621
- Creates a generator from a `string` (hashed via `hashSeed`) or a finite `number` (coerced to uint32). Throws on a non-finite numeric seed.
2622
-
2623
- ### `Rng` methods
2624
-
2625
- | Method | Returns | Throws when |
2626
- |--------|---------|-------------|
2627
- | `next()` | float `[0, 1)` | — |
2628
- | `int(maxExclusive)` | int `[0, max)` | `max` not a positive integer |
2629
- | `range(min, max)` | int `[min, max)` | bounds non-integer, or `max <= min` |
2630
- | `float(min, max)` | float `[min, max)` | `max < min` |
2631
- | `chance(p)` | boolean (`true` ~`p`) | `p` outside `[0, 1]` |
2632
- | `pick(items)` | random element | `items` empty |
2633
- | `shuffle(items)` | same array, shuffled in place | — |
2634
- | `fork()` | independent `Rng` (advances parent one step) | — |
2635
-
2636
- ```ts
2637
- const rng = createRng('cave-level-7')
2638
- const roomCount = rng.range(3, 7)
2639
- const theme = rng.pick(['spider', 'centipede', 'crystal'])
2640
- if (rng.chance(0.15)) placeSecret()
2641
-
2642
- // independent sub-streams so adding enemies doesn't shift terrain layout
2643
- const terrainRng = rng.fork()
2644
- const enemyRng = rng.fork()
2645
- ```
2646
-
2647
- ### `hashSeed(seed): number`
2648
-
2649
- Hashes a string to an unsigned 32-bit integer (FNV-1a). Deterministic; exported for keying sub-streams by name.
2650
-
2651
- ---
2652
-
2653
- ## `lighting.ts` — Dithered Cave Darkness
2654
-
2655
- The ZX way to fake light: hard 8×8 light pools with an ordered (Bayer) **dither** edge — no alpha gradients. The look of *Knight Lore* / *Head over Heels*, not modern soft shadows.
2656
-
2657
- Done the naive way (recompute a full-screen `ImageData` and `putImageData` it every frame) this is a real CPU/upload hog — it measured ~27% of a frame in an actual game. This module instead **pre-renders the dither for each darkness level to a tiny tile, darkens the view cell-by-cell into a persistent buffer (repainting only cells whose level changed), and blits the buffer with one `drawImage`** — no per-frame `putImageData`.
2658
-
2659
- You own the *policy* (where it's dark) via a per-cell callback; the module owns the fast *rendering*.
2660
-
2661
- ### `Light` interface
2662
-
2663
- ```ts
2664
- interface Light { x: number; y: number; radius: number; intensity: number } // intensity 0..1
2665
- ```
2666
-
2667
- ### `brightnessAt(px, py, lights): number`
2668
-
2669
- Brightest attenuated light at a point — `max((1 - dist/radius) * intensity)` over all lights, clamped to `0..1`. Turn it into darkness with `1 - brightnessAt(...)`. Pure.
2670
-
2671
- ### `ditherBlack(px, py, amount): boolean`
2672
-
2673
- The ordered-dither rule used to bake the tiles: is pixel `(px, py)` black at darkness `amount` (0..1)? Pure and deterministic — handy for tests or custom effects.
2674
-
2675
- ### `createDarknessLayer(width, height, levels?): DarknessLayer`
2676
-
2677
- Builds a view-sized overlay: pre-bakes `levels` dither tiles (default **8** — more = smoother), a persistent buffer, and a per-cell level cache. Call **once**; reuse across frames. Throws if `levels < 2`.
2678
-
2679
- ### `renderDarkness(layer, ctx, darknessAt): void`
2680
-
2681
- Darkens `ctx` for this frame. `darknessAt(col, row)` returns the darkness of each 8×8 cell — **0 = lit, 1 = pitch black** (clamped, then quantised to the layer's levels). Only cells whose level changed since the last call are repainted; then the buffer is blitted once.
2682
-
2683
- ```ts
2684
- import { createDarknessLayer, renderDarkness, brightnessAt, CELL } from 'zx-kit'
2685
-
2686
- const dark = createDarknessLayer(256, 192) // once
2687
-
2688
- // each frame, after drawing the scene, before the HUD:
2689
- const lights = [{ x: playerX, y: playerY, radius: 72, intensity: 1 }]
2690
- renderDarkness(dark, ctx, (col, row) => {
2691
- const b = brightnessAt(col * CELL + 4, row * CELL + 4, lights)
2692
- return 1 - b // 0 = lit … 1 = dark
2693
- })
2694
- ```
2695
-
2696
- The `darknessAt` callback is where you add **depth gradients** (darker the deeper you are), fog, flashing — anything; the renderer stays fast because it only repaints cells whose quantised level actually changed.
2697
-
2698
- > Lights are in **screen** pixels (apply the camera yourself). Rendering needs a real canvas; in a headless test environment the layer degrades to a no-op blit while the level math still runs.
2699
-
2700
- ---
2701
-
2702
- ## `music.ts` — Note-Name AY Music
2703
-
2704
- Write AY tunes by **note name** instead of raw frequencies, and **loop** them for
2705
- background music. A thin, friendly layer over [`playAY`](#playaypattern-startdelay-void):
2706
- the AY chip already plays three channels of `AYNote`s — this lets you *author* and
2707
- *repeat* them without the maths (so "I don't read note tables" is no longer a blocker).
2708
-
2709
- ### `noteToFreq(name): number`
2710
-
2711
- Note name → frequency (Hz), equal temperament, **A4 = 440**. Accepts `A5`, `C#4`,
2712
- `Db3`, `Fs5` (`s` = sharp). `r` / `-` is a rest → `0` (a silent note). Throws on a
2713
- malformed name. Pure.
2714
-
2715
- ```ts
2716
- noteToFreq('A4') // 440
2717
- noteToFreq('C4') // 261.63 (middle C)
2718
- noteToFreq('A5') // 880
2719
- ```
2720
-
2721
- ### `seq(spec, options?): AYNote[]`
2722
-
2723
- Parses a compact note string into one channel's `AYNote[]`. Tokens are
2724
- whitespace-separated `Note` or `Note:durMs`; `r` (or `-`) is a rest. `options.dur`
2725
- sets the default duration (200 ms); `options.noise` / `noisePeriod` mix LFSR noise
2726
- into every note (handy for a texture/percussion channel).
2727
-
2728
- ```ts
2729
- seq('A4 C5:400 r:200 E5') // A4 @default, C5 @400ms, rest @200ms, E5 @default
2730
- seq('r r r r', { dur: 240, noise: true }) // a noise-only texture line
2731
- ```
2732
-
2733
- ### `playAYLoop(pattern): { stop() }`
2734
-
2735
- Plays a 3-channel pattern **on repeat** — background music. Re-schedules each loop
2736
- after the pattern's length (its longest channel) and returns a handle to `stop()`.
2737
- No-ops (returns a do-nothing stop) when there's no audio context yet or the pattern
2738
- is empty. Call after a user gesture has unlocked audio.
2739
-
2740
- ```ts
2741
- const track = playAYLoop({
2742
- a: seq('A4 C5 E5 C5', { dur: 240 }), // melody
2743
- b: seq('A2:480 E2:480', { dur: 480 }), // slow bass drone
2744
- c: seq('r r r r', { dur: 240, noise: true }), // texture
2745
- })
2746
- // later…
2747
- track.stop()
2748
- ```
2749
-
2750
- > Looping re-schedules at the pattern boundary via a timer — fine for ambient /
2751
- > background loops; for tight musical sync you'd want a sample-accurate scheduler.
2752
-
2753
- ---
2754
-
2755
- ## `cache.ts` — Offscreen Layer Cache
2756
-
2757
- `drawBitmap`, `drawSprite` and `drawTileMapAt` paint **one `fillRect` per lit
2758
- pixel** — perfect for a moving sprite, but lethal for a full-screen layer redrawn
2759
- every frame (a scrolling tile map can be thousands of `fillRect`s per frame). The
2760
- cure is to render that layer **once** to an offscreen canvas, then blit it with a
2761
- single `drawImage`.
2762
-
2763
- A `LayerCache` is an offscreen canvas plus a `dirty` flag. `refreshLayer` re-runs
2764
- your draw callback **only while the cache is dirty**, then clears the flag; call
2765
- `invalidateLayer` whenever the layer's contents change (a tile edited, a level
2766
- reset) to force exactly one re-render. Headless-safe: with no `document` the
2767
- canvas is `null`, the draw is skipped, and nothing throws.
2768
-
2769
- ### `createLayerCache(width, height): LayerCache`
2770
-
2771
- Creates an offscreen cache of the given pixel size, starting `dirty`. Image
2772
- smoothing is disabled for crisp ZX output. Create once; reuse across frames.
2773
- Throws if `width`/`height` is not a positive number.
2774
-
2775
- ### `invalidateLayer(layer): void`
2776
-
2777
- Marks the cache stale so the next `refreshLayer` re-renders it.
2778
-
2779
- ### `refreshLayer(layer, render): HTMLCanvasElement | null`
2780
-
2781
- Runs `render(offscreenCtx)` only if the cache is dirty (the context is cleared
2782
- first), then clears the flag and returns the offscreen canvas to blit (or `null`
2783
- when headless). Blitting is yours: a `drawImage` source window for a scrolling
2784
- camera, or `drawImage(canvas, 0, 0)` for a static overlay.
2785
-
2786
- ```ts
2787
- // Cache a whole tile map; blit a moving camera window each frame.
2788
- const world = tileMapWorldSize(map)
2789
- const tiles = createLayerCache(world.width, world.height) // once
2790
-
2791
- // game loop:
2792
- refreshLayer(tiles, (lctx) => drawTileMapAt(lctx, map, 0, 0, world.width, world.height))
2793
- if (tiles.canvas) ctx.drawImage(tiles.canvas, camX, camY, 256, 192, 0, 0, 256, 192)
2794
-
2795
- // when a tile changes (a platform crumbles, the level resets):
2796
- invalidateLayer(tiles)
2797
- ```
2798
-
2799
- > Pairs naturally with `tilescroll` / `tilemap`: cache the static geometry and
2800
- > invalidate only on the rare tile change. The same primitive caches any static
2801
- > overlay — e.g. `refreshLayer(overlay, (c) => drawScanlines(c))`.
2802
-
2803
- ---
2804
-
2805
- ## `attrscreen.ts` — Attribute Clash (opt-in)
2806
-
2807
- The real Spectrum stored the screen as **two planes**: a 1-bit pixel bitmap and a
2808
- 32×24 grid where each 8×8 cell holds exactly *one* ink + *one* paper. Drawing into a
2809
- cell rewrote that single attribute, so the sprite and the background sharing the
2810
- cell snapped to the same two colours — the famous **colour clash**.
2811
-
2812
- zx-kit composites in full colour by default (no clash — see *Spectrum-inspired, not
2813
- hardware-accurate*). `attrscreen.ts` is the opt-in way to get the authentic bleed,
2814
- the same shape as `drawScanlines` / `renderDarkness`: route a frame through an
2815
- `AttrScreen`, then `flushAttrScreen` once.
2816
-
2817
- `stampMono` writes a monochrome bitmap's *shape* into the pixel plane and
2818
- re-attributes every cell a lit pixel lands in — and it **never clears** other
2819
- pixels, so whatever was already in a touched cell renders in the new colour. That
2820
- is the clash. The flush resolves both planes into one reused `ImageData` and uploads
2821
- it with a single `putImageData` + `drawImage` — never per-pixel `fillRect`.
2822
-
2823
- > Not hardware-exact by choice: ink and paper may be any two of the 15 colours (the
2824
- > real machine forced both to share the BRIGHT bit), and FLASH is not modelled (yet).
2825
- > Assumes a little-endian platform (every browser). Headless-safe.
2826
-
2827
- ### `createAttrScreen(cols = 32, rows = 24): AttrScreen`
2828
-
2829
- Allocates a screen-space attribute plane (default 32×24 cells = 256×192). Create
2830
- once; reuse across frames. Throws on a non-positive size.
2831
-
2832
- ### `clearAttrScreen(scr, paper, ink?)`
2833
-
2834
- Resets for a new frame: clears all pixels to paper and fills every cell's
2835
- attributes (`ink` defaults to `paper`). Call before stamping.
2836
-
2837
- ### `stampMono(scr, bitmap, x, y, ink, paper, policy?)`
2838
-
2839
- Stamps a monochrome `Bitmap` at screen pixel `(x, y)` (rounded; may be sub-cell,
2840
- negative, or off-screen — clipped). `policy` controls how touched cells recolour:
2841
- `'both'` (default — ink **and** paper, the most authentic bleed), `'ink-only'`
2842
- (keep the existing paper), `'paper-only'`.
2843
-
2844
- ### `flushAttrScreen(ctx, scr)`
2845
-
2846
- Resolves the two planes into RGBA and uploads with one `putImageData` + `drawImage`
2847
- at `(0, 0)` under the current transform (so `setupCanvas`'s `×scale` fills the
2848
- canvas). Headless: fills `scr.rgba` and skips the blit.
2849
-
2850
- ```ts
2851
- const scr = createAttrScreen() // once
2852
- // each frame, in screen space:
2853
- clearAttrScreen(scr, C.BLACK)
2854
- stampMono(scr, caveBitmap, 0, 0, C.B_BLUE, C.BLACK) // background
2855
- stampMono(scr, rabbitBitmap, rx, ry, C.B_WHITE, C.BLACK) // sprite → its cells clash to white
2856
- flushAttrScreen(ctx, scr)
2857
- ```
2858
-
2859
- > A cell clashes only when a lit pixel lands in it (silhouette clash), so a sprite
2860
- > recolours exactly the cells it visibly occupies.
2861
-
2862
- ---
2863
-
2864
- ## `monoscreen.ts` — Monochrome Playfield
2865
-
2866
- The surest cure for attribute clash is to not have it: render the action area in a
2867
- **single ink + paper** and keep the colour in a separate HUD/border. Light Force,
2868
- Bobby Bearing, Highway Encounter and Head Over Heels all did exactly this.
2869
-
2870
- A `MonoScreen` is a **1-bit foreground mask of its own size** (smaller than the
2871
- canvas) plus two colours. Everything drawn into it collapses to ink (lit pixels)
2872
- or paper — a white sprite, a green tile and a cyan hero all become the same ink, so
2873
- they can never clash. Draw the colourful HUD normally *outside* the region;
2874
- `flushMonoScreen` blits the playfield at an offset.
2875
-
2876
- Versus `attrscreen` (authentic *per-cell* clash): mono is one ink/paper for the
2877
- **whole** region — simpler, cheaper, and the right tool for a clean retro look
2878
- rather than the colour-bleed artefact. Flush is one `putImageData` + `drawImage`.
2879
- Little-endian; headless-safe.
2880
-
2881
- ### `createMonoScreen(width, height, ink, paper): MonoScreen`
2882
-
2883
- Creates a playfield of its own pixel size (not the canvas). `ink`/`paper` are
2884
- mutable — recolour the whole playfield any time (e.g. per biome). Throws on a
2885
- non-positive size.
2886
-
2887
- ### `clearMonoScreen(scr)`
2888
-
2889
- Resets the mask to all-paper. Call at the start of each frame.
2890
-
2891
- ### `drawMonoBitmap(scr, bitmap, x, y)`
2892
-
2893
- Draws a monochrome `Bitmap`'s lit pixels as foreground (clipped). Clear pixels are
2894
- left untouched, so paper and earlier draws show through.
2895
-
2896
- ### `fillMono(scr, x, y, w, h)`
2897
-
2898
- Sets a filled foreground rectangle (clipped) — a 1px-wide rect is a thread, rail or
2899
- laser; a block is a platform.
2900
-
2901
- ### `flushMonoScreen(ctx, scr, dx?, dy?)`
2902
-
2903
- Resolves the mask (ink where lit, paper elsewhere) and blits the region at canvas
2904
- offset `(dx, dy)` under the current transform. Headless: fills `scr.rgba`, skips
2905
- the blit.
2906
-
2907
- ```ts
2908
- const play = createMonoScreen(256, 160, C.BLACK, C.B_CYAN) // playfield, once
2909
- // each frame, in playfield space:
2910
- clearMonoScreen(play)
2911
- drawMonoBitmap(play, tileBmp, tx, ty)
2912
- drawMonoBitmap(play, heroBmp, hx, hy)
2913
- fillMono(play, threadX, threadY, 1, len)
2914
- flushMonoScreen(ctx, play, 0, 16) // playfield below a 16px colour HUD
2915
- ```
2916
-
2917
- > Tip: it carries its own `ink`/`paper`, so a one-line swap reskins the whole
2918
- > playfield — a cheap "biome" tint or a damage flash.
2919
-
2920
- ---
174
+ Everything is re-exported from the package root — `import { setupCanvas, createAY, /* ... */ } from 'zx-kit'`.
175
+ Zero runtime dependencies, `sideEffects: false`, fully tree-shakeable.
176
+
177
+ | Module | Summary | Guide |
178
+ |--------|---------|-------|
179
+ | `palette` | `SCALE`, `CELL`, the 15-colour `C` object, `SpectrumColor` type | [rendering](docs/rendering.md#palettets--color-constants) |
180
+ | `font` | 96-char ROM 8×8 bitmap font, `getCharRow` | [rendering](docs/rendering.md#fontts--rom-bitmap-font) |
181
+ | `renderer` | Canvas setup, sprites, text, bitmaps, attribute maps, scanlines, border flash | [rendering](docs/rendering.md#rendererts--canvas-renderer) |
182
+ | `cache` | Offscreen layer cache with dirty-flag invalidation | [rendering](docs/rendering.md#cachets--offscreen-layer-cache) |
183
+ | `attrscreen` | Opt-in authentic per-cell ink/paper colour clash | [rendering](docs/rendering.md#attrscreents--attribute-clash-opt-in) |
184
+ | `monoscreen` | Opt-in monochrome playfield + colour HUD (clash-proof) | [rendering](docs/rendering.md#monoscreents--monochrome-playfield) |
185
+ | `tilescroll` | Pixel-smooth sub-tile tilemap scrolling | [rendering](docs/rendering.md#tilescrollts--pixel-smooth-scrolling) |
186
+ | `lighting` | Dithered cave darkness, one blit per frame | [rendering](docs/rendering.md#lightingts--dithered-cave-darkness) |
187
+ | `audio` | 1-bit beeper: square-wave notes, patterns, volume | [audio](docs/audio.md#audiots--beeper-audio) |
188
+ | `ay` | AY-3-8912: 3-channel tone, LFSR noise, 16 envelopes | [audio](docs/audio.md#ayts--ay-3-8912-melodik-audio) |
189
+ | `music` | AY music by note name (`A5`, `C#4`) + looping | [audio](docs/audio.md#musicts--note-name-ay-music) |
190
+ | `collision` | AABB / rect-vs-tile / pixel-precise mask overlap | [collision](docs/collision.md) |
191
+ | `save` | Typed save/load: versioning, migration, slots, throttle | [save](docs/save.md) |
192
+ | `input` | Keyboard + gamepad movement, key-repeat, action flags | [api](docs/api.md#inputts--keyboard--gamepad-input) |
193
+ | `sprite` | Free-roaming sprites: position, velocity, gravity, flip | [api](docs/api.md#spritets--free-roaming-sprites) |
194
+ | `animation` | Frame timer, position tween, blinker | [api](docs/api.md#animationts--frame-timer--tween) |
195
+ | `camera` | Viewport follow with lerp + deadzone, world clamp | [api](docs/api.md#camerats--scrolling-camera) |
196
+ | `scene` | Stack-based scene manager with lifecycle hooks | [api](docs/api.md#scenets--scene-manager) |
197
+ | `tilemap` | Scrollable maps, solid tiles, O(1) id-index, bg swap | [api](docs/api.md#tilemapts--tile-map-engine) |
198
+ | `ui` | Boxes, frames, panel titles, progress bars, gauges | [api](docs/api.md#uits--ui-widgets) |
199
+ | `particles` | Allocation-free particle pool for pixel effects | [api](docs/api.md#particlests--particle-pool) |
200
+ | `rng` | Seeded mulberry32 PRNG (int/range/float/pick/shuffle/fork) | [api](docs/api.md#rngts--seeded-rng) |
201
+ | `i18n` | Type-safe runtime locale selection | [api](docs/api.md#i18nts--runtime-locale-selection) |
202
+ | `presentation` | Title/loading helpers: blink, tape stripes, menus | [api](docs/api.md#presentationts--title--loading-helpers) |
203
+ | `debug` | Frame-timing monitor + FPS/CPU/custom overlay | [api](docs/api.md#debugts--debug-overlay) |
2921
204
 
2922
205
  ## Architecture
2923
206