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 +414 -0
- package/package.json +11 -0
- package/src/audio.ts +39 -0
- package/src/font.ts +203 -0
- package/src/index.ts +5 -0
- package/src/input.ts +77 -0
- package/src/palette.ts +24 -0
- package/src/renderer.ts +74 -0
- package/tsconfig.json +16 -0
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
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]
|
package/src/renderer.ts
ADDED
|
@@ -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
|
+
}
|