zx-kit 0.10.0 → 0.11.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
44
118
 
45
- drawText(ctx, 'SCORE:00000', 0, 0, C.B_WHITE, C.BLACK)
46
- requestAnimationFrame(t => loop(t - lastT))
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)
123
+
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?)`
274
+
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)`
167
278
 
168
- Centers a string within `cols` character columns. Bind `cols` once in a helper to avoid repetition.
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()`
296
+
297
+ Fade out one or all channels (5ms release). Cancels any pending envelope automation.
179
298
 
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`).
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`
206
352
 
207
- Resumes a suspended `AudioContext`. Call before scheduling audio in the game loop.
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.
208
354
 
209
- #### `getAudioContext(): AudioContext | null`
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
+ ```
359
+
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()`.
307
458
 
308
- #### `tickMovement(dtMs): Direction | null`
459
+ ### `getMasterGain(): GainNode | null`
309
460
 
310
- Returns the movement direction for this frame, or `null`. Call once per frame.
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()`.
466
+
467
+ ### `setMasterVolume(volume): void`
468
+
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
+ ```
329
489
 
330
- Returns whether a key is currently held. Argument is `KeyboardEvent.key`.
490
+ ### `playPattern(notes, startDelay?): void`
491
+
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,499 @@ appPhase = 'intro'
469
642
 
470
643
  ---
471
644
 
472
- ### `tilemap.ts` — Scrollable tile map
645
+ ## `input.ts` — Keyboard Input
473
646
 
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`.
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.
476
648
 
477
- #### Types
649
+ ### `Direction` type
478
650
 
479
- **`Tile`**
651
+ ```ts
652
+ type Direction = 'up' | 'down' | 'left' | 'right'
653
+ ```
480
654
 
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, …) |
655
+ ### `initInput(repeatDelay?, repeatInterval?): void`
489
656
 
490
- **`Viewport`**
657
+ Attaches `keydown`/`keyup` listeners. Idempotent — safe to call multiple times; timing parameters are always updated but listeners are only registered once.
491
658
 
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 |
659
+ Default key bindings: arrows = movement, `W A S D` = also movement, `F` = flag action, `P` = pause, `Ctrl+Shift+B` = debug toggle.
498
660
 
499
- #### `createTileMap(cols, rows): TileMap`
661
+ ```ts
662
+ initInput() // default: 150ms initial delay, 80ms repeat
663
+ initInput(200, 60) // custom timing
664
+ ```
500
665
 
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).
666
+ ### `tickMovement(dtMs): Direction | null`
502
667
 
503
- #### Method reference
668
+ Returns the active movement direction for this frame, or `null`. Handles the delay/repeat state machine internally. Call exactly once per frame.
504
669
 
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). |
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
+ ```
516
677
 
517
- #### Smart background swapping (`setBackground`)
678
+ ### Consume flags
518
679
 
519
- `setBackground` has two modes depending on whether a background has been registered before:
680
+ Each function returns `true` exactly once per key press, then resets to `false`. Designed for single-fire events — menus, flags, pause, etc.
520
681
 
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.
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 |
688
+
689
+ ```ts
690
+ if (consumeFlag()) toggleFlag(playerX, playerY)
691
+ if (consumePause()) appPhase = appPhase === 'paused' ? 'game' : 'paused'
692
+ if (consumeAnyKey()) appPhase = 'game' // dismiss title screen
693
+ ```
694
+
695
+ ### `isHeld(key): boolean`
523
696
 
524
- Comparison is by `id` value, so it works correctly after shallow copies.
697
+ Returns whether a key is currently held down. Argument is `KeyboardEvent.key`.
525
698
 
526
699
  ```ts
