zx-kit 0.1.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 ADDED
@@ -0,0 +1,414 @@
1
+ # zx-kit
2
+
3
+ Reusable ZX Spectrum primitives for browser games built with Vite + TypeScript + Canvas + Web Audio API.
4
+
5
+ Extracted from [Minefield](https://github.com/zrebec/minefield) — a ZX Spectrum-style minesweeper game. All modules enforce strict Spectrum authenticity: 8×8 pixel grid, 15-color palette, 1-bit square-wave audio, bitmap font.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ The package ships TypeScript source directly (no build step). Consume it via a local path in `package.json`:
12
+
13
+ ```json
14
+ "dependencies": {
15
+ "zx-kit": "file:../zx-kit"
16
+ }
17
+ ```
18
+
19
+ Then add a Vite alias so bundler resolution works (Vite doesn't transform `node_modules` by default):
20
+
21
+ ```ts
22
+ // vite.config.ts
23
+ import { resolve } from 'path'
24
+
25
+ export default defineConfig({
26
+ resolve: {
27
+ alias: { 'zx-kit': resolve(__dirname, '../zx-kit/src/index.ts') },
28
+ },
29
+ })
30
+ ```
31
+
32
+ Import from the barrel:
33
+
34
+ ```ts
35
+ import { C, CELL, initAudio, beep, drawSprite, initInput, tickMovement } from 'zx-kit'
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Modules
41
+
42
+ ### `palette.ts` — ZX Spectrum color constants
43
+
44
+ Defines the exact 15 colors of the ZX Spectrum and the 8-pixel cell size. **Never use any other hex values** — all game graphics must stay within this palette.
45
+
46
+ #### Exports
47
+
48
+ | Export | Type | Value | Description |
49
+ |--------|------|-------|-------------|
50
+ | `SCALE` | `number` | `4` | CSS pixel scale factor (1 game pixel = 4 CSS pixels) |
51
+ | `CELL` | `number` | `8` | Sprite / character grid size in game pixels |
52
+ | `C` | `object` | see below | All 15 Spectrum colors as `#RRGGBB` hex strings |
53
+ | `SpectrumColor` | `type` | union of all `C` values | TypeScript type for palette-safe color values |
54
+
55
+ #### Color table (`C` object)
56
+
57
+ Normal brightness colors (prefix-less):
58
+
59
+ | Key | Hex | Appearance |
60
+ |-----|-----|-----------|
61
+ | `C.BLACK` | `#000000` | Black |
62
+ | `C.BLUE` | `#0000CD` | Dark blue |
63
+ | `C.RED` | `#CD0000` | Dark red |
64
+ | `C.MAGENTA` | `#CD00CD` | Dark magenta |
65
+ | `C.GREEN` | `#00CD00` | Dark green |
66
+ | `C.CYAN` | `#00CDCD` | Dark cyan |
67
+ | `C.YELLOW` | `#CDCD00` | Dark yellow |
68
+ | `C.WHITE` | `#CDCDCD` | Light grey |
69
+
70
+ Bright variants (`B_` prefix):
71
+
72
+ | Key | Hex | Appearance |
73
+ |-----|-----|-----------|
74
+ | `C.B_BLACK` | `#000000` | Same as `BLACK` |
75
+ | `C.B_BLUE` | `#0000FF` | Bright blue |
76
+ | `C.B_RED` | `#FF0000` | Bright red |
77
+ | `C.B_MAGENTA` | `#FF00FF` | Bright magenta |
78
+ | `C.B_GREEN` | `#00FF00` | Bright green |
79
+ | `C.B_CYAN` | `#00FFFF` | Bright cyan |
80
+ | `C.B_YELLOW` | `#FFFF00` | Bright yellow |
81
+ | `C.B_WHITE` | `#FFFFFF` | Pure white |
82
+
83
+ #### Usage example
84
+
85
+ ```ts
86
+ import { C, CELL } from 'zx-kit'
87
+
88
+ ctx.fillStyle = C.B_CYAN // bright cyan
89
+ ctx.fillRect(x, y, CELL, CELL)
90
+ ```
91
+
92
+ ---
93
+
94
+ ### `font.ts` — ZX Spectrum ROM bitmap font
95
+
96
+ Contains the exact ZX Spectrum ROM character set — 96 printable characters (ASCII 32–127), each stored as 8 bytes (one byte per row, bit 7 = leftmost pixel). Character 127 is a solid block `█`, used for things like life counters.
97
+
98
+ The font data is sourced from the original Spectrum ROM and must not be altered — any change breaks Spectrum authenticity.
99
+
100
+ #### Exports
101
+
102
+ | Export | Type | Description |
103
+ |--------|------|-------------|
104
+ | `FONT` | `Uint8Array` | Flat array: 96 chars × 8 bytes = 768 bytes total. `FONT[(charCode-32)*8 + row]` = one row bitmap. |
105
+ | `getCharRow(charCode, row)` | `(number, number) => number` | Returns the bitmap byte for the given ASCII code and row (0–7). Returns `0` for out-of-range codes. |
106
+
107
+ #### Usage example
108
+
109
+ ```ts
110
+ import { getCharRow } from 'zx-kit'
111
+
112
+ // Draw character 'A' (65) row by row
113
+ for (let row = 0; row < 8; row++) {
114
+ const byte = getCharRow(65, row)
115
+ for (let bit = 0; bit < 8; bit++) {
116
+ if (byte & (0x80 >> bit)) {
117
+ ctx.fillRect(x + bit, y + row, 1, 1)
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ In practice you won't need this directly — use `drawChar` / `drawText` from `renderer.ts` instead.
124
+
125
+ ---
126
+
127
+ ### `renderer.ts` — Canvas drawing primitives
128
+
129
+ All drawing functions work in **game pixels** (not CSS pixels). At `SCALE=4` each game pixel maps to a 4×4 CSS pixel block. Canvas must have `imageSmoothingEnabled = false` set once at init.
130
+
131
+ Every function follows the ZX Spectrum **ink / paper** model: each 8×8 cell has exactly one foreground color (ink) and one background color (paper).
132
+
133
+ #### Exports
134
+
135
+ ##### `mirrorSprite(src: Uint8Array): Uint8Array`
136
+
137
+ Flips an 8×8 sprite horizontally. Returns a new `Uint8Array`. Used to derive left-facing sprites from right-facing ones at module load time.
138
+
139
+ ```ts
140
+ import { mirrorSprite } from 'zx-kit'
141
+
142
+ export const PLAYER_RIGHT = new Uint8Array([0x18, 0x3C, ...])
143
+ export const PLAYER_LEFT = mirrorSprite(PLAYER_RIGHT)
144
+ ```
145
+
146
+ ##### `drawSprite(ctx, sprite, x, y, ink, paper): void`
147
+
148
+ Draws an 8×8 sprite at game coordinates `(x, y)`. Always fills the full 8×8 cell with `paper` first, then renders `ink` pixels from the sprite bitmap.
149
+
150
+ | Parameter | Type | Description |
151
+ |-----------|------|-------------|
152
+ | `ctx` | `CanvasRenderingContext2D` | Target canvas context |
153
+ | `sprite` | `Uint8Array` | 8-byte sprite (one byte per row) |
154
+ | `x` | `number` | Left edge in game pixels |
155
+ | `y` | `number` | Top edge in game pixels |
156
+ | `ink` | `string` | Foreground color (Spectrum palette hex) |
157
+ | `paper` | `string` | Background color (Spectrum palette hex) |
158
+
159
+ ```ts
160
+ import { drawSprite, C } from 'zx-kit'
161
+
162
+ drawSprite(ctx, MINE_SPRITE, col * 8, row * 8, C.B_RED, C.BLACK)
163
+ ```
164
+
165
+ ##### `drawChar(ctx, code, x, y, ink, paper?): void`
166
+
167
+ Draws a single ASCII character at game coordinates using the ROM font. If `paper` is omitted, the background is not cleared (transparent background).
168
+
169
+ | Parameter | Type | Description |
170
+ |-----------|------|-------------|
171
+ | `ctx` | `CanvasRenderingContext2D` | Target canvas context |
172
+ | `code` | `number` | ASCII character code (32–127) |
173
+ | `x` | `number` | Left edge in game pixels |
174
+ | `y` | `number` | Top edge in game pixels |
175
+ | `ink` | `string` | Foreground color |
176
+ | `paper?` | `string` | Optional background color |
177
+
178
+ ```ts
179
+ import { drawChar, C } from 'zx-kit'
180
+
181
+ drawChar(ctx, 127, x, y, C.B_GREEN, C.BLACK) // solid block █
182
+ ```
183
+
184
+ ##### `drawText(ctx, text, x, y, ink, paper?): void`
185
+
186
+ Draws a string left-to-right starting at `(x, y)`, one character per `CELL`-wide slot.
187
+
188
+ | Parameter | Type | Description |
189
+ |-----------|------|-------------|
190
+ | `ctx` | `CanvasRenderingContext2D` | Target canvas context |
191
+ | `text` | `string` | ASCII string to render |
192
+ | `x` | `number` | Left edge in game pixels |
193
+ | `y` | `number` | Top edge in game pixels |
194
+ | `ink` | `string` | Foreground color |
195
+ | `paper?` | `string` | Optional background color |
196
+
197
+ ```ts
198
+ import { drawText, C } from 'zx-kit'
199
+
200
+ drawText(ctx, 'SCORE:00000', 0, statusY, C.B_WHITE, C.BLACK)
201
+ ```
202
+
203
+ ##### `drawTextCentered(ctx, text, y, cols, ink, paper?): void`
204
+
205
+ Draws a string horizontally centered within a canvas of `cols` character columns. The `cols` parameter must match your game's grid width (e.g. 32 for a standard Spectrum layout).
206
+
207
+ | Parameter | Type | Description |
208
+ |-----------|------|-------------|
209
+ | `ctx` | `CanvasRenderingContext2D` | Target canvas context |
210
+ | `text` | `string` | ASCII string to render |
211
+ | `y` | `number` | Top edge in game pixels |
212
+ | `cols` | `number` | Total character columns (canvas width ÷ CELL) |
213
+ | `ink` | `string` | Foreground color |
214
+ | `paper?` | `string` | Optional background color |
215
+
216
+ ```ts
217
+ import { drawTextCentered, C } from 'zx-kit'
218
+
219
+ drawTextCentered(ctx, 'GAME OVER', cy * 8, 32, C.B_RED, C.BLACK)
220
+ ```
221
+
222
+ **Tip for game code:** Bind `cols` once to avoid passing it everywhere:
223
+
224
+ ```ts
225
+ function drawCentered(ctx: CanvasRenderingContext2D, text: string, y: number, ink: string, paper?: string) {
226
+ drawTextCentered(ctx, text, y, COLS, ink, paper)
227
+ }
228
+ ```
229
+
230
+ ---
231
+
232
+ ### `audio.ts` — Web Audio engine (ZX Spectrum style)
233
+
234
+ Wraps the Web Audio API to produce authentic 1-bit square-wave sound, exactly like the ZX Spectrum's single-channel beeper. All audio goes through a shared `AudioContext` and a single `GainNode` master bus.
235
+
236
+ **Important:** `AudioContext` must be created in response to a user gesture (click or keypress) due to browser autoplay policy. Call `initAudio()` from a key/click handler, then call `resumeAudio()` before playing sound in the game loop.
237
+
238
+ #### Exports
239
+
240
+ ##### `initAudio(volume?: number): void`
241
+
242
+ Creates the `AudioContext` and master `GainNode`. Idempotent — safe to call multiple times (subsequent calls are no-ops). Default volume: `0.3`.
243
+
244
+ ```ts
245
+ import { initAudio } from 'zx-kit'
246
+
247
+ document.addEventListener('keydown', () => initAudio(), { once: true })
248
+ ```
249
+
250
+ ##### `resumeAudio(): void`
251
+
252
+ Resumes a suspended `AudioContext`. Browsers suspend the context when the tab is hidden or on first load. Call this before scheduling any beep in the game loop.
253
+
254
+ ```ts
255
+ import { resumeAudio, beep, getAudioContext } from 'zx-kit'
256
+
257
+ const ctx = getAudioContext()
258
+ if (ctx) {
259
+ resumeAudio()
260
+ beep(440, 80, ctx.currentTime)
261
+ }
262
+ ```
263
+
264
+ ##### `getAudioContext(): AudioContext | null`
265
+
266
+ Returns the current `AudioContext`, or `null` if `initAudio()` has not been called yet. Use this to get `currentTime` for scheduling beeps.
267
+
268
+ ##### `getMasterGain(): GainNode | null`
269
+
270
+ Returns the master `GainNode`. Connect your own oscillators/gains to this node to respect the global volume level. Returns `null` before `initAudio()`.
271
+
272
+ ```ts
273
+ const osc = ctx.createOscillator()
274
+ const gain = ctx.createGain()
275
+ osc.connect(gain)
276
+ gain.connect(getMasterGain()!)
277
+ ```
278
+
279
+ ##### `beep(freq: number, durationMs: number, startTime: number): void`
280
+
281
+ Schedules a single square-wave beep. Uses a 5ms linear ramp attack and release to avoid click artifacts. The beep is routed through the master gain.
282
+
283
+ | Parameter | Type | Description |
284
+ |-----------|------|-------------|
285
+ | `freq` | `number` | Frequency in Hz |
286
+ | `durationMs` | `number` | Duration in milliseconds |
287
+ | `startTime` | `number` | `AudioContext.currentTime` offset to start at |
288
+
289
+ ```ts
290
+ import { beep, resumeAudio, getAudioContext } from 'zx-kit'
291
+
292
+ const ctx = getAudioContext()
293
+ if (ctx) {
294
+ resumeAudio()
295
+ const now = ctx.currentTime
296
+ // Two-pip warning: 880Hz beep twice with 60ms gap
297
+ beep(880, 80, now)
298
+ beep(880, 80, now + 0.14)
299
+ }
300
+ ```
301
+
302
+ ---
303
+
304
+ ### `input.ts` — Keyboard input with key-repeat
305
+
306
+ Handles arrow key movement with configurable key-repeat (first-press immediate, hold for auto-repeat), plus single-consume flags for action keys. Designed for grid-based games where one key press = one step.
307
+
308
+ **Important:** Call `initInput()` once at startup (after DOM is ready) to attach `keydown`/`keyup` listeners. Then call `tickMovement(dt)` every frame.
309
+
310
+ #### Exports
311
+
312
+ ##### `initInput(repeatDelay?: number, repeatInterval?: number): void`
313
+
314
+ Attaches global `keydown`/`keyup` event listeners. Default values:
315
+ - `repeatDelay`: `150` ms — time before auto-repeat kicks in after initial press
316
+ - `repeatInterval`: `80` ms — time between repeats while key is held
317
+
318
+ ```ts
319
+ import { initInput } from 'zx-kit'
320
+
321
+ initInput(150, 80)
322
+ ```
323
+
324
+ ##### `tickMovement(dtMs: number): Direction | null`
325
+
326
+ Call once per frame with the frame delta in milliseconds. Returns the direction to move this frame, or `null` if no movement. On the first frame a key is pressed, returns immediately. While held, returns a direction every `repeatInterval` ms (after initial `repeatDelay`).
327
+
328
+ ```ts
329
+ import { tickMovement } from 'zx-kit'
330
+
331
+ function gameLoop(dtMs: number) {
332
+ const dir = tickMovement(dtMs)
333
+ if (dir) movePlayer(dir)
334
+ }
335
+ ```
336
+
337
+ ##### `consumeFlag(): boolean`
338
+
339
+ Returns `true` once if `F` key was pressed since the last call, then resets. Designed for the flag-placement action (mark suspected mine cell).
340
+
341
+ ##### `consumeDebug(): boolean`
342
+
343
+ Returns `true` once if `Ctrl+Shift+B` was pressed. Used to toggle debug mode (reveal all mines). Calls `e.preventDefault()` to suppress browser shortcuts.
344
+
345
+ ##### `consumePause(): boolean`
346
+
347
+ Returns `true` once if `P` key was pressed.
348
+
349
+ ##### `consumeAnyKey(): boolean`
350
+
351
+ Returns `true` once if any key was pressed. Used to dismiss intro screens or game-over overlays.
352
+
353
+ ##### `isHeld(key: string): boolean`
354
+
355
+ Returns whether a key is currently held. Argument is the `KeyboardEvent.key` string (e.g. `'ArrowUp'`, `' '`).
356
+
357
+ ##### `Direction` type
358
+
359
+ ```ts
360
+ export type Direction = 'up' | 'down' | 'left' | 'right'
361
+ ```
362
+
363
+ ---
364
+
365
+ ## File structure
366
+
367
+ ```
368
+ zx-kit/
369
+ ├── package.json # { "exports": { ".": "./src/index.ts" } }
370
+ ├── tsconfig.json # strict, bundler moduleResolution, noEmit
371
+ ├── README.md
372
+ └── src/
373
+ ├── index.ts # barrel — re-exports everything
374
+ ├── palette.ts # SCALE, CELL, C color constants, SpectrumColor type
375
+ ├── font.ts # ROM font Uint8Array + getCharRow()
376
+ ├── renderer.ts # mirrorSprite, drawSprite, drawChar, drawText, drawTextCentered
377
+ ├── audio.ts # initAudio, resumeAudio, beep, getAudioContext, getMasterGain
378
+ └── input.ts # initInput, tickMovement, consumeFlag/Debug/Pause/AnyKey, isHeld
379
+ ```
380
+
381
+ ---
382
+
383
+ ## Design principles
384
+
385
+ - **No build step in the library** — exports raw TypeScript source. Consuming project's bundler (Vite, esbuild) handles transpilation.
386
+ - **No runtime dependencies** — only Web platform APIs (`CanvasRenderingContext2D`, `AudioContext`, `KeyboardEvent`).
387
+ - **Strict TypeScript** — `strict: true`, `noUnusedLocals`, `noUnusedParameters`. No `any`.
388
+ - **Singleton state** — `audio.ts` and `input.ts` hold module-level state. Suitable for single-game use; not suitable for running multiple independent game instances in the same page.
389
+ - **ZX Spectrum constraint enforcement** — palette, cell size, and font data are constants, not configuration. The library is deliberately opinionated.
390
+
391
+ ---
392
+
393
+ ## Migrating from inline code
394
+
395
+ When extracting from a game that previously had these utilities inline:
396
+
397
+ | What was inline | Now in zx-kit | Change required in game |
398
+ |-----------------|---------------|------------------------|
399
+ | `drawSprite`, `drawChar`, `drawText` | `renderer.ts` | Import from `'zx-kit'`; remove duplicate bodies |
400
+ | `drawTextCentered` | `renderer.ts` (takes `cols` param) | Wrap with a local helper that binds your game's `COLS` |
401
+ | `mirrorSprite` | `renderer.ts` | Import from `'zx-kit'`; keep sprite data in game's `sprites.ts` |
402
+ | `C` color object | `palette.ts` | Re-export from game's `constants.ts` via `export { C } from 'zx-kit'` |
403
+ | `SCALE`, `CELL` | `palette.ts` | Same as above |
404
+ | `FONT`, `getCharRow` | `font.ts` | Re-export from game's `font.ts` |
405
+ | `AudioContext` + `GainNode` init | `audio.ts` | Replace with `initAudio()` / `getAudioContext()` / `getMasterGain()` |
406
+ | `beep()` helper | `audio.ts` | Import directly; remove inline version |
407
+ | Arrow-key repeat logic | `input.ts` | Replace with `initInput()` + `tickMovement(dt)` |
408
+ | Flag / debug / pause key flags | `input.ts` | Use `consumeFlag()`, `consumeDebug()`, `consumePause()` |
409
+
410
+ ---
411
+
412
+ ## License
413
+
414
+ MIT
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "zx-kit",
3
+ "version": "0.1.0",
4
+ "description": "Reusable ZX Spectrum primitives: font, palette, renderer, audio, input",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "keywords": ["zx-spectrum", "retro", "game", "canvas"],
10
+ "license": "MIT"
11
+ }
package/src/audio.ts ADDED
@@ -0,0 +1,39 @@
1
+ let ctx: AudioContext | null = null
2
+ let masterGain: GainNode | null = null
3
+
4
+ export function initAudio(volume = 0.3): void {
5
+ if (ctx) return
6
+ ctx = new AudioContext()
7
+ masterGain = ctx.createGain()
8
+ masterGain.gain.value = volume
9
+ masterGain.connect(ctx.destination)
10
+ }
11
+
12
+ export function resumeAudio(): void {
13
+ if (ctx && ctx.state === 'suspended') void ctx.resume()
14
+ }
15
+
16
+ export function getAudioContext(): AudioContext | null {
17
+ return ctx
18
+ }
19
+
20
+ export function getMasterGain(): GainNode | null {
21
+ return masterGain
22
+ }
23
+
24
+ // Core primitive — schedule a square-wave beep on the shared AudioContext
25
+ export function beep(freq: number, durationMs: number, startTime: number): void {
26
+ if (!ctx || !masterGain) return
27
+ const osc = ctx.createOscillator()
28
+ const gain = ctx.createGain()
29
+ osc.type = 'square'
30
+ osc.frequency.value = freq
31
+ gain.gain.setValueAtTime(0, startTime)
32
+ gain.gain.linearRampToValueAtTime(0.8, startTime + 0.005)
33
+ gain.gain.setValueAtTime(0.8, startTime + durationMs / 1000 - 0.005)
34
+ gain.gain.linearRampToValueAtTime(0, startTime + durationMs / 1000)
35
+ osc.connect(gain)
36
+ gain.connect(masterGain)
37
+ osc.start(startTime)
38
+ osc.stop(startTime + durationMs / 1000 + 0.01)
39
+ }
package/src/font.ts ADDED
@@ -0,0 +1,203 @@
1
+ // ZX Spectrum ROM font — characters 32–127, 8 bytes per char (row-major, bit7=leftmost pixel)
2
+ const RAW: readonly number[] = [
3
+ // 32 space
4
+ 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,
5
+ // 33 !
6
+ 0x00,0x10,0x10,0x10,0x10,0x00,0x10,0x00,
7
+ // 34 "
8
+ 0x00,0x28,0x28,0x00,0x00,0x00,0x00,0x00,
9
+ // 35 #
10
+ 0x00,0x28,0x7C,0x28,0x28,0x7C,0x28,0x00,
11
+ // 36 $
12
+ 0x00,0x10,0x3C,0x50,0x3C,0x14,0x7C,0x10,
13
+ // 37 %
14
+ 0x00,0x62,0x64,0x08,0x10,0x26,0x46,0x00,
15
+ // 38 &
16
+ 0x00,0x10,0x28,0x10,0x2A,0x44,0x3A,0x00,
17
+ // 39 '
18
+ 0x00,0x08,0x08,0x00,0x00,0x00,0x00,0x00,
19
+ // 40 (
20
+ 0x00,0x04,0x08,0x08,0x08,0x08,0x04,0x00,
21
+ // 41 )
22
+ 0x00,0x20,0x10,0x10,0x10,0x10,0x20,0x00,
23
+ // 42 *
24
+ 0x00,0x00,0x14,0x08,0x3E,0x08,0x14,0x00,
25
+ // 43 +
26
+ 0x00,0x00,0x08,0x08,0x3E,0x08,0x08,0x00,
27
+ // 44 ,
28
+ 0x00,0x00,0x00,0x00,0x00,0x08,0x08,0x10,
29
+ // 45 -
30
+ 0x00,0x00,0x00,0x00,0x3E,0x00,0x00,0x00,
31
+ // 46 .
32
+ 0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x00,
33
+ // 47 /
34
+ 0x00,0x02,0x04,0x08,0x10,0x20,0x40,0x00,
35
+ // 48 0
36
+ 0x00,0x3C,0x46,0x4A,0x52,0x62,0x3C,0x00,
37
+ // 49 1
38
+ 0x00,0x08,0x18,0x08,0x08,0x08,0x1C,0x00,
39
+ // 50 2
40
+ 0x00,0x3C,0x42,0x04,0x18,0x20,0x7E,0x00,
41
+ // 51 3
42
+ 0x00,0x3C,0x42,0x0C,0x02,0x42,0x3C,0x00,
43
+ // 52 4
44
+ 0x00,0x08,0x18,0x28,0x48,0x7E,0x08,0x00,
45
+ // 53 5
46
+ 0x00,0x7E,0x40,0x7C,0x02,0x42,0x3C,0x00,
47
+ // 54 6
48
+ 0x00,0x3C,0x40,0x7C,0x42,0x42,0x3C,0x00,
49
+ // 55 7
50
+ 0x00,0x7E,0x02,0x04,0x08,0x10,0x10,0x00,
51
+ // 56 8
52
+ 0x00,0x3C,0x42,0x3C,0x42,0x42,0x3C,0x00,
53
+ // 57 9
54
+ 0x00,0x3C,0x42,0x42,0x3E,0x02,0x3C,0x00,
55
+ // 58 :
56
+ 0x00,0x00,0x10,0x00,0x00,0x10,0x00,0x00,
57
+ // 59 ;
58
+ 0x00,0x00,0x10,0x00,0x00,0x10,0x10,0x20,
59
+ // 60 <
60
+ 0x00,0x04,0x08,0x10,0x08,0x04,0x00,0x00,
61
+ // 61 =
62
+ 0x00,0x00,0x00,0x7C,0x00,0x7C,0x00,0x00,
63
+ // 62 >
64
+ 0x00,0x20,0x10,0x08,0x10,0x20,0x00,0x00,
65
+ // 63 ?
66
+ 0x00,0x3C,0x42,0x04,0x08,0x00,0x08,0x00,
67
+ // 64 @
68
+ 0x00,0x3C,0x4A,0x56,0x5C,0x40,0x3C,0x00,
69
+ // 65 A
70
+ 0x00,0x18,0x24,0x42,0x7E,0x42,0x42,0x00,
71
+ // 66 B
72
+ 0x00,0x7C,0x42,0x7C,0x42,0x42,0x7C,0x00,
73
+ // 67 C
74
+ 0x00,0x3C,0x42,0x40,0x40,0x42,0x3C,0x00,
75
+ // 68 D
76
+ 0x00,0x78,0x44,0x42,0x42,0x44,0x78,0x00,
77
+ // 69 E
78
+ 0x00,0x7E,0x40,0x7C,0x40,0x40,0x7E,0x00,
79
+ // 70 F
80
+ 0x00,0x7E,0x40,0x7C,0x40,0x40,0x40,0x00,
81
+ // 71 G
82
+ 0x00,0x3C,0x42,0x40,0x4E,0x42,0x3C,0x00,
83
+ // 72 H
84
+ 0x00,0x42,0x42,0x7E,0x42,0x42,0x42,0x00,
85
+ // 73 I
86
+ 0x00,0x3E,0x08,0x08,0x08,0x08,0x3E,0x00,
87
+ // 74 J
88
+ 0x00,0x02,0x02,0x02,0x02,0x42,0x3C,0x00,
89
+ // 75 K
90
+ 0x00,0x44,0x48,0x70,0x48,0x44,0x42,0x00,
91
+ // 76 L
92
+ 0x00,0x40,0x40,0x40,0x40,0x40,0x7E,0x00,
93
+ // 77 M
94
+ 0x00,0x42,0x66,0x5A,0x42,0x42,0x42,0x00,
95
+ // 78 N
96
+ 0x00,0x42,0x62,0x52,0x4A,0x46,0x42,0x00,
97
+ // 79 O
98
+ 0x00,0x3C,0x42,0x42,0x42,0x42,0x3C,0x00,
99
+ // 80 P
100
+ 0x00,0x7C,0x42,0x42,0x7C,0x40,0x40,0x00,
101
+ // 81 Q
102
+ 0x00,0x3C,0x42,0x42,0x4A,0x44,0x3A,0x00,
103
+ // 82 R
104
+ 0x00,0x7C,0x42,0x42,0x7C,0x48,0x44,0x00,
105
+ // 83 S
106
+ 0x00,0x3C,0x40,0x3C,0x02,0x42,0x3C,0x00,
107
+ // 84 T
108
+ 0x00,0x7E,0x08,0x08,0x08,0x08,0x08,0x00,
109
+ // 85 U
110
+ 0x00,0x42,0x42,0x42,0x42,0x42,0x3C,0x00,
111
+ // 86 V
112
+ 0x00,0x42,0x42,0x42,0x42,0x24,0x18,0x00,
113
+ // 87 W
114
+ 0x00,0x42,0x42,0x42,0x5A,0x66,0x42,0x00,
115
+ // 88 X
116
+ 0x00,0x42,0x24,0x18,0x18,0x24,0x42,0x00,
117
+ // 89 Y
118
+ 0x00,0x42,0x42,0x24,0x18,0x08,0x08,0x00,
119
+ // 90 Z
120
+ 0x00,0x7E,0x04,0x08,0x10,0x20,0x7E,0x00,
121
+ // 91 [
122
+ 0x00,0x1C,0x10,0x10,0x10,0x10,0x1C,0x00,
123
+ // 92 backslash
124
+ 0x00,0x40,0x20,0x10,0x08,0x04,0x02,0x00,
125
+ // 93 ]
126
+ 0x00,0x38,0x08,0x08,0x08,0x08,0x38,0x00,
127
+ // 94 ^
128
+ 0x00,0x08,0x14,0x22,0x00,0x00,0x00,0x00,
129
+ // 95 _
130
+ 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x7E,
131
+ // 96 `
132
+ 0x00,0x10,0x08,0x00,0x00,0x00,0x00,0x00,
133
+ // 97 a
134
+ 0x00,0x00,0x3C,0x02,0x3E,0x42,0x3E,0x00,
135
+ // 98 b
136
+ 0x00,0x20,0x20,0x3E,0x22,0x22,0x3E,0x00,
137
+ // 99 c
138
+ 0x00,0x00,0x1E,0x20,0x20,0x20,0x1E,0x00,
139
+ // 100 d
140
+ 0x00,0x02,0x02,0x3E,0x42,0x42,0x3E,0x00,
141
+ // 101 e
142
+ 0x00,0x00,0x3C,0x42,0x7E,0x40,0x3C,0x00,
143
+ // 102 f
144
+ 0x00,0x0C,0x10,0x3C,0x10,0x10,0x10,0x00,
145
+ // 103 g
146
+ 0x00,0x00,0x3E,0x42,0x42,0x3E,0x02,0x3C,
147
+ // 104 h
148
+ 0x00,0x20,0x20,0x2C,0x32,0x22,0x22,0x00,
149
+ // 105 i
150
+ 0x00,0x08,0x00,0x18,0x08,0x08,0x1C,0x00,
151
+ // 106 j
152
+ 0x00,0x04,0x00,0x0C,0x04,0x04,0x44,0x38,
153
+ // 107 k
154
+ 0x00,0x20,0x28,0x30,0x28,0x24,0x22,0x00,
155
+ // 108 l
156
+ 0x00,0x18,0x08,0x08,0x08,0x08,0x1C,0x00,
157
+ // 109 m
158
+ 0x00,0x00,0x76,0x49,0x49,0x49,0x49,0x00,
159
+ // 110 n
160
+ 0x00,0x00,0x2C,0x32,0x22,0x22,0x22,0x00,
161
+ // 111 o
162
+ 0x00,0x00,0x3C,0x42,0x42,0x42,0x3C,0x00,
163
+ // 112 p
164
+ 0x00,0x00,0x3C,0x22,0x22,0x3C,0x20,0x20,
165
+ // 113 q
166
+ 0x00,0x00,0x1E,0x22,0x22,0x1E,0x02,0x02,
167
+ // 114 r
168
+ 0x00,0x00,0x2C,0x30,0x20,0x20,0x20,0x00,
169
+ // 115 s
170
+ 0x00,0x00,0x1E,0x20,0x1C,0x02,0x3C,0x00,
171
+ // 116 t
172
+ 0x00,0x10,0x7C,0x10,0x10,0x12,0x0C,0x00,
173
+ // 117 u
174
+ 0x00,0x00,0x22,0x22,0x22,0x26,0x1A,0x00,
175
+ // 118 v
176
+ 0x00,0x00,0x42,0x42,0x42,0x24,0x18,0x00,
177
+ // 119 w
178
+ 0x00,0x00,0x42,0x42,0x49,0x55,0x22,0x00,
179
+ // 120 x
180
+ 0x00,0x00,0x42,0x24,0x18,0x24,0x42,0x00,
181
+ // 121 y
182
+ 0x00,0x00,0x44,0x44,0x44,0x3C,0x04,0x38,
183
+ // 122 z
184
+ 0x00,0x00,0x7E,0x04,0x18,0x20,0x7E,0x00,
185
+ // 123 {
186
+ 0x00,0x0C,0x10,0x60,0x10,0x10,0x0C,0x00,
187
+ // 124 |
188
+ 0x00,0x08,0x08,0x08,0x08,0x08,0x08,0x00,
189
+ // 125 }
190
+ 0x00,0x30,0x08,0x06,0x08,0x08,0x30,0x00,
191
+ // 126 ~
192
+ 0x00,0x00,0x32,0x4C,0x00,0x00,0x00,0x00,
193
+ // 127 solid block (used for lives / filled blocks)
194
+ 0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,0xFF,
195
+ ]
196
+
197
+ export const FONT = new Uint8Array(RAW)
198
+
199
+ export function getCharRow(charCode: number, row: number): number {
200
+ const idx = charCode - 32
201
+ if (idx < 0 || idx >= 96) return 0
202
+ return FONT[idx * 8 + row]
203
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './palette.ts'
2
+ export * from './font.ts'
3
+ export * from './renderer.ts'
4
+ export * from './audio.ts'
5
+ export * from './input.ts'
package/src/input.ts ADDED
@@ -0,0 +1,77 @@
1
+ export type Direction = 'up' | 'down' | 'left' | 'right'
2
+
3
+ const DIR_KEYS: Record<string, Direction> = {
4
+ ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
5
+ }
6
+
7
+ const held = new Set<string>()
8
+ let pendingFlag = false
9
+ let pendingDebug = false
10
+ let pendingPause = false
11
+ let pendingAnyKey = false
12
+
13
+ let repeatDir: Direction | null = null
14
+ let repeatTimer = 0
15
+ let repeatPhase: 'delay' | 'repeat' | 'idle' = 'idle'
16
+ let pendingImmediate: Direction | null = null
17
+ let _repeatDelay = 150
18
+ let _repeatInterval = 80
19
+
20
+ export function initInput(repeatDelay = 150, repeatInterval = 80): void {
21
+ _repeatDelay = repeatDelay
22
+ _repeatInterval = repeatInterval
23
+
24
+ window.addEventListener('keydown', (e: KeyboardEvent) => {
25
+ if (e.repeat) return
26
+ pendingAnyKey = true
27
+ held.add(e.key)
28
+
29
+ const dir = DIR_KEYS[e.key]
30
+ if (dir) {
31
+ repeatDir = dir
32
+ repeatPhase = 'delay'
33
+ repeatTimer = _repeatDelay
34
+ pendingImmediate = dir
35
+ }
36
+
37
+ if (e.key === 'f' || e.key === 'F') pendingFlag = true
38
+ if (e.key === 'p' || e.key === 'P') pendingPause = true
39
+
40
+ if (e.ctrlKey && e.shiftKey && (e.key === 'b' || e.key === 'B')) {
41
+ pendingDebug = true
42
+ e.preventDefault()
43
+ }
44
+ })
45
+
46
+ window.addEventListener('keyup', (e: KeyboardEvent) => {
47
+ held.delete(e.key)
48
+ const dir = DIR_KEYS[e.key]
49
+ if (dir && repeatDir === dir) {
50
+ repeatDir = null
51
+ repeatPhase = 'idle'
52
+ }
53
+ })
54
+ }
55
+
56
+ export function tickMovement(dtMs: number): Direction | null {
57
+ if (pendingImmediate !== null) {
58
+ const d = pendingImmediate
59
+ pendingImmediate = null
60
+ return d
61
+ }
62
+ if (repeatDir !== null && repeatPhase !== 'idle') {
63
+ repeatTimer -= dtMs
64
+ if (repeatTimer <= 0) {
65
+ repeatTimer += _repeatInterval
66
+ if (repeatPhase === 'delay') repeatPhase = 'repeat'
67
+ return repeatDir
68
+ }
69
+ }
70
+ return null
71
+ }
72
+
73
+ export function consumeFlag(): boolean { const v = pendingFlag; pendingFlag = false; return v }
74
+ export function consumeDebug(): boolean { const v = pendingDebug; pendingDebug = false; return v }
75
+ export function consumePause(): boolean { const v = pendingPause; pendingPause = false; return v }
76
+ export function consumeAnyKey(): boolean { const v = pendingAnyKey; pendingAnyKey = false; return v }
77
+ export function isHeld(key: string): boolean { return held.has(key) }
package/src/palette.ts ADDED
@@ -0,0 +1,24 @@
1
+ export const SCALE = 4
2
+ export const CELL = 8
3
+
4
+ // ZX Spectrum palette — exactly 15 colours, EXCLUSIVELY these hex values
5
+ export const C = {
6
+ BLACK: '#000000',
7
+ BLUE: '#0000CD',
8
+ RED: '#CD0000',
9
+ MAGENTA: '#CD00CD',
10
+ GREEN: '#00CD00',
11
+ CYAN: '#00CDCD',
12
+ YELLOW: '#CDCD00',
13
+ WHITE: '#CDCDCD',
14
+ B_BLACK: '#000000',
15
+ B_BLUE: '#0000FF',
16
+ B_RED: '#FF0000',
17
+ B_MAGENTA: '#FF00FF',
18
+ B_GREEN: '#00FF00',
19
+ B_CYAN: '#00FFFF',
20
+ B_YELLOW: '#FFFF00',
21
+ B_WHITE: '#FFFFFF',
22
+ } as const
23
+
24
+ export type SpectrumColor = typeof C[keyof typeof C]
@@ -0,0 +1,74 @@
1
+ import { CELL } from './palette.ts'
2
+ import { getCharRow } from './font.ts'
3
+
4
+ // Flip an 8×8 sprite horizontally
5
+ export function mirrorSprite(src: Uint8Array): Uint8Array {
6
+ const out = new Uint8Array(8)
7
+ for (let r = 0; r < 8; r++) {
8
+ let b = src[r], m = 0
9
+ for (let i = 0; i < 8; i++) {
10
+ if (b & (1 << i)) m |= (1 << (7 - i))
11
+ }
12
+ out[r] = m
13
+ }
14
+ return out
15
+ }
16
+
17
+ export function drawSprite(
18
+ ctx: CanvasRenderingContext2D,
19
+ sprite: Uint8Array,
20
+ x: number, y: number,
21
+ ink: string, paper: string,
22
+ ): void {
23
+ ctx.fillStyle = paper
24
+ ctx.fillRect(x, y, CELL, CELL)
25
+ ctx.fillStyle = ink
26
+ for (let row = 0; row < 8; row++) {
27
+ const byte = sprite[row]
28
+ for (let bit = 0; bit < 8; bit++) {
29
+ if (byte & (0x80 >> bit)) ctx.fillRect(x + bit, y + row, 1, 1)
30
+ }
31
+ }
32
+ }
33
+
34
+ export function drawChar(
35
+ ctx: CanvasRenderingContext2D,
36
+ code: number,
37
+ x: number, y: number,
38
+ ink: string, paper?: string,
39
+ ): void {
40
+ if (paper !== undefined) {
41
+ ctx.fillStyle = paper
42
+ ctx.fillRect(x, y, CELL, CELL)
43
+ }
44
+ ctx.fillStyle = ink
45
+ for (let row = 0; row < 8; row++) {
46
+ const byte = getCharRow(code, row)
47
+ for (let bit = 0; bit < 8; bit++) {
48
+ if (byte & (0x80 >> bit)) ctx.fillRect(x + bit, y + row, 1, 1)
49
+ }
50
+ }
51
+ }
52
+
53
+ export function drawText(
54
+ ctx: CanvasRenderingContext2D,
55
+ text: string,
56
+ x: number, y: number,
57
+ ink: string, paper?: string,
58
+ ): void {
59
+ for (let i = 0; i < text.length; i++) {
60
+ drawChar(ctx, text.charCodeAt(i), x + i * CELL, y, ink, paper)
61
+ }
62
+ }
63
+
64
+ // cols = total character columns of the canvas (e.g. 32 for standard Spectrum)
65
+ export function drawTextCentered(
66
+ ctx: CanvasRenderingContext2D,
67
+ text: string,
68
+ y: number,
69
+ cols: number,
70
+ ink: string, paper?: string,
71
+ ): void {
72
+ const x = Math.floor((cols - text.length) / 2) * CELL
73
+ drawText(ctx, text, x, y, ink, paper)
74
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2023",
4
+ "module": "esnext",
5
+ "lib": ["ES2023", "DOM"],
6
+ "moduleResolution": "bundler",
7
+ "allowImportingTsExtensions": true,
8
+ "verbatimModuleSyntax": true,
9
+ "moduleDetection": "force",
10
+ "noEmit": true,
11
+ "strict": true,
12
+ "noUnusedLocals": true,
13
+ "noUnusedParameters": true
14
+ },
15
+ "include": ["src"]
16
+ }