zx-kit 0.25.0 → 0.27.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **Build browser games that look and sound like a ZX Spectrum — without any of its limitations.**
4
4
 
5
- Three-channel chiptune audio. Pixel-perfect canvas rendering. Authentic 15-color palette. ROM bitmap font. Tile maps with seasonal swapping. Physics-based sprites. Collision detection. A complete retro game engine in a single zero-dependency npm package.
5
+ Three-channel chiptune audio. Pixel-perfect canvas rendering. Authentic 15-color palette. ROM bitmap font. Tile maps with seasonal swapping. Free-roaming sprites with velocity and gravity helpers. Collision detection. A complete retro game toolkit in a single zero-dependency npm package.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/zx-kit)](https://www.npmjs.com/package/zx-kit)
8
8
  [![license](https://img.shields.io/npm/l/zx-kit)](LICENSE)
@@ -13,7 +13,7 @@ Three-channel chiptune audio. Pixel-perfect canvas rendering. Authentic 15-color
13
13
 
14
14
  The ZX Spectrum was a marvel of constraint. Its 8×8 pixel grid, 15-color palette, and 1-bit beeper defined an entire visual and sonic language. Thousands of games were made with nearly nothing — and they were unforgettable.
15
15
 
16
- zx-kit lets you build in that same visual tradition, but with everything the original hardware was too limited to provide: three-channel AY-3-8912 chiptune audio with hardware-accurate envelopes and LFSR noise, smooth canvas rendering, physics-based sprites, and collision detection — all in TypeScript, all in the browser, all with zero dependencies.
16
+ zx-kit lets you build in that same visual tradition, but with everything the original hardware was too limited to provide: three-channel AY-3-8912 chiptune audio with hardware-accurate envelopes and LFSR noise, smooth canvas rendering, free-roaming sprites with velocity and gravity helpers, and collision detection — all in TypeScript, all in the browser, all with zero dependencies.
17
17
 
18
18
  The goal is simple: **it should look and sound like a Spectrum, but run like a modern game.**
19
19
 
@@ -28,11 +28,11 @@ The goal is simple: **it should look and sound like a Spectrum, but run like a m
28
28
  - **Tile map engine** — scrollable maps, O(1) id-index, smart seasonal background swapping, solid-tile collision queries
29
29
  - **Free-roaming sprites** — position, velocity, gravity, `flipX` caching, transparent or opaque background
30
30
  - **Three-tier collision** — AABB overlap tests, generic rect-vs-tile wall resolution (any sprite size), and pixel-precise mask overlap with O(pixels) sorted-merge intersection — no allocations per frame
31
- - **Keyboard input** — configurable key-repeat, single-consume action flags, instant state reset on phase transitions
31
+ - **Keyboard and gamepad input** — configurable key-repeat, transparent gamepad polling, 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
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
34
34
  - **Runtime locale switching** — type-safe string-pack selection via `pickLocale()`, so a game can switch language while running — unimaginable on the original Spectrum, natural in the browser
35
- - **Zero dependencies** — only Web platform APIs: `Canvas`, `Web Audio`, `KeyboardEvent`
35
+ - **Zero dependencies** — only Web platform APIs: `Canvas`, `Web Audio`, `KeyboardEvent`, `Gamepad`
36
36
  - **Tree-shakeable** — `sideEffects: false`, so unused modules are dropped from your production bundle
37
37
  - **TypeScript-first** — strict mode, full `.d.ts` declarations, no `any`
38
38
 
@@ -44,6 +44,25 @@ The goal is simple: **it should look and sound like a Spectrum, but run like a m
44
44
 
45
45
  ---
46
46
 
47
+ ## Examples
48
+
49
+ The repository includes small static examples that import `../../dist/index.js`
50
+ directly, so each one doubles as a browser-checkable API recipe:
51
+
52
+ | Example | Shows |
53
+ |---------|-------|
54
+ | `examples/ay-music/` | AY channels A/B/C plus beeper SFX as a four-voice Spectrum-style setup |
55
+ | `examples/pixel-collision/` | AABB false positives vs `bitmapPixelMask()` / `masksOverlap()` |
56
+ | `examples/particles/` | Allocation-free particle pools for sparks, smoke, and explosions |
57
+ | `examples/i18n-runtime/` | Runtime language switching with `pickLocale()` and persisted preference |
58
+ | `examples/bitmap-attrs/` | `Bitmap`, `AttrMap`, mirroring, colour clash, and `inkOnly` rendering |
59
+ | `examples/save-slots/` | Save profiles, auto/manual slots, latest-slot restore, throttling, and delete |
60
+
61
+ Build first with `npm run build`, then serve the repository root and open any
62
+ example path in the browser.
63
+
64
+ ---
65
+
47
66
  ## Installation
48
67
 
49
68
  ### From npm (recommended)
@@ -142,7 +161,7 @@ No prior game development experience needed. You need basic JavaScript/TypeScrip
142
161
 
143
162
  | Tool | Where to get it | Why |
144
163
  |------|----------------|-----|
145
- | **Node.js 18+** | [nodejs.org](https://nodejs.org) | Runs npm — the package manager we use to install zx-kit |
164
+ | **Node.js 22+** | [nodejs.org](https://nodejs.org) | Runs npm — the package manager we use to install zx-kit |
146
165
  | **A code editor** | [code.visualstudio.com](https://code.visualstudio.com) (free) | Edits your source files |
147
166
  | **A terminal** | Built into macOS/Linux; use PowerShell on Windows | Runs commands |
148
167
 
@@ -188,7 +207,7 @@ Open `package.json` and replace it with the following. The two key additions are
188
207
  "build": "vite build"
189
208
  },
190
209
  "dependencies": {
191
- "zx-kit": "^0.15.0"
210
+ "zx-kit": "^0.25.0"
192
211
  },
193
212
  "devDependencies": {
194
213
  "vite": "^6.0.0"
@@ -535,10 +554,10 @@ requestAnimationFrame(loop)
535
554
  | Module | What it provides |
536
555
  |--------|-----------------|
537
556
  | [`ay.ts`](#ayts--ay-3-8912-melodik-audio) | AY chip emulator: 3-channel tone, LFSR noise, 16 envelope shapes |
538
- | [`renderer.ts`](#rendererts--canvas-renderer) | Canvas setup, sprites, text, scanlines, border flash |
557
+ | [`renderer.ts`](#rendererts--canvas-renderer) | Canvas setup, 8x8 sprites, arbitrary-size bitmaps, attribute maps, text, scanlines, border flash |
539
558
  | [`audio.ts`](#audiots--beeper-audio) | 1-bit beeper: square-wave notes, patterns, volume control |
540
559
  | [`ui.ts`](#uits--ui-widgets) | Boxes, frames, panel titles, progress bars + instrumentation widgets (dotted grids, segmented bars, fluid tanks, dials, text compass) |
541
- | [`input.ts`](#inputts--keyboard-input) | Movement with key-repeat, action flags, state reset |
560
+ | [`input.ts`](#inputts--keyboard--gamepad-input) | Keyboard/gamepad movement, key-repeat, action flags, state reset |
542
561
  | [`sprite.ts`](#spritets--free-roaming-sprites) | Sprites: position, velocity, gravity, flip, render |
543
562
  | [`collision.ts`](#collisionts--collision-detection) | AABB overlap + rect-based tile resolution, pixel-precise mask overlap and tile checks |
544
563
  | [`animation.ts`](#animationts--frame-timer--tween) | Frame-timer for sprite strips, position tween between two points |
@@ -552,6 +571,8 @@ requestAnimationFrame(loop)
552
571
  | [`palette.ts`](#palettets--color-constants) | 15 Spectrum colors, `SpectrumColor` type, `CELL`, `SCALE` |
553
572
  | [`font.ts`](#fontts--rom-bitmap-font) | 96-character ROM font, raw bitmap access |
554
573
  | [`i18n.ts`](#i18nts--runtime-locale-selection) | Type-safe runtime locale selection for translated string packs |
574
+ | [`lighting.ts`](#lightingts--dithered-cave-darkness) | Dithered cave darkness: pre-baked level tiles + dirty-cell buffer, one blit/frame (no per-frame putImageData) |
575
+ | [`music.ts`](#musicts--note-name-ay-music) | Write AY music by note name (`A5`, `C#4`) and loop it for background tracks |
555
576
 
556
577
  ---
557
578
 
@@ -907,6 +928,37 @@ curveDisplay(ctx) // default strength
907
928
  curveDisplay(ctx, 256, 208, 6) // stronger warp
908
929
  ```
909
930
 
931
+ ### `Bitmap` interface
932
+
933
+ An arbitrary-size monochrome bitmap. Width must be a positive multiple of 8 so
934
+ each row is byte-aligned. `data` is row-major; bit 7 is the leftmost pixel in
935
+ each byte.
936
+
937
+ ```ts
938
+ interface Bitmap {
939
+ data: Uint8Array
940
+ width: number
941
+ height: number
942
+ }
943
+ ```
944
+
945
+ Use `Bitmap` for 16x16 enemies, 16x24 heroes, 32x32 bosses, tall objects, and
946
+ anything that outgrows the classic 8x8 `drawSprite()` format.
947
+
948
+ ### `createBitmap(data, width, height): Bitmap`
949
+
950
+ Builds a `Bitmap` from packed bytes and validates the dimensions and byte
951
+ count immediately. Throws if width is not byte-aligned, height is invalid, or
952
+ the data length does not match `(width / 8) * height`.
953
+
954
+ ```ts
955
+ const HERO = createBitmap(new Uint8Array([
956
+ 0x03, 0xC0,
957
+ 0x07, 0xE0,
958
+ // ...22 more 16px-wide rows
959
+ ]), 16, 24)
960
+ ```
961
+
910
962
  ### `createBitmapFromRows(rows): Bitmap`
911
963
 
912
964
  Builds an arbitrary-size `Bitmap` from readable pixel-art rows instead of
@@ -949,6 +1001,49 @@ Draws an arbitrary-size `Bitmap`. The colour model has three modes, ordered by h
949
1001
 
950
1002
  `inkOnly` (last parameter, default `false`) **suppresses the paper rectangle even when a `paper` colour is supplied.** For `drawBitmap` this is functionally identical to omitting `paper` — its value is ergonomic: keep a sprite's configured `paper` and toggle the opaque box on or off with a boolean, instead of conditionally choosing whether to pass the argument at the call site.
951
1003
 
1004
+ ### `mirrorBitmap(src): Bitmap`
1005
+
1006
+ Returns a horizontally flipped copy of a `Bitmap`. The original is not
1007
+ modified. Use it at module load time to derive left-facing sprites from one
1008
+ right-facing definition.
1009
+
1010
+ ```ts
1011
+ const HERO_RIGHT = createBitmapFromRows([...])
1012
+ const HERO_LEFT = mirrorBitmap(HERO_RIGHT)
1013
+ ```
1014
+
1015
+ ### `AttrMap` interface
1016
+
1017
+ Per-8x8-cell ink and paper colours for a `Bitmap`, mirroring the ZX Spectrum
1018
+ attribute buffer. `cols` must match `bitmap.width / 8`; `rows` must match
1019
+ `bitmap.height / 8`.
1020
+
1021
+ ```ts
1022
+ interface AttrMap {
1023
+ readonly cols: number
1024
+ readonly rows: number
1025
+ readonly inks: readonly SpectrumColor[]
1026
+ readonly papers?: readonly SpectrumColor[]
1027
+ }
1028
+ ```
1029
+
1030
+ Omit `papers` for transparent per-cell ink rendering, or provide papers for
1031
+ the authentic colour-clash look.
1032
+
1033
+ ### `createAttrMap(cols, rows, inks, papers?): AttrMap`
1034
+
1035
+ Builds an `AttrMap` with validation. `inks` must contain `cols * rows` colours.
1036
+ `papers` can be omitted, supplied as a matching per-cell array, or supplied as
1037
+ one colour to fill every cell.
1038
+
1039
+ ```ts
1040
+ const HERO_ATTRS = createAttrMap(2, 3, [
1041
+ C.B_YELLOW, C.B_YELLOW,
1042
+ C.B_RED, C.B_MAGENTA,
1043
+ C.B_CYAN, C.B_GREEN,
1044
+ ], C.BLACK)
1045
+ ```
1046
+
952
1047
  ### `drawBitmapAttrs(ctx, bitmap, attrs, x, y, inkOnly?): void`
953
1048
 
954
1049
  Renders a `Bitmap` with a per-cell `AttrMap` — each 8×8 cell carries its own `(ink, paper)`, the authentic Spectrum attribute model. Here `inkOnly` is **not** redundant: it keeps every per-cell *ink* colour but skips all per-cell *paper* fills. One fully-coloured `AttrMap` (with `papers` for the boxed look on a plain background) then renders two ways — flip `inkOnly` per frame, with no second paper-less map to build and keep in sync. Dimension validation still throws under `inkOnly`: the flag changes what is painted, never the contract.
@@ -960,6 +1055,17 @@ drawBitmapAttrs(ctx, BUNNY, BUNNY_ATTRS, x, y, true)
960
1055
  // the cave behind it. The rabbit reads by its own silhouette.
961
1056
  ```
962
1057
 
1058
+ ### `mirrorAttrMap(attrs): AttrMap`
1059
+
1060
+ Returns a horizontally flipped copy of an `AttrMap`, reversing each attribute
1061
+ row. Pair it with `mirrorBitmap()` so a mirrored sprite keeps its colours on
1062
+ the matching 8x8 cells.
1063
+
1064
+ ```ts
1065
+ const HERO_LEFT = mirrorBitmap(HERO_RIGHT)
1066
+ const HERO_LEFT_ATTRS = mirrorAttrMap(HERO_RIGHT_ATTRS)
1067
+ ```
1068
+
963
1069
  ### Why does `inkOnly` exist? (and why is it, honestly, a little bit of debt?)
964
1070
 
965
1071
  This is the kind of decision worth writing down, because the "obvious" answer is the wrong one.
@@ -1148,6 +1254,8 @@ Five stateless primitives for HUDs, dashboards and tactical displays — gauges,
1148
1254
 
1149
1255
  #### `drawDottedGrid(ctx, options): void`
1150
1256
 
1257
+ Options type: `DrawDottedGridOptions`.
1258
+
1151
1259
  Regularly-spaced dot pattern. Useful for radar / sonar screens, tactical scanner overlays, debug grids, stippled backgrounds, alien-invasion detection grids.
1152
1260
 
1153
1261
  ```ts
@@ -1174,6 +1282,8 @@ drawDottedGrid(ctx, {
1174
1282
 
1175
1283
  #### `drawSegmentedBar(ctx, options): void`
1176
1284
 
1285
+ Options type: `DrawSegmentedBarOptions`.
1286
+
1177
1287
  Discrete segmented bar — health, ammo, shield, fuel, stamina, mana, battery, damage. Computes `round(value/max * segments)` filled segments.
1178
1288
 
1179
1289
  Two colouring strategies, mutually exclusive:
@@ -1217,6 +1327,8 @@ drawSegmentedBar(ctx, {
1217
1327
 
1218
1328
  #### `drawTank(ctx, options): void`
1219
1329
 
1330
+ Options type: `DrawTankOptions`.
1331
+
1220
1332
  Fluid container — ballast tanks, fuel gauges, water reservoirs, lava levels, oil drums, chemical canisters. Liquid fills from the bottom up.
1221
1333
 
1222
1334
  ```ts
@@ -1250,6 +1362,8 @@ drawTank(ctx, {
1250
1362
 
1251
1363
  #### `drawDial(ctx, options): void`
1252
1364
 
1365
+ Options type: `DrawDialOptions`.
1366
+
1253
1367
  Circular analog gauge with movable needle — RPM, speedometer, fuel, temperature, volume knob. Decorations (face fill, rim outline, tick marks) are optional; the needle alone is the minimum visible output.
1254
1368
 
1255
1369
  ```ts
@@ -1288,6 +1402,8 @@ Angles use canvas convention: `0` = right, `π/2` = down, `π` = left, `3π/2` =
1288
1402
 
1289
1403
  #### `drawCompassText(ctx, options): void`
1290
1404
 
1405
+ Options type: `DrawCompassTextOptions`.
1406
+
1291
1407
  Text-based heading indicator in the classic 80s tactical-display style `[W [NW] N [NE] E]` — current direction in the centre, highlighted, with two neighbouring directions on each side. Heading rounds to the nearest 45° step.
1292
1408
 
1293
1409
  ```ts
@@ -1365,9 +1481,11 @@ appPhase = 'intro'
1365
1481
 
1366
1482
  ---
1367
1483
 
1368
- ## `input.ts` — Keyboard Input
1484
+ ## `input.ts` — Keyboard & Gamepad Input
1369
1485
 
1370
- Handles directional movement with configurable key-repeat (immediate on first press, configurable auto-repeat delay on hold) plus single-consume flags for action keys. Call `initInput()` once at startup, then `tickMovement(dt)` every frame.
1486
+ Handles directional movement with configurable keyboard repeat, transparent
1487
+ gamepad polling, and single-consume flags for action buttons. Call
1488
+ `initInput()` once at startup, then `tickMovement(dt)` every frame.
1371
1489
 
1372
1490
  ### `Direction` type
1373
1491
 
@@ -1381,6 +1499,11 @@ Attaches `keydown`/`keyup` listeners. Idempotent — safe to call multiple times
1381
1499
 
1382
1500
  Default key bindings: arrows = movement, `W A S D` = also movement, `F` = flag action, `P` = pause, `Ctrl+Shift+B` = debug toggle.
1383
1501
 
1502
+ Gamepad support is automatic. `tickMovement()` polls the first connected
1503
+ gamepad via the browser Gamepad API: D-pad / left stick move, button 0 maps to
1504
+ `consumeFlag()`, button 9 maps to `consumePause()`, button 3 maps to
1505
+ `consumeDebug()`, and any button triggers `consumeAnyKey()`.
1506
+
1384
1507
  ```ts
1385
1508
  initInput() // default: 150ms initial delay, 80ms repeat
1386
1509
  initInput(200, 60) // custom timing
@@ -1388,7 +1511,9 @@ initInput(200, 60) // custom timing
1388
1511
 
1389
1512
  ### `tickMovement(dtMs): Direction | null`
1390
1513
 
1391
- Returns the active movement direction for this frame, or `null`. Handles the delay/repeat state machine internally. Call exactly once per frame.
1514
+ Returns the active movement direction for this frame, or `null`. Handles the
1515
+ keyboard delay/repeat state machine and gamepad polling internally. Call
1516
+ exactly once per frame.
1392
1517
 
1393
1518
  ```ts
1394
1519
  const dir = tickMovement(dt)
@@ -1404,10 +1529,10 @@ Each function returns `true` exactly once per key press, then resets to `false`.
1404
1529
 
1405
1530
  | Function | Default key | Typical use |
1406
1531
  |----------|-------------|-------------|
1407
- | `consumeFlag()` | `F` | Flag / unflag a tile |
1408
- | `consumePause()` | `P` | Pause / unpause |
1409
- | `consumeDebug()` | `Ctrl+Shift+B` | Toggle debug overlay |
1410
- | `consumeAnyKey()` | Any key | Dismiss overlays, start game |
1532
+ | `consumeFlag()` | `F` / gamepad button 0 | Flag / unflag a tile |
1533
+ | `consumePause()` | `P` / gamepad button 9 | Pause / unpause |
1534
+ | `consumeDebug()` | `Ctrl+Shift+B` / gamepad button 3 | Toggle debug overlay |
1535
+ | `consumeAnyKey()` | Any key / any gamepad button | Dismiss overlays, start game |
1411
1536
 
1412
1537
  ```ts
1413
1538
  if (consumeFlag()) toggleFlag(playerX, playerY)
@@ -1921,6 +2046,23 @@ for (const e of enemies) {
1921
2046
  | `deadzoneW`, `deadzoneH` | Deadzone size — target may move ±`deadzoneW/2` from centre before scrolling |
1922
2047
  | `targetX`, `targetY` | Current follow target (set via `setCameraTarget`) |
1923
2048
 
2049
+ ### `CameraOptions` interface
2050
+
2051
+ Options passed to `createCamera()`. `viewW`, `viewH`, `worldW`, and `worldH`
2052
+ are required; `lerp`, `deadzoneW`, and `deadzoneH` are optional.
2053
+
2054
+ ```ts
2055
+ interface CameraOptions {
2056
+ viewW: number
2057
+ viewH: number
2058
+ worldW: number
2059
+ worldH: number
2060
+ lerp?: number
2061
+ deadzoneW?: number
2062
+ deadzoneH?: number
2063
+ }
2064
+ ```
2065
+
1924
2066
  ### `createCamera(opts): Camera`
1925
2067
 
1926
2068
  Creates a camera at world origin `(0, 0)`. `lerp` defaults to `1` (snap), deadzones default to `0`.
@@ -2377,13 +2519,23 @@ interface ParticleSystem {
2377
2519
  }
2378
2520
  ```
2379
2521
 
2522
+ ### `Ranged` type
2523
+
2524
+ Several emitter options accept either a fixed value or a `[min, max]` range.
2525
+
2526
+ ```ts
2527
+ type Ranged = number | readonly [number, number]
2528
+ ```
2529
+
2380
2530
  ### `createParticleSystem(capacity): ParticleSystem`
2381
2531
 
2382
2532
  Creates a pool of `capacity` particles, all inactive. Throws when `capacity` is not a positive integer.
2383
2533
 
2384
2534
  ### `emitParticles(ps, opts): number`
2385
2535
 
2386
- Emits up to `opts.count` particles and returns the number actually emitted (fewer when the pool is full). Throws when `count` is negative or non-integer.
2536
+ Emits up to `opts.count` particles and returns the number actually emitted
2537
+ (fewer when the pool is full). Throws when `count` is negative or non-integer.
2538
+ The options object is exported as `EmitOptions`.
2387
2539
 
2388
2540
  | Option | Type | Default | Meaning |
2389
2541
  |--------|------|---------|---------|
@@ -2469,6 +2621,108 @@ Hashes a string to an unsigned 32-bit integer (FNV-1a). Deterministic; exported
2469
2621
 
2470
2622
  ---
2471
2623
 
2624
+ ## `lighting.ts` — Dithered Cave Darkness
2625
+
2626
+ The ZX way to fake light: hard 8×8 light pools with an ordered (Bayer) **dither** edge — no alpha gradients. The look of *Knight Lore* / *Head over Heels*, not modern soft shadows.
2627
+
2628
+ Done the naive way (recompute a full-screen `ImageData` and `putImageData` it every frame) this is a real CPU/upload hog — it measured ~27% of a frame in an actual game. This module instead **pre-renders the dither for each darkness level to a tiny tile, darkens the view cell-by-cell into a persistent buffer (repainting only cells whose level changed), and blits the buffer with one `drawImage`** — no per-frame `putImageData`.
2629
+
2630
+ You own the *policy* (where it's dark) via a per-cell callback; the module owns the fast *rendering*.
2631
+
2632
+ ### `Light` interface
2633
+
2634
+ ```ts
2635
+ interface Light { x: number; y: number; radius: number; intensity: number } // intensity 0..1
2636
+ ```
2637
+
2638
+ ### `brightnessAt(px, py, lights): number`
2639
+
2640
+ Brightest attenuated light at a point — `max((1 - dist/radius) * intensity)` over all lights, clamped to `0..1`. Turn it into darkness with `1 - brightnessAt(...)`. Pure.
2641
+
2642
+ ### `ditherBlack(px, py, amount): boolean`
2643
+
2644
+ The ordered-dither rule used to bake the tiles: is pixel `(px, py)` black at darkness `amount` (0..1)? Pure and deterministic — handy for tests or custom effects.
2645
+
2646
+ ### `createDarknessLayer(width, height, levels?): DarknessLayer`
2647
+
2648
+ Builds a view-sized overlay: pre-bakes `levels` dither tiles (default **8** — more = smoother), a persistent buffer, and a per-cell level cache. Call **once**; reuse across frames. Throws if `levels < 2`.
2649
+
2650
+ ### `renderDarkness(layer, ctx, darknessAt): void`
2651
+
2652
+ Darkens `ctx` for this frame. `darknessAt(col, row)` returns the darkness of each 8×8 cell — **0 = lit, 1 = pitch black** (clamped, then quantised to the layer's levels). Only cells whose level changed since the last call are repainted; then the buffer is blitted once.
2653
+
2654
+ ```ts
2655
+ import { createDarknessLayer, renderDarkness, brightnessAt, CELL } from 'zx-kit'
2656
+
2657
+ const dark = createDarknessLayer(256, 192) // once
2658
+
2659
+ // each frame, after drawing the scene, before the HUD:
2660
+ const lights = [{ x: playerX, y: playerY, radius: 72, intensity: 1 }]
2661
+ renderDarkness(dark, ctx, (col, row) => {
2662
+ const b = brightnessAt(col * CELL + 4, row * CELL + 4, lights)
2663
+ return 1 - b // 0 = lit … 1 = dark
2664
+ })
2665
+ ```
2666
+
2667
+ The `darknessAt` callback is where you add **depth gradients** (darker the deeper you are), fog, flashing — anything; the renderer stays fast because it only repaints cells whose quantised level actually changed.
2668
+
2669
+ > Lights are in **screen** pixels (apply the camera yourself). Rendering needs a real canvas; in a headless test environment the layer degrades to a no-op blit while the level math still runs.
2670
+
2671
+ ---
2672
+
2673
+ ## `music.ts` — Note-Name AY Music
2674
+
2675
+ Write AY tunes by **note name** instead of raw frequencies, and **loop** them for
2676
+ background music. A thin, friendly layer over [`playAY`](#playaypattern-startdelay-void):
2677
+ the AY chip already plays three channels of `AYNote`s — this lets you *author* and
2678
+ *repeat* them without the maths (so "I don't read note tables" is no longer a blocker).
2679
+
2680
+ ### `noteToFreq(name): number`
2681
+
2682
+ Note name → frequency (Hz), equal temperament, **A4 = 440**. Accepts `A5`, `C#4`,
2683
+ `Db3`, `Fs5` (`s` = sharp). `r` / `-` is a rest → `0` (a silent note). Throws on a
2684
+ malformed name. Pure.
2685
+
2686
+ ```ts
2687
+ noteToFreq('A4') // 440
2688
+ noteToFreq('C4') // 261.63 (middle C)
2689
+ noteToFreq('A5') // 880
2690
+ ```
2691
+
2692
+ ### `seq(spec, options?): AYNote[]`
2693
+
2694
+ Parses a compact note string into one channel's `AYNote[]`. Tokens are
2695
+ whitespace-separated `Note` or `Note:durMs`; `r` (or `-`) is a rest. `options.dur`
2696
+ sets the default duration (200 ms); `options.noise` / `noisePeriod` mix LFSR noise
2697
+ into every note (handy for a texture/percussion channel).
2698
+
2699
+ ```ts
2700
+ seq('A4 C5:400 r:200 E5') // A4 @default, C5 @400ms, rest @200ms, E5 @default
2701
+ seq('r r r r', { dur: 240, noise: true }) // a noise-only texture line
2702
+ ```
2703
+
2704
+ ### `playAYLoop(pattern): { stop() }`
2705
+
2706
+ Plays a 3-channel pattern **on repeat** — background music. Re-schedules each loop
2707
+ after the pattern's length (its longest channel) and returns a handle to `stop()`.
2708
+ No-ops (returns a do-nothing stop) when there's no audio context yet or the pattern
2709
+ is empty. Call after a user gesture has unlocked audio.
2710
+
2711
+ ```ts
2712
+ const track = playAYLoop({
2713
+ a: seq('A4 C5 E5 C5', { dur: 240 }), // melody
2714
+ b: seq('A2:480 E2:480', { dur: 480 }), // slow bass drone
2715
+ c: seq('r r r r', { dur: 240, noise: true }), // texture
2716
+ })
2717
+ // later…
2718
+ track.stop()
2719
+ ```
2720
+
2721
+ > Looping re-schedules at the pattern boundary via a timer — fine for ambient /
2722
+ > background loops; for tight musical sync you'd want a sample-accurate scheduler.
2723
+
2724
+ ---
2725
+
2472
2726
  ## Architecture
2473
2727
 
2474
2728
  ### Module structure
package/dist/index.d.ts CHANGED
@@ -16,4 +16,6 @@ export * from './camera.js';
16
16
  export * from './scene.js';
17
17
  export * from './save.js';
18
18
  export * from './i18n.js';
19
+ export * from './lighting.js';
20
+ export * from './music.js';
19
21
  //# 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,iBAAiB,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,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,iBAAiB,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"}
package/dist/index.js CHANGED
@@ -16,4 +16,6 @@ export * from './camera.js';
16
16
  export * from './scene.js';
17
17
  export * from './save.js';
18
18
  export * from './i18n.js';
19
+ export * from './lighting.js';
20
+ export * from './music.js';
19
21
  //# 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,iBAAiB,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,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,iBAAiB,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,UAAU,CAAA;AACxB,cAAc,gBAAgB,CAAA;AAC9B,cAAc,aAAa,CAAA;AAC3B,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"}
@@ -0,0 +1,45 @@
1
+ /** A point light: position (screen px), reach `radius` (px) and `intensity` 0..1. */
2
+ export interface Light {
3
+ x: number;
4
+ y: number;
5
+ radius: number;
6
+ intensity: number;
7
+ }
8
+ /**
9
+ * The ordered-dither rule: is pixel `(px, py)` black at darkness `amount` (0..1)?
10
+ * Pure and deterministic — used to bake the level tiles, and handy to test.
11
+ */
12
+ export declare function ditherBlack(px: number, py: number, amount: number): boolean;
13
+ /**
14
+ * Brightest attenuated light at a point: `max((1 - dist/radius) * intensity)`
15
+ * over all lights, clamped to 0..1. Turn it into darkness with `1 - brightnessAt(...)`.
16
+ */
17
+ export declare function brightnessAt(px: number, py: number, lights: readonly Light[]): number;
18
+ /** A view-sized darkness overlay with pre-baked dither tiles + a cached buffer. */
19
+ export interface DarknessLayer {
20
+ readonly width: number;
21
+ readonly height: number;
22
+ readonly levels: number;
23
+ readonly cols: number;
24
+ readonly rows: number;
25
+ /** Pre-rendered 8×8 dither tiles, index 0 (lit, `null`) … levels-1 (darkest). */
26
+ readonly tiles: ReadonlyArray<HTMLCanvasElement | null>;
27
+ /** Persistent darkness buffer (view-sized), blitted each frame. */
28
+ readonly buffer: HTMLCanvasElement | null;
29
+ /** Last level drawn per cell, row-major; -1 = never drawn (forces a repaint). */
30
+ readonly cellLevel: Int16Array;
31
+ }
32
+ /**
33
+ * Creates a darkness layer sized to the view. `levels` is the number of darkness
34
+ * steps (default 8) — more is a smoother dither for a little more memory. Call
35
+ * once; reuse across frames.
36
+ */
37
+ export declare function createDarknessLayer(width: number, height: number, levels?: number): DarknessLayer;
38
+ /**
39
+ * Renders dithered darkness onto `ctx`. `darknessAt(col, row)` returns the
40
+ * darkness of each 8×8 cell: **0 = lit**, **1 = pitch black** (values are clamped
41
+ * and quantised to the layer's `levels`). Only cells whose level changed since the
42
+ * last call are repainted on the cached buffer; the buffer is then blitted once.
43
+ */
44
+ export declare function renderDarkness(layer: DarknessLayer, ctx: CanvasRenderingContext2D, darknessAt: (col: number, row: number) => number): void;
45
+ //# sourceMappingURL=lighting.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lighting.d.ts","sourceRoot":"","sources":["../src/lighting.ts"],"names":[],"mappings":"AAkCA,qFAAqF;AACrF,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAA;IACT,CAAC,EAAE,MAAM,CAAA;IACT,MAAM,EAAE,MAAM,CAAA;IACd,SAAS,EAAE,MAAM,CAAA;CAClB;AAMD;;;GAGG;AACH,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAE3E;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,KAAK,EAAE,GAAG,MAAM,CAarF;AAED,mFAAmF;AACnF,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,iFAAiF;IACjF,QAAQ,CAAC,KAAK,EAAE,aAAa,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAAA;IACvD,mEAAmE;IACnE,QAAQ,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACzC,iFAAiF;IACjF,QAAQ,CAAC,SAAS,EAAE,UAAU,CAAA;CAC/B;AAmBD;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,SAAI,GAAG,aAAa,CAkB5F;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,aAAa,EACpB,GAAG,EAAE,wBAAwB,EAC7B,UAAU,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,MAAM,GAC/C,IAAI,CAwBN"}
@@ -0,0 +1,138 @@
1
+ /**
2
+ * @module lighting
3
+ *
4
+ * **Dithered cave darkness** — the ZX way to fake light: hard 8×8 light pools
5
+ * with an ordered (Bayer) dither edge, no alpha gradients. Think *Knight Lore*,
6
+ * not modern soft shadows.
7
+ *
8
+ * Built for speed. The naive way (recompute a full-screen `ImageData` and
9
+ * `putImageData` it every frame) is a CPU/upload hog — it was ~27% of a frame in
10
+ * a real game. Instead:
11
+ *
12
+ * 1. The dither for each darkness **level** is pre-rendered once to a tiny 8×8
13
+ * tile ({@link createDarknessLayer}).
14
+ * 2. The view is darkened **cell by cell** into a persistent buffer, and only
15
+ * cells whose level **changed** since the last frame are repainted.
16
+ * 3. The whole buffer is blitted with **one `drawImage`** — no per-frame
17
+ * `putImageData`.
18
+ *
19
+ * The game supplies a per-cell darkness via a callback, so it owns the *policy*
20
+ * (lights, depth gradients, fog…) while this module owns the fast *rendering*.
21
+ * {@link brightnessAt} is a ready helper for the common "pools of light" case.
22
+ *
23
+ * @example
24
+ * ```ts
25
+ * const dark = createDarknessLayer(256, 192) // once, view-sized
26
+ * // each frame, after drawing the scene:
27
+ * renderDarkness(dark, ctx, (col, row) => {
28
+ * const b = brightnessAt(col * CELL + 4, row * CELL + 4, lights)
29
+ * return 1 - b // 0 = lit, 1 = pitch black
30
+ * })
31
+ * ```
32
+ */
33
+ import { CELL } from './palette.js';
34
+ // Dispersed 4×4 Bayer matrix (values 0..15), row-major — drives the stipple.
35
+ // 8×8 cells are a multiple of 4, so tiles dither seamlessly across cell borders.
36
+ const BAYER4 = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5];
37
+ /**
38
+ * The ordered-dither rule: is pixel `(px, py)` black at darkness `amount` (0..1)?
39
+ * Pure and deterministic — used to bake the level tiles, and handy to test.
40
+ */
41
+ export function ditherBlack(px, py, amount) {
42
+ return (BAYER4[((py & 3) << 2) | (px & 3)] + 0.5) / 16 < amount;
43
+ }
44
+ /**
45
+ * Brightest attenuated light at a point: `max((1 - dist/radius) * intensity)`
46
+ * over all lights, clamped to 0..1. Turn it into darkness with `1 - brightnessAt(...)`.
47
+ */
48
+ export function brightnessAt(px, py, lights) {
49
+ let b = 0;
50
+ for (const l of lights) {
51
+ if (l.radius <= 0)
52
+ continue;
53
+ const dx = px - l.x;
54
+ const dy = py - l.y;
55
+ const d = Math.sqrt(dx * dx + dy * dy);
56
+ if (d < l.radius) {
57
+ const c = (1 - d / l.radius) * l.intensity;
58
+ if (c > b)
59
+ b = c;
60
+ }
61
+ }
62
+ return b > 1 ? 1 : b < 0 ? 0 : b;
63
+ }
64
+ /** Bakes one 8×8 dither tile for darkness `amount`, or `null` when fully lit. */
65
+ function makeTile(amount) {
66
+ if (amount <= 0 || typeof document === 'undefined')
67
+ return null;
68
+ const c = document.createElement('canvas');
69
+ c.width = CELL;
70
+ c.height = CELL;
71
+ const ctx = c.getContext('2d');
72
+ if (!ctx)
73
+ return null;
74
+ ctx.fillStyle = '#000';
75
+ for (let y = 0; y < CELL; y++) {
76
+ for (let x = 0; x < CELL; x++) {
77
+ if (ditherBlack(x, y, amount))
78
+ ctx.fillRect(x, y, 1, 1);
79
+ }
80
+ }
81
+ return c;
82
+ }
83
+ /**
84
+ * Creates a darkness layer sized to the view. `levels` is the number of darkness
85
+ * steps (default 8) — more is a smoother dither for a little more memory. Call
86
+ * once; reuse across frames.
87
+ */
88
+ export function createDarknessLayer(width, height, levels = 8) {
89
+ if (!Number.isInteger(levels) || levels < 2) {
90
+ throw new Error(`createDarknessLayer: levels must be an integer >= 2, got ${levels}`);
91
+ }
92
+ const cols = Math.ceil(width / CELL);
93
+ const rows = Math.ceil(height / CELL);
94
+ const tiles = [];
95
+ for (let i = 0; i < levels; i++)
96
+ tiles.push(makeTile(i / (levels - 1)));
97
+ let buffer = null;
98
+ if (typeof document !== 'undefined') {
99
+ buffer = document.createElement('canvas');
100
+ buffer.width = width;
101
+ buffer.height = height;
102
+ }
103
+ const cellLevel = new Int16Array(cols * rows).fill(-1);
104
+ return { width, height, levels, cols, rows, tiles, buffer, cellLevel };
105
+ }
106
+ /**
107
+ * Renders dithered darkness onto `ctx`. `darknessAt(col, row)` returns the
108
+ * darkness of each 8×8 cell: **0 = lit**, **1 = pitch black** (values are clamped
109
+ * and quantised to the layer's `levels`). Only cells whose level changed since the
110
+ * last call are repainted on the cached buffer; the buffer is then blitted once.
111
+ */
112
+ export function renderDarkness(layer, ctx, darknessAt) {
113
+ const { buffer, tiles, levels, cols, rows, cellLevel } = layer;
114
+ const bctx = buffer ? buffer.getContext('2d') : null;
115
+ const maxLevel = levels - 1;
116
+ for (let row = 0; row < rows; row++) {
117
+ for (let col = 0; col < cols; col++) {
118
+ let a = darknessAt(col, row);
119
+ a = a < 0 ? 0 : a > 1 ? 1 : a;
120
+ const level = Math.round(a * maxLevel);
121
+ const idx = row * cols + col;
122
+ if (cellLevel[idx] === level)
123
+ continue; // unchanged → skip the repaint
124
+ cellLevel[idx] = level;
125
+ if (bctx) {
126
+ const x = col * CELL;
127
+ const y = row * CELL;
128
+ bctx.clearRect(x, y, CELL, CELL);
129
+ const tile = tiles[level];
130
+ if (tile)
131
+ bctx.drawImage(tile, x, y);
132
+ }
133
+ }
134
+ }
135
+ if (buffer)
136
+ ctx.drawImage(buffer, 0, 0);
137
+ }
138
+ //# sourceMappingURL=lighting.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lighting.js","sourceRoot":"","sources":["../src/lighting.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAA;AAUnC,6EAA6E;AAC7E,iFAAiF;AACjF,MAAM,MAAM,GAAG,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC,CAAU,CAAA;AAE9E;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,EAAU,EAAE,EAAU,EAAE,MAAc;IAChE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,CAAE,GAAG,GAAG,CAAC,GAAG,EAAE,GAAG,MAAM,CAAA;AAClE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,YAAY,CAAC,EAAU,EAAE,EAAU,EAAE,MAAwB;IAC3E,IAAI,CAAC,GAAG,CAAC,CAAA;IACT,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACvB,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC;YAAE,SAAQ;QAC3B,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,CAAA;QACnB,MAAM,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,CAAA;QACnB,MAAM,CAAC,GAAG,IAAI,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAA;QACtC,IAAI,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,CAAC;YACjB,MAAM,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,SAAS,CAAA;YAC1C,IAAI,CAAC,GAAG,CAAC;gBAAE,CAAC,GAAG,CAAC,CAAA;QAClB,CAAC;IACH,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAClC,CAAC;AAiBD,iFAAiF;AACjF,SAAS,QAAQ,CAAC,MAAc;IAC9B,IAAI,MAAM,IAAI,CAAC,IAAI,OAAO,QAAQ,KAAK,WAAW;QAAE,OAAO,IAAI,CAAA;IAC/D,MAAM,CAAC,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAA;IAC1C,CAAC,CAAC,KAAK,GAAG,IAAI,CAAA;IACd,CAAC,CAAC,MAAM,GAAG,IAAI,CAAA;IACf,MAAM,GAAG,GAAG,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAA;IAC9B,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAA;IACrB,GAAG,CAAC,SAAS,GAAG,MAAM,CAAA;IACtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;QAC9B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,EAAE,CAAC,EAAE,EAAE,CAAC;YAC9B,IAAI,WAAW,CAAC,CAAC,EAAE,CAAC,EAAE,MAAM,CAAC;gBAAE,GAAG,CAAC,QAAQ,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;QACzD,CAAC;IACH,CAAC;IACD,OAAO,CAAC,CAAA;AACV,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAa,EAAE,MAAc,EAAE,MAAM,GAAG,CAAC;IAC3E,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5C,MAAM,IAAI,KAAK,CAAC,4DAA4D,MAAM,EAAE,CAAC,CAAA;IACvF,CAAC;IACD,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,CAAA;IACpC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACrC,MAAM,KAAK,GAAiC,EAAE,CAAA;IAC9C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,EAAE;QAAE,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAA;IAEvE,IAAI,MAAM,GAA6B,IAAI,CAAA;IAC3C,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QACpC,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,QAAQ,CAAC,CAAA;QACzC,MAAM,CAAC,KAAK,GAAG,KAAK,CAAA;QACpB,MAAM,CAAC,MAAM,GAAG,MAAM,CAAA;IACxB,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,UAAU,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAA;IACtD,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,CAAA;AACxE,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAC5B,KAAoB,EACpB,GAA6B,EAC7B,UAAgD;IAEhD,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,SAAS,EAAE,GAAG,KAAK,CAAA;IAC9D,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IACpD,MAAM,QAAQ,GAAG,MAAM,GAAG,CAAC,CAAA;IAE3B,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC;QACpC,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC;YACpC,IAAI,CAAC,GAAG,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,CAAA;YAC5B,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;YAC7B,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,QAAQ,CAAC,CAAA;YACtC,MAAM,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,GAAG,CAAA;YAC5B,IAAI,SAAS,CAAC,GAAG,CAAC,KAAK,KAAK;gBAAE,SAAQ,CAAC,+BAA+B;YACtE,SAAS,CAAC,GAAG,CAAC,GAAG,KAAK,CAAA;YACtB,IAAI,IAAI,EAAE,CAAC;gBACT,MAAM,CAAC,GAAG,GAAG,GAAG,IAAI,CAAA;gBACpB,MAAM,CAAC,GAAG,GAAG,GAAG,IAAI,CAAA;gBACpB,IAAI,CAAC,SAAS,CAAC,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAA;gBAChC,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAA;gBACzB,IAAI,IAAI;oBAAE,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;YACtC,CAAC;QACH,CAAC;IACH,CAAC;IAED,IAAI,MAAM;QAAE,GAAG,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAA;AACzC,CAAC"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @module music
3
+ *
4
+ * Write AY music by **note name** instead of raw frequencies, and **loop** it for
5
+ * background tracks. A thin, friendly layer over {@link playAY} (the AY chip
6
+ * already plays three channels of {@link AYNote}s — this just lets you author and
7
+ * repeat them without doing the maths).
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { seq, playAYLoop, noteToFreq } from 'zx-kit'
12
+ *
13
+ * const loop = playAYLoop({
14
+ * a: seq('A4 C5 E5 C5', { dur: 240 }), // melody by name
15
+ * b: seq('A2:480 E2:480', { dur: 480 }), // slow bass drone
16
+ * c: seq('r r r r', { dur: 240, noise: true }), // a little texture
17
+ * })
18
+ * // later: loop.stop()
19
+ * ```
20
+ */
21
+ import { type AYNote } from './ay.js';
22
+ /**
23
+ * Note name → frequency (Hz), equal temperament, A4 = 440. Accepts `A5`, `C#4`,
24
+ * `Db3`, `Fs5` (`s` = sharp); `r` / `-` is a rest → `0` (a silent {@link AYNote}).
25
+ * Throws on a malformed name.
26
+ */
27
+ export declare function noteToFreq(name: string): number;
28
+ /** Options for {@link seq}. */
29
+ export interface SeqOptions {
30
+ /** Default note duration in ms when a token doesn't specify one (default 200). */
31
+ dur?: number;
32
+ /** Mix LFSR noise into every note (e.g. for a percussive/texture channel). */
33
+ noise?: boolean;
34
+ /** Noise period when `noise` is set. */
35
+ noisePeriod?: number;
36
+ }
37
+ /**
38
+ * Parses a compact note string into an {@link AYNote} array for one channel.
39
+ * Tokens are whitespace-separated `Note` or `Note:durMs`; `r` (or `-`) is a rest.
40
+ *
41
+ * @example
42
+ * seq('A4 C5:400 r:200 E5') // A4 @default, C5 @400ms, rest @200ms, E5 @default
43
+ */
44
+ export declare function seq(spec: string, opts?: SeqOptions): AYNote[];
45
+ /** A running looped track. Call {@link LoopHandle.stop} to end it. */
46
+ export interface LoopHandle {
47
+ stop(): void;
48
+ }
49
+ /**
50
+ * Plays a 3-channel AY pattern on repeat — background music. Re-schedules each
51
+ * loop after the pattern's length (the longest channel). No-ops (returns a stop
52
+ * that does nothing) when there is no audio context yet or the pattern is empty.
53
+ *
54
+ * Call after the audio context is unlocked by a user gesture.
55
+ */
56
+ export declare function playAYLoop(pattern: {
57
+ a?: AYNote[];
58
+ b?: AYNote[];
59
+ c?: AYNote[];
60
+ }): LoopHandle;
61
+ //# sourceMappingURL=music.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"music.d.ts","sourceRoot":"","sources":["../src/music.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,EAAU,KAAK,MAAM,EAAE,MAAM,SAAS,CAAA;AAK7C;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAW/C;AAED,+BAA+B;AAC/B,MAAM,WAAW,UAAU;IACzB,kFAAkF;IAClF,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,8EAA8E;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,wCAAwC;IACxC,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;;;;GAMG;AACH,wBAAgB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,UAAe,GAAG,MAAM,EAAE,CAajE;AAED,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,IAAI,IAAI,IAAI,CAAA;CACb;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE;IAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,UAAU,CAa5F"}
package/dist/music.js ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @module music
3
+ *
4
+ * Write AY music by **note name** instead of raw frequencies, and **loop** it for
5
+ * background tracks. A thin, friendly layer over {@link playAY} (the AY chip
6
+ * already plays three channels of {@link AYNote}s — this just lets you author and
7
+ * repeat them without doing the maths).
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { seq, playAYLoop, noteToFreq } from 'zx-kit'
12
+ *
13
+ * const loop = playAYLoop({
14
+ * a: seq('A4 C5 E5 C5', { dur: 240 }), // melody by name
15
+ * b: seq('A2:480 E2:480', { dur: 480 }), // slow bass drone
16
+ * c: seq('r r r r', { dur: 240, noise: true }), // a little texture
17
+ * })
18
+ * // later: loop.stop()
19
+ * ```
20
+ */
21
+ import { playAY } from './ay.js';
22
+ import { getAudioContext } from './audio.js';
23
+ const SEMITONE = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
24
+ /**
25
+ * Note name → frequency (Hz), equal temperament, A4 = 440. Accepts `A5`, `C#4`,
26
+ * `Db3`, `Fs5` (`s` = sharp); `r` / `-` is a rest → `0` (a silent {@link AYNote}).
27
+ * Throws on a malformed name.
28
+ */
29
+ export function noteToFreq(name) {
30
+ const n = name.trim().toLowerCase();
31
+ if (n === 'r' || n === '-' || n === '')
32
+ return 0;
33
+ const m = /^([a-g])([#sb]?)(-?\d)$/.exec(n);
34
+ if (!m)
35
+ throw new Error(`noteToFreq: bad note "${name}" (expected e.g. A5, C#4, Bb3, or r)`);
36
+ let semis = SEMITONE[m[1]];
37
+ if (m[2] === '#' || m[2] === 's')
38
+ semis += 1;
39
+ else if (m[2] === 'b')
40
+ semis -= 1;
41
+ const octave = parseInt(m[3], 10);
42
+ const midi = (octave + 1) * 12 + semis; // C4 = MIDI 60, A4 = 69
43
+ return 440 * Math.pow(2, (midi - 69) / 12);
44
+ }
45
+ /**
46
+ * Parses a compact note string into an {@link AYNote} array for one channel.
47
+ * Tokens are whitespace-separated `Note` or `Note:durMs`; `r` (or `-`) is a rest.
48
+ *
49
+ * @example
50
+ * seq('A4 C5:400 r:200 E5') // A4 @default, C5 @400ms, rest @200ms, E5 @default
51
+ */
52
+ export function seq(spec, opts = {}) {
53
+ const defDur = opts.dur ?? 200;
54
+ const out = [];
55
+ for (const tok of spec.split(/\s+/).filter(Boolean)) {
56
+ const [name, durStr] = tok.split(':');
57
+ const note = { freq: noteToFreq(name), dur: durStr ? Number(durStr) : defDur };
58
+ if (opts.noise) {
59
+ note.noise = true;
60
+ if (opts.noisePeriod !== undefined)
61
+ note.noisePeriod = opts.noisePeriod;
62
+ }
63
+ out.push(note);
64
+ }
65
+ return out;
66
+ }
67
+ /**
68
+ * Plays a 3-channel AY pattern on repeat — background music. Re-schedules each
69
+ * loop after the pattern's length (the longest channel). No-ops (returns a stop
70
+ * that does nothing) when there is no audio context yet or the pattern is empty.
71
+ *
72
+ * Call after the audio context is unlocked by a user gesture.
73
+ */
74
+ export function playAYLoop(pattern) {
75
+ if (!getAudioContext())
76
+ return { stop() { } };
77
+ const total = (ns) => (ns ? ns.reduce((s, n) => s + n.dur, 0) : 0);
78
+ const loopMs = Math.max(total(pattern.a), total(pattern.b), total(pattern.c));
79
+ if (loopMs <= 0)
80
+ return { stop() { } };
81
+ playAY(pattern);
82
+ const id = setInterval(() => playAY(pattern), loopMs);
83
+ return {
84
+ stop() {
85
+ clearInterval(id);
86
+ },
87
+ };
88
+ }
89
+ //# sourceMappingURL=music.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"music.js","sourceRoot":"","sources":["../src/music.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.25.0",
3
+ "version": "0.27.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/zrebec/zx-kit.git"