527
- map.fill(TILE_GRASS)
528
- map.setBackground(TILE_GRASS) // register — map unchanged
700
+ if (isHeld('ArrowUp') && isHeld('ArrowRight')) moveDiagonal()
701
+ ```
529
702
 
530
- map.setTile(5, 3, TILE_PLAYER) // player placed on grass
703
+ ### `resetInput(): void`
531
704
 
532
- map.setBackground(TILE_SNOW) // TILE_GRASS TILE_SNOW everywhere
533
- // TILE_PLAYER at (5, 3) — untouched
705
+ Clears all pending key state immediately — held keys, direction, all consume flags. Call on phase transitions to prevent stale inputs carrying over.
534
706
 
535
- map.setBackground(TILE_NIGHT) // TILE_SNOW → TILE_NIGHT
536
- // TILE_PLAYER — still untouched
707
+ ```ts
708
+ appPhase = 'gameover'
709
+ resetInput() // discard any queued keypresses from gameplay
537
710
  ```
538
711
 
539
712
  ---
540
713
 
541
- #### Boulder Dash-style level
714
+ ## `sprite.ts` — Free-Roaming Sprites
542
715
 
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.
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.
717
+
718
+ ### `Sprite` interface
719
+
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 |
731
+
732
+ ### `createSprite(bitmap, ink, paper?): Sprite`
733
+
734
+ Creates a `Sprite` at `(0, 0)` with zero velocity. `paper` defaults to `null` (transparent).
544
735
 
545
736
  ```ts
546
- import { createTileMap, C, CELL } from 'zx-kit'
547
- import type { Tile, Viewport } from 'zx-kit'
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])
548
739
 
549
- // ── Tile definitions ──────────────────────────────────────────────────────────
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
+ ```
550
744
 
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
- }
745
+ ### `moveSprite(sprite, dt): void`
578
746
 
579
- // ── Map setup ─────────────────────────────────────────────────────────────────
747
+ Advances `sprite.x` by `vx * dt` and `sprite.y` by `vy * dt`. Call once per frame, before collision resolution.
580
748
 
581
- const COLS = 64
582
- const ROWS = 32
583
- const map = createTileMap(COLS, ROWS)
749
+ ### `applyGravity(sprite, gravity, dt): void`
584
750
 
585
- // Fill with dirt and register it as the seasonal background
586
- map.fill(TILE_DIRT)
587
- map.setBackground(TILE_DIRT)
751
+ Adds `gravity * dt` to `sprite.vy`. Call once per frame, before `moveSprite`.
588
752
 
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
753
+ - `gravity` in px/ms² — typical values: `0.002`–`0.005` (platformer), `0.008` (debris)
594
754
 
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)
755
+ ```ts
756
+ applyGravity(player, 0.003, dt)
757
+ moveSprite(player, dt)
758
+ // then resolveX / resolveY...
759
+ ```
600
760
 
601
- // ── Seasonal swap ─────────────────────────────────────────────────────────────
761
+ ### `renderSprite(ctx, sprite): void`
602
762
 
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
- }
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).
764
+
765
+ ```ts
766
+ player.flipX = player.vx < 0 // face the direction of movement
767
+ renderSprite(ctx, player)
768
+ ```
608
769
 
609
- // Winter: only dirt tiles become snow — walls, rocks, gems, exit are untouched
610
- map.setBackground(TILE_SNOW)
770
+ ---
611
771
 
612
- // ── Game loop ─────────────────────────────────────────────────────────────────
772
+ ## `collision.ts` AABB Collision
613
773
 
614
- const SCREEN_COLS = 32
615
- const SCREEN_ROWS = 24
616
- let playerX = 2
617
- let playerY = 2
618
- let score = 0
774
+ Axis-aligned bounding box overlap tests and sprite-vs-tile-map wall resolution.
619
775
 
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))
776
+ ### `Rect` interface
624
777
 
625
- map.render(ctx, { x: camX, y: camY, cols: SCREEN_COLS, rows: SCREEN_ROWS })
626
- }
778
+ ```ts
779
+ interface Rect { x: number; y: number; w: number; h: number }
780
+ ```
627
781
 
628
- // ── Collision & interaction ───────────────────────────────────────────────────
782
+ ### `spriteRect(sprite): Rect`
629
783
 
630
- function tryMove(dx: number, dy: number) {
631
- const nx = playerX + dx
632
- const ny = playerY + dy
784
+ Returns the `CELL × CELL` bounding box of a sprite at its current position.
633
785
 
634
- if (map.isSolid(nx, ny)) return // wall, rock, or map boundary
786
+ ### `rectsOverlap(a, b): boolean`
635
787
 
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
- }
788
+ Returns `true` when two rectangles share at least one pixel. Touching edges return `false`.
641
789
 
642
- playerX = nx
643
- playerY = ny
644
- }
790
+ ```ts
791
+ rectsOverlap(spriteRect(bullet), spriteRect(enemy)) // hit test
792
+ ```
645
793
 
646
- // ── Level completion ──────────────────────────────────────────────────────────
794
+ ### `spritesOverlap(a, b): boolean`
647
795
 
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
- }
796
+ Shorthand: `rectsOverlap(spriteRect(a), spriteRect(b))`.
653
797
 
654
- // Count remaining gems
655
- const gemsLeft = map.findById('gem').length
656
- console.log(`${gemsLeft} gems remaining`)
798
+ ```ts
799
+ if (spritesOverlap(player, coin)) collectCoin()
800
+ if (enemies.some(e => spritesOverlap(player, e))) loseLife()
657
801
  ```
658
802
 
659
- ---
803
+ ### `isSolidAt(map, px, py): boolean`
660
804
 
661
- ### `sprite.ts` Free-roaming entity system
805
+ Tests whether the game-pixel `(px, py)` falls inside a solid tile. Out-of-bounds pixels return `true` (implicit solid boundary).
662
806
 
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.
807
+ ### `resolveX(sprite, map, newX): { x, hitLeft, hitRight }`
664
808
 
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.
809
+ Resolves a proposed horizontal move against solid tiles. Returns the clamped position and directional hit flags. No collision returns `newX` unchanged.
666
810
 
667
- #### `Sprite` interface
811
+ ```ts
812
+ const { x, hitLeft, hitRight } = resolveX(player, map, player.x)
813
+ player.x = x
814
+ if (hitLeft || hitRight) player.vx = 0
815
+ ```
668
816
 
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 |
817
+ ### `resolveY(sprite, map, newY): { y, hitTop, hitBottom }`
680
818
 
681
- #### `createSprite(bitmap, ink, paper?): Sprite`
819
+ Resolves a proposed vertical move against solid tiles.
682
820
 
683
- Creates a `Sprite` at `(0, 0)` with zero velocity. `paper` defaults to `null` (transparent background).
821
+ - `hitBottom` landed on a floor (use for jump ground detection)
822
+ - `hitTop` — bumped a ceiling
684
823
 
685
824
  ```ts
