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 +761 -578
- package/dist/animation.d.ts +196 -0
- package/dist/animation.d.ts.map +1 -0
- package/dist/animation.js +177 -0
- package/dist/animation.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,384 +1,560 @@
|
|
|
1
1
|
# zx-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Build browser games that look and sound like a ZX Spectrum — without any of its limitations.**
|
|
4
4
|
|
|
5
|
-
|
|
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
|
+
[](https://www.npmjs.com/package/zx-kit)
|
|
8
|
+
[](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
|
-
|
|
53
|
+
Then import directly — no Vite alias, no path mapping, no bundler configuration required:
|
|
16
54
|
|
|
17
55
|
```ts
|
|
18
|
-
import { C, CELL,
|
|
56
|
+
import { setupCanvas, C, CELL, initAudio, playAY, initInput } from 'zx-kit'
|
|
19
57
|
```
|
|
20
58
|
|
|
21
|
-
|
|
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
|
|
81
|
+
## Quick Start
|
|
82
|
+
|
|
83
|
+
A game loop in under 30 lines:
|
|
26
84
|
|
|
27
85
|
```ts
|
|
28
|
-
import {
|
|
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) //
|
|
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
|
|
38
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
|
59
|
-
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
| `
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
149
|
+
## `ay.ts` — AY-3-8912 Melodik Audio
|
|
96
150
|
|
|
97
|
-
|
|
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
|
-
|
|
153
|
+
Two usage modes:
|
|
100
154
|
|
|
101
|
-
|
|
|
102
|
-
|
|
103
|
-
|
|
|
104
|
-
|
|
|
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
|
-
|
|
160
|
+
Both modes route through the zx-kit master `GainNode`, so `setMasterVolume()` works globally.
|
|
107
161
|
|
|
108
|
-
|
|
162
|
+
### `AY_CLOCK`
|
|
109
163
|
|
|
110
|
-
|
|
164
|
+
```ts
|
|
165
|
+
export const AY_CLOCK = 1_773_400 // Hz — ZX Spectrum 128K / Melodik
|
|
166
|
+
```
|
|
111
167
|
|
|
112
|
-
|
|
168
|
+
The AY-3-8912 master clock. Exported for use in frequency calculations:
|
|
169
|
+
`f_Hz = AY_CLOCK / (16 × period_register)`.
|
|
113
170
|
|
|
114
|
-
|
|
171
|
+
### `AY_VOL`
|
|
115
172
|
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
203
|
+
### `AYChannel` type
|
|
135
204
|
|
|
136
205
|
```ts
|
|
137
|
-
|
|
138
|
-
export const PLAYER_LEFT = mirrorSprite(PLAYER_RIGHT)
|
|
206
|
+
type AYChannel = 'A' | 'B' | 'C'
|
|
139
207
|
```
|
|
140
208
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
Draws an 8×8 sprite at game coordinates. Always fills the background first.
|
|
209
|
+
### `AYNote` interface
|
|
144
210
|
|
|
145
211
|
```ts
|
|
146
|
-
|
|
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
|
-
|
|
223
|
+
### `AYChip` interface
|
|
150
224
|
|
|
151
|
-
|
|
225
|
+
The handle returned by `createAY()`.
|
|
152
226
|
|
|
153
227
|
```ts
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
239
|
+
### `createAY(): AYChip`
|
|
159
240
|
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
#### `
|
|
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
|
-
|
|
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
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
#### `
|
|
295
|
+
#### `ay.mute(ch)` / `ay.muteAll()`
|
|
179
296
|
|
|
180
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
333
|
+
## `renderer.ts` — Canvas Renderer
|
|
191
334
|
|
|
192
|
-
|
|
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
|
-
|
|
337
|
+
### `setupCanvas(canvas, scale, width?, height?): CanvasRenderingContext2D`
|
|
195
338
|
|
|
196
|
-
|
|
339
|
+
One-call canvas initialization. Sets dimensions, CSS size, disables smoothing, applies scale transform.
|
|
197
340
|
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
360
|
+
### `drawSprite(ctx, sprite, x, y, ink, paper): void`
|
|
210
361
|
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
+
### `drawChar(ctx, charCode, x, y, ink, paper?): void`
|
|
216
371
|
|
|
217
|
-
|
|
372
|
+
Draws one ASCII character from the ROM font. Omit `paper` for a transparent background (only ink pixels are drawn).
|
|
218
373
|
|
|
219
|
-
|
|
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
|
-
|
|
379
|
+
### `drawText(ctx, text, x, y, ink, paper?): void`
|
|
222
380
|
|
|
223
|
-
|
|
381
|
+
Draws a string left-to-right, one character per `CELL`-wide slot.
|
|
224
382
|
|
|
225
383
|
```ts
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
388
|
+
### `drawTextCentered(ctx, text, y, cols, ink, paper?): void`
|
|
232
389
|
|
|
233
|
-
|
|
390
|
+
Centers a string within `cols` character columns.
|
|
234
391
|
|
|
235
392
|
```ts
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
397
|
+
print('GAME OVER', 88, C.B_RED)
|
|
398
|
+
print('PRESS ANY KEY', 104, C.B_WHITE)
|
|
242
399
|
```
|
|
243
400
|
|
|
244
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
413
|
+
### `drawScanlines(ctx, width?, height?, alpha?): void`
|
|
254
414
|
|
|
255
|
-
|
|
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
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
424
|
+
### `curveDisplay(ctx, width?, height?, strength?): void`
|
|
275
425
|
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
442
|
+
### `initAudio(volume?): void`
|
|
292
443
|
|
|
293
|
-
|
|
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
|
-
|
|
447
|
+
window.addEventListener('keydown', () => initAudio(), { once: true })
|
|
448
|
+
window.addEventListener('click', () => initAudio(), { once: true })
|
|
297
449
|
```
|
|
298
450
|
|
|
299
|
-
|
|
451
|
+
### `resumeAudio(): void`
|
|
300
452
|
|
|
301
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
467
|
+
### `setMasterVolume(volume): void`
|
|
309
468
|
|
|
310
|
-
|
|
469
|
+
Sets master volume. Clamped to 0.0–1.0. No-op before `initAudio()`.
|
|
311
470
|
|
|
312
471
|
```ts
|
|
313
|
-
|
|
314
|
-
|
|
472
|
+
setMasterVolume(0.5) // 50%
|
|
473
|
+
setMasterVolume(0) // mute
|
|
474
|
+
setMasterVolume(1) // full
|
|
315
475
|
```
|
|
316
476
|
|
|
317
|
-
|
|
477
|
+
### `increaseVolume() / decreaseVolume(): void`
|
|
318
478
|
|
|
319
|
-
|
|
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
|
-
|
|
481
|
+
### `Note` interface
|
|
327
482
|
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
511
|
+
### `beep(freq, durationMs, startTime): void`
|
|
337
512
|
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
528
|
+
### Types
|
|
353
529
|
|
|
354
|
-
|
|
530
|
+
#### `BorderOptions`
|
|
355
531
|
|
|
356
532
|
| Field | Type | Default | Description |
|
|
357
533
|
|-------|------|---------|-------------|
|
|
358
|
-
| `enabled` | `boolean` | `true` | Set
|
|
359
|
-
| `thickness` | `number` | `1` | Border thickness in pixels |
|
|
360
|
-
| `color` | `SpectrumColor` |
|
|
361
|
-
| `style` | `'solid' \| 'dashed'` | `'solid'` |
|
|
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
|
-
|
|
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
|
|
546
|
+
| `width` | `number` | — | Total width (multiples of `CELL = 8` recommended) |
|
|
371
547
|
| `value` | `number` | — | Current value |
|
|
372
|
-
| `min` | `number` | `0` |
|
|
373
|
-
| `max` | `number` | `1` |
|
|
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
|
|
552
|
+
| `border` | `BorderOptions` | — | Optional border |
|
|
377
553
|
| `visibilityLength` | `number` | `500` | Ms to stay visible after last call; `0` = permanent |
|
|
378
554
|
|
|
379
|
-
|
|
555
|
+
### Stateless primitives
|
|
380
556
|
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
579
|
+
#### `drawPanelTitle(ctx, options): void`
|
|
404
580
|
|
|
405
|
-
Renders a text strip (
|
|
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
|
-
|
|
592
|
+
### Stateful widget — Progress Bar
|
|
418
593
|
|
|
419
|
-
The progress bar is a managed widget
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
619
|
+
#### `tickUI(dtMs): void`
|
|
447
620
|
|
|
448
621
|
Advances all managed bar timers. Expired bars are removed. Call once per frame.
|
|
449
622
|
|
|
450
|
-
|
|
623
|
+
#### `renderUI(ctx): void`
|
|
451
624
|
|
|
452
625
|
Redraws all currently visible bars. Call every frame **after** the game world render.
|
|
453
626
|
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
649
|
+
### `Direction` type
|
|
480
650
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
655
|
+
### `initInput(repeatDelay?, repeatInterval?): void`
|
|
491
656
|
|
|
492
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
+
```ts
|
|
662
|
+
initInput() // default: 150ms initial delay, 80ms repeat
|
|
663
|
+
initInput(200, 60) // custom timing
|
|
664
|
+
```
|
|
502
665
|
|
|
503
|
-
|
|
666
|
+
### `tickMovement(dtMs): Direction | null`
|
|
504
667
|
|
|
505
|
-
|
|
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
|
-
|
|
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
|
-
|
|
678
|
+
### Consume flags
|
|
520
679
|
|
|
521
|
-
|
|
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
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
695
|
+
### `isHeld(key): boolean`
|
|
531
696
|
|
|
532
|
-
|
|
533
|
-
// TILE_PLAYER at (5, 3) — untouched
|
|
697
|
+
Returns whether a key is currently held down. Argument is `KeyboardEvent.key`.
|
|
534
698
|
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
547
|
-
|
|
708
|
+
appPhase = 'gameover'
|
|
709
|
+
resetInput() // discard any queued keypresses from gameplay
|
|
710
|
+
```
|
|
548
711
|
|
|
549
|
-
|
|
712
|
+
---
|
|
550
713
|
|
|
551
|
-
|
|
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
|
-
|
|
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
|
-
|
|
582
|
-
const ROWS = 32
|
|
583
|
-
const map = createTileMap(COLS, ROWS)
|
|
718
|
+
### `Sprite` interface
|
|
584
719
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
610
|
-
map.setBackground(TILE_SNOW)
|
|
745
|
+
### `moveSprite(sprite, dt): void`
|
|
611
746
|
|
|
612
|
-
|
|
747
|
+
Advances `sprite.x` by `vx * dt` and `sprite.y` by `vy * dt`. Call once per frame, before collision resolution.
|
|
613
748
|
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
}
|
|
753
|
+
- `gravity` in px/ms² — typical values: `0.002`–`0.005` (platformer), `0.008` (debris)
|
|
627
754
|
|
|
628
|
-
|
|
755
|
+
```ts
|
|
756
|
+
applyGravity(player, 0.003, dt)
|
|
757
|
+
moveSprite(player, dt)
|
|
758
|
+
// then resolveX / resolveY...
|
|
759
|
+
```
|
|
629
760
|
|
|
630
|
-
|
|
631
|
-
const nx = playerX + dx
|
|
632
|
-
const ny = playerY + dy
|
|
761
|
+
### `renderSprite(ctx, sprite): void`
|
|
633
762
|
|
|
634
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
}
|
|
765
|
+
```ts
|
|
766
|
+
player.flipX = player.vx < 0 // face the direction of movement
|
|
767
|
+
renderSprite(ctx, player)
|
|
768
|
+
```
|
|
641
769
|
|
|
642
|
-
|
|
643
|
-
playerY = ny
|
|
644
|
-
}
|
|
770
|
+
---
|
|
645
771
|
|
|
646
|
-
|
|
772
|
+
## `collision.ts` — AABB Collision
|
|
647
773
|
|
|
648
|
-
|
|
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
|
-
|
|
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
|
|
782
|
+
### `spriteRect(sprite): Rect`
|
|
662
783
|
|
|
663
|
-
|
|
784
|
+
Returns the `CELL × CELL` bounding box of a sprite at its current position.
|
|
664
785
|
|
|
665
|
-
|
|
786
|
+
### `rectsOverlap(a, b): boolean`
|
|
666
787
|
|
|
667
|
-
|
|
788
|
+
Returns `true` when two rectangles share at least one pixel. Touching edges return `false`.
|
|
668
789
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
794
|
+
### `spritesOverlap(a, b): boolean`
|
|
682
795
|
|
|
683
|
-
|
|
796
|
+
Shorthand: `rectsOverlap(spriteRect(a), spriteRect(b))`.
|
|
684
797
|
|
|
685
798
|
```ts
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
const
|
|
693
|
-
player.x =
|
|
694
|
-
player.
|
|
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
|
-
|
|
817
|
+
### `resolveY(sprite, map, newY): { y, hitTop, hitBottom }`
|
|
818
|
+
|
|
819
|
+
Resolves a proposed vertical move against solid tiles.
|
|
698
820
|
|
|
699
|
-
|
|
821
|
+
- `hitBottom` — landed on a floor (use for jump ground detection)
|
|
822
|
+
- `hitTop` — bumped a ceiling
|
|
700
823
|
|
|
701
824
|
```ts
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
## `animation.ts` — Frame Timer & Tween
|
|
834
|
+
|
|
835
|
+
Two small primitives for time-based animation:
|
|
713
836
|
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
721
|
-
applyGravity(player, 0.003, dt)
|
|
845
|
+
type Easing = (t: number) => number // 0..1 → eased value
|
|
722
846
|
|
|
723
|
-
//
|
|
724
|
-
|
|
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
|
-
|
|
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
|
-
|
|
854
|
+
### `Animation` interface
|
|
734
855
|
|
|
735
856
|
```ts
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
741
|
-
bullet.paper = C.BLACK
|
|
742
|
-
renderSprite(ctx, bullet)
|
|
867
|
+
### `createAnimation(frameCount, frameMs, opts?): Animation`
|
|
743
868
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
887
|
+
### `getAnimationFrame(anim): number`
|
|
757
888
|
|
|
758
|
-
|
|
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
|
-
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
//
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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
|
-
|
|
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
|
-
|
|
921
|
+
### `tickTween(tween, dt): boolean`
|
|
800
922
|
|
|
801
|
-
|
|
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
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
925
|
+
```ts
|
|
926
|
+
if (state.walkTween) {
|
|
927
|
+
tickTween(state.walkTween, dt)
|
|
928
|
+
// renderer reads state.walkTween.x / .y
|
|
929
|
+
}
|
|
930
|
+
```
|
|
809
931
|
|
|
810
|
-
|
|
932
|
+
### Combining Animation + Tween
|
|
811
933
|
|
|
812
|
-
|
|
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
|
-
|
|
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
|
-
|
|
955
|
+
### `Blinker` — on/off toggle timer
|
|
819
956
|
|
|
820
|
-
|
|
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
|
-
|
|
824
|
-
|
|
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
|
-
#### `
|
|
969
|
+
#### `createBlinker(intervalMs, opts?): Blinker`
|
|
828
970
|
|
|
829
|
-
|
|
971
|
+
| Option | Type | Default | Description |
|
|
972
|
+
|--------|------|---------|-------------|
|
|
973
|
+
| `opts.initialState` | `boolean` | `true` | Starting state — `true` = visible |
|
|
830
974
|
|
|
831
975
|
```ts
|
|
832
|
-
|
|
833
|
-
|
|
976
|
+
const blinker = createBlinker(500) // toggle every 500 ms
|
|
977
|
+
const cursor = createBlinker(400, { initialState: false }) // starts hidden
|
|
834
978
|
```
|
|
835
979
|
|
|
836
|
-
#### `
|
|
980
|
+
#### `tickBlinker(blinker, dt): boolean`
|
|
837
981
|
|
|
838
|
-
|
|
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
|
-
//
|
|
842
|
-
|
|
985
|
+
// Module-level setup (once):
|
|
986
|
+
const blinker = createBlinker(BLINK_INTERVAL_MS)
|
|
843
987
|
|
|
844
|
-
//
|
|
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
|
-
|
|
994
|
+
---
|
|
995
|
+
|
|
996
|
+
## `tilemap.ts` — Tile Map Engine
|
|
848
997
|
|
|
849
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1063
|
+
### `CELL`
|
|
866
1064
|
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
882
|
-
import { createSprite, moveSprite, renderSprite, spritesOverlap, C, CELL } from 'zx-kit'
|
|
883
|
-
import type { Sprite } from 'zx-kit'
|
|
1088
|
+
---
|
|
884
1089
|
|
|
885
|
-
|
|
886
|
-
const ENEMY_BM = new Uint8Array([0x3C, 0x7E, 0xFF, 0xDB, 0xFF, 0x66, 0x42, 0x81])
|
|
1090
|
+
## `font.ts` — ROM Bitmap Font
|
|
887
1091
|
|
|
888
|
-
|
|
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
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
##
|
|
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/
|
|
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,
|
|
948
|
-
│ │ # drawTextCentered, flashBorder,
|
|
949
|
-
│
|
|
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
|
-
│ ├──
|
|
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 #
|
|
960
|
-
│
|
|
961
|
-
│
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.*
|