js-game-zero 0.0.0-placeholder → 0.3.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.
Files changed (53) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +135 -3
  3. package/dist/active-service.d.ts +21 -0
  4. package/dist/actor.d.ts +182 -0
  5. package/dist/animation.d.ts +61 -0
  6. package/dist/assets.d.ts +148 -0
  7. package/dist/audio.d.ts +150 -0
  8. package/dist/browser-asset-backends.d.ts +8 -0
  9. package/dist/browser.d.ts +10 -0
  10. package/dist/chunk-brk0rjcy.js +7846 -0
  11. package/dist/clock.d.ts +29 -0
  12. package/dist/color.d.ts +15 -0
  13. package/dist/events.d.ts +145 -0
  14. package/dist/friendly-names.d.ts +43 -0
  15. package/dist/geometry.d.ts +125 -0
  16. package/dist/helpers.d.ts +28 -0
  17. package/dist/host.d.ts +58 -0
  18. package/dist/host.js +435 -0
  19. package/dist/index.d.ts +340 -0
  20. package/dist/index.js +258 -0
  21. package/dist/input.d.ts +200 -0
  22. package/dist/inspection.d.ts +58 -0
  23. package/dist/jgz.js +8419 -0
  24. package/dist/node.d.ts +14 -0
  25. package/dist/node.js +145 -0
  26. package/dist/pixel-density.d.ts +23 -0
  27. package/dist/random.d.ts +31 -0
  28. package/dist/reference/actor.md +197 -0
  29. package/dist/reference/audio.md +128 -0
  30. package/dist/reference/clock.md +150 -0
  31. package/dist/reference/helpers.md +46 -0
  32. package/dist/reference/hooks.md +379 -0
  33. package/dist/reference/index.d.ts +2 -0
  34. package/dist/reference/index.js +39 -0
  35. package/dist/reference/loaders.md +164 -0
  36. package/dist/reference/rect.md +129 -0
  37. package/dist/reference/runtime.md +478 -0
  38. package/dist/reference/screen.md +313 -0
  39. package/dist/reference/storage.md +90 -0
  40. package/dist/rendering.d.ts +188 -0
  41. package/dist/runtime-contracts.d.ts +117 -0
  42. package/dist/runtime-diagnostics.d.ts +37 -0
  43. package/dist/runtime-persistence.d.ts +32 -0
  44. package/dist/runtime-source.d.ts +82 -0
  45. package/dist/runtime.d.ts +81 -0
  46. package/dist/source-scan.d.ts +49 -0
  47. package/dist/storage-path.d.ts +3 -0
  48. package/dist/storage.d.ts +88 -0
  49. package/dist/tone.d.ts +81 -0
  50. package/dist/touch-controls.d.ts +24 -0
  51. package/dist/touch-pointer.d.ts +11 -0
  52. package/dist/util.d.ts +62 -0
  53. package/package.json +63 -4
