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