zx-kit 0.10.0 → 0.12.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
@@ -1,384 +1,560 @@
1
1
  # zx-kit
2
2
 
3
- Reusable ZX Spectrum primitives for browser games built with Vite + TypeScript + Canvas + Web Audio API.
3
+ > **Build browser games that look and sound like a ZX Spectrum without any of its limitations.**
4
4
 
5
- Extracted from [Minefield](https://github.com/zrebec/minefield) a ZX Spectrum-style minesweeper game. All modules enforce strict Spectrum authenticity: 8×8 pixel grid, 15-color palette, 1-bit square-wave audio, bitmap font.
5
+ Three-channel chiptune audio. Pixel-perfect canvas rendering. Authentic 15-color palette. ROM bitmap font. Tile maps with seasonal swapping. Physics-based sprites. Collision detection. A complete retro game engine in a single zero-dependency npm package.
6
+
7
+ [![npm](https://img.shields.io/npm/v/zx-kit)](https://www.npmjs.com/package/zx-kit)
8
+ [![license](https://img.shields.io/npm/l/zx-kit)](LICENSE)
9
+
10
+ ---
11
+
12
+ ## Why zx-kit?
13
+
14
+ The ZX Spectrum was a marvel of constraint. Its 8×8 pixel grid, 15-color palette, and 1-bit beeper defined an entire visual and sonic language. Thousands of games were made with nearly nothing — and they were unforgettable.
15
+
16
+ zx-kit lets you build in that same visual tradition, but with everything the original hardware was too limited to provide: three-channel AY-3-8912 chiptune audio with hardware-accurate envelopes and LFSR noise, smooth canvas rendering, physics-based sprites, and collision detection — all in TypeScript, all in the browser, all with zero dependencies.
17
+
18
+ The goal is simple: **it should look and sound like a Spectrum, but run like a modern game.**
19
+
20
+ ---
21
+
22
+ ## Key Features
23
+
24
+ - **AY-3-8912 Melodik emulator** — three independent square-wave channels, LFSR noise generator, all 16 hardware envelope shapes, logarithmic amplitude table accurate to the real chip
25
+ - **ZX Spectrum ROM font** — all 96 printable ASCII characters, 8×8 pixels, byte-for-byte faithful to the original ROM
26
+ - **Authentic 15-color palette** — normal and bright variants, palette-enforced at compile time via the `SpectrumColor` type
27
+ - **Canvas renderer** — pixel-perfect scaled rendering, sprite flipping, text drawing, CRT scanline overlay, animated border flashing
28
+ - **Tile map engine** — scrollable maps, O(1) id-index, smart seasonal background swapping, solid-tile collision queries
29
+ - **Free-roaming sprites** — position, velocity, gravity, `flipX` caching, transparent or opaque background
30
+ - **AABB collision resolution** — sprite vs. sprite overlap, sprite vs. tile map wall resolution with directional hit flags
31
+ - **Keyboard input** — configurable key-repeat, single-consume action flags, instant state reset on phase transitions
32
+ - **ZX-style UI widgets** — progress bars with managed lifetime, boxes, frames, panel titles
33
+ - **Zero dependencies** — only Web platform APIs: `Canvas`, `Web Audio`, `KeyboardEvent`
34
+ - **Tree-shakeable** — `sideEffects: false`, so unused modules are dropped from your production bundle
35
+ - **TypeScript-first** — strict mode, full `.d.ts` declarations, no `any`
36
+
37
+ ---
38
+
39
+ ## Live Demo
40
+
41
+ **[Minefield — ZX Spectrum Minesweeper](https://zrebec.github.io/minefield/)** — built entirely with zx-kit.
6
42
 
7
43
  ---
8
44
 
9
45
  ## Installation
10
46
 
47
+ ### From npm (recommended)
48
+
11
49
  ```bash
12
50
  npm install zx-kit
13
51
  ```
14
52
 
15
- Import from the barrel:
53
+ Then import directly — no Vite alias, no path mapping, no bundler configuration required:
16
54
 
17
55
  ```ts
18
- import { C, CELL, setupCanvas, initAudio, playPattern, initInput, tickMovement } from 'zx-kit'
56
+ import { setupCanvas, C, CELL, initAudio, playAY, initInput } from 'zx-kit'
19
57
  ```
20
58
 
21
- No Vite alias or path mapping required — the package ships compiled JavaScript (`dist/`).
59
+ The package ships compiled JavaScript (`dist/`) with full TypeScript declarations.
60
+
61
+ ### From source (local / offline development)
62
+
63
+ Clone the repository and link it into your project:
64
+
65
+ ```bash
66
+ # 1. Clone and build zx-kit
67
+ git clone https://github.com/zrebec/zx-kit.git
68
+ cd zx-kit
69
+ npm install
70
+ npm run build
71
+
72
+ # 2. In your game project — install from local path
73
+ npm install ../zx-kit
74
+ ```
75
+
76
+ > Use `npm install ../zx-kit --prefer-online` if npm caches the local path aggressively.
77
+ > Switch back to the npm version any time: `npm install zx-kit@latest`
22
78
 
23
79
  ---
24
80
 
25
- ## Quick start
81
+ ## Quick Start
82
+
83
+ A game loop in under 30 lines:
26
84
 
27
85
  ```ts
28
- import { setupCanvas, C, CELL, drawText, initAudio, playPattern, initInput, tickMovement } from 'zx-kit'
86
+ import {
87
+ setupCanvas, C, CELL,
88
+ drawText, drawSprite,
89
+ initAudio, createAY,
90
+ initInput, tickMovement,
91
+ } from 'zx-kit'
29
92
 
30
- // Canvas — one call replaces the manual boilerplate
31
93
  const canvas = document.getElementById('game') as HTMLCanvasElement
32
- const ctx = setupCanvas(canvas, 4) // scale=4, 256×192 game px → 1024×768 CSS px
94
+ const ctx = setupCanvas(canvas, 4) // 256×192 game px → 1024×768 CSS px
33
95
 
34
- // Input
35
96
  initInput()
36
97
 
37
- // Audio (must be inside a user gesture)
38
- window.addEventListener('keydown', () => initAudio(), { once: true })
98
+ // Audio must start inside a user gesture (browser policy)
99
+ let ay: ReturnType<typeof createAY> | null = null
100
+ window.addEventListener('keydown', () => {
101
+ initAudio()
102
+ ay = createAY()
103
+ ay.tone('A', 440, 10) // start a tone on channel A
104
+ }, { once: true })
105
+
106
+ const PLAYER = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
107
+ let px = 120, py = 88
108
+
109
+ let last = performance.now()
110
+ function loop(now: number) {
111
+ const dt = now - last; last = now
39
112
 
40
- // Game loop
41
- function loop(dt: number) {
42
113
  const dir = tickMovement(dt)
43
- if (dir) movePlayer(dir)
114
+ if (dir === 'left') px -= 1
115
+ if (dir === 'right') px += 1
116
+ if (dir === 'up') py -= 1
117
+ if (dir === 'down') py += 1
118
+
119
+ ctx.fillStyle = C.BLACK
120
+ ctx.fillRect(0, 0, 256, 192)
121
+ drawText(ctx, 'ZX-KIT', 0, 0, C.B_GREEN, C.BLACK)
122
+ drawSprite(ctx, PLAYER, px, py, C.B_CYAN, C.BLACK)
44
123
 
45
- drawText(ctx, 'SCORE:00000', 0, 0, C.B_WHITE, C.BLACK)
46
- requestAnimationFrame(t => loop(t - lastT))
124
+ requestAnimationFrame(loop)
47
125
  }
126
+ requestAnimationFrame(loop)
48
127
  ```
49
128
 
50
129
  ---
51
130
 
52
131
  ## Modules
53
132
 
54
- ### `palette.ts` ZX Spectrum color constants
55
-
56
- #### Exports
57
-
58
- | Export | Type | Description |
59
- |--------|------|-------------|
60
- | `SCALE` | `number` | CSS pixel scale factor (1 game pixel = 4 CSS pixels) |
61
- | `CELL` | `number` | Sprite / character grid size: `8` game pixels |
62
- | `C` | `object` | All 15 Spectrum colors as `#RRGGBB` hex strings |
63
- | `SpectrumColor` | `type` | Union of every value in `C` use for compile-time palette enforcement |
64
-
65
- #### Color table
66
-
67
- Normal brightness:
68
-
69
- | Key | Hex |
70
- |-----|-----|
71
- | `C.BLACK` | `#000000` |
72
- | `C.BLUE` | `#0000CD` |
73
- | `C.RED` | `#CD0000` |
74
- | `C.MAGENTA` | `#CD00CD` |
75
- | `C.GREEN` | `#00CD00` |
76
- | `C.CYAN` | `#00CDCD` |
77
- | `C.YELLOW` | `#CDCD00` |
78
- | `C.WHITE` | `#CDCDCD` |
79
-
80
- Bright variants (`B_` prefix):
81
-
82
- | Key | Hex |
83
- |-----|-----|
84
- | `C.B_BLACK` | `#000000` |
85
- | `C.B_BLUE` | `#0000FF` |
86
- | `C.B_RED` | `#FF0000` |
87
- | `C.B_MAGENTA` | `#FF00FF` |
88
- | `C.B_GREEN` | `#00FF00` |
89
- | `C.B_CYAN` | `#00FFFF` |
90
- | `C.B_YELLOW` | `#FFFF00` |
91
- | `C.B_WHITE` | `#FFFFFF` |
133
+ | Module | What it provides |
134
+ |--------|-----------------|
135
+ | [`ay.ts`](#ayts--ay-3-8912-melodik-audio) | AY chip emulator: 3-channel tone, LFSR noise, 16 envelope shapes |
136
+ | [`renderer.ts`](#rendererts--canvas-renderer) | Canvas setup, sprites, text, scanlines, border flash |
137
+ | [`audio.ts`](#audiots--beeper-audio) | 1-bit beeper: square-wave notes, patterns, volume control |
138
+ | [`ui.ts`](#uits--ui-widgets) | Progress bars, boxes, frames, panel titles |
139
+ | [`input.ts`](#inputts--keyboard-input) | Movement with key-repeat, action flags, state reset |
140
+ | [`sprite.ts`](#spritets--free-roaming-sprites) | Sprites: position, velocity, gravity, flip, render |
141
+ | [`collision.ts`](#collisionts--aabb-collision) | AABB overlap tests, tile-map wall resolution |
142
+ | [`animation.ts`](#animationts--frame-timer--tween) | Frame-timer for sprite strips, position tween between two points |
143
+ | [`tilemap.ts`](#tilemapts--tile-map-engine) | Scrollable maps, solid tiles, O(1) id-index, background swap |
144
+ | [`palette.ts`](#palettets--color-constants) | 15 Spectrum colors, `SpectrumColor` type, `CELL`, `SCALE` |
145
+ | [`font.ts`](#fontts--rom-bitmap-font) | 96-character ROM font, raw bitmap access |
92
146
 
93
147
  ---
94
148
 
95
- ### `font.ts` — ZX Spectrum ROM bitmap font
149
+ ## `ay.ts` — AY-3-8912 Melodik Audio
96
150
 
97
- 96 printable characters (ASCII 32–127), each 8×8 pixels. Character 127 is a solid block █.
151
+ 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.
98
152
 
99
- #### Exports
153
+ Two usage modes:
100
154
 
101
- | Export | Signature | Description |
102
- |--------|-----------|-------------|
103
- | `FONT` | `Uint8Array` | Flat array: 96 chars × 8 bytes. `FONT[(code-32)*8 + row]` = one row bitmap. |
104
- | `getCharRow` | `(charCode, row) => number` | Bitmap byte for one row of a character (bit 7 = leftmost pixel). |
155
+ | Mode | Function | Use case |
156
+ |------|----------|----------|
157
+ | **Real-time** | `createAY()` | Persistent chip handle set channels live (SFX, dynamic music) |
158
+ | **Sequencer** | `playAY(pattern)` | Pre-scheduled, fire-and-forget (music tracks, jingles) |
105
159
 
106
- In practice use `drawChar` / `drawText` from `renderer.ts` you rarely need `getCharRow` directly.
160
+ Both modes route through the zx-kit master `GainNode`, so `setMasterVolume()` works globally.
107
161
 
108
- ---
162
+ ### `AY_CLOCK`
109
163
 
110
- ### `renderer.ts` — Canvas drawing primitives
164
+ ```ts
165
+ export const AY_CLOCK = 1_773_400 // Hz — ZX Spectrum 128K / Melodik
166
+ ```
111
167
 
112
- All functions work in **game pixels**. At `SCALE=4` each game pixel maps to a 4×4 CSS pixel block. Call `setupCanvas` once at startup to configure the canvas correctly.
168
+ The AY-3-8912 master clock. Exported for use in frequency calculations:
169
+ `f_Hz = AY_CLOCK / (16 × period_register)`.
113
170
 
114
- Every draw function follows the ZX Spectrum **ink / paper** model: each 8×8 cell has one foreground (ink) and one background (paper) color.
171
+ ### `AY_VOL`
115
172
 
116
- #### `setupCanvas(canvas, scale, width?, height?): CanvasRenderingContext2D`
173
+ ```ts
174
+ export const AY_VOL: readonly number[] = [
175
+ 0, 0.0089, 0.0118, 0.0156, 0.0211, 0.0289, 0.0403, 0.0549,
176
+ 0.0744, 0.1060, 0.1518, 0.2139, 0.2969, 0.4259, 0.6098, 1.0,
177
+ ]
178
+ ```
117
179
 
118
- Initialises a canvas element for pixel-perfect scaled rendering. Sets canvas dimensions, applies CSS size, disables image smoothing, and calls `ctx.scale(scale, scale)` so all subsequent draw calls use game-pixel coordinates. **Replaces the manual canvas setup boilerplate.**
180
+ Hardware-accurate logarithmic amplitude table. Each step √2 (3 dB), matching the real chip's resistor ladder. Index 0 = silence, index 15 = full amplitude.
119
181
 
120
- - `scale` — CSS pixels per game pixel (`4` = standard ZX Spectrum display)
121
- - `width` — game pixels wide (default `256`)
122
- - `height` — game pixels tall (default `192`)
182
+ ### `AY_ENVELOPE_SHAPES`
123
183
 
124
184
  ```ts
125
- const canvas = document.getElementById('game') as HTMLCanvasElement
126
- const ctx = setupCanvas(canvas, 4) // 256×192 game px → 1024×768 CSS px
127
- const ctx = setupCanvas(canvas, 4, 256, 208) // taller canvas for status rows
128
- // ctx.imageSmoothingEnabled is already false — no need to set it manually
129
- // all draw calls use game-pixel coordinates — ctx.scale() is already applied
185
+ export const AY_ENVELOPE_SHAPES: readonly string[]
130
186
  ```
131
187
 
132
- #### `mirrorSprite(src): Uint8Array`
188
+ Human-readable names for all 16 R13 envelope shapes — useful for documentation, tooling, and debugging.
189
+
190
+ | R13 | Shape | Description |
191
+ |-----|-------|-------------|
192
+ | 0–3 | `\_ ` | One-shot decay, hold at zero |
193
+ | 4–7 | `/_ ` | One-shot attack, hold at zero |
194
+ | 8 | `\\\\` | Repeat decay (sawtooth down) |
195
+ | 9 | `\_` | One-shot decay, hold at zero |
196
+ | 10 | `\/\/` | Alternate down/up (triangle) |
197
+ | 11 | `\‾` | One-shot decay, hold at maximum |
198
+ | 12 | `//` | Repeat attack (sawtooth up) |
199
+ | 13 | `/‾` | One-shot attack, hold at maximum |
200
+ | 14 | `/\/\`| Alternate up/down (triangle) |
201
+ | 15 | `/_` | One-shot attack, hold at zero |
133
202
 
134
- Flips an 8×8 sprite horizontally. Returns a new `Uint8Array`.
203
+ ### `AYChannel` type
135
204
 
136
205
  ```ts
137
- export const PLAYER_RIGHT = new Uint8Array([...])
138
- export const PLAYER_LEFT = mirrorSprite(PLAYER_RIGHT)
206
+ type AYChannel = 'A' | 'B' | 'C'
139
207
  ```
140
208
 
141
- #### `drawSprite(ctx, sprite, x, y, ink, paper): void`
142
-
143
- Draws an 8×8 sprite at game coordinates. Always fills the background first.
209
+ ### `AYNote` interface
144
210
 
145
211
  ```ts
146
- drawSprite(ctx, MINE_SPRITE, col * CELL, row * CELL, C.B_RED, C.BLACK)
212
+ interface AYNote {
213
+ freq: number // Hz — 0 = rest
214
+ dur: number // milliseconds
215
+ vol?: number // 0–15 (default 15). Ignored when envShape is set.
216
+ noise?: boolean // mix LFSR noise alongside tone (default false)
217
+ noisePeriod?: number // 1–31 — higher = darker texture (default 8)
218
+ envShape?: number // 0–15 (R13) — activates envelope, overrides vol
219
+ envCycleDurMs?: number // ms for one ramp (15→0 or 0→15). Default = note duration.
220
+ }
147
221
  ```
148
222
 
149
- #### `drawChar(ctx, code, x, y, ink, paper?): void`
223
+ ### `AYChip` interface
150
224
 
151
- Draws one ASCII character using the ROM font. Omit `paper` for transparent background.
225
+ The handle returned by `createAY()`.
152
226
 
153
227
  ```ts
154
- drawChar(ctx, 127, x, y, C.B_GREEN, C.BLACK) // solid block █
155
- drawChar(ctx, 'A'.charCodeAt(0), x, y, C.B_WHITE)
228
+ interface AYChip {
229
+ tone(ch: AYChannel, freq: number, vol?: number): void
230
+ enableNoise(ch: AYChannel, period?: number): void
231
+ disableNoise(ch: AYChannel): void
232
+ envelope(ch: AYChannel, shape: number, cycleDurMs: number): void
233
+ mute(ch: AYChannel): void
234
+ muteAll(): void
235
+ stop(): void
236
+ }
156
237
  ```
157
238
 
158
- #### `drawText(ctx, text, x, y, ink, paper?): void`
239
+ ### `createAY(): AYChip`
159
240
 
160
- Draws a string left-to-right, one character per `CELL`-wide slot.
241
+ Creates three persistent AY channels wired to the master gain. Each channel has:
242
+ - An independent square-wave oscillator (tone)
243
+ - An LFSR noise path (shared 17-bit noise source, per-channel lowpass filter and gain)
244
+ - `AudioParam` automation for envelope
245
+
246
+ Must be called inside a user-gesture handler.
161
247
 
162
248
  ```ts
163
- drawText(ctx, 'SCORE:00000', 0, statusY, C.B_WHITE, C.BLACK)
249
+ button.addEventListener('click', () => {
250
+ initAudio()
251
+ const ay = createAY()
252
+
253
+ // Simple tone
254
+ ay.tone('A', 440, 12) // channel A: A4, amplitude level 12
255
+
256
+ // Tone + noise mix
257
+ ay.tone('B', 220, 10)
258
+ ay.enableNoise('B', 16) // darker noise (higher period = lower cutoff)
259
+
260
+ // Envelope — shape 10 = \/\/ triangle, 400ms cycle
261
+ ay.tone('C', 110, 0) // oscillator active but tone gain is silent
262
+ ay.envelope('C', 10, 400) // envelope drives the amplitude
263
+
264
+ setTimeout(() => ay.muteAll(), 3000)
265
+ setTimeout(() => ay.stop(), 3500)
266
+ })
164
267
  ```
165
268
 
166
- #### `drawTextCentered(ctx, text, y, cols, ink, paper?): void`
269
+ #### `ay.tone(ch, freq, vol?)`
270
+
271
+ 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.
272
+
273
+ #### `ay.enableNoise(ch, period?)`
167
274
 
168
- Centers a string within `cols` character columns. Bind `cols` once in a helper to avoid repetition.
275
+ 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).
276
+
277
+ #### `ay.disableNoise(ch)`
278
+
279
+ Fades noise out on a channel with a 5ms release.
280
+
281
+ #### `ay.envelope(ch, shape, cycleDurMs)`
282
+
283
+ 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.
169
284
 
170
285
  ```ts
171
- // Bind once:
172
- const centered = (ctx: CanvasRenderingContext2D, text: string, y: number, ink: SpectrumColor) =>
173
- drawTextCentered(ctx, text, y, 32, ink)
286
+ // Explosion: channel C, shape 8 (repeat decay), 60ms per cycle
287
+ ay.enableNoise('C', 5)
288
+ ay.envelope('C', 8, 60)
174
289
 
175
- centered(ctx, 'GAME OVER', y, C.B_RED)
290
+ // Organ: shape 13 (/‾ fast attack, hold high), 20ms attack
291
+ ay.tone('A', 523, 0)
292
+ ay.envelope('A', 13, 20)
176
293
  ```
177
294
 
178
- #### `flashBorder(color, times, intervalMs, resetColor?): void`
295
+ #### `ay.mute(ch)` / `ay.muteAll()`
179
296
 
180
- Flashes `document.body.style.backgroundColor` between `color` and `resetColor`. Fire-and-forget — does not block. One flash = one `color → resetColor` cycle. Always resets to `resetColor` on completion (default `C.BLACK`).
297
+ Fade out one or all channels (5ms release). Cancels any pending envelope automation.
298
+
299
+ #### `ay.stop()`
300
+
301
+ Stops all oscillators and the noise source, disconnects all Web Audio nodes. Call when discarding the chip instance.
302
+
303
+ ---
304
+
305
+ ### `playAY(pattern, startDelay?): void`
306
+
307
+ 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.
181
308
 
182
309
  ```ts
183
- flashBorder(C.B_RED, 3, 150) // explosion 3 red flashes black
184
- flashBorder(C.B_GREEN, 2, 200) // level complete
185
- flashBorder(C.B_CYAN, 2, 120, C.BLUE) // flash → reset to blue border
310
+ // Three-channel chiptune jingle with envelope and noise
311
+ playAY({
312
+ a: [
313
+ { freq: 523, dur: 300, envShape: 13, envCycleDurMs: 20 }, // C5, organ attack
314
+ { freq: 659, dur: 300, envShape: 13, envCycleDurMs: 20 }, // E5
315
+ { freq: 784, dur: 600, envShape: 12, envCycleDurMs: 100 }, // G5, sawtooth swell
316
+ ],
317
+ b: [
318
+ { freq: 261, dur: 600, vol: 10 }, // C4 bass note
319
+ { freq: 329, dur: 600, vol: 10 }, // E4
320
+ ],
321
+ c: [
322
+ { freq: 0, dur: 100, noise: true, noisePeriod: 5, envShape: 8, envCycleDurMs: 40 }, // snare hit
323
+ { freq: 0, dur: 1100 }, // silence
324
+ ],
325
+ })
326
+
327
+ // With a 500ms startup delay
328
+ playAY({ a: melody, b: bass }, 500)
186
329
  ```
187
330
 
188
331
  ---
189
332
 
190
- ### `audio.ts` — Web Audio engine (ZX Spectrum style)
333
+ ## `renderer.ts` — Canvas Renderer
191
334
 
192
- Wraps the Web Audio API for authentic 1-bit square-wave sound. All audio goes through a shared `AudioContext` and a single master `GainNode`.
335
+ 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.
193
336
 
194
- **Browser autoplay policy:** `AudioContext` must be created inside a user gesture (click or keydown). Call `initAudio()` from an event handler.
337
+ ### `setupCanvas(canvas, scale, width?, height?): CanvasRenderingContext2D`
195
338
 
196
- #### `initAudio(volume?): void`
339
+ One-call canvas initialization. Sets dimensions, CSS size, disables smoothing, applies scale transform.
197
340
 
198
- Creates the `AudioContext` and master gain. Idempotent safe to call multiple times. `volume` is clamped to 0.0–1.0.
341
+ - `scale` — CSS pixels per game pixel. `4` = standard ZX display (256×192 → 1024×768)
342
+ - `width` — game pixels wide, default `256`
343
+ - `height` — game pixels tall, default `192`
199
344
 
200
345
  ```ts
201
- window.addEventListener('keydown', () => initAudio(), { once: true })
202
- window.addEventListener('click', () => initAudio(), { once: true })
346
+ const ctx = setupCanvas(canvas, 4) // standard 256×192
347
+ const ctx = setupCanvas(canvas, 4, 256, 208) // +2 extra rows for status bar
348
+ const ctx = setupCanvas(canvas, 3) // 768×576 CSS — smaller screen
203
349
  ```
204
350
 
205
- #### `resumeAudio(): void`
351
+ ### `mirrorSprite(src): Uint8Array`
352
+
353
+ 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.
206
354
 
207
- Resumes a suspended `AudioContext`. Call before scheduling audio in the game loop.
355
+ ```ts
356
+ export const PLAYER_RIGHT = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
357
+ export const PLAYER_LEFT = mirrorSprite(PLAYER_RIGHT)
358
+ ```
208
359
 
209
- #### `getAudioContext(): AudioContext | null`
360
+ ### `drawSprite(ctx, sprite, x, y, ink, paper): void`
210
361
 
211
- Returns the current context, or `null` before `initAudio()`.
362
+ Draws an 8×8 bitmap at game coordinates. Always paints the `paper` background first. `ink` and `paper` must be `SpectrumColor` values.
212
363
 
213
- #### `getMasterGain(): GainNode | null`
364
+ ```ts
365
+ drawSprite(ctx, MINE_SPRITE, col * CELL, row * CELL, C.B_RED, C.BLACK)
366
+ drawSprite(ctx, GEM_SPRITE, col * CELL, row * CELL, C.B_CYAN, C.BLACK)
367
+ drawSprite(ctx, DOOR_SPRITE, col * CELL, row * CELL, C.YELLOW, C.B_BLUE)
368
+ ```
214
369
 
215
- Returns the master gain node. Connect custom oscillators here to respect global volume.
370
+ ### `drawChar(ctx, charCode, x, y, ink, paper?): void`
216
371
 
217
- #### `getMasterVolume(): number`
372
+ Draws one ASCII character from the ROM font. Omit `paper` for a transparent background (only ink pixels are drawn).
218
373
 
219
- Returns the current master volume (0.0–1.0), or `0` before `initAudio()`.
374
+ ```ts
375
+ drawChar(ctx, 127, x, y, C.B_GREEN, C.BLACK) // solid block █
376
+ drawChar(ctx, 'A'.charCodeAt(0), x, y, C.B_WHITE) // transparent bg
377
+ ```
220
378
 
221
- #### `setMasterVolume(volume): void`
379
+ ### `drawText(ctx, text, x, y, ink, paper?): void`
222
380
 
223
- Sets master volume. Clamped to 0.0–1.0. No-op before `initAudio()`.
381
+ Draws a string left-to-right, one character per `CELL`-wide slot.
224
382
 
225
383
  ```ts
226
- setMasterVolume(0.5) // 50%
227
- setMasterVolume(0) // mute
228
- setMasterVolume(1) // full
384
+ drawText(ctx, 'SCORE:00000', 0, statusY, C.B_WHITE, C.BLACK)
385
+ drawText(ctx, 'PRESS ANY KEY', x, y, C.B_YELLOW) // transparent bg
229
386
  ```
230
387
 
231
- #### `increaseVolume(): void` / `decreaseVolume(): void`
388
+ ### `drawTextCentered(ctx, text, y, cols, ink, paper?): void`
232
389
 
233
- Adjusts master volume by ±0.1, clamped at 0.0–1.0.
390
+ Centers a string within `cols` character columns.
234
391
 
235
392
  ```ts
236
- // Volume keys
237
- if (consumeAnyKey()) increaseVolume() // example: + key
238
- decreaseVolume() // example: - key
393
+ // Bind the column count once to keep call sites clean
394
+ const print = (text: string, y: number, ink: SpectrumColor) =>
395
+ drawTextCentered(ctx, text, y, 32, ink)
239
396
 
240
- // Current state
241
- const vol = getMasterVolume() // e.g. 0.4 after one increaseVolume()
397
+ print('GAME OVER', 88, C.B_RED)
398
+ print('PRESS ANY KEY', 104, C.B_WHITE)
242
399
  ```
243
400
 
244
- #### `Note` interface
401
+ ### `flashBorder(color, times, intervalMs, resetColor?): void`
402
+
403
+ 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.
404
+
405
+ - `resetColor` defaults to `C.BLACK`
245
406
 
246
407
  ```ts
247
- interface Note {
248
- freq: number // Hz — use 0 for a rest (silence)
249
- dur: number // ms duration of note or rest
250
- }
408
+ flashBorder(C.B_RED, 3, 150) // 3 red flashes → black (explosion)
409
+ flashBorder(C.B_GREEN, 2, 200) // level complete
410
+ flashBorder(C.B_CYAN, 2, 120, C.B_BLUE) // flash reset to blue border
251
411
  ```
252
412
 
253
- #### `playPattern(notes, startDelay?): void`
413
+ ### `drawScanlines(ctx, width?, height?, alpha?): void`
254
414
 
255
- Schedules a sequence of notes. `freq: 0` = rest (advances time, no sound). `startDelay` delays the whole pattern in milliseconds.
415
+ 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).
256
416
 
257
417
  ```ts
258
- // Rising arpeggio
259
- playPattern([
260
- { freq: 262, dur: 100 }, // C4
261
- { freq: 330, dur: 100 }, // E4
262
- { freq: 392, dur: 100 }, // G4
263
- { freq: 523, dur: 200 }, // C5
264
- ])
265
-
266
- // With rests and a 200ms startup delay
267
- playPattern([
268
- { freq: 523, dur: 120 }, // C5
269
- { freq: 0, dur: 40 }, // rest
270
- { freq: 784, dur: 200 }, // G5
271
- ], 200)
418
+ // At the end of each frame, after all game content:
419
+ drawScanlines(ctx) // standard 256×192, alpha=0.18
420
+ drawScanlines(ctx, 256, 208) // taller canvas
421
+ drawScanlines(ctx, 256, 192, 0.25) // darker scanlines
272
422
  ```
273
423
 
274
- #### `beep(freq, durationMs, startTime): void`
424
+ ### `curveDisplay(ctx, width?, height?, strength?): void`
275
425
 
276
- Schedules a single square-wave beep at an absolute `AudioContext.currentTime`. Use `playPattern` for sequences; use `beep` directly when you need algorithmic timing control.
426
+ 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.
277
427
 
278
428
  ```ts
279
- const audio = getAudioContext()!
280
- resumeAudio()
281
- beep(880, 80, audio.currentTime)
282
- beep(880, 80, audio.currentTime + 0.14) // 140ms later
429
+ // Last step, after drawScanlines:
430
+ curveDisplay(ctx) // default strength
431
+ curveDisplay(ctx, 256, 208, 6) // stronger warp
283
432
  ```
284
433
 
285
434
  ---
286
435
 
287
- ### `input.ts` — Keyboard input with key-repeat
436
+ ## `audio.ts` — Beeper Audio
437
+
438
+ Single-channel 1-bit square-wave audio, faithful to the ZX Spectrum beeper. Use this for simple SFX and monophonic melodies. For music with harmony, noise, and envelopes, use `ay.ts`.
288
439
 
289
- Handles arrow-key movement with configurable key-repeat (immediate on first press, auto-repeat on hold) plus single-consume flags for action keys.
440
+ All audio routes through a shared `AudioContext` and master `GainNode`. **`initAudio()` must be called inside a user-gesture handler** due to browser autoplay policy.
290
441
 
291
- **Call `initInput()` once at startup.** Then call `tickMovement(dt)` every frame.
442
+ ### `initAudio(volume?): void`
292
443
 
293
- #### `Direction` type
444
+ Creates the `AudioContext` and master gain node. Idempotent — safe to call multiple times. `volume` is clamped to 0.0–1.0 (default `0.3`).
294
445
 
295
446
  ```ts
296
- type Direction = 'up' | 'down' | 'left' | 'right'
447
+ window.addEventListener('keydown', () => initAudio(), { once: true })
448
+ window.addEventListener('click', () => initAudio(), { once: true })
297
449
  ```
298
450
 
299
- #### `initInput(repeatDelay?, repeatInterval?): void`
451
+ ### `resumeAudio(): void`
300
452
 
301
- Attaches `keydown`/`keyup` listeners. Idempotent timing params are always updated; listeners are only attached on the first call. Keys: arrows = movement, `F` = flag, `P` = pause, `Ctrl+Shift+B` = debug.
453
+ Resumes a suspended `AudioContext`. Browsers suspend the context on tab hide or first load. Call before scheduling any audio in the game loop.
302
454
 
303
- ```ts
304
- initInput() // 150ms delay, 80ms repeat
305
- initInput(200, 60) // custom timing; safe to call again to reconfigure
306
- ```
455
+ ### `getAudioContext(): AudioContext | null`
456
+
457
+ Returns the shared context, or `null` before `initAudio()`.
458
+
459
+ ### `getMasterGain(): GainNode | null`
460
+
461
+ Returns the master gain node. Connect custom oscillators here to participate in the global volume level.
462
+
463
+ ### `getMasterVolume(): number`
464
+
465
+ Returns the current master volume (0.0–1.0), or `0` before `initAudio()`.
307
466
 
308
- #### `tickMovement(dtMs): Direction | null`
467
+ ### `setMasterVolume(volume): void`
309
468
 
310
- Returns the movement direction for this frame, or `null`. Call once per frame.
469
+ Sets master volume. Clamped to 0.0–1.0. No-op before `initAudio()`.
311
470
 
312
471
  ```ts
313
- const dir = tickMovement(dt)
314
- if (dir) movePlayer(dir)
472
+ setMasterVolume(0.5) // 50%
473
+ setMasterVolume(0) // mute
474
+ setMasterVolume(1) // full
315
475
  ```
316
476
 
317
- #### Consume flags
477
+ ### `increaseVolume() / decreaseVolume(): void`
318
478
 
319
- | Function | Trigger | Use case |
320
- |----------|---------|----------|
321
- | `consumeFlag()` | `F` key | Flag / unflag a cell |
322
- | `consumeDebug()` | `Ctrl+Shift+B` | Toggle debug mode |
323
- | `consumePause()` | `P` key | Pause / unpause |
324
- | `consumeAnyKey()` | Any key | Dismiss overlays, start game |
479
+ Adjusts master volume by ±0.1, clamped to 0.0–1.0.
325
480
 
326
- Each returns `true` once per press, then resets.
481
+ ### `Note` interface
327
482
 
328
- #### `isHeld(key): boolean`
483
+ ```ts
484
+ interface Note {
485
+ freq: number // Hz — 0 = rest (silence, advances timeline)
486
+ dur: number // ms
487
+ }
488
+ ```
489
+
490
+ ### `playPattern(notes, startDelay?): void`
329
491
 
330
- Returns whether a key is currently held. Argument is `KeyboardEvent.key`.
492
+ Schedules a note sequence on the shared `AudioContext`. `freq: 0` entries produce silence for their duration. `startDelay` offsets the entire pattern in milliseconds.
331
493
 
332
494
  ```ts
333
- if (isHeld('ArrowUp')) { ... }
495
+ // Rising arpeggio
496
+ playPattern([
497
+ { freq: 262, dur: 80 }, // C4
498
+ { freq: 330, dur: 80 }, // E4
499
+ { freq: 392, dur: 80 }, // G4
500
+ { freq: 523, dur: 160 }, // C5
501
+ ])
502
+
503
+ // With rest and startup delay
504
+ playPattern([
505
+ { freq: 880, dur: 100 },
506
+ { freq: 0, dur: 50 }, // rest
507
+ { freq: 880, dur: 100 },
508
+ ], 200)
334
509
  ```
335
510
 
336
- #### `resetInput(): void`
511
+ ### `beep(freq, durationMs, startTime): void`
337
512
 
338
- Clears all pending key state immediately. Call on game phase transitions to prevent stale inputs carrying over.
513
+ 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.
339
514
 
340
515
  ```ts
341
- appPhase = 'gameover'
342
- resetInput() // discard any queued keypresses from the previous phase
516
+ const audio = getAudioContext()!
517
+ resumeAudio()
518
+ beep(440, 80, audio.currentTime)
519
+ beep(880, 80, audio.currentTime + 0.15) // 150ms later
343
520
  ```
344
521
 
345
522
  ---
346
523
 
347
- ### `ui.ts` — ZX-style UI primitives
524
+ ## `ui.ts` — UI Widgets
348
525
 
349
- High-level drawing helpers and a stateful widget system for HUD elements.
350
- All primitives operate in game pixels and enforce the Spectrum palette via `SpectrumColor`.
526
+ High-level drawing helpers and a stateful widget system for HUD elements. All primitives operate in game pixels and enforce the Spectrum palette.
351
527
 
352
- #### Types
528
+ ### Types
353
529
 
354
- **`BorderOptions`**
530
+ #### `BorderOptions`
355
531
 
356
532
  | Field | Type | Default | Description |
357
533
  |-------|------|---------|-------------|
358
- | `enabled` | `boolean` | `true` | Set to `false` to suppress the border without removing the object |
359
- | `thickness` | `number` | `1` | Border thickness in pixels |
360
- | `color` | `SpectrumColor` | same as ink/color | Overrides the parent function's foreground color |
361
- | `style` | `'solid' \| 'dashed'` | `'solid'` | Solid = continuous lines; dashed = 2 px on / 2 px off |
534
+ | `enabled` | `boolean` | `true` | Set `false` to suppress border without removing the object |
535
+ | `thickness` | `number` | `1` | Border thickness in game pixels |
536
+ | `color` | `SpectrumColor` | parent ink | Overrides the parent function's foreground color |
537
+ | `style` | `'solid' \| 'dashed'` | `'solid'` | `'dashed'` = 2 px on / 2 px off |
362
538
 
363
- **`DrawProgressBarOptions`**
539
+ #### `DrawProgressBarOptions`
364
540
 
365
541
  | Field | Type | Default | Description |
366
542
  |-------|------|---------|-------------|
367
543
  | `id` | `string` | `"${x},${y}"` | Stable key for managed redraws |
368
544
  | `x` | `number` | — | Left edge in game pixels |
369
545
  | `y` | `number` | — | Top edge in game pixels |
370
- | `width` | `number` | — | Total width in game pixels (multiples of `CELL = 8` recommended) |
546
+ | `width` | `number` | — | Total width (multiples of `CELL = 8` recommended) |
371
547
  | `value` | `number` | — | Current value |
372
- | `min` | `number` | `0` | Value at the left (empty) edge |
373
- | `max` | `number` | `1` | Value at the right (full) edge |
548
+ | `min` | `number` | `0` | Empty-edge value |
549
+ | `max` | `number` | `1` | Full-edge value |
374
550
  | `ink` | `SpectrumColor` | `C.B_WHITE` | Filled-block color |
375
551
  | `paper` | `SpectrumColor` | `C.BLACK` | Empty-block background |
376
- | `border` | `BorderOptions` | — | Optional border around the bar |
552
+ | `border` | `BorderOptions` | — | Optional border |
377
553
  | `visibilityLength` | `number` | `500` | Ms to stay visible after last call; `0` = permanent |
378
554
 
379
- #### Stateless primitives
555
+ ### Stateless primitives
380
556
 
381
- ##### `drawBox(ctx, options): void`
557
+ #### `drawBox(ctx, options): void`
382
558
 
383
559
  Fills a rectangle with `paper` and draws an optional border.
384
560
 
@@ -390,7 +566,7 @@ drawBox(ctx, {
390
566
  })
391
567
  ```
392
568
 
393
- ##### `drawFrame(ctx, options): void`
569
+ #### `drawFrame(ctx, options): void`
394
570
 
395
571
  Draws a border only — no background fill.
396
572
 
@@ -400,10 +576,9 @@ drawFrame(ctx, { x: 16, y: 16, width: 64, height: 32, color: C.B_RED,
400
576
  border: { style: 'dashed' } })
401
577
  ```
402
578
 
403
- ##### `drawPanelTitle(ctx, options): void`
579
+ #### `drawPanelTitle(ctx, options): void`
404
580
 
405
- Renders a text strip (height = `CELL + padding * 2`) with optional background fill.
406
- Does NOT draw the surrounding container — use `drawBox` / `drawFrame` separately.
581
+ Renders a text strip (`CELL + padding * 2` height) with optional background fill. Does not draw a surrounding container — combine with `drawBox` or `drawFrame`.
407
582
 
408
583
  ```ts
409
584
  drawBox(ctx, { x: 8, y: 24, width: 128, height: 56, paper: C.BLACK })
@@ -414,18 +589,16 @@ drawPanelTitle(ctx, {
414
589
  })
415
590
  ```
416
591
 
417
- #### Stateful widget — progress bar
592
+ ### Stateful widget — Progress Bar
418
593
 
419
- The progress bar is a managed widget: after a `drawProgressBar` call, the bar is
420
- re-rendered automatically on subsequent frames by `renderUI` until `visibilityLength`
421
- milliseconds have elapsed. Calling `drawProgressBar` again resets the timer.
594
+ 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.
422
595
 
423
- ##### `drawProgressBar(ctx, options): void`
596
+ #### `drawProgressBar(ctx, options): void`
424
597
 
425
598
  Draws the bar immediately **and** registers it for managed redraws.
426
599
 
427
600
  ```ts
428
- // On value change:
601
+ // Appears for 1.5 s after each volume change
429
602
  drawProgressBar(ctx, {
430
603
  id: 'volume', x: 88, y: 88, width: 80,
431
604
  value: getMasterVolume(),
@@ -434,7 +607,7 @@ drawProgressBar(ctx, {
434
607
  visibilityLength: 1500,
435
608
  })
436
609
 
437
- // Permanent HUD element:
610
+ // Permanent HUD element (visibilityLength: 0)
438
611
  drawProgressBar(ctx, {
439
612
  id: 'health', x: 0, y: 184, width: 40,
440
613
  value: lives, min: 0, max: 3,
@@ -443,25 +616,25 @@ drawProgressBar(ctx, {
443
616
  })
444
617
  ```
445
618
 
446
- ##### `tickUI(dtMs): void`
619
+ #### `tickUI(dtMs): void`
447
620
 
448
621
  Advances all managed bar timers. Expired bars are removed. Call once per frame.
449
622
 
450
- ##### `renderUI(ctx): void`
623
+ #### `renderUI(ctx): void`
451
624
 
452
625
  Redraws all currently visible bars. Call every frame **after** the game world render.
453
626
 
454
- ##### `resetUI(): void`
627
+ #### `resetUI(): void`
455
628
 
456
- Clears all managed state. Call alongside `resetInput()` on phase transitions.
629
+ Clears all managed widget state. Call alongside `resetInput()` on phase transitions.
457
630
 
458
631
  ```ts
459
- // Typical game loop:
632
+ // Typical game loop
460
633
  renderFrame(ctx, state)
461
634
  tickUI(dt)
462
635
  renderUI(ctx)
463
636
 
464
- // Phase transition:
637
+ // Phase transition
465
638
  resetInput()
466
639
  resetUI()
467
640
  appPhase = 'intro'
@@ -469,528 +642,538 @@ appPhase = 'intro'
469
642
 
470
643
  ---
471
644
 
472
- ### `tilemap.ts` — Scrollable tile map
473
-
474
- A scrollable, queryable `TileMap` backed by an O(1) id-index. Tiles use the same 8×8 sprite format as `drawSprite`. Supports seasonal background swapping, viewport-clipped rendering, collision queries, and fast id-based lookups.
475
- Tile colours are palette-typed: `ink` and `paper` must be `SpectrumColor` values from `C`.
645
+ ## `input.ts` — Keyboard Input
476
646
 
477
- #### Types
647
+ Handles directional movement with configurable key-repeat (immediate on first press, configurable auto-repeat delay on hold) plus single-consume flags for action keys. Call `initInput()` once at startup, then `tickMovement(dt)` every frame.
478
648
 
479
- **`Tile`**
649
+ ### `Direction` type
480
650
 
481
- | Field | Type | Description |
482
- |-------|------|-------------|
483
- | `sprite` | `Uint8Array` | 8-byte bitmap — same format as `drawSprite()` |
484
- | `ink` | `SpectrumColor` | Foreground colour (`C.*` palette value) |
485
- | `paper` | `SpectrumColor` | Background colour (`C.*` palette value) |
486
- | `solid` | `boolean` | `true` = blocks movement (walls, rocks, closed doors) |
487
- | `id` | `string \| number` | Stable identifier for game logic and background swapping |
488
- | `metadata?` | `Record<string, unknown>` | Optional game-specific payload (points, next level, …) |
651
+ ```ts
652
+ type Direction = 'up' | 'down' | 'left' | 'right'
653
+ ```
489
654
 
490
- **`Viewport`**
655
+ ### `initInput(repeatDelay?, repeatInterval?): void`
491
656
 
492
- | Field | Type | Description |
493
- |-------|------|-------------|
494
- | `x` | `number` | First visible column (tile units) |
495
- | `y` | `number` | First visible row (tile units) |
496
- | `cols` | `number` | Number of columns to render |
497
- | `rows` | `number` | Number of rows to render |
657
+ Attaches `keydown`/`keyup` listeners. Idempotent safe to call multiple times; timing parameters are always updated but listeners are only registered once.
498
658
 
499
- #### `createTileMap(cols, rows): TileMap`
659
+ Default key bindings: arrows = movement, `W A S D` = also movement, `F` = flag action, `P` = pause, `Ctrl+Shift+B` = debug toggle.
500
660
 
501
- Creates an empty map of `cols × rows` tiles — all cells start `null`. Returns a plain object implementing the `TileMap` interface (factory pattern, consistent with the rest of zx-kit).
661
+ ```ts
662
+ initInput() // default: 150ms initial delay, 80ms repeat
663
+ initInput(200, 60) // custom timing
664
+ ```
502
665
 
503
- #### Method reference
666
+ ### `tickMovement(dtMs): Direction | null`
504
667
 
505
- | Method | Description |
506
- |--------|-------------|
507
- | `setTile(x, y, tile)` | Store a shallow copy of `tile`. Out-of-bounds is a silent no-op. |
508
- | `getTile(x, y)` | Return the tile at `(x, y)`, or `null`. Never throws. |
509
- | `clearTile(x, y)` | Remove the tile (e.g. collect gem, break wall). Out-of-bounds is a no-op. |
510
- | `fill(tile)` | Fill every cell with independent shallow copies of `tile`. |
511
- | `fillRect(x, y, w, h, tile)` | Fill a rectangle; regions outside the map are silently clipped. |
512
- | `setBackground(tile)` | Register or swap the background tile (see below). |
513
- | `render(ctx, viewport?)` | Render the map or viewport via `drawSprite`. Empty cells are skipped. |
514
- | `isSolid(x, y)` | `true` when the tile is solid, or when the position is out-of-bounds. |
515
- | `findById(id)` | Return `{ x, y, tile }[]` for all tiles with the given `id` — O(1). |
668
+ Returns the active movement direction for this frame, or `null`. Handles the delay/repeat state machine internally. Call exactly once per frame.
516
669
 
517
- #### Smart background swapping (`setBackground`)
670
+ ```ts
671
+ const dir = tickMovement(dt)
672
+ if (dir === 'left') player.x -= speed * dt
673
+ if (dir === 'right') player.x += speed * dt
674
+ if (dir === 'up') player.y -= speed * dt
675
+ if (dir === 'down') player.y += speed * dt
676
+ ```
518
677
 
519
- `setBackground` has two modes depending on whether a background has been registered before:
678
+ ### Consume flags
520
679
 
521
- - **First call** registers the tile as the current background. The map is not modified; call `fill` or `fillRect` first to actually place the background tiles.
522
- - **Subsequent calls (smart swap)** — replaces every cell whose `id` still matches the previous background with a fresh copy of the new tile. Cells with any other `id` (player, gems, rocks, modified terrain) are left completely untouched.
680
+ Each function returns `true` exactly once per key press, then resets to `false`. Designed for single-fire events menus, flags, pause, etc.
523
681
 
524
- Comparison is by `id` value, so it works correctly after shallow copies.
682
+ | Function | Default key | Typical use |
683
+ |----------|-------------|-------------|
684
+ | `consumeFlag()` | `F` | Flag / unflag a tile |
685
+ | `consumePause()` | `P` | Pause / unpause |
686
+ | `consumeDebug()` | `Ctrl+Shift+B` | Toggle debug overlay |
687
+ | `consumeAnyKey()` | Any key | Dismiss overlays, start game |
525
688
 
526
689
  ```ts
527
- map.fill(TILE_GRASS)
528
- map.setBackground(TILE_GRASS) // register map unchanged
690
+ if (consumeFlag()) toggleFlag(playerX, playerY)
691
+ if (consumePause()) appPhase = appPhase === 'paused' ? 'game' : 'paused'
692
+ if (consumeAnyKey()) appPhase = 'game' // dismiss title screen
693
+ ```
529
694
 
530
- map.setTile(5, 3, TILE_PLAYER) // player placed on grass
695
+ ### `isHeld(key): boolean`
531
696
 
532
- map.setBackground(TILE_SNOW) // TILE_GRASS TILE_SNOW everywhere
533
- // TILE_PLAYER at (5, 3) — untouched
697
+ Returns whether a key is currently held down. Argument is `KeyboardEvent.key`.
534
698
 
535
- map.setBackground(TILE_NIGHT) // TILE_SNOW → TILE_NIGHT
536
- // TILE_PLAYER still untouched
699
+ ```ts
700
+ if (isHeld('ArrowUp') && isHeld('ArrowRight')) moveDiagonal()
537
701
  ```
538
702
 
539
- ---
540
-
541
- #### Boulder Dash-style level
703
+ ### `resetInput(): void`
542
704
 
543
- A complete setup showing map construction, collision detection, item collection, and seasonal background swap the typical usage pattern for a scrollable ZX Spectrum-style game.
705
+ Clears all pending key state immediately held keys, direction, all consume flags. Call on phase transitions to prevent stale inputs carrying over.
544
706
 
545
707
  ```ts
546
- import { createTileMap, C, CELL } from 'zx-kit'
547
- import type { Tile, Viewport } from 'zx-kit'
708
+ appPhase = 'gameover'
709
+ resetInput() // discard any queued keypresses from gameplay
710
+ ```
548
711
 
549
- // ── Tile definitions ──────────────────────────────────────────────────────────
712
+ ---
550
713
 
551
- const TILE_DIRT: Tile = {
552
- sprite: new Uint8Array([0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA]),
553
- ink: C.YELLOW, paper: C.BLACK,
554
- solid: false, id: 'dirt',
555
- }
556
- const TILE_WALL: Tile = {
557
- sprite: new Uint8Array([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]),
558
- ink: C.WHITE, paper: C.BLACK,
559
- solid: true, id: 'wall',
560
- }
561
- const TILE_ROCK: Tile = {
562
- sprite: new Uint8Array([0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x7E, 0x3C]),
563
- ink: C.B_WHITE, paper: C.BLACK,
564
- solid: true, id: 'rock',
565
- }
566
- const TILE_GEM: Tile = {
567
- sprite: new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x3C, 0x18]),
568
- ink: C.B_CYAN, paper: C.BLACK,
569
- solid: false, id: 'gem',
570
- metadata: { points: 10 },
571
- }
572
- const TILE_EXIT: Tile = {
573
- sprite: new Uint8Array([0x3C, 0x42, 0x99, 0xA5, 0xA5, 0x99, 0x42, 0x3C]),
574
- ink: C.B_YELLOW, paper: C.BLACK,
575
- solid: false, id: 'exit',
576
- metadata: { nextLevel: 2 },
577
- }
714
+ ## `sprite.ts` Free-Roaming Sprites
578
715
 
579
- // ── Map setup ─────────────────────────────────────────────────────────────────
716
+ 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.
580
717
 
581
- const COLS = 64
582
- const ROWS = 32
583
- const map = createTileMap(COLS, ROWS)
718
+ ### `Sprite` interface
584
719
 
585
- // Fill with dirt and register it as the seasonal background
586
- map.fill(TILE_DIRT)
587
- map.setBackground(TILE_DIRT)
720
+ | Field | Type | Default | Description |
721
+ |-------|------|---------|-------------|
722
+ | `x` | `number` | `0` | Horizontal position in game pixels (float allowed) |
723
+ | `y` | `number` | `0` | Vertical position in game pixels |
724
+ | `vx` | `number` | `0` | Horizontal velocity in px/ms |
725
+ | `vy` | `number` | `0` | Vertical velocity in px/ms |
726
+ | `bitmap` | `Uint8Array` | — | 8-byte sprite bitmap |
727
+ | `ink` | `SpectrumColor` | — | Foreground color |
728
+ | `paper` | `SpectrumColor \| null` | `null` | Background color, or `null` for transparent |
729
+ | `flipX` | `boolean` | `false` | Render mirrored horizontally (cached — no per-frame allocation) |
730
+ | `visible` | `boolean` | `true` | When `false`, `renderSprite` skips this entity |
588
731
 
589
- // Perimeter walls
590
- map.fillRect(0, 0, COLS, 1, TILE_WALL) // top
591
- map.fillRect(0, ROWS - 1, COLS, 1, TILE_WALL) // bottom
592
- map.fillRect(0, 0, 1, ROWS, TILE_WALL) // left
593
- map.fillRect(COLS - 1, 0, 1, ROWS, TILE_WALL) // right
732
+ ### `createSprite(bitmap, ink, paper?): Sprite`
594
733
 
595
- // Objects
596
- map.setTile(10, 5, TILE_ROCK)
597
- map.setTile(20, 14, TILE_GEM)
598
- map.setTile(35, 10, TILE_GEM)
599
- map.setTile(60, 15, TILE_EXIT)
734
+ Creates a `Sprite` at `(0, 0)` with zero velocity. `paper` defaults to `null` (transparent).
600
735
 
601
- // ── Seasonal swap ─────────────────────────────────────────────────────────────
736
+ ```ts
737
+ const PLAYER_BM = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
738
+ const BULLET_BM = new Uint8Array([0x00, 0x00, 0x18, 0x3C, 0x18, 0x00, 0x00, 0x00])
602
739
 
603
- const TILE_SNOW: Tile = {
604
- sprite: new Uint8Array([0x00, 0x18, 0x3C, 0xFF, 0x3C, 0x18, 0x00, 0x00]),
605
- ink: C.B_WHITE, paper: C.BLACK,
606
- solid: false, id: 'snow',
607
- }
740
+ const player = createSprite(PLAYER_BM, C.B_CYAN) // transparent bg
741
+ const bullet = createSprite(BULLET_BM, C.B_YELLOW, C.BLACK) // opaque bg
742
+ player.x = 16; player.y = 80
743
+ ```
608
744
 
609
- // Winter: only dirt tiles become snow — walls, rocks, gems, exit are untouched
610
- map.setBackground(TILE_SNOW)
745
+ ### `moveSprite(sprite, dt): void`
611
746
 
612
- // ── Game loop ─────────────────────────────────────────────────────────────────
747
+ Advances `sprite.x` by `vx * dt` and `sprite.y` by `vy * dt`. Call once per frame, before collision resolution.
613
748
 
614
- const SCREEN_COLS = 32
615
- const SCREEN_ROWS = 24
616
- let playerX = 2
617
- let playerY = 2
618
- let score = 0
749
+ ### `applyGravity(sprite, gravity, dt): void`
619
750
 
620
- function gameLoop(ctx: CanvasRenderingContext2D) {
621
- // Clamp camera so it doesn't scroll past map edges
622
- const camX = Math.max(0, Math.min(playerX - Math.floor(SCREEN_COLS / 2), COLS - SCREEN_COLS))
623
- const camY = Math.max(0, Math.min(playerY - Math.floor(SCREEN_ROWS / 2), ROWS - SCREEN_ROWS))
751
+ Adds `gravity * dt` to `sprite.vy`. Call once per frame, before `moveSprite`.
624
752
 
625
- map.render(ctx, { x: camX, y: camY, cols: SCREEN_COLS, rows: SCREEN_ROWS })
626
- }
753
+ - `gravity` in px/ms² typical values: `0.002`–`0.005` (platformer), `0.008` (debris)
627
754
 
628
- // ── Collision & interaction ───────────────────────────────────────────────────
755
+ ```ts
756
+ applyGravity(player, 0.003, dt)
757
+ moveSprite(player, dt)
758
+ // then resolveX / resolveY...
759
+ ```
629
760
 
630
- function tryMove(dx: number, dy: number) {
631
- const nx = playerX + dx
632
- const ny = playerY + dy
761
+ ### `renderSprite(ctx, sprite): void`
633
762
 
634
- if (map.isSolid(nx, ny)) return // wall, rock, or map boundary
763
+ 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).
635
764
 
636
- const target = map.getTile(nx, ny)
637
- if (target?.id === 'gem') {
638
- score += target.metadata!['points'] as number
639
- map.clearTile(nx, ny)
640
- }
765
+ ```ts
766
+ player.flipX = player.vx < 0 // face the direction of movement
767
+ renderSprite(ctx, player)
768
+ ```
641
769
 
642
- playerX = nx
643
- playerY = ny
644
- }
770
+ ---
645
771
 
646
- // ── Level completion ──────────────────────────────────────────────────────────
772
+ ## `collision.ts` AABB Collision
647
773
 
648
- const exits = map.findById('exit') // O(1) no map scan
649
- if (exits.some(e => e.x === playerX && e.y === playerY)) {
650
- const next = exits[0].tile.metadata!['nextLevel'] as number
651
- console.log(`Loading level ${next}`)
652
- }
774
+ Axis-aligned bounding box overlap tests and sprite-vs-tile-map wall resolution.
653
775
 
654
- // Count remaining gems
655
- const gemsLeft = map.findById('gem').length
656
- console.log(`${gemsLeft} gems remaining`)
657
- ```
776
+ ### `Rect` interface
658
777
 
659
- ---
778
+ ```ts
779
+ interface Rect { x: number; y: number; w: number; h: number }
780
+ ```
660
781
 
661
- ### `sprite.ts` — Free-roaming entity system
782
+ ### `spriteRect(sprite): Rect`
662
783
 
663
- Sprites are entities that move freely in pixel space — not locked to the tile grid like `TileMap` cells. Use sprites for players, enemies, bullets, particles: anything that moves at sub-pixel precision or with physics.
784
+ Returns the `CELL × CELL` bounding box of a sprite at its current position.
664
785
 
665
- Sprites use the same 8×8 bitmap format as `drawSprite` and the same `SpectrumColor` palette. They integrate directly with `resolveX` / `resolveY` from `collision.ts` and with `TileMap.isSolid` for platformer-style collision detection.
786
+ ### `rectsOverlap(a, b): boolean`
666
787
 
667
- #### `Sprite` interface
788
+ Returns `true` when two rectangles share at least one pixel. Touching edges return `false`.
668
789
 
669
- | Field | Type | Default | Description |
670
- |-------|------|---------|-------------|
671
- | `x` | `number` | `0` | Horizontal position in game pixels (float allowed — rounded on render) |
672
- | `y` | `number` | `0` | Vertical position in game pixels |
673
- | `vx` | `number` | `0` | Horizontal velocity in pixels per millisecond |
674
- | `vy` | `number` | `0` | Vertical velocity in pixels per millisecond |
675
- | `bitmap` | `Uint8Array` | — | 8-byte sprite — one byte per row, bit 7 = leftmost pixel |
676
- | `ink` | `SpectrumColor` | — | Foreground colour (`C.*` palette value) |
677
- | `paper` | `SpectrumColor \| null` | `null` | Background colour, or `null` for transparent background |
678
- | `flipX` | `boolean` | `false` | Render mirrored horizontally. Flipped bitmap is cached — no per-frame allocation |
679
- | `visible` | `boolean` | `true` | When `false`, `renderSprite` skips this entity entirely |
790
+ ```ts
791
+ rectsOverlap(spriteRect(bullet), spriteRect(enemy)) // hit test
792
+ ```
680
793
 
681
- #### `createSprite(bitmap, ink, paper?): Sprite`
794
+ ### `spritesOverlap(a, b): boolean`
682
795
 
683
- Creates a `Sprite` at `(0, 0)` with zero velocity. `paper` defaults to `null` (transparent background).
796
+ Shorthand: `rectsOverlap(spriteRect(a), spriteRect(b))`.
684
797
 
685
798
  ```ts
686
- import { createSprite, C } from 'zx-kit'
799
+ if (spritesOverlap(player, coin)) collectCoin()
800
+ if (enemies.some(e => spritesOverlap(player, e))) loseLife()
801
+ ```
802
+
803
+ ### `isSolidAt(map, px, py): boolean`
804
+
805
+ Tests whether the game-pixel `(px, py)` falls inside a solid tile. Out-of-bounds pixels return `true` (implicit solid boundary).
806
+
807
+ ### `resolveX(sprite, map, newX): { x, hitLeft, hitRight }`
687
808
 
688
- const PLAYER_BM = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x18, 0x3C])
689
- const BULLET_BM = new Uint8Array([0x00, 0x00, 0x18, 0x18, 0x18, 0x18, 0x00, 0x00])
809
+ Resolves a proposed horizontal move against solid tiles. Returns the clamped position and directional hit flags. No collision → returns `newX` unchanged.
690
810
 
691
- const player = createSprite(PLAYER_BM, C.B_CYAN) // transparent bg
692
- const bullet = createSprite(BULLET_BM, C.B_WHITE, C.BLACK) // opaque bg
693
- player.x = 16
694
- player.y = 80
811
+ ```ts
812
+ const { x, hitLeft, hitRight } = resolveX(player, map, player.x)
813
+ player.x = x
814
+ if (hitLeft || hitRight) player.vx = 0
695
815
  ```
696
816
 
697
- #### `moveSprite(sprite, dt): void`
817
+ ### `resolveY(sprite, map, newY): { y, hitTop, hitBottom }`
818
+
819
+ Resolves a proposed vertical move against solid tiles.
698
820
 
699
- Advances `sprite.x` by `vx * dt` and `sprite.y` by `vy * dt`. Call once per frame **before** collision resolution.
821
+ - `hitBottom` landed on a floor (use for jump ground detection)
822
+ - `hitTop` — bumped a ceiling
700
823
 
701
824
  ```ts
702
- // Typical frame update order:
703
- applyGravity(player, 0.003, dt)
704
- moveSprite(player, dt)
705
- const rx = resolveX(player, map, player.x)
706
- const ry = resolveY(player, map, player.y)
707
- player.x = rx.x
708
- player.y = ry.y
709
- if (ry.hitBottom) player.vy = 0
825
+ const { y, hitBottom, hitTop } = resolveY(player, map, player.y)
826
+ player.y = y
827
+ if (hitBottom) { player.vy = 0; onGround = true }
828
+ if (hitTop) { player.vy = 0 }
710
829
  ```
711
830
 
712
- #### `applyGravity(sprite, gravity, dt): void`
831
+ ---
832
+
833
+ ## `animation.ts` — Frame Timer & Tween
834
+
835
+ Two small primitives for time-based animation:
713
836
 
714
- Adds `gravity * dt` to `sprite.vy`. Call once per frame **before** `moveSprite`.
837
+ - **`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).
838
+ - **`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.
715
839
 
716
- - `gravity` is in pixels per millisecond² typical values: `0.002`–`0.005`
717
- - Accumulates naturally across frames so the sprite accelerates while airborne
840
+ Both are stateful objects you mutate via tick functions — same shape as `Sprite` + `moveSprite`. Neither uses module-level state, so multiple instances coexist freely.
841
+
842
+ ### `Easing` type & `Easings`
718
843
 
719
844
  ```ts
720
- // Gentle gravity (platformer)
721
- applyGravity(player, 0.003, dt)
845
+ type Easing = (t: number) => number // 0..1 → eased value
722
846
 
723
- // Strong gravity (bullet hell with falling debris)
724
- applyGravity(debris, 0.008, dt)
847
+ Easings.linear // (t) => t — constant velocity
848
+ Easings.easeIn // (t) => t * t — quadratic in (slow start)
849
+ Easings.easeOut // (t) => 1 - (1-t) * (1-t) — quadratic out (slow end)
725
850
  ```
726
851
 
727
- #### `renderSprite(ctx, sprite): void`
728
-
729
- Draws the sprite at `(Math.round(sprite.x), Math.round(sprite.y))`.
730
- Skips if `sprite.visible === false`.
731
- Respects `flipX` (cached mirror) and `paper: null` (transparent — only ink pixels drawn).
852
+ Pass any `(t: number) => number` to `createTween({ ease })` to roll your own.
732
853
 
733
- Call **after** all physics updates and collision resolution, as the last step before `drawScanlines`.
854
+ ### `Animation` interface
734
855
 
735
856
  ```ts
736
- // Transparent sprite over a scrolling background
737
- player.paper = null // default no background square
738
- renderSprite(ctx, player)
857
+ interface Animation {
858
+ frameCount: number // number of frames in cycle
859
+ frameMs: number // duration of each frame
860
+ loop: boolean // wrap, or stop on last frame
861
+ elapsed: number // accumulated time (internal)
862
+ done: boolean // true once non-looping anim reaches the end
863
+ onComplete?: () => void // fired exactly once (non-looping only)
864
+ }
865
+ ```
739
866
 
740
- // Opaque sprite (always paints its 8×8 cell background)
741
- bullet.paper = C.BLACK
742
- renderSprite(ctx, bullet)
867
+ ### `createAnimation(frameCount, frameMs, opts?): Animation`
743
868
 
744
- // Facing direction
745
- player.flipX = (player.vx < 0)
746
- renderSprite(ctx, player)
869
+ ```ts
870
+ const walkAnim = createAnimation(2, 60) // 2-frame walk cycle
871
+ const explosion = createAnimation(4, 50, {
872
+ loop: false,
873
+ onComplete: () => state.phase = 'gameover',
874
+ })
747
875
  ```
748
876
 
749
- #### Full example — platformer player
877
+ ### `tickAnimation(anim, dt): number`
878
+
879
+ 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.
750
880
 
751
881
  ```ts
752
- import { createSprite, moveSprite, applyGravity, renderSprite, C, CELL } from 'zx-kit'
753
- import { resolveX, resolveY } from 'zx-kit'
754
- import { tickMovement, consumeFlag } from 'zx-kit'
882
+ const idx = tickAnimation(walkAnim, dt)
883
+ const sprite = PLAYER_FRAMES[playerDir][idx] // your own lookup table
884
+ drawSprite(ctx, sprite, x, y, C.B_WHITE, C.BLACK)
885
+ ```
755
886
 
756
- const PLAYER_BM = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
887
+ ### `getAnimationFrame(anim): number`
757
888
 
758
- const player = createSprite(PLAYER_BM, C.B_CYAN)
759
- player.x = 2 * CELL
760
- player.y = 10 * CELL
889
+ Reads the current frame index without advancing time — useful when reading inside a renderer that runs after the tick.
761
890
 
762
- let onGround = false
763
- const JUMP_VELOCITY = -0.25 // pixels/ms upward
764
- const WALK_VELOCITY = 0.08 // pixels/ms horizontal
765
- const GRAVITY = 0.003 // pixels/ms²
891
+ ### `resetAnimation(anim): void`
766
892
 
767
- function updatePlayer(dt: number, map: TileMap) {
768
- // Input
769
- const dir = tickMovement(dt)
770
- player.vx = dir === 'right' ? WALK_VELOCITY
771
- : dir === 'left' ? -WALK_VELOCITY
772
- : 0
773
- if (dir === 'up' && onGround) player.vy = JUMP_VELOCITY
774
-
775
- // Flip sprite to face movement direction
776
- if (player.vx < 0) player.flipX = true
777
- if (player.vx > 0) player.flipX = false
778
-
779
- // Physics
780
- applyGravity(player, GRAVITY, dt)
781
- moveSprite(player, dt)
782
-
783
- // Collision
784
- const rx = resolveX(player, map, player.x)
785
- player.x = rx.x
786
- if (rx.hitLeft || rx.hitRight) player.vx = 0
787
-
788
- const ry = resolveY(player, map, player.y)
789
- player.y = ry.y
790
- onGround = ry.hitBottom
791
- if (ry.hitBottom || ry.hitTop) player.vy = 0
893
+ 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.
894
+
895
+ ### `Tween` interface
896
+
897
+ ```ts
898
+ interface Tween {
899
+ fromX, fromY, toX, toY: number
900
+ durationMs: number
901
+ elapsed: number // accumulated time (internal)
902
+ x, y: number // current interpolated position (read after tickTween)
903
+ ease: Easing
904
+ done: boolean
905
+ onComplete?: () => void
792
906
  }
793
907
  ```
794
908
 
795
- ---
909
+ ### `createTween(fromX, fromY, toX, toY, durationMs, opts?): Tween`
796
910
 
797
- ### `collision.ts` — AABB collision detection and resolution
911
+ ```ts
912
+ // Slide player from one cell to the next over 120ms
913
+ state.walkTween = createTween(
914
+ state.playerCol * 8, state.playerRow * 8,
915
+ newCol * 8, newRow * 8,
916
+ 120,
917
+ { onComplete: () => commitMove(state) },
918
+ )
919
+ ```
798
920
 
799
- Axis-aligned bounding box (AABB) helpers for sprite-vs-sprite overlap tests and sprite-vs-`TileMap` collision resolution. All coordinates are in game pixels.
921
+ ### `tickTween(tween, dt): boolean`
800
922
 
801
- #### `Rect` interface
923
+ 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.
802
924
 
803
- | Field | Type | Description |
804
- |-------|------|-------------|
805
- | `x` | `number` | Left edge in game pixels |
806
- | `y` | `number` | Top edge in game pixels |
807
- | `w` | `number` | Width in game pixels |
808
- | `h` | `number` | Height in game pixels |
925
+ ```ts
926
+ if (state.walkTween) {
927
+ tickTween(state.walkTween, dt)
928
+ // renderer reads state.walkTween.x / .y
929
+ }
930
+ ```
809
931
 
810
- #### `spriteRect(sprite): Rect`
932
+ ### Combining Animation + Tween
811
933
 
812
- Returns the bounding `Rect` for a sprite always `CELL × CELL` at the sprite's current position. Uses the raw float position (no rounding) for accurate same-frame overlap tests.
934
+ 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*.
813
935
 
814
936
  ```ts
815
- const rect = spriteRect(player) // { x: 24.5, y: 80.0, w: 8, h: 8 }
937
+ // On input:
938
+ state.walkTween = createTween(/* from cell, to cell, 120ms */, {
939
+ onComplete: () => commitMove(state),
940
+ })
941
+
942
+ // In game loop:
943
+ if (state.walkTween) {
944
+ tickAnimation(state.walkAnim, dt)
945
+ tickTween(state.walkTween, dt)
946
+ }
947
+
948
+ // In renderer:
949
+ const px = state.walkTween ? state.walkTween.x : state.playerCol * CELL
950
+ const py = state.walkTween ? state.walkTween.y : state.playerRow * CELL
951
+ const f = getAnimationFrame(state.walkAnim)
952
+ drawSprite(ctx, PLAYER_FRAMES[state.playerDir][f], Math.round(px), Math.round(py), ink, paper)
816
953
  ```
817
954
 
818
- #### `rectsOverlap(a, b): boolean`
955
+ ### `Blinker` on/off toggle timer
819
956
 
820
- Returns `true` when rectangles share at least one pixel. Touching edges (zero-area overlap) returns `false`.
957
+ 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.
958
+
959
+ #### `Blinker` interface
821
960
 
822
961
  ```ts
823
- rectsOverlap(spriteRect(bullet), spriteRect(enemy)) // hit test
824
- rectsOverlap({ x: 0, y: 0, w: 32, h: 32 }, { x: 16, y: 16, w: 8, h: 8 }) // contained → true
962
+ interface Blinker {
963
+ intervalMs: number // toggle interval in ms
964
+ elapsed: number // internal: accumulated time since last toggle
965
+ state: boolean // current state — true = on, false = off
966
+ }
825
967
  ```
826
968
 
827
- #### `spritesOverlap(a, b): boolean`
969
+ #### `createBlinker(intervalMs, opts?): Blinker`
828
970
 
829
- Shorthand for `rectsOverlap(spriteRect(a), spriteRect(b))`.
971
+ | Option | Type | Default | Description |
972
+ |--------|------|---------|-------------|
973
+ | `opts.initialState` | `boolean` | `true` | Starting state — `true` = visible |
830
974
 
831
975
  ```ts
832
- if (spritesOverlap(player, coin)) collectCoin(coin)
833
- if (bullets.some(b => spritesOverlap(b, player))) loseLife()
976
+ const blinker = createBlinker(500) // toggle every 500 ms
977
+ const cursor = createBlinker(400, { initialState: false }) // starts hidden
834
978
  ```
835
979
 
836
- #### `isSolidAt(map, px, py): boolean`
980
+ #### `tickBlinker(blinker, dt): boolean`
837
981
 
838
- Tests whether the game pixel `(px, py)` falls inside a solid tile. Converts to tile coordinates via `Math.floor(px / CELL)`. Out-of-bounds pixels return `true` the map boundary is an implicit solid wall.
982
+ 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.
839
983
 
840
984
  ```ts
841
- // Is the pixel directly below the player's feet solid?
842
- if (isSolidAt(map, player.x, player.y + CELL)) onGround = true
985
+ // Module-level setup (once):
986
+ const blinker = createBlinker(BLINK_INTERVAL_MS)
843
987
 
844
- // Same check via resolveY usually more convenient
988
+ // In game loop (replaces manual timer + toggle):
989
+ const blink = tickBlinker(blinker, dt)
990
+ renderIntro(ctx, blink)
991
+ state.blink = blink
845
992
  ```
846
993
 
847
- #### `resolveX(sprite, map, newX): { x, hitLeft, hitRight }`
994
+ ---
995
+
996
+ ## `tilemap.ts` — Tile Map Engine
848
997
 
849
- Resolves a proposed horizontal move against solid tiles. Tests the leading edge of the sprite's bounding box at `newX` and returns the clamped position plus hit flags.
998
+ 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.
850
999
 
851
- - `hitLeft` — the sprite hit a wall while moving left
852
- - `hitRight` — the sprite hit a wall while moving right
853
- - Always returns the original `newX` (unclamped) when no collision occurs
854
- - Out-of-bounds overshoot is clamped correctly — the map boundary acts as an implicit wall
1000
+ ### Types
1001
+
1002
+ #### `Tile`
1003
+
1004
+ | Field | Type | Description |
1005
+ |-------|------|-------------|
1006
+ | `sprite` | `Uint8Array` | 8-byte bitmap |
1007
+ | `ink` | `SpectrumColor` | Foreground color |
1008
+ | `paper` | `SpectrumColor` | Background color |
1009
+ | `solid` | `boolean` | `true` = blocks movement |
1010
+ | `id` | `string \| number` | Stable identifier for logic and swap operations |
1011
+ | `metadata?` | `Record<string, unknown>` | Optional game payload (points, next level, …) |
1012
+
1013
+ #### `Viewport`
1014
+
1015
+ | Field | Type | Description |
1016
+ |-------|------|-------------|
1017
+ | `x` | `number` | First visible column (tile units) |
1018
+ | `y` | `number` | First visible row (tile units) |
1019
+ | `cols` | `number` | Number of columns to render |
1020
+ | `rows` | `number` | Number of rows to render |
1021
+
1022
+ ### `createTileMap(cols, rows): TileMap`
1023
+
1024
+ Creates an empty `cols × rows` map — all cells start `null`.
1025
+
1026
+ ### Method reference
1027
+
1028
+ | Method | Description |
1029
+ |--------|-------------|
1030
+ | `setTile(x, y, tile)` | Store a shallow copy. Out-of-bounds = silent no-op. |
1031
+ | `getTile(x, y)` | Return tile or `null`. Never throws. |
1032
+ | `clearTile(x, y)` | Remove tile (collect gem, break wall). |
1033
+ | `fill(tile)` | Fill every cell with independent shallow copies. |
1034
+ | `fillRect(x, y, w, h, tile)` | Fill rectangle; clips to map bounds. |
1035
+ | `setBackground(tile)` | Register or swap the background (see below). |
1036
+ | `render(ctx, viewport?)` | Render map or viewport via `drawSprite`. Empty cells skipped. |
1037
+ | `isSolid(x, y)` | `true` if tile is solid or position is out-of-bounds. |
1038
+ | `findById(id)` | `{ x, y, tile }[]` for all tiles with given `id` — O(1). |
1039
+
1040
+ ### Smart background swapping (`setBackground`)
1041
+
1042
+ - **First call** — registers the tile as the current background. Map is not modified; call `fill` first to place tiles.
1043
+ - **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.
855
1044
 
856
1045
  ```ts
857
- moveSprite(player, dt)
858
- const { x, hitLeft, hitRight } = resolveX(player, map, player.x)
859
- player.x = x
860
- if (hitLeft || hitRight) player.vx = 0
1046
+ map.fill(TILE_GRASS)
1047
+ map.setBackground(TILE_GRASS) // register
1048
+
1049
+ map.setTile(5, 3, TILE_PLAYER) // player placed on grass
1050
+
1051
+ map.setBackground(TILE_SNOW) // grass → snow; player tile untouched
1052
+ map.setBackground(TILE_NIGHT) // snow → night; player still safe
861
1053
  ```
862
1054
 
863
- #### `resolveY(sprite, map, newY): { y, hitTop, hitBottom }`
1055
+ ---
1056
+
1057
+ ## `palette.ts` — Color Constants
1058
+
1059
+ ### `SCALE`
1060
+
1061
+ Default CSS-pixel scale factor: `4`. One game pixel = 4×4 CSS pixels at standard Spectrum resolution.
864
1062
 
865
- Resolves a proposed vertical move against solid tiles. Same contract as `resolveX`.
1063
+ ### `CELL`
866
1064
 
867
- - `hitBottom` the sprite landed on a floor tile (use to detect "on ground" for jump logic)
868
- - `hitTop` — the sprite bumped its head on a ceiling tile
1065
+ Tile and character grid size: `8` game pixels. Matches the ZX Spectrum's 8×8 character cell.
1066
+
1067
+ ### `C` — Color object
1068
+
1069
+ | Key | Hex | Key | Hex |
1070
+ |-----|-----|-----|-----|
1071
+ | `C.BLACK` | `#000000` | `C.B_BLACK` | `#000000` |
1072
+ | `C.BLUE` | `#0000CD` | `C.B_BLUE` | `#0000FF` |
1073
+ | `C.RED` | `#CD0000` | `C.B_RED` | `#FF0000` |
1074
+ | `C.MAGENTA` | `#CD00CD` | `C.B_MAGENTA` | `#FF00FF` |
1075
+ | `C.GREEN` | `#00CD00` | `C.B_GREEN` | `#00FF00` |
1076
+ | `C.CYAN` | `#00CDCD` | `C.B_CYAN` | `#00FFFF` |
1077
+ | `C.YELLOW` | `#CDCD00` | `C.B_YELLOW` | `#FFFF00` |
1078
+ | `C.WHITE` | `#CDCDCD` | `C.B_WHITE` | `#FFFFFF` |
1079
+
1080
+ ### `SpectrumColor` type
869
1081
 
870
1082
  ```ts
871
- applyGravity(player, 0.003, dt)
872
- moveSprite(player, dt)
873
- const { y, hitBottom, hitTop } = resolveY(player, map, player.y)
874
- player.y = y
875
- if (hitBottom) { player.vy = 0; onGround = true }
876
- if (hitTop) { player.vy = 0 }
1083
+ type SpectrumColor = typeof C[keyof typeof C]
877
1084
  ```
878
1085
 
879
- #### Full examplebullet hell enemy swarm
1086
+ 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.
880
1087
 
881
- ```ts
882
- import { createSprite, moveSprite, renderSprite, spritesOverlap, C, CELL } from 'zx-kit'
883
- import type { Sprite } from 'zx-kit'
1088
+ ---
884
1089
 
885
- const BULLET_BM = new Uint8Array([0x00, 0x18, 0x3C, 0x3C, 0x3C, 0x3C, 0x18, 0x00])
886
- const ENEMY_BM = new Uint8Array([0x3C, 0x7E, 0xFF, 0xDB, 0xFF, 0x66, 0x42, 0x81])
1090
+ ## `font.ts` ROM Bitmap Font
887
1091
 
888
- const bullets: Sprite[] = []
889
- const enemies: Sprite[] = []
1092
+ 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 `█`.
890
1093
 
891
- // Spawn a bullet traveling upward
892
- function fireBullet(x: number, y: number) {
893
- const b = createSprite(BULLET_BM, C.B_YELLOW)
894
- b.x = x; b.y = y; b.vy = -0.3 // pixels/ms upward
895
- bullets.push(b)
896
- }
1094
+ ### `FONT`
897
1095
 
898
- // Spawn enemies in a row
899
- for (let i = 0; i < 8; i++) {
900
- const e = createSprite(ENEMY_BM, C.B_RED, C.BLACK)
901
- e.x = i * 2 * CELL + 8
902
- e.y = 2 * CELL
903
- e.vx = 0.03 * (i % 2 === 0 ? 1 : -1) // alternate directions
904
- enemies.push(e)
905
- }
1096
+ ```ts
1097
+ const FONT: Uint8Array // 768 bytes: 96 chars × 8 rows
1098
+ // Row bitmap: FONT[(charCode - 32) * 8 + row]
1099
+ // Bit layout: bit 7 = leftmost pixel
1100
+ ```
906
1101
 
907
- // Per-frame update
908
- function updateBulletHell(dt: number) {
909
- // Move all bullets
910
- for (const b of bullets) moveSprite(b, dt)
911
-
912
- // Move all enemies
913
- for (const e of enemies) moveSprite(e, dt)
914
-
915
- // Hit tests — O(bullets × enemies), fine for dozens of sprites
916
- for (const b of bullets) {
917
- for (const e of enemies) {
918
- if (e.visible && b.visible && spritesOverlap(b, e)) {
919
- b.visible = false // bullet disappears
920
- e.visible = false // enemy explodes
921
- }
922
- }
923
- }
1102
+ ### `getCharRow(charCode, row): number`
924
1103
 
925
- // Remove off-screen bullets
926
- bullets.splice(0, bullets.length, ...bullets.filter(b => b.visible && b.y > -CELL))
1104
+ 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.
927
1105
 
928
- // Render
929
- for (const e of enemies) if (e.visible) renderSprite(ctx, e)
930
- for (const b of bullets) if (b.visible) renderSprite(ctx, b)
1106
+ ```ts
1107
+ // Draw a character manually
1108
+ for (let row = 0; row < 8; row++) {
1109
+ const byte = getCharRow('A'.charCodeAt(0), row)
1110
+ for (let bit = 0; bit < 8; bit++) {
1111
+ if (byte & (0x80 >> bit)) ctx.fillRect(x + bit, y + row, 1, 1)
1112
+ }
931
1113
  }
932
1114
  ```
933
1115
 
934
1116
  ---
935
1117
 
936
- ## File structure
1118
+ ## Architecture
1119
+
1120
+ ### Module structure
937
1121
 
938
1122
  ```
939
1123
  zx-kit/
940
- ├── package.json # exports: { ".": "./dist/index.js" }
1124
+ ├── package.json # exports: { ".": "./dist/index.js" }, sideEffects: false
941
1125
  ├── tsconfig.json # strict, emits to dist/
942
1126
  ├── README.md
943
- ├── src/ # TypeScript source
1127
+ ├── src/
944
1128
  │ ├── index.ts # barrel — re-exports everything
945
1129
  │ ├── palette.ts # SCALE, CELL, C, SpectrumColor
946
1130
  │ ├── font.ts # FONT, getCharRow
947
- │ ├── renderer.ts # setupCanvas, mirrorSprite, drawSprite, drawChar, drawText,
948
- │ │ # drawTextCentered, flashBorder, drawScanlines
949
- ├── audio.ts # initAudio, resumeAudio, beep, playPattern, Note,
1131
+ │ ├── renderer.ts # setupCanvas, mirrorSprite, drawSprite, drawChar,
1132
+ │ │ # drawText, drawTextCentered, flashBorder,
1133
+ # drawScanlines, curveDisplay
1134
+ │ ├── audio.ts # initAudio, resumeAudio, beep, playPattern,
950
1135
  │ │ # getAudioContext, getMasterGain,
951
1136
  │ │ # getMasterVolume, setMasterVolume,
952
- │ │ # increaseVolume, decreaseVolume
953
- │ ├── input.ts # initInput, tickMovement, consumeFlag/Debug/Pause/AnyKey,
1137
+ │ │ # increaseVolume, decreaseVolume, Note
1138
+ │ ├── ay.ts # createAY, playAY, AYChannel, AYNote, AYChip,
1139
+ │ │ # AY_VOL, AY_CLOCK, AY_ENVELOPE_SHAPES
1140
+ │ ├── input.ts # initInput, tickMovement, consumeFlag,
1141
+ │ │ # consumePause, consumeDebug, consumeAnyKey,
954
1142
  │ │ # isHeld, resetInput, Direction
955
1143
  │ ├── ui.ts # drawBox, drawFrame, drawPanelTitle,
956
1144
  │ │ # drawProgressBar, tickUI, renderUI, resetUI,
957
1145
  │ │ # BorderOptions, DrawProgressBarOptions
958
1146
  │ ├── tilemap.ts # createTileMap, Tile, Viewport, TileMap
959
- │ ├── sprite.ts # Sprite, createSprite, moveSprite, applyGravity, renderSprite
960
- └── collision.ts # Rect, spriteRect, rectsOverlap, spritesOverlap,
961
- # isSolidAt, resolveX, resolveY
962
- └── dist/ # compiled output (generated by npm run build)
963
- ├── index.js / .d.ts
1147
+ │ ├── sprite.ts # createSprite, moveSprite, applyGravity,
1148
+ # renderSprite, Sprite
1149
+ └── collision.ts # spriteRect, rectsOverlap, spritesOverlap,
1150
+ # isSolidAt, resolveX, resolveY, Rect
1151
+ └── dist/ # compiled output (npm run build)
1152
+ ├── index.js
1153
+ ├── index.d.ts
964
1154
  └── ...
965
1155
  ```
966
1156
 
967
- ---
1157
+ ### Design decisions
968
1158
 
969
- ## Design principles
1159
+ **No runtime dependencies.** Every module uses only Web platform APIs — `CanvasRenderingContext2D`, `AudioContext`, `KeyboardEvent`. There is nothing to install, no transitive vulnerabilities, no version drift from third-party packages.
970
1160
 
971
- - **Compiled distribution** ships compiled JS + `.d.ts` in `dist/`. No bundler configuration needed in the consuming project.
972
- - **No runtime dependencies** — only Web platform APIs (`CanvasRenderingContext2D`, `AudioContext`, `KeyboardEvent`).
973
- - **Strict TypeScript** — `strict: true`, `noUnusedLocals`, `noUnusedParameters`. No `any`.
974
- - **Palette-typed game data** — UI colours and tile `ink` / `paper` use `SpectrumColor`, so consumers stay inside the Spectrum palette at compile time.
975
- - **Singleton state** — `audio.ts` and `input.ts` hold module-level state. Suitable for single-game use; not suitable for multiple independent game instances on the same page.
976
- - **ZX Spectrum authenticity** — palette values, cell size, and font bytes are constants, not configuration. The library is deliberately opinionated.
1161
+ **Singleton state.** `audio.ts`, `ay.ts`, and `input.ts` hold module-level state. This is intentional: a game has one audio context, one input handler. It is not suitable for multiple independent game instances on the same page.
977
1162
 
978
- ---
1163
+ **Compiled distribution.** The package ships compiled JS + `.d.ts` to `dist/`. Any bundler (Vite, webpack, esbuild, Rollup) consumes it without aliases or configuration.
979
1164
 
980
- ## Local development
1165
+ **`sideEffects: false`.** All module-level initialisation is lazy — no DOM access, no event listeners, no network calls at import time. Bundlers can tree-shake any module whose exports are not used. Import only `playAY` and `createAY` and the beeper, input, and UI modules are completely excluded from your production bundle.
981
1166
 
982
- To work against a local checkout instead of the npm version:
1167
+ **ZX Spectrum authenticity.** The palette values, cell size (`CELL = 8`), and font bytes are constants, not configuration. The `SpectrumColor` type enforces the palette at the TypeScript level — you cannot accidentally pass an arbitrary hex string where a palette color is expected.
983
1168
 
984
- ```bash
985
- # In your game project
986
- npm install ../zx-kit --prefer-online
987
- ```
988
-
989
- > The `--prefer-online` flag ensures npm resolves from the local path without caching issues.
990
- > After publishing a new version to npm, switch back with `npm install zx-kit@latest`.
1169
+ **AY clock accuracy.** `AY_CLOCK = 1_773_400 Hz` and `AY_VOL[]` are measured values from the real AY-3-8912 chip. The LFSR noise buffer uses the correct 17-bit polynomial (`bit = (lfsr ^ (lfsr >> 2)) & 1`). The logarithmic amplitude table uses the real chip's ≈ √2 step factor (3 dB per level).
991
1170
 
992
1171
  ---
993
1172
 
994
1173
  ## License
995
1174
 
996
- MIT
1175
+ MIT — see [LICENSE](LICENSE).
1176
+
1177
+ ---
1178
+
1179
+ *zx-kit is extracted from [Minefield](https://github.com/zrebec/minefield), a ZX Spectrum-style minesweeper game.*