686
- import { createSprite, C } from 'zx-kit'
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 }
829
+ ```
687
830
 
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])
831
+ ---
690
832
 
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
695
- ```
833
+ ## `animation.ts` Frame Timer & Tween
834
+
835
+ Two small primitives for time-based animation:
836
+
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.
696
839
 
697
- #### `moveSprite(sprite, dt): void`
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.
698
841
 
699
- Advances `sprite.x` by `vx * dt` and `sprite.y` by `vy * dt`. Call once per frame **before** collision resolution.
842
+ ### `Easing` type & `Easings`
700
843
 
701
844
  ```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
845
+ type Easing = (t: number) => number // 0..1 eased value
846
+
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)
710
850
  ```
711
851
 
712
- #### `applyGravity(sprite, gravity, dt): void`
852
+ Pass any `(t: number) => number` to `createTween({ ease })` to roll your own.
853
+
854
+ ### `Animation` interface
713
855
 
714
- Adds `gravity * dt` to `sprite.vy`. Call once per frame **before** `moveSprite`.
856
+ ```ts
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
+ ```
715
866
 
716
- - `gravity` is in pixels per millisecond² — typical values: `0.002`–`0.005`
717
- - Accumulates naturally across frames so the sprite accelerates while airborne
867
+ ### `createAnimation(frameCount, frameMs, opts?): Animation`
718
868
 
719
869
  ```ts
720
- // Gentle gravity (platformer)
721
- applyGravity(player, 0.003, dt)
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
+ })
875
+ ```
876
+
877
+ ### `tickAnimation(anim, dt): number`
722
878
 
723
- // Strong gravity (bullet hell with falling debris)
724
- applyGravity(debris, 0.008, dt)
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.
880
+
881
+ ```ts
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)
725
885
  ```
726
886
 
727
- #### `renderSprite(ctx, sprite): void`
887
+ ### `getAnimationFrame(anim): number`
728
888
 
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).
889
+ Reads the current frame index without advancing time — useful when reading inside a renderer that runs after the tick.
732
890
 
733
- Call **after** all physics updates and collision resolution, as the last step before `drawScanlines`.
891
+ ### `resetAnimation(anim): void`
892
+
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
734
896
 
735
897
  ```ts
736
- // Transparent sprite over a scrolling background
737
- player.paper = null // default — no background square
738
- renderSprite(ctx, player)
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
906
+ }
907
+ ```
739
908
 
740
- // Opaque sprite (always paints its 8×8 cell background)
741
- bullet.paper = C.BLACK
742
- renderSprite(ctx, bullet)
909
+ ### `createTween(fromX, fromY, toX, toY, durationMs, opts?): Tween`
743
910
 