package/dist/node.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { type StorageAdapter } from "./storage.js";
2
+ export type SandboxFileStorageAdapterOptions = {
3
+ readonly projectRoot: string;
4
+ readonly path?: string;
5
+ readonly readText?: (path: string) => Promise<string | null> | string | null;
6
+ readonly writeText?: (path: string, text: string) => Promise<void> | void;
7
+ };
8
+ /**
9
+ * Creates a Node.js filesystem-backed adapter for a sandbox project.
10
+ *
11
+ * This lives behind the `js-game-zero/node` entry point so browser
12
+ * consumers never load or bundle Node built-ins.
13
+ */
14
+ export declare function createSandboxFileStorageAdapter(options: SandboxFileStorageAdapterOptions): StorageAdapter;
package/dist/node.js ADDED
@@ -0,0 +1,145 @@
1
+ /*!
2
+ * js-game-zero 0.3.0
3
+ * https://github.com/Leaf-Learning/js-game-zero
4
+ *
5
+ * MIT License
6
+ *
7
+ * Copyright (c) 2026 Taha Rafiq
8
+ * Copyright (c) 2001 Robert Penner (easing equations)
9
+ *
10
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ * of this software and associated documentation files (the "Software"), to deal
12
+ * in the Software without restriction, including without limitation the rights
13
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ * copies of the Software, and to permit persons to whom the Software is
15
+ * furnished to do so, subject to the following conditions:
16
+ *
17
+ * The above copyright notice and this permission notice shall be included in all
18
+ * copies or substantial portions of the Software.
19
+ *
20
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ * SOFTWARE.
27
+ */
28
+ import { createRequire } from "node:module";
29
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
30
+
31
+ // packages/js-game-zero/src/storage-path.ts
32
+ function normalizeStoragePath(path) {
33
+ if (path.length === 0 || path.includes("\\") || path.startsWith("/")) {
34
+ throw new TypeError("storage path must be a relative forward-slash path.");
35
+ }
36
+ const segments = path.split("/");
37
+ if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
38
+ throw new TypeError("storage path cannot include empty or traversal segments.");
39
+ }
40
+ return segments.join("/");
41
+ }
42
+ function normalizeSandboxStoragePath(path) {
43
+ if (/^(\/|[a-z]:[\\/])/i.test(path)) {
44
+ throw new TypeError("sandbox storage path must be relative.");
45
+ }
46
+ return normalizeStoragePath(path);
47
+ }
48
+ function sandboxStoragePath(projectRoot, safePath) {
49
+ if (!projectRoot || projectRoot.includes("\x00")) {
50
+ throw new TypeError("projectRoot must be a non-empty filesystem path.");
51
+ }
52
+ return `${projectRoot.replace(/[\\/]+$/, "")}/${safePath}`;
53
+ }
54
+
55
+ // packages/js-game-zero/src/node.ts
56
+ function createSandboxFileStorageAdapter(options) {
57
+ const safePath = normalizeSandboxStoragePath(options.path ?? "storage.json");
58
+ const path = sandboxStoragePath(options.projectRoot, safePath);
59
+ return {
60
+ path,
61
+ async load() {
62
+ if (options.readText)
63
+ return options.readText(path);
64
+ return readSandboxText(options.projectRoot, safePath);
65
+ },
66
+ async save(text) {
67
+ if (options.writeText) {
68
+ await options.writeText(path, text);
69
+ return;
70
+ }
71
+ await writeSandboxText(options.projectRoot, safePath, text);
72
+ }
73
+ };
74
+ }
75
+ async function readSandboxText(projectRoot, safePath) {
76
+ const resolved = await resolveSandboxFilePath(projectRoot, safePath, false);
77
+ if (!resolved)
78
+ return null;
79
+ try {
80
+ return await resolved.fs.readFile(resolved.targetPath, "utf8");
81
+ } catch (error) {
82
+ if (isMissingFileError(error))
83
+ return null;
84
+ throw error;
85
+ }
86
+ }
87
+ async function writeSandboxText(projectRoot, safePath, text) {
88
+ const resolved = await resolveSandboxFilePath(projectRoot, safePath, true);
89
+ if (!resolved)
90
+ return;
91
+ const tempPath = resolved.path.join(resolved.parentPath, `.jgz-storage-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
92
+ try {
93
+ await resolved.fs.writeFile(tempPath, text, "utf8");
94
+ await resolved.fs.rename(tempPath, resolved.targetPath);
95
+ } catch (error) {
96
+ await resolved.fs.rm(tempPath, { force: true });
97
+ throw error;
98
+ }
99
+ }
100
+ async function resolveSandboxFilePath(projectRoot, safePath, createParent) {
101
+ const fs = await import("node:fs/promises");
102
+ const path = await import("node:path");
103
+ const rootPath = path.resolve(projectRoot);
104
+ const realRoot = await fs.realpath(rootPath);
105
+ const lexicalTarget = path.join(realRoot, safePath);
106
+ const lexicalParent = path.dirname(lexicalTarget);
107
+ if (createParent) {
108
+ await fs.mkdir(lexicalParent, { recursive: true });
109
+ }
110
+ let parentPath;
111
+ try {
112
+ parentPath = await fs.realpath(lexicalParent);
113
+ } catch (error) {
114
+ if (!createParent && isMissingFileError(error))
115
+ return null;
116
+ throw error;
117
+ }
118
+ if (!isPathInside(parentPath, realRoot, path)) {
119
+ throw new TypeError("sandbox storage path must stay inside project root.");
120
+ }
121
+ const targetPath = path.join(parentPath, path.basename(lexicalTarget));
122
+ if (!isPathInside(targetPath, realRoot, path)) {
123
+ throw new TypeError("sandbox storage path must stay inside project root.");
124
+ }
125
+ try {
126
+ const stat = await fs.lstat(targetPath);
127
+ if (stat.isSymbolicLink()) {
128
+ throw new TypeError("sandbox storage path must not be a symbolic link.");
129
+ }
130
+ } catch (error) {
131
+ if (!isMissingFileError(error))
132
+ throw error;
133
+ }
134
+ return { fs, path, parentPath, targetPath };
135
+ }
136
+ function isPathInside(targetPath, rootPath, path) {
137
+ const relative = path.relative(rootPath, targetPath);
138
+ return relative === "" || relative.length > 0 && !relative.startsWith("..") && !path.isAbsolute(relative);
139
+ }
140
+ function isMissingFileError(error) {
141
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
142
+ }
143
+ export {
144
+ createSandboxFileStorageAdapter
145
+ };
@@ -0,0 +1,23 @@
1
+ import type { RuntimeDiagnostic } from "./runtime-contracts.js";
2
+ /** The highest density the device's pixel ratio picks by itself. */
3
+ export declare const MAX_AUTO_PIXEL_DENSITY = 3;
4
+ /**
5
+ * The most pixels a canvas may hold. iOS Safari leaves a bigger canvas
6
+ * blank, so density comes down until the canvas fits.
7
+ */
8
+ export declare const MAX_CANVAS_PIXELS: number;
9
+ type DensityCanvas = {
10
+ readonly ownerDocument?: {
11
+ readonly defaultView?: Window | null;
12
+ } | null;
13
+ };
14
+ /** The device's pixel ratio where the canvas is shown, or 1 outside a browser. */
15
+ export declare function devicePixelRatioOf(canvas: DensityCanvas): number;
16
+ /**
17
+ * Canvas pixels per logical pixel. With no `PIXEL_DENSITY`, it's the
18
+ * device's pixel ratio, between 1 and `MAX_AUTO_PIXEL_DENSITY`. Any density
19
+ * comes down so the canvas stays within `MAX_CANVAS_PIXELS`, but never below
20
+ * 1 unless the game asked for less.
21
+ */
22
+ export declare function resolvePixelDensity(value: unknown, width: number, height: number, deviceRatio: number, emit: (diagnostic: RuntimeDiagnostic) => void): number;
23
+ export {};
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Repeatable randomness for a run that asks for it (`run(source, { seed })`),
3
+ * and the random source the helpers read while a run is active. The CLI's
4
+ * `--seed` passes its seed to the same `run()` option, so one seed gives the
5
+ * same sequence in the CLI and in a host page.
6
+ */
7
+ export type RandomSource = () => number;
8
+ /** A seed a host can pass: a string, or a number read as its digits. */
9
+ export type RandomSeed = string | number;
10
+ /**
11
+ * The 32-bit state for a seed. Up to nine plain digits are used as the number
12
+ * they spell, so a seed chosen for one harness reproduces the same sequence in
13
+ * another; anything else is hashed (FNV-1a). A number is read as `String(n)`,
14
+ * so `42` and `"42"` are the same seed.
15
+ */
16
+ export declare function hashSeed(seed: RandomSeed): number;
17
+ /**
18
+ * A repeatable stand-in for `Math.random`: numbers from 0 up to 1, the same
19
+ * sequence for the same seed (Mulberry32 over `hashSeed(seed)`).
20
+ */
21
+ export declare function createSeededRandom(seed: RandomSeed): RandomSource;
22
+ /**
23
+ * A `Math` for one game's scope whose `random` is that game's source. Every
24
+ * other member is the real `Math`'s, so `Math.floor` and `Math.PI` read as
25
+ * usual, and the page's own `Math.random` is untouched.
26
+ */
27
+ export declare function mathWithRandom(random: RandomSource): Math;
28
+ /** Installs the running game's random source; returns the previous one. */
29
+ export declare function setActiveRandom(random: RandomSource | null): RandomSource | null;
30
+ /** The active run's random source, or `Math.random` outside a run. */
31
+ export declare function random(): number;
@@ -0,0 +1,197 @@
1
+ # Actor
2
+
3
+ `Actor` bundles an image, a position, and a hitbox rectangle into one object
4
+ you can move, rotate, fade, and collide-test. It's the class you'll use for
5
+ players, enemies, bullets — anything that moves around the screen.
6
+
7
+ ```ts
8
+ new Actor(image: string, pos?: [number, number] | ActorOptions, options?: ActorOptions): Actor
9
+ ```
10
+
11
+ ```js
12
+ let player = new Actor("player.svg", { center: [160, 90] });
13
+
14
+ function draw() {
15
+ screen.fill("#111827");
16
+ player.draw();
17
+ }
18
+ ```
19
+
20
+ `image` is an asset name resolved under `images/` (see
21
+ [`docs/reference/loaders.md`](loaders.md)). In `jgz`, the sandbox, and a page
22
+ that decodes its images first with
23
+ [`loadImages`](loaders.md#assets-without-a-server), the image is ready
24
+ before your code runs, so `player.width` and an edge position like
25
+ `midBottom` are right from the first line. Anywhere else, loading starts
26
+ when the Actor is made: until it finishes, the Actor has no size and `draw()`
27
+ is a no-op, and the runtime holds the first frame until images touched while
28
+ your script starts up have loaded. `player.ready()` returns a promise for
29
+ the image; the top level of a zero-boilerplate game can't `await`, so use
30
+ `player.ready().then(() => …)` there.
31
+
32
+ ## Positioning
33
+
34
+ Give **at most one** position source — either the second constructor
35
+ argument as a `[x, y]` point, or exactly one of these option keys:
36
+ `pos`, `topLeft`, `topRight`, `bottomLeft`, `bottomRight`, `midTop`,
37
+ `midLeft`, `midRight`, `midBottom`, `center`. Passing more than one throws a
38
+ `TypeError`. With no position at all, the actor starts at `topLeft: [0, 0]`.
39
+
40
+ ```js
41
+ new Actor("player.svg", [100, 50]); // shorthand for { pos: [100, 50] }
42
+ new Actor("player.svg", { midBottom: [160, 180] }); // stands on the floor at y=180
43
+ ```
44
+
45
+ `anchor` controls which point of the image maps to the position — it
46
+ defaults to `["center", "center"]`, so by default `player.x`/`player.y` (and
47
+ `pos`) refer to the image's center, matching pgzero. Each axis accepts
48
+ `"left"`/`"center"`/`"right"` (horizontal) or `"top"`/`"center"`/`"bottom"`
49
+ (vertical), or a literal pixel offset number.
50
+
51
+ ```js
52
+ new Actor("player.svg", { topLeft: [0, 0], anchor: ["left", "top"] });
53
+ ```
54
+
55
+ ## Properties
56
+
57
+ | Property | Type | Notes |
58
+ | ---------------------------------------------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------- |
59
+ | `image` | `string` | Reassigning starts loading the new image. |
60
+ | `pos`, `x`, `y` | point / number | The anchor point's position. |
61
+ | `angle` | `number` | Degrees, counterclockwise, around the anchor. |
62
+ | `opacity` | `number` | `0`–`1`; out-of-range values are clamped, not rejected. |
63
+ | `anchor` | `[h, v]` | See above. |
64
+ | `scale` | `number` | Drawn size as a multiple of the image's native size; default `1`. |
65
+ | `width`, `height` | `number` | The current (possibly rotated) bounding box size. Setting one resizes the sprite to that size, keeping its proportions. |
66
+ | `topLeft`, `topRight`, `bottomLeft`, `bottomRight`, `midTop`, `midLeft`, `midRight`, `midBottom`, `center` | point | Read or move the actor by any edge/corner of its bounding box. |
67
+ | `left`, `top`, `right`, `bottom` | `number` | Same idea, one axis at a time. |
68
+
69
+ Setting `angle` rotates the sprite around its anchor and changes the
70
+ axis-aligned bounding box returned by `rect()`/`width`/`height` — useful when
71
+ you collide-test a rotated actor, since the hitbox grows to cover the
72
+ rotated silhouette. The edge and corner properties read and move that same
73
+ box, so `ship.left = 0` puts a rotated ship's leftmost point at the left of
74
+ the screen.
75
+
76
+ ```js
77
+ function update(dt) {
78
+ if (keyboard.right) player.x += 120 * dt;
79
+ player.angle += 90 * dt; // spin
80
+ }
81
+ ```
82
+
83
+ ## Sizing
84
+
85
+ Sprites draw at their native pixel size by default. To fit a picture to
86
+ your game, give it a `scale`, or a `width` or `height` (at most one of the
87
+ three; the other axis follows so the picture keeps its proportions):
88
+
89
+ ```js
90
+ let cow = new Actor("cow.png", { midBottom: [240, 620], width: 80 }); // 80px wide
91
+ let car = new Actor("car.png", { scale: 3 }); // three times its native size
92
+ cow.height = 60; // resize later; the midBottom stays on the floor
93
+ ```
94
+
95
+ The hitbox, `rect()`, and every edge/corner property follow the drawn size,
96
+ and the position you gave (a `midBottom` on the floor, a `center`) stays
97
+ put when the size changes. A size set before the image has loaded is
98
+ applied as soon as it arrives. `inflateIp` still grows only the hitbox,
99
+ never the picture.
100
+
101
+ ## Methods
102
+
103
+ - `rect(): Rect` — the actor's current bounding rectangle (see
104
+ [`docs/reference/rect.md`](rect.md)); useful for custom drawing or manual
105
+ collision math.
106
+ - `draw(screen?)` — draws the actor at its current position, angle, and
107
+ opacity. Normally called with no arguments inside `draw()`.
108
+ - `ready(): Promise<SurfaceLike>` — resolves once the current image has
109
+ loaded.
110
+ - `unloadImage()` — releases the cached image (advanced; rarely needed).
111
+ - `angleTo(target)` / `distanceTo(target)` — `target` is another `Actor`
112
+ or a `[x, y]` point. `angleTo` returns degrees, `distanceTo` returns
113
+ pixels.
114
+ - `move(x, y)` / `moveIp(x, y)` — like `Rect.move`/`moveIp`; `move` returns
115
+ a new `Rect`, `moveIp` moves the actor itself.
116
+ - `inflate(x, y)` / `inflateIp(x, y)` — grow/shrink the hitbox around its
117
+ center; the picture keeps its size and place.
118
+ - `touches(target)` — `true` when the actor overlaps `target`, which can be
119
+ another `Actor`, a `Rect`, a `[left, top, width, height]` tuple, or an
120
+ `[x, y]` point such as a click position.
121
+ - `collidePoint(point)`, `collideRect(other)`, `collideList(others)`,
122
+ `collideListAll(others)`, `contains(other)` — Pygame Zero's collision
123
+ tests, delegating to the actor's `rect()`. `other` can be a `Rect`, a
124
+ plain `[left, top, width, height]` tuple, or another `Actor`.
125
+ - `clampIp(area)` — moves the actor (without resizing it) so its bounding
126
+ box fits inside `area`. `player.clampIp(screen.bounds())` keeps the
127
+ player on screen.
128
+ - `clamp(other)`, `clip(other)`, `union(other)`, `unionAll(others)`,
129
+ `fit(other)` — geometry helpers, same semantics as the matching `Rect`
130
+ methods, again accepting `Actor`s directly. They return a new `Rect` and
131
+ leave the actor where it is.
132
+
133
+ ```js
134
+ function update(dt) {
135
+ if (keyboard.right) player.x += 120 * dt;
136
+ player.clampIp(screen.bounds()); // stop at the edges of the screen
137
+ }
138
+
139
+ function onMouseDown(pos) {
140
+ if (player.touches(pos)) console.log("clicked the player");
141
+ }
142
+
143
+ if (player.touches(enemy)) loseLife();
144
+ ```
145
+
146
+ ## Animating an Actor
147
+
148
+ `Actor` is a plain object as far as `animate()` is concerned — any numeric
149
+ property (`x`, `y`, `angle`, `opacity`, ...) can be animated:
150
+
151
+ ```js
152
+ animate(player, { x: 300, duration: 0.6, tween: "bounceEnd" });
153
+ ```
154
+
155
+ See [`docs/reference/clock.md`](clock.md#animation) for the full tween list.
156
+
157
+ ## Flip-book animation: `images` and `fps`
158
+
159
+ Give an actor a list of pictures and it flips through them by itself, like a
160
+ flip book — for walking legs, spinning coins, or explosions:
161
+
162
+ ```js
163
+ let coin = new Actor("coin_0", {
164
+ center: [200, 120],
165
+ images: ["coin_0", "coin_1", "coin_2", "coin_3"],
166
+ fps: 8, // pictures per second
167
+ });
168
+
169
+ function draw() {
170
+ coin.draw();
171
+ }
172
+ ```
173
+
174
+ | Property | Meaning |
175
+ | ---------- | ------------------------------------------------------------------------------------- |
176
+ | `images` | The pictures to show, in order. Setting it starts again from the first; `null` stops. |
177
+ | `fps` | Pictures per second (default `10`). Changing it keeps the current picture. |
178
+ | `loop` | Start over after the last picture (default `true`). `false` stops on the last. |
179
+ | `frame` | Which picture is showing: `0` for the first. |
180
+ | `finished` | `true` once a `loop: false` animation has shown its last picture. |
181
+
182
+ The pictures change on the game's own clock, so they pause when the game
183
+ does and keep the same speed at any frame rate. All of them start loading
184
+ as soon as you set `images`. For a one-shot effect, remove the actor once
185
+ it's `finished`:
186
+
187
+ ```js
188
+ explosions.push(
189
+ new Actor("boom_0", {
190
+ center: pos,
191
+ images: ["boom_0", "boom_1", "boom_2"],
192
+ fps: 16,
193
+ loop: false,
194
+ }),
195
+ );
196
+ explosions = explosions.filter((explosion) => !explosion.finished);
197
+ ```
@@ -0,0 +1,128 @@
1
+ # Sounds, Music, And Tone
2
+
3
+ Browsers block audio from playing until the page has seen a user gesture
4
+ (a click or key press). Every audio API below works fine technically before
5
+ that gesture, but nothing will actually be _audible_ until the player has
6
+ interacted with the page at least once — trigger your first sound from an
7
+ `onMouseDown` or `onKeyDown` handler, not from top-level code or the
8
+ first `draw()` frame.
9
+
10
+ ## Sound effects: `sounds`
11
+
12
+ `sounds.<name>` resolves an asset under `sounds/` (see
13
+ [`docs/reference/loaders.md`](loaders.md)) the first time you touch it.
14
+
15
+ ```js
16
+ function onMouseDown() {
17
+ sounds.tap.play();
18
+ }
19
+ ```
20
+
21
+ ### `.play(loops = 0)`
22
+
23
+ Plays the sound. `loops` follows pygame's convention: it counts the
24
+ _extra_ plays after the first. `0` (the default) plays it once, `2` plays it
25
+ three times in total, and `-1` loops forever until `.stop()` is called.
26
+
27
+ Calling `.play()` again while a previous playback is still going **does not
28
+ cut it off** — sounds overlap, just like pygame's channel mixing, so rapid
29
+ repeated triggers (e.g. a machine-gun sound effect) layer instead of
30
+ stuttering.
31
+
32
+ ### `.loop()`
33
+
34
+ Plays the sound over and over until `.stop()` — the same as `.play(-1)`.
35
+ Handy for engine hums and background ambience:
36
+
37
+ ```js
38
+ sounds.engine.loop();
39
+ // later
40
+ sounds.engine.stop();
41
+ ```
42
+
43
+ ### `.stop()`
44
+
45
+ Stops every currently-overlapping playback of this sound.
46
+
47
+ ### `.getLength()`
48
+
49
+ Duration in seconds (`0` if not yet known).
50
+
51
+ ### `.setVolume(volume)` / `.getVolume()`
52
+
53
+ Per-sound volume, `0`–`1`.
54
+
55
+ ## Music: `music`
56
+
57
+ Unlike `sounds`, `music` plays **one track at a time** — starting a new
58
+ track always stops whatever was playing.
59
+
60
+ ```js
61
+ music.play("theme.ogg"); // loops forever
62
+ ```
63
+
64
+ | Method | Behavior |
65
+ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | `music.play(name)` | Stops the current track, plays `name` on a loop. |
67
+ | `music.playOnce(name)` | Stops the current track, plays `name` once. |
68
+ | `music.queue(name)` | Appends `name` to the queue; starts immediately (as a one-shot) if nothing is currently playing. Queued tracks play in order, each once. |
69
+ | `music.stop()` | Stops playback and clears the queue. |
70
+ | `music.pause()` / `music.unpause()` | Pause and resume without losing position. |
71
+ | `music.rewind()` | Restarts the current track from the beginning without stopping it. |
72
+ | `music.fadeOut(seconds)` | Fades the volume to zero over `seconds`, then stops. `0` stops immediately. |
73
+ | `music.setVolume(volume)` / `music.getVolume()` | `0`–`1`. |
74
+ | `music.position` | Seconds into the current track (`0` if nothing is loaded). Set it to jump: `music.position = 30`. |
75
+ | `music.getPos()` | **Milliseconds** the current track has been playing, or `-1` if nothing is loaded (Pygame's unit). |
76
+ | `music.setPos(seconds)` | Seeks the current track to a position, in **seconds**. To save and restore a spot, use `music.position` for both. |
77
+ | `music.isPlaying()` | `true` if a track is currently playing and not paused. |
78
+
79
+ `onMusicEnd()` (or a callback given to `music.setOnMusicEnd(callback)`)
80
+ fires once the queue is exhausted and nothing is left to play.
81
+
82
+ ## Generated tones: `tone`
83
+
84
+ `tone` synthesizes a short beep with the Web Audio API — no sound asset file
85
+ needed, useful for quick feedback sounds (menu blips, jumps, coin pickups).
86
+
87
+ ```js
88
+ function onMouseDown() {
89
+ tone.play("A4", 0.2);
90
+ }
91
+ ```
92
+
93
+ ### `tone.play(noteOrFrequency, duration = 1, options?)`
94
+
95
+ - `noteOrFrequency` — either a note name like `"A4"`, `"C#5"`, or `"Bb3"`
96
+ (letter, optional `#`/`b`, octave number, using standard scientific pitch
97
+ notation with A4 = 440Hz), or a positive number of Hz.
98
+ - `duration` — seconds, **must be greater than `0` and at most `4`**; longer
99
+ values throw a `RangeError`. Generated tones are meant for short blips,
100
+ not background music — use `music` for that.
101
+ - `options.waveform` — `"sine"` (default), `"square"`, `"sawtooth"`,
102
+ `"triangle"`, or the pgzero-style short aliases `"sin"` (→ `"sine"`) and
103
+ `"saw"` (→ `"sawtooth"`).
104
+ - `options.volume` — `0`–`1`, defaults to `0.8` (matching pgzero's `tone`
105
+ module default, which is louder than most other defaults in this API).
106
+
107
+ Returns a `Promise` that resolves once the tone has been scheduled.
108
+
109
+ ### `tone.create(noteOrFrequency, duration = 1, options?)`
110
+
111
+ Builds a reusable tone spec without playing it yet — call `.play(options?)`
112
+ on the result whenever you want to trigger it, optionally overriding
113
+ `waveform`/`volume` for that one playback.
114
+
115
+ ```js
116
+ const jumpTone = tone.create("E5", 0.12, { waveform: "square" });
117
+
118
+ function onKeyDown(key) {
119
+ if (key === keys.SPACE) jumpTone.play();
120
+ }
121
+ ```
122
+
123
+ ### `tone.isUnlocked()`
124
+
125
+ Returns whether the browser has granted audio playback yet — it flips to
126
+ `true` the first time a sound or tone actually plays after a user gesture.
127
+ This unlock state is shared across `sounds` and `tone`, so a single tap
128
+ un-gates both.