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 +724 -580
- package/dist/animation.d.ts +149 -0
- package/dist/animation.d.ts.map +1 -0
- package/dist/animation.js +141 -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
|
|
44
118
|
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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?)`
|
|
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
|
-
|
|
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()`
|
|
296
|
+
|
|
297
|
+
Fade out one or all channels (5ms release). Cancels any pending envelope automation.
|
|
179
298
|
|
|
180
|
-
|
|
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`
|
|
206
352
|
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()`.
|
|
307
458
|
|
|
308
|
-
|
|
459
|
+
### `getMasterGain(): GainNode | null`
|
|
309
460
|
|
|
310
|
-
Returns the
|
|
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
|
-
|
|
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
|
+
```
|
|
329
489
|
|
|
330
|
-
|
|
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
|
-
|
|
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,499 @@ appPhase = 'intro'
|
|
|
469
642
|
|
|
470
643
|
---
|
|
471
644
|
|
|
472
|
-
|
|
645
|
+
## `input.ts` — Keyboard Input
|
|
473
646
|
|
|
474
|
-
|
|
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
|
-
|
|
649
|
+
### `Direction` type
|
|
478
650
|
|
|
479
|
-
|
|
651
|
+
```ts
|
|
652
|
+
type Direction = 'up' | 'down' | 'left' | 'right'
|
|
653
|
+
```
|
|
480
654
|
|
|
481
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
661
|
+
```ts
|
|
662
|
+
initInput() // default: 150ms initial delay, 80ms repeat
|
|
663
|
+
initInput(200, 60) // custom timing
|
|
664
|
+
```
|
|
500
665
|
|
|
501
|
-
|
|
666
|
+
### `tickMovement(dtMs): Direction | null`
|
|
502
667
|
|
|
503
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
678
|
+
### Consume flags
|
|
518
679
|
|
|
519
|
-
`
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
697
|
+
Returns whether a key is currently held down. Argument is `KeyboardEvent.key`.
|
|
525
698
|
|
|
526
699
|
```ts
|
|
527
|
-
|
|
528
|
-
|
|
700
|
+
if (isHeld('ArrowUp') && isHeld('ArrowRight')) moveDiagonal()
|
|
701
|
+
```
|
|
529
702
|
|
|
530
|
-
|
|
703
|
+
### `resetInput(): void`
|
|
531
704
|
|
|
532
|
-
|
|
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
|
-
|
|
536
|
-
|
|
707
|
+
```ts
|
|
708
|
+
appPhase = 'gameover'
|
|
709
|
+
resetInput() // discard any queued keypresses from gameplay
|
|
537
710
|
```
|
|
538
711
|
|
|
539
712
|
---
|
|
540
713
|
|
|
541
|
-
|
|
714
|
+
## `sprite.ts` — Free-Roaming Sprites
|
|
542
715
|
|
|
543
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
747
|
+
Advances `sprite.x` by `vx * dt` and `sprite.y` by `vy * dt`. Call once per frame, before collision resolution.
|
|
580
748
|
|
|
581
|
-
|
|
582
|
-
const ROWS = 32
|
|
583
|
-
const map = createTileMap(COLS, ROWS)
|
|
749
|
+
### `applyGravity(sprite, gravity, dt): void`
|
|
584
750
|
|
|
585
|
-
|
|
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
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
755
|
+
```ts
|
|
756
|
+
applyGravity(player, 0.003, dt)
|
|
757
|
+
moveSprite(player, dt)
|
|
758
|
+
// then resolveX / resolveY...
|
|
759
|
+
```
|
|
600
760
|
|
|
601
|
-
|
|
761
|
+
### `renderSprite(ctx, sprite): void`
|
|
602
762
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
610
|
-
map.setBackground(TILE_SNOW)
|
|
770
|
+
---
|
|
611
771
|
|
|
612
|
-
|
|
772
|
+
## `collision.ts` — AABB Collision
|
|
613
773
|
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
626
|
-
}
|
|
778
|
+
```ts
|
|
779
|
+
interface Rect { x: number; y: number; w: number; h: number }
|
|
780
|
+
```
|
|
627
781
|
|
|
628
|
-
|
|
782
|
+
### `spriteRect(sprite): Rect`
|
|
629
783
|
|
|
630
|
-
|
|
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
|
-
|
|
786
|
+
### `rectsOverlap(a, b): boolean`
|
|
635
787
|
|
|
636
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
790
|
+
```ts
|
|
791
|
+
rectsOverlap(spriteRect(bullet), spriteRect(enemy)) // hit test
|
|
792
|
+
```
|
|
645
793
|
|
|
646
|
-
|
|
794
|
+
### `spritesOverlap(a, b): boolean`
|
|
647
795
|
|
|
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
|
-
}
|
|
796
|
+
Shorthand: `rectsOverlap(spriteRect(a), spriteRect(b))`.
|
|
653
797
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
-
|
|
807
|
+
### `resolveX(sprite, map, newX): { x, hitLeft, hitRight }`
|
|
664
808
|
|
|
665
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
819
|
+
Resolves a proposed vertical move against solid tiles.
|
|
682
820
|
|
|
683
|
-
|
|
821
|
+
- `hitBottom` — landed on a floor (use for jump ground detection)
|
|
822
|
+
- `hitTop` — bumped a ceiling
|
|
684
823
|
|
|
685
824
|
```ts
|
|
686
|
-
|
|
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
|
-
|
|
689
|
-
const BULLET_BM = new Uint8Array([0x00, 0x00, 0x18, 0x18, 0x18, 0x18, 0x00, 0x00])
|
|
831
|
+
---
|
|
690
832
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
842
|
+
### `Easing` type & `Easings`
|
|
700
843
|
|
|
701
844
|
```ts
|
|
702
|
-
//
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
852
|
+
Pass any `(t: number) => number` to `createTween({ ease })` to roll your own.
|
|
853
|
+
|
|
854
|
+
### `Animation` interface
|
|
713
855
|
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
- Accumulates naturally across frames so the sprite accelerates while airborne
|
|
867
|
+
### `createAnimation(frameCount, frameMs, opts?): Animation`
|
|
718
868
|
|
|
719
869
|
```ts
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
724
|
-
|
|
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
|
-
|
|
887
|
+
### `getAnimationFrame(anim): number`
|
|
728
888
|
|
|
729
|
-
|
|
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
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
741
|
-
bullet.paper = C.BLACK
|
|
742
|
-
renderSprite(ctx, bullet)
|
|
909
|
+
### `createTween(fromX, fromY, toX, toY, durationMs, opts?): Tween`
|
|
743
910
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
926
|
+
if (state.walkTween) {
|
|
927
|
+
tickTween(state.walkTween, dt)
|
|
928
|
+
// renderer reads state.walkTween.x / .y
|
|
929
|
+
}
|
|
930
|
+
```
|
|
755
931
|
|
|
756
|
-
|
|
932
|
+
### Combining Animation + Tween
|
|
757
933
|
|
|
758
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
936
|
+
```ts
|
|
937
|
+
// On input:
|
|
938
|
+
state.walkTween = createTween(/* from cell, to cell, 120ms */, {
|
|
939
|
+
onComplete: () => commitMove(state),
|
|
940
|
+
})
|
|
766
941
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
961
|
+
### Types
|
|
800
962
|
|
|
801
|
-
#### `
|
|
963
|
+
#### `Tile`
|
|
802
964
|
|
|
803
965
|
| Field | Type | Description |
|
|
804
966
|
|-------|------|-------------|
|
|
805
|
-
| `
|
|
806
|
-
| `
|
|
807
|
-
| `
|
|
808
|
-
| `
|
|
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
|
-
#### `
|
|
974
|
+
#### `Viewport`
|
|
811
975
|
|
|
812
|
-
|
|
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
|
-
|
|
815
|
-
const rect = spriteRect(player) // { x: 24.5, y: 80.0, w: 8, h: 8 }
|
|
816
|
-
```
|
|
983
|
+
### `createTileMap(cols, rows): TileMap`
|
|
817
984
|
|
|
818
|
-
|
|
985
|
+
Creates an empty `cols × rows` map — all cells start `null`.
|
|
819
986
|
|
|
820
|
-
|
|
987
|
+
### Method reference
|
|
821
988
|
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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
|
-
|
|
1001
|
+
### Smart background swapping (`setBackground`)
|
|
828
1002
|
|
|
829
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
```
|
|
1007
|
+
map.fill(TILE_GRASS)
|
|
1008
|
+
map.setBackground(TILE_GRASS) // register
|
|
835
1009
|
|
|
836
|
-
|
|
1010
|
+
map.setTile(5, 3, TILE_PLAYER) // player placed on grass
|
|
837
1011
|
|
|
838
|
-
|
|
1012
|
+
map.setBackground(TILE_SNOW) // grass → snow; player tile untouched
|
|
1013
|
+
map.setBackground(TILE_NIGHT) // snow → night; player still safe
|
|
1014
|
+
```
|
|
839
1015
|
|
|
840
|
-
|
|
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
|
-
|
|
845
|
-
```
|
|
1018
|
+
## `palette.ts` — Color Constants
|
|
846
1019
|
|
|
847
|
-
|
|
1020
|
+
### `SCALE`
|
|
848
1021
|
|
|
849
|
-
|
|
1022
|
+
Default CSS-pixel scale factor: `4`. One game pixel = 4×4 CSS pixels at standard Spectrum resolution.
|
|
850
1023
|
|
|
851
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1028
|
+
### `C` — Color object
|
|
864
1029
|
|
|
865
|
-
|
|
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
|
-
|
|
868
|
-
- `hitTop` — the sprite bumped its head on a ceiling tile
|
|
1041
|
+
### `SpectrumColor` type
|
|
869
1042
|
|
|
870
1043
|
```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 }
|
|
1044
|
+
type SpectrumColor = typeof C[keyof typeof C]
|
|
877
1045
|
```
|
|
878
1046
|
|
|
879
|
-
|
|
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
|
-
|
|
882
|
-
import { createSprite, moveSprite, renderSprite, spritesOverlap, C, CELL } from 'zx-kit'
|
|
883
|
-
import type { Sprite } from 'zx-kit'
|
|
1049
|
+
---
|
|
884
1050
|
|
|
885
|
-
|
|
886
|
-
const ENEMY_BM = new Uint8Array([0x3C, 0x7E, 0xFF, 0xDB, 0xFF, 0x66, 0x42, 0x81])
|
|
1051
|
+
## `font.ts` — ROM Bitmap Font
|
|
887
1052
|
|
|
888
|
-
|
|
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
|
-
|
|
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
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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
|
-
##
|
|
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/
|
|
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,
|
|
948
|
-
│ │ # drawTextCentered, flashBorder,
|
|
949
|
-
│
|
|
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
|
-
│ ├──
|
|
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 #
|
|
960
|
-
│
|
|
961
|
-
│
|
|
962
|
-
|
|
963
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.*
|