zx-kit 0.15.0 → 0.16.1

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 CHANGED
@@ -30,6 +30,7 @@ The goal is simple: **it should look and sound like a Spectrum, but run like a m
30
30
  - **AABB collision resolution** — sprite vs. sprite overlap, sprite vs. tile map wall resolution with directional hit flags
31
31
  - **Keyboard input** — configurable key-repeat, single-consume action flags, instant state reset on phase transitions
32
32
  - **ZX-style UI widgets** — progress bars with managed lifetime, boxes, frames, panel titles
33
+ - **Typed save / load** — persistent saves via `localStorage` with schema versioning, migrations, slot enumeration, in-memory throttling, and discriminated Result types for every failure mode
33
34
  - **Zero dependencies** — only Web platform APIs: `Canvas`, `Web Audio`, `KeyboardEvent`
34
35
  - **Tree-shakeable** — `sideEffects: false`, so unused modules are dropped from your production bundle
35
36
  - **TypeScript-first** — strict mode, full `.d.ts` declarations, no `any`
@@ -128,6 +129,406 @@ requestAnimationFrame(loop)
128
129
 
129
130
  ---
130
131
 
132
+ ## Getting Started — Build Your First Game
133
+
134
+ 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.
135
+
136
+ No prior game development experience needed. You need basic JavaScript/TypeScript knowledge (variables, functions, arrays).
137
+
138
+ ---
139
+
140
+ ### What you will need
141
+
142
+ | Tool | Where to get it | Why |
143
+ |------|----------------|-----|
144
+ | **Node.js 18+** | [nodejs.org](https://nodejs.org) | Runs npm — the package manager we use to install zx-kit |
145
+ | **A code editor** | [code.visualstudio.com](https://code.visualstudio.com) (free) | Edits your source files |
146
+ | **A terminal** | Built into macOS/Linux; use PowerShell on Windows | Runs commands |
147
+
148
+ ---
149
+
150
+ ### Step 1 — Create the project
151
+
152
+ Open a terminal and run:
153
+
154
+ ```bash
155
+ mkdir my-first-game
156
+ cd my-first-game
157
+ npm init -y
158
+ ```
159
+
160
+ `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.
161
+
162
+ ---
163
+
164
+ ### Step 2 — Install dependencies
165
+
166
+ ```bash
167
+ npm install zx-kit
168
+ npm install --save-dev vite
169
+ ```
170
+
171
+ - **zx-kit** — the game engine you are building with
172
+ - **vite** — a development server that reloads the browser whenever you save a file (installed as a dev tool, not part of your shipped game)
173
+
174
+ ---
175
+
176
+ ### Step 3 — Configure package.json
177
+
178
+ 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):
179
+
180
+ ```json
181
+ {
182
+ "name": "my-first-game",
183
+ "version": "1.0.0",
184
+ "type": "module",
185
+ "scripts": {
186
+ "dev": "vite",
187
+ "build": "vite build"
188
+ },
189
+ "dependencies": {
190
+ "zx-kit": "^0.15.0"
191
+ },
192
+ "devDependencies": {
193
+ "vite": "^6.0.0"
194
+ }
195
+ }
196
+ ```
197
+
198
+ ---
199
+
200
+ ### Step 4 — Create index.html
201
+
202
+ Create a file called `index.html` in your project root:
203
+
204
+ ```html
205
+ <!DOCTYPE html>
206
+ <html lang="en">
207
+ <head>
208
+ <meta charset="UTF-8" />
209
+ <title>My First Game</title>
210
+ <style>
211
+ body {
212
+ margin: 0;
213
+ background: #000;
214
+ display: flex;
215
+ align-items: center;
216
+ justify-content: center;
217
+ height: 100vh;
218
+ }
219
+ </style>
220
+ </head>
221
+ <body>
222
+ <canvas id="game"></canvas>
223
+ <script type="module" src="/src/main.ts"></script>
224
+ </body>
225
+ </html>
226
+ ```
227
+
228
+ The `<canvas>` is the game screen. The `<script>` tag loads your game code.
229
+
230
+ ---
231
+
232
+ ### Step 5 — Create the game
233
+
234
+ Create the folder `src/` and inside it a file called `main.ts`. We will build it one piece at a time.
235
+
236
+ ---
237
+
238
+ #### 5a — Canvas setup
239
+
240
+ 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.
241
+
242
+ ```ts
243
+ import { setupCanvas, C, CELL } from 'zx-kit'
244
+
245
+ const canvas = document.getElementById('game') as HTMLCanvasElement
246
+ const ctx = setupCanvas(canvas, 4)
247
+ // The screen is 256 × 192 game pixels, scaled up 4× in the browser.
248
+ // From here on draw everything in game pixels — setupCanvas handles the scale.
249
+ ```
250
+
251
+ `C` is the color palette. `CELL` is the size of one sprite: 8 pixels.
252
+
253
+ ---
254
+
255
+ #### 5b — Define a sprite
256
+
257
+ 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.
258
+
259
+ 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.
260
+
261
+ We will use two walking frames so the character animates when it moves:
262
+
263
+ ```ts
264
+ // 76543210 ← bit position (7 = leftmost pixel)
265
+ const WALK_A = new Uint8Array([
266
+ 0b00111100, // ..####.. head
267
+ 0b01111110, // .######.
268
+ 0b00011000, // ...##... neck
269
+ 0b01111110, // .######. arms + body
270
+ 0b00011000, // ...##... waist
271
+ 0b01011010, // .#.##.#. legs apart
272
+ 0b01000010, // .#....#. feet
273
+ 0b00000000, // ........
274
+ ])
275
+
276
+ const WALK_B = new Uint8Array([
277
+ 0b00111100, // ..####.. head
278
+ 0b01111110, // .######.
279
+ 0b00011000, // ...##... neck
280
+ 0b01111110, // .######. arms + body
281
+ 0b00011000, // ...##... waist
282
+ 0b00111100, // ..####.. legs together
283
+ 0b00011000, // ...##...
284
+ 0b00000000, // ........
285
+ ])
286
+
287
+ const FRAMES = [WALK_A, WALK_B] // frame 0 = legs apart, frame 1 = legs together
288
+ ```
289
+
290
+ ---
291
+
292
+ #### 5c — Input
293
+
294
+ `initInput()` attaches keyboard listeners. Call it once at startup, not inside the game loop.
295
+
296
+ `isHeld(key)` returns `true` while a key is pressed. We use it to check whether an arrow key is being held down each frame.
297
+
298
+ ```ts
299
+ import { initInput, isHeld } from 'zx-kit'
300
+
301
+ initInput()
302
+ ```
303
+
304
+ ---
305
+
306
+ #### 5d — Audio
307
+
308
+ 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:
309
+
310
+ ```ts
311
+ import { initAudio, getAudioContext, resumeAudio, beep } from 'zx-kit'
312
+
313
+ window.addEventListener('keydown', () => initAudio(0.3), { once: true })
314
+ // initAudio(0.3) = master volume 30%. Called at most once thanks to { once: true }.
315
+ ```
316
+
317
+ ---
318
+
319
+ #### 5e — Player state and animation
320
+
321
+ ```ts
322
+ import { createAnimation, tickAnimation } from 'zx-kit'
323
+
324
+ let px = 120 // player x position in game pixels
325
+ let py = 88 // player y position
326
+ const SPEED = 60 // pixels per second
327
+
328
+ // A 2-frame looping animation, 150ms per frame = one full step every 300ms
329
+ const walkAnim = createAnimation(2, 150)
330
+ let stepTimer = 0 // footstep sound timer
331
+ ```
332
+
333
+ ---
334
+
335
+ #### 5f — The game loop
336
+
337
+ Every frame the browser calls `loop`. Inside, we:
338
+
339
+ 1. Calculate `dt` — how many milliseconds passed since last frame
340
+ 2. Move the player based on held keys
341
+ 3. Keep the player inside the screen
342
+ 4. Pick the right animation frame
343
+ 5. Play a footstep sound periodically while moving
344
+ 6. Clear the screen and redraw everything
345
+
346
+ ```ts
347
+ import { drawSprite, drawText } from 'zx-kit'
348
+
349
+ let lastTime = 0
350
+
351
+ function loop(now: number): void {
352
+ const dt = Math.min(now - lastTime, 100)
353
+ // dt is milliseconds since the last frame (usually ~16ms at 60fps).
354
+ // We cap at 100ms so a background tab returning doesn't cause a huge position jump.
355
+ lastTime = now
356
+
357
+ // ── Move player ────────────────────────────────────────────────────────────
358
+ let moving = false
359
+ if (isHeld('ArrowRight')) { px += SPEED * dt / 1000; moving = true }
360
+ if (isHeld('ArrowLeft')) { px -= SPEED * dt / 1000; moving = true }
361
+ if (isHeld('ArrowDown')) { py += SPEED * dt / 1000; moving = true }
362
+ if (isHeld('ArrowUp')) { py -= SPEED * dt / 1000; moving = true }
363
+ // SPEED (60) is pixels per second. dt is milliseconds. Divide by 1000 to convert.
364
+
365
+ // Keep inside the 256×192 canvas
366
+ px = Math.max(0, Math.min(256 - CELL, px))
367
+ py = Math.max(0, Math.min(192 - CELL, py))
368
+
369
+ // ── Animation ──────────────────────────────────────────────────────────────
370
+ // tickAnimation advances the timer and returns the current frame index (0 or 1).
371
+ // When standing still we always use frame 0.
372
+ const frame = moving ? tickAnimation(walkAnim, dt) : 0
373
+
374
+ // ── Footstep sound ─────────────────────────────────────────────────────────
375
+ if (moving) {
376
+ stepTimer -= dt
377
+ if (stepTimer <= 0) {
378
+ stepTimer = 250 // play a sound every 250ms while moving
379
+ const audio = getAudioContext()
380
+ if (audio) {
381
+ resumeAudio() // un-suspend if the tab was hidden
382
+ beep(220, 30, audio.currentTime) // 220 Hz, 30ms — a short thud
383
+ }
384
+ }
385
+ } else {
386
+ stepTimer = 0 // reset so next movement starts immediately
387
+ }
388
+
389
+ // ── Draw ───────────────────────────────────────────────────────────────────
390
+ ctx.fillStyle = C.BLACK
391
+ ctx.fillRect(0, 0, 256, 192) // clear the whole screen
392
+
393
+ drawSprite(ctx, FRAMES[frame], Math.round(px), Math.round(py), C.B_CYAN, C.BLACK)
394
+ // ↑ position ↑ ink color ↑ paper (background)
395
+
396
+ drawText(ctx, 'ARROW KEYS = MOVE', 8, 184, C.WHITE)
397
+ // drawText draws one ASCII character per 8px slot, left-to-right.
398
+
399
+ requestAnimationFrame(loop) // ask the browser to call us again next frame
400
+ }
401
+
402
+ requestAnimationFrame(loop) // kick off the first frame
403
+ ```
404
+
405
+ ---
406
+
407
+ ### Step 6 — Run the game
408
+
409
+ ```bash
410
+ npm run dev
411
+ ```
412
+
413
+ Open [http://localhost:5173](http://localhost:5173) in your browser. Press an arrow key. Your character walks.
414
+
415
+ ---
416
+
417
+ ### Complete file
418
+
419
+ The full `src/main.ts` all in one place:
420
+
421
+ ```ts
422
+ import {
423
+ setupCanvas, C, CELL,
424
+ drawSprite, drawText,
425
+ initInput, isHeld,
426
+ initAudio, getAudioContext, resumeAudio, beep,
427
+ createAnimation, tickAnimation,
428
+ } from 'zx-kit'
429
+
430
+ // ── Canvas ────────────────────────────────────────────────────────────────────
431
+ const canvas = document.getElementById('game') as HTMLCanvasElement
432
+ const ctx = setupCanvas(canvas, 4)
433
+
434
+ // ── Sprites ───────────────────────────────────────────────────────────────────
435
+ const WALK_A = new Uint8Array([
436
+ 0b00111100, // ..####.. head
437
+ 0b01111110, // .######.
438
+ 0b00011000, // ...##... neck
439
+ 0b01111110, // .######. arms + body
440
+ 0b00011000, // ...##... waist
441
+ 0b01011010, // .#.##.#. legs apart
442
+ 0b01000010, // .#....#. feet
443
+ 0b00000000, // ........
444
+ ])
445
+
446
+ const WALK_B = new Uint8Array([
447
+ 0b00111100, // ..####.. head
448
+ 0b01111110, // .######.
449
+ 0b00011000, // ...##... neck
450
+ 0b01111110, // .######. arms + body
451
+ 0b00011000, // ...##... waist
452
+ 0b00111100, // ..####.. legs together
453
+ 0b00011000, // ...##...
454
+ 0b00000000, // ........
455
+ ])
456
+
457
+ const FRAMES = [WALK_A, WALK_B]
458
+
459
+ // ── Input ─────────────────────────────────────────────────────────────────────
460
+ initInput()
461
+
462
+ // ── Audio ─────────────────────────────────────────────────────────────────────
463
+ window.addEventListener('keydown', () => initAudio(0.3), { once: true })
464
+
465
+ // ── Player state ──────────────────────────────────────────────────────────────
466
+ let px = 120
467
+ let py = 88
468
+ const SPEED = 60 // pixels per second
469
+
470
+ const walkAnim = createAnimation(2, 150)
471
+ let stepTimer = 0
472
+
473
+ // ── Game loop ─────────────────────────────────────────────────────────────────
474
+ let lastTime = 0
475
+
476
+ function loop(now: number): void {
477
+ const dt = Math.min(now - lastTime, 100)
478
+ lastTime = now
479
+
480
+ let moving = false
481
+ if (isHeld('ArrowRight')) { px += SPEED * dt / 1000; moving = true }
482
+ if (isHeld('ArrowLeft')) { px -= SPEED * dt / 1000; moving = true }
483
+ if (isHeld('ArrowDown')) { py += SPEED * dt / 1000; moving = true }
484
+ if (isHeld('ArrowUp')) { py -= SPEED * dt / 1000; moving = true }
485
+
486
+ px = Math.max(0, Math.min(256 - CELL, px))
487
+ py = Math.max(0, Math.min(192 - CELL, py))
488
+
489
+ const frame = moving ? tickAnimation(walkAnim, dt) : 0
490
+
491
+ if (moving) {
492
+ stepTimer -= dt
493
+ if (stepTimer <= 0) {
494
+ stepTimer = 250
495
+ const audio = getAudioContext()
496
+ if (audio) { resumeAudio(); beep(220, 30, audio.currentTime) }
497
+ }
498
+ } else {
499
+ stepTimer = 0
500
+ }
501
+
502
+ ctx.fillStyle = C.BLACK
503
+ ctx.fillRect(0, 0, 256, 192)
504
+
505
+ drawSprite(ctx, FRAMES[frame], Math.round(px), Math.round(py), C.B_CYAN, C.BLACK)
506
+ drawText(ctx, 'ARROW KEYS = MOVE', 8, 184, C.WHITE)
507
+
508
+ requestAnimationFrame(loop)
509
+ }
510
+
511
+ requestAnimationFrame(loop)
512
+ ```
513
+
514
+ ---
515
+
516
+ ### What to try next
517
+
518
+ **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.
519
+
520
+ **Change the color.** Replace `C.B_CYAN` with any palette color: `C.B_GREEN`, `C.B_YELLOW`, `C.B_RED`, `C.B_MAGENTA`, `C.B_WHITE`. The full list is in the [palette reference](#palettets--color-constants).
521
+
522
+ **Add a second character.** Copy the player variables (`px2`, `py2`, `walkAnim2`) and add `W A S D` controls using `isHeld('w')` etc.
523
+
524
+ **Add obstacles.** Use `createTileMap` to place solid wall tiles and `resolveX` / `resolveY` to stop the player at them.
525
+
526
+ **Add chiptune music.** Call `playAY()` with a note array to play a three-channel melody — see [`ay.ts`](#ayts--ay-3-8912-melodik-audio).
527
+
528
+ **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.
529
+
530
+ ---
531
+
131
532
  ## Modules
132
533
 
133
534
  | Module | What it provides |
@@ -142,6 +543,7 @@ requestAnimationFrame(loop)
142
543
  | [`animation.ts`](#animationts--frame-timer--tween) | Frame-timer for sprite strips, position tween between two points |
143
544
  | [`camera.ts`](#camerats--scrolling-camera) | Viewport that follows a target with lerp + deadzone, world-bounds clamping |
144
545
  | [`scene.ts`](#scenets--scene-manager) | Stack-based scene manager with onEnter/onExit/onPause/onResume hooks |
546
+ | [`save.ts`](#savets--typed-save--load) | Typed save/load via callbacks, versioning + migrations, slot enumeration, throttling, Result types |
145
547
  | [`tilemap.ts`](#tilemapts--tile-map-engine) | Scrollable maps, solid tiles, O(1) id-index, background swap |
146
548
  | [`palette.ts`](#palettets--color-constants) | 15 Spectrum colors, `SpectrumColor` type, `CELL`, `SCALE` |
147
549
  | [`font.ts`](#fontts--rom-bitmap-font) | 96-character ROM font, raw bitmap access |
@@ -1134,6 +1536,135 @@ Renders every scene from bottom to top. No-op on an empty manager.
1134
1536
 
1135
1537
  ---
1136
1538
 
1539
+ ## `save.ts` — Typed Save / Load
1540
+
1541
+ 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.
1542
+
1543
+ ```ts
1544
+ import {
1545
+ createSaveProfile, writeSave, writeSaveThrottled,
1546
+ readSave, readSaveLatest, saveExists, deleteSave, listSaves,
1547
+ } from 'zx-kit'
1548
+
1549
+ type MyGameSave = {
1550
+ score: number
1551
+ lives: number
1552
+ probed: string[] // Set<string> serialized to array
1553
+ }
1554
+
1555
+ const save = createSaveProfile<MyGameSave>({
1556
+ key: 'my-game',
1557
+ version: 1,
1558
+ serialize: () => ({
1559
+ score: game.score,
1560
+ lives: game.lives,
1561
+ probed: [...game.probedCells],
1562
+ }),
1563
+ deserialize: (data) => {
1564
+ game.score = data.score
1565
+ game.lives = data.lives
1566
+ game.probedCells = new Set(data.probed)
1567
+ },
1568
+ })
1569
+
1570
+ writeSave(save, 'manual') // immediate write to 'manual'
1571
+ writeSaveThrottled(save, 'auto', 5000) // skips if last 'auto' write < 5s ago
1572
+ readSaveLatest(save) // load newest slot, calls deserialize
1573
+ deleteSave(save, 'auto') // remove slot + clear its throttle entry
1574
+ ```
1575
+
1576
+ ### Why callbacks, not a "save the whole state" snapshot
1577
+
1578
+ 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.
1579
+
1580
+ ### `SaveProfileConfig<T>` interface
1581
+
1582
+ | Field | Description |
1583
+ |-------|-------------|
1584
+ | `key` | Game key — used as namespace in storage. Unique per game. |
1585
+ | `version` | Current schema version. Increment when the shape of `T` changes. |
1586
+ | `serialize` | Returns the current game state as a JSON-safe `T`. |
1587
+ | `deserialize` | Applies a loaded `T` back to the game (side effect — the game owns the mutation). |
1588
+ | `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`. |
1589
+
1590
+ ### `SaveResult` / `LoadResult`
1591
+
1592
+ ```ts
1593
+ type SaveResult =
1594
+ | { ok: true }
1595
+ | { ok: false, reason: 'quota' | 'disabled' | 'serialize_error' | 'throttled', error?: Error }
1596
+
1597
+ type LoadResult =
1598
+ | { ok: true, slot: string }
1599
+ | { ok: false, reason: 'not_found' | 'corrupt' | 'version_unsupported' | 'parse_error' | 'disabled', error?: Error }
1600
+ ```
1601
+
1602
+ `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.
1603
+
1604
+ ### `createSaveProfile<T>(config): SaveProfile<T>`
1605
+
1606
+ 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).
1607
+
1608
+ ### `writeSave(profile, slot?): SaveResult`
1609
+
1610
+ Writes immediately. Calls `serialize`, wraps the result as `{ version, timestamp, data }` and stores under `zxkit:<key>:<slot>`. Default slot is `'default'`.
1611
+
1612
+ ### `writeSaveThrottled(profile, slot, minIntervalMs): SaveResult`
1613
+
1614
+ 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.
1615
+
1616
+ ### `readSave(profile, slot?): LoadResult`
1617
+
1618
+ 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.
1619
+
1620
+ ### `readSaveLatest(profile): LoadResult`
1621
+
1622
+ 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.
1623
+
1624
+ ### `saveExists(profile, slot?): boolean`
1625
+
1626
+ True iff the slot key exists in storage. Does not validate envelope shape — use `readSave` for that.
1627
+
1628
+ ### `deleteSave(profile, slot?): boolean`
1629
+
1630
+ 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.
1631
+
1632
+ ### `listSaves(profile): SlotInfo[]`
1633
+
1634
+ 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`.
1635
+
1636
+ ### Versioning and migration
1637
+
1638
+ 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.
1639
+
1640
+ ```ts
1641
+ createSaveProfile({
1642
+ key: 'my-game',
1643
+ version: 3,
1644
+ migrate: (data, fromVersion) => {
1645
+ let d = data as Record<string, unknown>
1646
+ if (fromVersion < 2) d = { ...d, lives: 3 } // v1 → v2: gained 'lives'
1647
+ if (fromVersion < 3) d = { ...d, probed: [] } // v2 → v3: gained 'probed'
1648
+ return d as MyGameSave
1649
+ },
1650
+ deserialize: (data) => { /* always receives v3 shape */ },
1651
+ })
1652
+ ```
1653
+
1654
+ If `migrate` throws, the read fails with `corrupt`.
1655
+
1656
+ ### Slot naming — convention, not policy
1657
+
1658
+ The kit places no policy on slot names. A useful pattern for retro-style games:
1659
+
1660
+ - `'auto'` — written via `writeSaveThrottled` at meaningful game events (level complete, checkpoint, after a major state change).
1661
+ - `'manual'` — written via `writeSave` when the player hits a save key.
1662
+ - Load via `readSaveLatest` to pick whichever is newer.
1663
+
1664
+ This composes a "your last meaningful checkpoint is always available" UX without a load-slot menu.
1665
+
1666
+ ---
1667
+
1137
1668
  ## `tilemap.ts` — Tile Map Engine
1138
1669
 
1139
1670
  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.
package/dist/index.d.ts CHANGED
@@ -11,4 +11,5 @@ export * from './collision.js';
11
11
  export * from './animation.js';
12
12
  export * from './camera.js';
13
13
  export * from './scene.js';
14
+ export * from './save.js';
14
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,SAAS,CAAA;AACvB,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,SAAS,CAAA;AACvB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,SAAS,CAAA;AACvB,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,SAAS,CAAA;AACvB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA"}
package/dist/index.js CHANGED
@@ -11,4 +11,5 @@ export * from './collision.js';
11
11
  export * from './animation.js';
12
12
  export * from './camera.js';
13
13
  export * from './scene.js';
14
+ export * from './save.js';
14
15
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,SAAS,CAAA;AACvB,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,SAAS,CAAA;AACvB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,SAAS,CAAA;AACvB,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,SAAS,CAAA;AACvB,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA"}
package/dist/save.d.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Configuration for a game's save profile.
3
+ *
4
+ * @typeParam T - The JSON-safe shape of the saved payload. The game converts
5
+ * non-JSON values (Sets, Maps, class instances) inside `serialize` /
6
+ * `deserialize`.
7
+ */
8
+ export interface SaveProfileConfig<T> {
9
+ /** Game key — used as namespace in storage. Must be unique per game. */
10
+ key: string;
11
+ /** Current schema version. Increment when the shape of T changes. */
12
+ version: number;
13
+ /** Returns the current game state as a JSON-safe value. */
14
+ serialize: () => T;
15
+ /** Applies a loaded snapshot back to the game (side effect). */
16
+ deserialize: (data: T) => void;
17
+ /**
18
+ * Optional migration. Called when the loaded envelope's version is older
19
+ * than `version`. Receives the raw data and the version it was saved at,
20
+ * returns data shaped for the current version. If absent and an older
21
+ * version is encountered, the load fails with `version_unsupported`.
22
+ */
23
+ migrate?: (data: unknown, fromVersion: number) => T;
24
+ }
25
+ /**
26
+ * Returned by {@link createSaveProfile}. Carries config plus in-memory
27
+ * throttle state. Pass to all save/load operations.
28
+ *
29
+ * Throttle state lives only in memory — a page reload resets it, so the
30
+ * first {@link writeSaveThrottled} after reload always proceeds.
31
+ */
32
+ export interface SaveProfile<T> {
33
+ readonly key: string;
34
+ readonly version: number;
35
+ readonly config: SaveProfileConfig<T>;
36
+ /** Per-slot last successful write timestamp (ms). Internal. */
37
+ lastWrites: Map<string, number>;
38
+ }
39
+ /** Metadata for a single stored slot. */
40
+ export interface SlotInfo {
41
+ name: string;
42
+ timestamp: number;
43
+ version: number;
44
+ sizeBytes: number;
45
+ }
46
+ /**
47
+ * Result of a write operation.
48
+ *
49
+ * `throttled` indicates the write was intentionally skipped because the
50
+ * throttle interval had not elapsed — not a true failure. Callers that
51
+ * only care whether data hit storage can branch on `ok`; callers that
52
+ * want to distinguish "skipped" from "failed" can check `reason`.
53
+ */
54
+ export type SaveResult = {
55
+ ok: true;
56
+ } | {
57
+ ok: false;
58
+ reason: 'quota' | 'disabled' | 'serialize_error' | 'throttled';
59
+ error?: Error;
60
+ };
61
+ /** Result of a read operation. On `ok: true`, `deserialize` has already been called. */
62
+ export type LoadResult = {
63
+ ok: true;
64
+ slot: string;
65
+ } | {
66
+ ok: false;
67
+ reason: 'not_found' | 'corrupt' | 'version_unsupported' | 'parse_error' | 'disabled';
68
+ error?: Error;
69
+ };
70
+ /**
71
+ * Registers a save profile for a game. Call once at startup and reuse the
72
+ * returned handle for all save/load operations.
73
+ *
74
+ * @example
75
+ * const save = createSaveProfile<MinefieldSave>({
76
+ * key: 'minefield',
77
+ * version: 1,
78
+ * serialize: () => ({ score, lives, probed: [...probedCells] }),
79
+ * deserialize: (data) => { score = data.score; probedCells = new Set(data.probed) },
80
+ * })
81
+ */
82
+ export declare function createSaveProfile<T>(config: SaveProfileConfig<T>): SaveProfile<T>;
83
+ /**
84
+ * Writes the current game state to a slot immediately. Calls `serialize`
85
+ * to obtain the payload, then stores `{ version, timestamp, data }` as JSON.
86
+ *
87
+ * @param slot - Slot name; defaults to `'default'`. Games typically use
88
+ * convention-based names like `'auto'` or `'manual'`.
89
+ */
90
+ export declare function writeSave<T>(profile: SaveProfile<T>, slot?: string): SaveResult;
91
+ /**
92
+ * Writes to `slot` only if at least `minIntervalMs` has elapsed since the
93
+ * last successful write to the same slot in this session. Otherwise returns
94
+ * `{ ok: false, reason: 'throttled' }`.
95
+ *
96
+ * Throttle state is in-memory; a page reload resets it.
97
+ */
98
+ export declare function writeSaveThrottled<T>(profile: SaveProfile<T>, slot: string, minIntervalMs: number): SaveResult;
99
+ /**
100
+ * Reads a slot, runs migration if needed, then calls `deserialize` with
101
+ * the resulting payload. On success the game state has been restored.
102
+ */
103
+ export declare function readSave<T>(profile: SaveProfile<T>, slot?: string): LoadResult;
104
+ /**
105
+ * Reads whichever slot has the newest timestamp. Returns `not_found` if
106
+ * no slots exist for this profile's key.
107
+ */
108
+ export declare function readSaveLatest<T>(profile: SaveProfile<T>): LoadResult;
109
+ /** True iff the slot exists in storage. Does not validate envelope shape. */
110
+ export declare function saveExists<T>(profile: SaveProfile<T>, slot?: string): boolean;
111
+ /**
112
+ * Removes a slot. Returns `true` if a slot was removed, `false` if it
113
+ * didn't exist or storage is unavailable.
114
+ */
115
+ export declare function deleteSave<T>(profile: SaveProfile<T>, slot?: string): boolean;
116
+ /**
117
+ * Enumerates all slots that belong to this profile's key. Corrupt or
118
+ * mis-shaped entries are silently skipped — they will surface as `corrupt`
119
+ * if loaded explicitly via {@link readSave}.
120
+ */
121
+ export declare function listSaves<T>(profile: SaveProfile<T>): SlotInfo[];
122
+ //# sourceMappingURL=save.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"save.d.ts","sourceRoot":"","sources":["../src/save.ts"],"names":[],"mappings":"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"}
package/dist/save.js ADDED
@@ -0,0 +1,253 @@
1
+ // ── Save: typed save/load with versioning, throttling, slot enumeration ─────
2
+ const NAMESPACE = 'zxkit';
3
+ const DEFAULT_SLOT = 'default';
4
+ function storageKey(profileKey, slot) {
5
+ return `${NAMESPACE}:${profileKey}:${slot}`;
6
+ }
7
+ /**
8
+ * Resolves the localStorage reference, returning null if unavailable
9
+ * (Node, SSR, private browsing where access itself throws, etc.).
10
+ */
11
+ function getStorage() {
12
+ try {
13
+ if (typeof globalThis === 'undefined')
14
+ return null;
15
+ return globalThis.localStorage ?? null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ function isValidEnvelope(value) {
22
+ if (typeof value !== 'object' || value === null)
23
+ return false;
24
+ const v = value;
25
+ return (typeof v.version === 'number' &&
26
+ Number.isFinite(v.version) &&
27
+ typeof v.timestamp === 'number' &&
28
+ Number.isFinite(v.timestamp) &&
29
+ 'data' in v);
30
+ }
31
+ /**
32
+ * Registers a save profile for a game. Call once at startup and reuse the
33
+ * returned handle for all save/load operations.
34
+ *
35
+ * @example
36
+ * const save = createSaveProfile<MinefieldSave>({
37
+ * key: 'minefield',
38
+ * version: 1,
39
+ * serialize: () => ({ score, lives, probed: [...probedCells] }),
40
+ * deserialize: (data) => { score = data.score; probedCells = new Set(data.probed) },
41
+ * })
42
+ */
43
+ export function createSaveProfile(config) {
44
+ return {
45
+ key: config.key,
46
+ version: config.version,
47
+ config,
48
+ lastWrites: new Map(),
49
+ };
50
+ }
51
+ /**
52
+ * Writes the current game state to a slot immediately. Calls `serialize`
53
+ * to obtain the payload, then stores `{ version, timestamp, data }` as JSON.
54
+ *
55
+ * @param slot - Slot name; defaults to `'default'`. Games typically use
56
+ * convention-based names like `'auto'` or `'manual'`.
57
+ */
58
+ export function writeSave(profile, slot = DEFAULT_SLOT) {
59
+ const storage = getStorage();
60
+ if (!storage)
61
+ return { ok: false, reason: 'disabled' };
62
+ let payload;
63
+ try {
64
+ payload = profile.config.serialize();
65
+ }
66
+ catch (error) {
67
+ return { ok: false, reason: 'serialize_error', error: error };
68
+ }
69
+ const envelope = {
70
+ version: profile.version,
71
+ timestamp: Date.now(),
72
+ data: payload,
73
+ };
74
+ let serialized;
75
+ try {
76
+ serialized = JSON.stringify(envelope);
77
+ }
78
+ catch (error) {
79
+ return { ok: false, reason: 'serialize_error', error: error };
80
+ }
81
+ try {
82
+ storage.setItem(storageKey(profile.key, slot), serialized);
83
+ }
84
+ catch (error) {
85
+ const err = error;
86
+ if (err.name === 'QuotaExceededError' || err.code === 22) {
87
+ return { ok: false, reason: 'quota', error: err };
88
+ }
89
+ return { ok: false, reason: 'disabled', error: err };
90
+ }
91
+ profile.lastWrites.set(slot, envelope.timestamp);
92
+ return { ok: true };
93
+ }
94
+ /**
95
+ * Writes to `slot` only if at least `minIntervalMs` has elapsed since the
96
+ * last successful write to the same slot in this session. Otherwise returns
97
+ * `{ ok: false, reason: 'throttled' }`.
98
+ *
99
+ * Throttle state is in-memory; a page reload resets it.
100
+ */
101
+ export function writeSaveThrottled(profile, slot, minIntervalMs) {
102
+ const last = profile.lastWrites.get(slot);
103
+ if (last !== undefined && Date.now() - last < minIntervalMs) {
104
+ return { ok: false, reason: 'throttled' };
105
+ }
106
+ return writeSave(profile, slot);
107
+ }
108
+ /**
109
+ * Reads a slot, runs migration if needed, then calls `deserialize` with
110
+ * the resulting payload. On success the game state has been restored.
111
+ */
112
+ export function readSave(profile, slot = DEFAULT_SLOT) {
113
+ const storage = getStorage();
114
+ if (!storage)
115
+ return { ok: false, reason: 'disabled' };
116
+ const raw = storage.getItem(storageKey(profile.key, slot));
117
+ if (raw === null)
118
+ return { ok: false, reason: 'not_found' };
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ }
123
+ catch (error) {
124
+ return { ok: false, reason: 'parse_error', error: error };
125
+ }
126
+ if (!isValidEnvelope(parsed)) {
127
+ return { ok: false, reason: 'corrupt' };
128
+ }
129
+ let data = parsed.data;
130
+ if (parsed.version !== profile.version) {
131
+ if (parsed.version > profile.version) {
132
+ return { ok: false, reason: 'version_unsupported' };
133
+ }
134
+ if (!profile.config.migrate) {
135
+ return { ok: false, reason: 'version_unsupported' };
136
+ }
137
+ try {
138
+ data = profile.config.migrate(data, parsed.version);
139
+ }
140
+ catch (error) {
141
+ return { ok: false, reason: 'corrupt', error: error };
142
+ }
143
+ }
144
+ try {
145
+ profile.config.deserialize(data);
146
+ }
147
+ catch (error) {
148
+ return { ok: false, reason: 'corrupt', error: error };
149
+ }
150
+ return { ok: true, slot };
151
+ }
152
+ /**
153
+ * Reads whichever slot has the newest timestamp. Returns `not_found` if
154
+ * no slots exist for this profile's key.
155
+ */
156
+ export function readSaveLatest(profile) {
157
+ const slots = listSaves(profile);
158
+ if (slots.length === 0)
159
+ return { ok: false, reason: 'not_found' };
160
+ let newest = slots[0];
161
+ for (let i = 1; i < slots.length; i++) {
162
+ if (slots[i].timestamp > newest.timestamp)
163
+ newest = slots[i];
164
+ }
165
+ return readSave(profile, newest.name);
166
+ }
167
+ /** True iff the slot exists in storage. Does not validate envelope shape. */
168
+ export function saveExists(profile, slot = DEFAULT_SLOT) {
169
+ const storage = getStorage();
170
+ if (!storage)
171
+ return false;
172
+ try {
173
+ return storage.getItem(storageKey(profile.key, slot)) !== null;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
179
+ /**
180
+ * Removes a slot. Returns `true` if a slot was removed, `false` if it
181
+ * didn't exist or storage is unavailable.
182
+ */
183
+ export function deleteSave(profile, slot = DEFAULT_SLOT) {
184
+ const storage = getStorage();
185
+ if (!storage)
186
+ return false;
187
+ const key = storageKey(profile.key, slot);
188
+ try {
189
+ if (storage.getItem(key) === null)
190
+ return false;
191
+ storage.removeItem(key);
192
+ }
193
+ catch {
194
+ return false;
195
+ }
196
+ profile.lastWrites.delete(slot);
197
+ return true;
198
+ }
199
+ /**
200
+ * Enumerates all slots that belong to this profile's key. Corrupt or
201
+ * mis-shaped entries are silently skipped — they will surface as `corrupt`
202
+ * if loaded explicitly via {@link readSave}.
203
+ */
204
+ export function listSaves(profile) {
205
+ const storage = getStorage();
206
+ if (!storage)
207
+ return [];
208
+ const prefix = `${NAMESPACE}:${profile.key}:`;
209
+ const slots = [];
210
+ let length = 0;
211
+ try {
212
+ length = storage.length;
213
+ }
214
+ catch {
215
+ return [];
216
+ }
217
+ for (let i = 0; i < length; i++) {
218
+ let key = null;
219
+ try {
220
+ key = storage.key(i);
221
+ }
222
+ catch {
223
+ continue;
224
+ }
225
+ if (!key || !key.startsWith(prefix))
226
+ continue;
227
+ let raw = null;
228
+ try {
229
+ raw = storage.getItem(key);
230
+ }
231
+ catch {
232
+ continue;
233
+ }
234
+ if (raw === null)
235
+ continue;
236
+ try {
237
+ const parsed = JSON.parse(raw);
238
+ if (isValidEnvelope(parsed)) {
239
+ slots.push({
240
+ name: key.slice(prefix.length),
241
+ timestamp: parsed.timestamp,
242
+ version: parsed.version,
243
+ sizeBytes: raw.length,
244
+ });
245
+ }
246
+ }
247
+ catch {
248
+ // skip corrupt entries
249
+ }
250
+ }
251
+ return slots;
252
+ }
253
+ //# sourceMappingURL=save.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"save.js","sourceRoot":"","sources":["../src/save.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zx-kit",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zrebec/zx-kit.git"
@@ -36,11 +36,15 @@
36
36
  "node": ">=22"
37
37
  },
38
38
  "license": "MIT",
39
+ "overrides": {
40
+ "npm": ">=11.14.1"
41
+ },
39
42
  "devDependencies": {
40
43
  "@semantic-release/changelog": "^6.0.3",
41
44
  "@semantic-release/git": "^10.0.1",
42
45
  "@semantic-release/github": "^12.0.6",
43
46
  "@semantic-release/npm": "^13.1.5",
47
+ "@vitest/coverage-v8": "^4.1.6",
44
48
  "jsdom": "^29.1.1",
45
49
  "semantic-release": "^25.0.3",
46
50
  "typescript": "^6.0.3",