744
- // Facing direction
745
- player.flipX = (player.vx < 0)
746
- renderSprite(ctx, player)
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
+ )
747
919
  ```
748
920
 
749
- #### Full example — platformer player
921
+ ### `tickTween(tween, dt): boolean`
922
+
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.
750
924
 
751
925
  ```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'
926
+ if (state.walkTween) {
927
+ tickTween(state.walkTween, dt)
928
+ // renderer reads state.walkTween.x / .y
929
+ }
930
+ ```
755
931
 
756
- const PLAYER_BM = new Uint8Array([0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x24, 0x66])
932
+ ### Combining Animation + Tween
757
933
 
758
- const player = createSprite(PLAYER_BM, C.B_CYAN)
759
- player.x = 2 * CELL
760
- player.y = 10 * CELL
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*.
761
935
 
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²
936
+ ```ts
937
+ // On input:
938
+ state.walkTween = createTween(/* from cell, to cell, 120ms */, {
939
+ onComplete: () => commitMove(state),
940
+ })
766
941
 
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
942
+ // In game loop:
943
+ if (state.walkTween) {
944
+ tickAnimation(state.walkAnim, dt)
945
+ tickTween(state.walkTween, dt)
792
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)
793
953
  ```
794
954
 
795
955
  ---
796
956
 
797
- ### `collision.ts` — AABB collision detection and resolution
957
+ ## `tilemap.ts` — Tile Map Engine
958
+
959
+ 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.
798
960
 
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.
961
+ ### Types
800
962
 
801
- #### `Rect` interface
963
+ #### `Tile`
802
964
 
803
965
  | Field | Type | Description |
804
966
  |-------|------|-------------|
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 |
967
+ | `sprite` | `Uint8Array` | 8-byte bitmap |
968
+ | `ink` | `SpectrumColor` | Foreground color |
969
+ | `paper` | `SpectrumColor` | Background color |
970
+ | `solid` | `boolean` | `true` = blocks movement |
971
+ | `id` | `string \| number` | Stable identifier for logic and swap operations |
972
+ | `metadata?` | `Record<string, unknown>` | Optional game payload (points, next level, …) |
809
973
 
810
- #### `spriteRect(sprite): Rect`
974
+ #### `Viewport`
811
975
 
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.
976
+ | Field | Type | Description |
977
+ |-------|------|-------------|
978
+ | `x` | `number` | First visible column (tile units) |
979
+ | `y` | `number` | First visible row (tile units) |
980
+ | `cols` | `number` | Number of columns to render |
981
+ | `rows` | `number` | Number of rows to render |
813
982
 
814
- ```ts
815
- const rect = spriteRect(player) // { x: 24.5, y: 80.0, w: 8, h: 8 }
816
- ```
983
+ ### `createTileMap(cols, rows): TileMap`
817
984
 
818
- #### `rectsOverlap(a, b): boolean`
985
+ Creates an empty `cols × rows` map — all cells start `null`.
819
986
 
820
- Returns `true` when rectangles share at least one pixel. Touching edges (zero-area overlap) returns `false`.
987
+ ### Method reference
821
988
 
822
- ```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
825
- ```
989
+ | Method | Description |
990
+ |--------|-------------|
991
+ | `setTile(x, y, tile)` | Store a shallow copy. Out-of-bounds = silent no-op. |
992
+ | `getTile(x, y)` | Return tile or `null`. Never throws. |
993
+ | `clearTile(x, y)` | Remove tile (collect gem, break wall). |
994
+ | `fill(tile)` | Fill every cell with independent shallow copies. |
995
+ | `fillRect(x, y, w, h, tile)` | Fill rectangle; clips to map bounds. |
996
+ | `setBackground(tile)` | Register or swap the background (see below). |
997
+ | `render(ctx, viewport?)` | Render map or viewport via `drawSprite`. Empty cells skipped. |
998
+ | `isSolid(x, y)` | `true` if tile is solid or position is out-of-bounds. |
999
+ | `findById(id)` | `{ x, y, tile }[]` for all tiles with given `id` — O(1). |
826
1000
 
827
- #### `spritesOverlap(a, b): boolean`
1001
+ ### Smart background swapping (`setBackground`)
828
1002
 
829
- Shorthand for `rectsOverlap(spriteRect(a), spriteRect(b))`.
1003
+ - **First call** — registers the tile as the current background. Map is not modified; call `fill` first to place tiles.
1004
+ - **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.
830
1005
 
831
1006
  ```ts
832
- if (spritesOverlap(player, coin)) collectCoin(coin)
833
- if (bullets.some(b => spritesOverlap(b, player))) loseLife()
834
- ```
1007
+ map.fill(TILE_GRASS)
1008
+ map.setBackground(TILE_GRASS) // register
835
1009
 
836
- #### `isSolidAt(map, px, py): boolean`
1010
+ map.setTile(5, 3, TILE_PLAYER) // player placed on grass
837
1011
 
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.
1012
+ map.setBackground(TILE_SNOW) // grass snow; player tile untouched
1013
+ map.setBackground(TILE_NIGHT) // snow → night; player still safe
1014
+ ```
839
1015
 
840
- ```ts
841
- // Is the pixel directly below the player's feet solid?
842
- if (isSolidAt(map, player.x, player.y + CELL)) onGround = true
1016
+ ---
843
1017
 
844
- // Same check via resolveY usually more convenient
845
- ```
1018
+ ## `palette.ts`Color Constants
846
1019
 
847
- #### `resolveX(sprite, map, newX): { x, hitLeft, hitRight }`
1020
+ ### `SCALE`
848
1021
 
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.
1022
+ Default CSS-pixel scale factor: `4`. One game pixel = 4×4 CSS pixels at standard Spectrum resolution.
850
1023
 
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
1024
+ ### `CELL`
855
1025
 
856
- ```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
861
- ```
1026
+ Tile and character grid size: `8` game pixels. Matches the ZX Spectrum's 8×8 character cell.
862
1027
 
863
- #### `resolveY(sprite, map, newY): { y, hitTop, hitBottom }`
1028
+ ### `C` Color object
864
1029
 
865
- Resolves a proposed vertical move against solid tiles. Same contract as `resolveX`.
1030
+ | Key | Hex | Key | Hex |
1031
+ |-----|-----|-----|-----|
1032
+ | `C.BLACK` | `#000000` | `C.B_BLACK` | `#000000` |
1033
+ | `C.BLUE` | `#0000CD` | `C.B_BLUE` | `#0000FF` |
1034
+ | `C.RED` | `#CD0000` | `C.B_RED` | `#FF0000` |
1035
+ | `C.MAGENTA` | `#CD00CD` | `C.B_MAGENTA` | `#FF00FF` |
1036
+ | `C.GREEN` | `#00CD00` | `C.B_GREEN` | `#00FF00` |
1037
+ | `C.CYAN` | `#00CDCD` | `C.B_CYAN` | `#00FFFF` |
1038
+ | `C.YELLOW` | `#CDCD00` | `C.B_YELLOW` | `#FFFF00` |
1039
+ | `C.WHITE` | `#CDCDCD` | `C.B_WHITE` | `#FFFFFF` |
866
1040
 
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
1041
+ ### `SpectrumColor` type
869
1042
 
870
1043
  ```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 }
1044
+ type SpectrumColor = typeof C[keyof typeof C]
877
1045
  ```
878
1046
 
879
- #### Full examplebullet hell enemy swarm
1047
+ 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
1048
 
881
- ```ts
882
- import { createSprite, moveSprite, renderSprite, spritesOverlap, C, CELL } from 'zx-kit'
883
- import type { Sprite } from 'zx-kit'
1049
+ ---
884
1050
 
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])
1051
+ ## `font.ts` ROM Bitmap Font
887
1052
 
888
- const bullets: Sprite[] = []
889
- const enemies: Sprite[] = []
1053
+ 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
1054
 
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
- }
1055
+ ### `FONT`
897
1056
 
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
- }
1057
+ ```ts
1058
+ const FONT: Uint8Array // 768 bytes: 96 chars × 8 rows
1059
+ // Row bitmap: FONT[(charCode - 32) * 8 + row]
1060
+ // Bit layout: bit 7 = leftmost pixel
1061
+ ```
906
1062
 
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
- }
1063
+ ### `getCharRow(charCode, row): number`
924
1064
 
925
- // Remove off-screen bullets
926
- bullets.splice(0, bullets.length, ...bullets.filter(b => b.visible && b.y > -CELL))
1065
+ 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
1066
 
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)
1067
+ ```ts
1068
+ // Draw a character manually
1069
+ for (let row = 0; row < 8; row++) {
1070
+ const byte = getCharRow('A'.charCodeAt(0), row)
1071
+ for (let bit = 0; bit < 8; bit++) {
1072
+ if (byte & (0x80 >> bit)) ctx.fillRect(x + bit, y + row, 1, 1)
1073
+ }
931
1074
  }
932
1075
  ```
933
1076
 
934
1077
  ---
935
1078
 
936
- ## File structure
1079
+ ## Architecture
1080
+
1081
+ ### Module structure
937
1082
 
938
1083
  ```
939
1084
  zx-kit/
940
- ├── package.json # exports: { ".": "./dist/index.js" }
1085
+ ├── package.json # exports: { ".": "./dist/index.js" }, sideEffects: false
941
1086
  ├── tsconfig.json # strict, emits to dist/
942
1087
  ├── README.md
943
- ├── src/ # TypeScript source
1088
+ ├── src/
944
1089
  │ ├── index.ts # barrel — re-exports everything
945
1090
  │ ├── palette.ts # SCALE, CELL, C, SpectrumColor
946
1091
  │ ├── font.ts # FONT, getCharRow
947
- │ ├── renderer.ts # setupCanvas, mirrorSprite, drawSprite, drawChar, drawText,
948
- │ │ # drawTextCentered, flashBorder, drawScanlines
949
- ├── audio.ts # initAudio, resumeAudio, beep, playPattern, Note,
1092
+ │ ├── renderer.ts # setupCanvas, mirrorSprite, drawSprite, drawChar,
1093
+ │ │ # drawText, drawTextCentered, flashBorder,
1094
+ # drawScanlines, curveDisplay
1095
+ │ ├── audio.ts # initAudio, resumeAudio, beep, playPattern,
950
1096
  │ │ # getAudioContext, getMasterGain,
951
1097
  │ │ # getMasterVolume, setMasterVolume,
952
- │ │ # increaseVolume, decreaseVolume
953
- │ ├── input.ts # initInput, tickMovement, consumeFlag/Debug/Pause/AnyKey,
1098
+ │ │ # increaseVolume, decreaseVolume, Note
1099
+ │ ├── ay.ts # createAY, playAY, AYChannel, AYNote, AYChip,
1100
+ │ │ # AY_VOL, AY_CLOCK, AY_ENVELOPE_SHAPES
1101
+ │ ├── input.ts # initInput, tickMovement, consumeFlag,
1102
+ │ │ # consumePause, consumeDebug, consumeAnyKey,
954
1103
  │ │ # isHeld, resetInput, Direction
955
1104
  │ ├── ui.ts # drawBox, drawFrame, drawPanelTitle,
956
1105
  │ │ # drawProgressBar, tickUI, renderUI, resetUI,
957
1106
  │ │ # BorderOptions, DrawProgressBarOptions
958
1107
  │ ├── 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
1108
+ │ ├── sprite.ts # createSprite, moveSprite, applyGravity,
1109
+ # renderSprite, Sprite
1110
+ └── collision.ts # spriteRect, rectsOverlap, spritesOverlap,
1111
+ # isSolidAt, resolveX, resolveY, Rect
1112
+ └── dist/ # compiled output (npm run build)
1113
+ ├── index.js
1114
+ ├── index.d.ts
964
1115
  └── ...
965
1116
  ```
966
1117
 
967
- ---
968
-
969
- ## Design principles
1118
+ ### Design decisions
970
1119
 
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.
1120
+ **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.
977
1121
 
978
- ---
1122
+ **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.
979
1123
 
980
- ## Local development
1124
+ **Compiled distribution.** The package ships compiled JS + `.d.ts` to `dist/`. Any bundler (Vite, webpack, esbuild, Rollup) consumes it without aliases or configuration.
981
1125
 
982
- To work against a local checkout instead of the npm version:
1126
+ **`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.
983
1127
 
984
- ```bash
985
- # In your game project
986
- npm install ../zx-kit --prefer-online
987
- ```
1128
+ **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.
988
1129
 
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`.
1130
+ **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
1131
 
992
1132
  ---
993
1133
 
994
1134
  ## License
995
1135
 
996
- MIT
1136
+ MIT — see [LICENSE](LICENSE).
1137
+
1138
+ ---
1139
+
1140
+ *zx-kit is extracted from [Minefield](https://github.com/zrebec/minefield), a ZX Spectrum-style minesweeper game.*