castle-web-cli 0.4.128 → 0.4.130

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 (157) hide show
  1. package/dist/agent-prompts.js +4 -4
  2. package/dist/filesChanged.d.ts +2 -0
  3. package/dist/filesChanged.js +6 -2
  4. package/dist/ide.js +15 -4
  5. package/dist/importBrowse.js +1 -0
  6. package/dist/index.js +10 -4
  7. package/dist/init.js +63 -15
  8. package/dist/native/loop.js +1 -1
  9. package/dist/native/tools.js +1 -1
  10. package/dist/serve.d.ts +1 -0
  11. package/dist/serve.js +20 -1
  12. package/dist/shell/assets/index-BpOLUyVO.js +441 -0
  13. package/dist/shell/assets/{index-CAq6f9B5.css → index-D6K-0YDB.css} +1 -1
  14. package/dist/shell/index.html +2 -2
  15. package/kits/physics-2d/CLAUDE.md +40 -23
  16. package/kits/physics-2d/behaviors/Joints.jsx +4 -3
  17. package/kits/physics-2d/behaviors/SoundPlayer.jsx +266 -0
  18. package/kits/physics-2d/behaviors/Sprite.jsx +3 -3
  19. package/kits/physics-2d/behaviors/Video.jsx +4 -3
  20. package/kits/physics-2d/castle.json +1 -1
  21. package/kits/physics-2d/editors/PlayOnly.jsx +15 -6
  22. package/kits/physics-2d/editors/PxArtEditor.jsx +95 -31
  23. package/kits/physics-2d/editors/SceneEditor.jsx +5 -5
  24. package/kits/physics-2d/editors/SingleEditor.jsx +37 -8
  25. package/kits/physics-2d/editors/StyleEditor.jsx +7 -4
  26. package/kits/physics-2d/editors/pixelEditorChrome.jsx +7 -7
  27. package/kits/physics-2d/editors/pixelInspector.jsx +13 -7
  28. package/kits/physics-2d/editors/pxArtTimeline.jsx +29 -11
  29. package/kits/physics-2d/editors/pxArtTimeline.module.css +11 -0
  30. package/kits/physics-2d/engine/audioContext.js +41 -0
  31. package/kits/physics-2d/engine/autoInspector.jsx +50 -33
  32. package/kits/physics-2d/engine/blueprint.js +15 -3
  33. package/kits/physics-2d/engine/liveReload.js +33 -21
  34. package/kits/physics-2d/engine/media.js +29 -39
  35. package/kits/physics-2d/engine/sound.js +316 -0
  36. package/kits/physics-2d/engine/systemRegistry.js +1 -1
  37. package/kits/physics-2d/engine/ui.jsx +274 -5
  38. package/kits/physics-2d/engine/ui.module.css +137 -16
  39. package/kits/physics-2d/scripts/testsounds.mjs +84 -0
  40. package/kits/physics-2d/systems/media.js +31 -15
  41. package/kits/physics-3d/CLAUDE.md +109 -0
  42. package/kits/physics-3d/behaviors/Body.jsx +51 -0
  43. package/kits/physics-3d/behaviors/Door.jsx +31 -0
  44. package/kits/physics-3d/behaviors/Lookable.jsx +11 -0
  45. package/kits/physics-3d/behaviors/Model.jsx +14 -0
  46. package/kits/physics-3d/behaviors/Pickup.jsx +39 -0
  47. package/kits/physics-3d/behaviors/Player.jsx +172 -0
  48. package/kits/physics-3d/behaviors/Portal.jsx +19 -0
  49. package/kits/physics-3d/behaviors/Shape.jsx +75 -0
  50. package/kits/physics-3d/behaviors/Solid.jsx +11 -0
  51. package/kits/physics-3d/behaviors/Transform.jsx +24 -0
  52. package/kits/physics-3d/blueprints/barrel.scene +15 -0
  53. package/kits/physics-3d/blueprints/crate.scene +29 -0
  54. package/kits/physics-3d/blueprints/door.scene +35 -0
  55. package/kits/physics-3d/blueprints/gem.scene +26 -0
  56. package/kits/physics-3d/blueprints/pillar.scene +25 -0
  57. package/kits/physics-3d/blueprints/platform.scene +23 -0
  58. package/kits/physics-3d/blueprints/player.scene +29 -0
  59. package/kits/physics-3d/blueprints/portal.scene +21 -0
  60. package/kits/physics-3d/blueprints/rock.scene +14 -0
  61. package/kits/physics-3d/blueprints/statue.scene +26 -0
  62. package/kits/physics-3d/blueprints/tree.scene +14 -0
  63. package/kits/physics-3d/blueprints/wall.scene +26 -0
  64. package/kits/{basic-2d → physics-3d}/castle.json +23 -5
  65. package/kits/physics-3d/docs/pxmodel-format.md +111 -0
  66. package/kits/physics-3d/drawings/crate.pxart +26 -0
  67. package/kits/physics-3d/drawings/door-edge.pxart +26 -0
  68. package/kits/physics-3d/drawings/door.pxart +27 -0
  69. package/kits/physics-3d/drawings/face.pxart +26 -0
  70. package/kits/physics-3d/drawings/floor.pxart +27 -0
  71. package/kits/physics-3d/drawings/platform.pxart +27 -0
  72. package/kits/physics-3d/drawings/statue.pxart +27 -0
  73. package/kits/physics-3d/drawings/wall.pxart +27 -0
  74. package/kits/physics-3d/engine3d/PlayOnly3D.jsx +32 -0
  75. package/kits/physics-3d/engine3d/PxModelEditor.jsx +619 -0
  76. package/kits/physics-3d/engine3d/Scene3DEditor.jsx +618 -0
  77. package/kits/physics-3d/engine3d/Scene3DPlayer.jsx +309 -0
  78. package/kits/physics-3d/engine3d/editor3dData.js +114 -0
  79. package/kits/physics-3d/engine3d/editor3dInspector.jsx +404 -0
  80. package/kits/physics-3d/engine3d/editorChrome.jsx +218 -0
  81. package/kits/physics-3d/engine3d/editorWorld.js +249 -0
  82. package/kits/physics-3d/engine3d/materials.js +174 -0
  83. package/kits/physics-3d/engine3d/meshops.js +123 -0
  84. package/kits/physics-3d/engine3d/modelEditorWorld.js +439 -0
  85. package/kits/physics-3d/engine3d/modelFiles.js +139 -0
  86. package/kits/physics-3d/engine3d/pxmodel.js +265 -0
  87. package/kits/physics-3d/engine3d/thumbnails.js +137 -0
  88. package/kits/physics-3d/engine3d/world3d.js +216 -0
  89. package/kits/{basic-2d → physics-3d}/eslint.config.js +10 -25
  90. package/kits/physics-3d/index.html +20 -0
  91. package/kits/physics-3d/main.jsx +30 -0
  92. package/kits/physics-3d/models/barrel.pxmodel +10 -0
  93. package/kits/physics-3d/models/player.pxmodel +222 -0
  94. package/kits/physics-3d/models/rock.pxmodel +30 -0
  95. package/kits/physics-3d/models/tree.pxmodel +10 -0
  96. package/kits/{basic-2d → physics-3d}/package-lock.json +140 -119
  97. package/kits/{basic-2d → physics-3d}/package.json +11 -9
  98. package/kits/{basic-2d → physics-3d}/pnpm-lock.yaml +162 -137
  99. package/kits/physics-3d/scenes/main.scene +414 -0
  100. package/kits/physics-3d/scenes/model-lab.scene +127 -0
  101. package/kits/physics-3d/systems/physics3d.js +356 -0
  102. package/package.json +5 -2
  103. package/dist/shell/assets/index-DKu9ejyh.js +0 -436
  104. package/kits/basic-2d/CLAUDE.md +0 -221
  105. package/kits/basic-2d/behaviors/Camera.jsx +0 -43
  106. package/kits/basic-2d/behaviors/Collider.jsx +0 -213
  107. package/kits/basic-2d/behaviors/Layout.jsx +0 -53
  108. package/kits/basic-2d/behaviors/Sprite.jsx +0 -357
  109. package/kits/basic-2d/behaviors/tint.js +0 -47
  110. package/kits/basic-2d/blueprints/cauldron.scene +0 -20
  111. package/kits/basic-2d/docs/pxart-format.md +0 -377
  112. package/kits/basic-2d/drawings/cauldron.pxart +0 -113
  113. package/kits/basic-2d/editors/BlueprintLibrary.jsx +0 -270
  114. package/kits/basic-2d/editors/ErrorBoundary.jsx +0 -59
  115. package/kits/basic-2d/editors/PlayOnly.jsx +0 -31
  116. package/kits/basic-2d/editors/PxArtEditor.jsx +0 -1092
  117. package/kits/basic-2d/editors/SceneEditor.jsx +0 -1780
  118. package/kits/basic-2d/editors/SelectionOverlay.jsx +0 -909
  119. package/kits/basic-2d/editors/SingleEditor.jsx +0 -122
  120. package/kits/basic-2d/editors/behaviorRegistry.js +0 -34
  121. package/kits/basic-2d/editors/editorHistory.js +0 -157
  122. package/kits/basic-2d/editors/inspectorSheet.js +0 -13
  123. package/kits/basic-2d/editors/pixelCanvas.js +0 -11
  124. package/kits/basic-2d/editors/pixelEditorChrome.jsx +0 -74
  125. package/kits/basic-2d/editors/pixelGeometry.js +0 -140
  126. package/kits/basic-2d/editors/pixelInspector.jsx +0 -633
  127. package/kits/basic-2d/editors/pxArtEditorModel.js +0 -732
  128. package/kits/basic-2d/editors/pxArtPlayback.js +0 -92
  129. package/kits/basic-2d/editors/pxArtTimeline.jsx +0 -752
  130. package/kits/basic-2d/editors/pxArtTimeline.module.css +0 -506
  131. package/kits/basic-2d/editors/pxArtTools.js +0 -232
  132. package/kits/basic-2d/editors/useArtboardFit.js +0 -105
  133. package/kits/basic-2d/engine/ScenePlayer.jsx +0 -209
  134. package/kits/basic-2d/engine/SceneUI.jsx +0 -59
  135. package/kits/basic-2d/engine/assets.js +0 -15
  136. package/kits/basic-2d/engine/autoInspector.jsx +0 -70
  137. package/kits/basic-2d/engine/behaviorExtensions.js +0 -32
  138. package/kits/basic-2d/engine/blueprint.js +0 -557
  139. package/kits/basic-2d/engine/collider.js +0 -200
  140. package/kits/basic-2d/engine/files.js +0 -141
  141. package/kits/basic-2d/engine/liveReload.js +0 -88
  142. package/kits/basic-2d/engine/pxart.js +0 -1032
  143. package/kits/basic-2d/engine/pxartSmooth.js +0 -222
  144. package/kits/basic-2d/engine/scene.js +0 -696
  145. package/kits/basic-2d/engine/spriteGeometry.js +0 -32
  146. package/kits/basic-2d/engine/systemRegistry.js +0 -16
  147. package/kits/basic-2d/engine/ui.jsx +0 -695
  148. package/kits/basic-2d/engine/ui.module.css +0 -2287
  149. package/kits/basic-2d/index.html +0 -24
  150. package/kits/basic-2d/main.jsx +0 -24
  151. package/kits/basic-2d/scenes/main.scene +0 -16
  152. package/kits/basic-2d/scripts/draw.mjs +0 -121
  153. package/kits/physics-2d/behaviors/Sound.jsx +0 -163
  154. package/kits/physics-2d/behaviors/Tone.jsx +0 -95
  155. package/kits/physics-2d/engine/tone.js +0 -112
  156. /package/kits/{basic-2d → physics-3d}/.prettierrc +0 -0
  157. /package/kits/{basic-2d → physics-3d}/vite.config.js +0 -0
@@ -1,24 +0,0 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
5
- <meta name="color-scheme" content="dark" />
6
- <title>Castle Basic 2D JS Kit</title>
7
- <!-- Declare the dark scheme + opaque dark background statically, before the
8
- JS-imported CSS module loads. Otherwise this frame paints with the
9
- default (light) scheme first and only switches to dark once the module
10
- applies -- and Safari fails to repaint a composited iframe on a
11
- color-scheme change (WebKit bug 309097), leaving static panels like the
12
- file list blank until a forced repaint (e.g. opening devtools). -->
13
- <style>
14
- html {
15
- color-scheme: dark;
16
- background: #0a0a0a;
17
- }
18
- </style>
19
- </head>
20
- <body>
21
- <div id="root"></div>
22
- <script type="module" src="/main.jsx"></script>
23
- </body>
24
- </html>
@@ -1,24 +0,0 @@
1
- import React from 'react';
2
- import { createRoot } from 'react-dom/client';
3
- import { isEdit, setup } from 'castle-web-sdk';
4
- import { PlayOnly } from './editors/PlayOnly';
5
- import { ErrorBoundary } from './editors/ErrorBoundary';
6
- import { SingleEditor } from './editors/SingleEditor';
7
- document.body.dataset.castleScreenshotTarget = 'viewport';
8
- setup();
9
- const root = document.getElementById('root');
10
- if (!root) throw new Error('Missing root element');
11
- // Route on URL params so the shell can mount each piece in its own panel iframe.
12
- // File browsing and the code/text editor are now builtin shell panels, so the
13
- // kit only renders the deck (play) and its rich per-file editors (scene/pxart):
14
- // (no edit / ?edit=0) -> play the deck (playtest panel)
15
- // ?file=<path>[&editor=<id>] -> a single rich editor for that file
16
- const params = new URLSearchParams(window.location.search);
17
- function pick() {
18
- const initialScene = params.get('scene') ?? undefined;
19
- if (!isEdit()) return <PlayOnly initialScene={initialScene} />;
20
- const file = params.get('file');
21
- if (file) return <SingleEditor path={file} editor={params.get('editor') ?? undefined} />;
22
- return <PlayOnly initialScene={initialScene} />;
23
- }
24
- createRoot(root).render(<ErrorBoundary>{pick()}</ErrorBoundary>);
@@ -1,16 +0,0 @@
1
- {
2
- "background": "#1a1932",
3
- "actors": [
4
- {
5
- "id": "cauldron",
6
- "blueprint": "blueprints/cauldron.scene",
7
- "components": {
8
- "Layout": {
9
- "x": 200,
10
- "y": 300
11
- }
12
- }
13
- }
14
- ],
15
- "name": "Main"
16
- }
@@ -1,121 +0,0 @@
1
- // `npm run draw -- <name>` — turn a terse svg-rect emission into a sprite.
2
- //
3
- // Reads an <svg> with one <rect> per pixel from stdin (or `--from <file>`),
4
- // decodes + palette-quantizes it onto the agent 16-color subset, and writes a
5
- // single-frame `drawings/<name>.pxart` in the deck (the script's cwd).
6
- //
7
- // This is a kit-layer concern: the `.pxart` format lives entirely in the kit
8
- // (engine/pxart.js); the harness has no knowledge of it.
9
-
10
- import * as fs from 'fs';
11
- import * as path from 'path';
12
- import {
13
- svgRectToPxArt,
14
- serializeCompact,
15
- AGENT_PALETTE_16,
16
- DEFAULT_RESOLUTION,
17
- } from '../engine/pxart.js';
18
-
19
- // Read all of stdin as a UTF-8 string. Resolves '' if stdin is a TTY with no
20
- // piped input so we can give a clear "no input" error instead of hanging.
21
- function readStdin() {
22
- return new Promise((resolve, reject) => {
23
- if (process.stdin.isTTY) {
24
- resolve('');
25
- return;
26
- }
27
- const chunks = [];
28
- process.stdin.on('data', (c) => chunks.push(c));
29
- process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
30
- process.stdin.on('error', reject);
31
- });
32
- }
33
-
34
- // Normalize a requested sprite name into a relative `drawings/<...>.pxart`
35
- // path. Tolerates a leading `drawings/` and/or a trailing `.pxart`/`.px.json`
36
- // so `draw ship`, `draw ship.pxart`, and `draw drawings/ship.pxart` all land in
37
- // the same place.
38
- function resolveSpritePath(dir, name) {
39
- let rel = name.trim().replace(/\\/g, '/');
40
- rel = rel.replace(/^\.?\/*/, '');
41
- if (rel.startsWith('drawings/')) rel = rel.slice('drawings/'.length);
42
- rel = rel.replace(/\.px\.json$/i, '').replace(/\.pxart$/i, '');
43
- if (!rel) return null;
44
-
45
- const drawingsDir = path.resolve(dir, 'drawings');
46
- const filepath = path.resolve(drawingsDir, `${rel}.pxart`);
47
- const within = path.relative(drawingsDir, filepath);
48
- if (within.startsWith('..') || path.isAbsolute(within)) return null;
49
- return filepath;
50
- }
51
-
52
- function getFlagValue(flag) {
53
- const idx = process.argv.indexOf(flag);
54
- return idx >= 0 ? process.argv[idx + 1] : undefined;
55
- }
56
-
57
- // All positional (non-flag) args after the script, in order.
58
- function positionals() {
59
- const args = process.argv.slice(2);
60
- const withValues = new Set(['--from']);
61
- const out = [];
62
- for (let i = 0; i < args.length; i++) {
63
- if (args[i].startsWith('--')) {
64
- if (withValues.has(args[i])) i++;
65
- continue;
66
- }
67
- out.push(args[i]);
68
- }
69
- return out;
70
- }
71
-
72
- async function main() {
73
- const name = positionals()[0];
74
- if (!name) {
75
- console.error('Usage: npm run draw -- <name> [--from FILE] (svg-rect on stdin -> drawings/<name>.pxart)');
76
- process.exit(1);
77
- }
78
-
79
- // The script's cwd is the deck; sprites land under <deck>/drawings/.
80
- const dir = process.cwd();
81
- const filepath = resolveSpritePath(dir, name);
82
- if (!filepath) {
83
- console.error(`Invalid sprite name: ${name}`);
84
- process.exit(1);
85
- }
86
-
87
- const fromFile = getFlagValue('--from');
88
- let svg;
89
- if (fromFile) {
90
- try {
91
- svg = fs.readFileSync(path.resolve(fromFile), 'utf-8');
92
- } catch (e) {
93
- console.error(`Could not read --from file ${fromFile}: ${e instanceof Error ? e.message : String(e)}`);
94
- process.exit(1);
95
- }
96
- } else {
97
- svg = await readStdin();
98
- }
99
-
100
- if (!svg.trim()) {
101
- console.error('No svg-rect input. Pipe an <svg> with <rect> cells via stdin or pass --from <file>.');
102
- process.exit(1);
103
- }
104
-
105
- const art = svgRectToPxArt(svg, { resolution: DEFAULT_RESOLUTION, palette: AGENT_PALETTE_16 });
106
- if (!art) {
107
- console.error('Could not decode any pixels from the svg-rect input (no usable <rect fill="...">). Emit one <rect> per filled pixel on a 16x16 viewBox.');
108
- process.exit(1);
109
- }
110
-
111
- fs.mkdirSync(path.dirname(filepath), { recursive: true });
112
- fs.writeFileSync(filepath, serializeCompact(art), 'utf-8');
113
-
114
- const rel = path.relative(path.resolve(dir), filepath);
115
- console.log(`Wrote ${rel}`);
116
- }
117
-
118
- main().catch((e) => {
119
- console.error(e instanceof Error ? e.message : String(e));
120
- process.exit(1);
121
- });
@@ -1,163 +0,0 @@
1
- import { resolveDeckFile } from 'castle-web-sdk';
2
- import React from 'react';
3
- import { mediaElement, playMedia, playOneShot, setMediaPan, stopMedia } from '../engine/media';
4
- import { mediaFilesOfKind } from '../engine/files';
5
- import { Panel, SelectField } from '../engine/ui';
6
- import { AutoFields, overrideProps } from '../engine/autoInspector';
7
- import { PAN, UNIT } from '../engine/propertyRanges';
8
-
9
- // Plays a sound file (.mp3 / .wav / .m4a) from an actor.
10
- //
11
- // Two ways to use it. Set `playOnStart` and it plays when the scene starts --
12
- // music, ambience, a jingle on a game-over screen. Or leave it off and trigger
13
- // it from a behavior, through the handle this puts on the actor:
14
- //
15
- // scene.getActor('sfx').runtime.sound.play(); // from the top
16
- // await actor.runtime.sound.play(); // ...and wait for it to end
17
- // actor.runtime.sound.stop();
18
- //
19
- // `play()` on a `polyphonic` sound overlaps copies instead of cutting the last
20
- // one off, which is what a sound effect fired twice in a row should do. Without
21
- // it, a second play restarts the one element, which is what music wants.
22
- //
23
- // Sound only plays during play, never in the editor: `update` is what wires the
24
- // element up, and the editor doesn't run it. Pressing Stop silences everything,
25
- // as does `scene.sound.stopAll()` (see systems/media.js).
26
- //
27
- // Browsers refuse to play audio until the player has touched the page. A sound
28
- // that starts before that isn't lost -- it's queued and starts on the first tap
29
- // or key (engine/media.js), which for a game is usually the first input anyway.
30
- export class Sound {
31
- static behaviorName = 'Sound';
32
-
33
- static defaultProps = {
34
- file: '',
35
- volume: 1,
36
- playbackRate: 1,
37
- pan: 0,
38
- loop: false,
39
- polyphonic: false,
40
- playOnStart: true,
41
- };
42
-
43
- // Mirrors the clamps `readSettings` already applies at runtime, so the
44
- // inspector can't author a value the sound would silently ignore -- including
45
- // playbackRate's 0.01..10, where the classic editor's ceiling lives.
46
- static propertyMeta = {
47
- volume: UNIT,
48
- playbackRate: { min: 0.01, max: 10, step: 0.05 },
49
- pan: PAN,
50
- };
51
-
52
- constructor(props) {
53
- this.props = props;
54
- }
55
-
56
- update(actor) {
57
- if (!actor.runtime || actor.runtime.collected) return;
58
- const file = resolveDeckFile(this.props.file);
59
- const element = mediaElement(actor.id, 'audio', file);
60
- if (!element) {
61
- actor.runtime.sound = null;
62
- return;
63
- }
64
- // Props are live: turning the volume down, the pitch up, or the loop on in
65
- // the inspector takes effect on the sound already playing.
66
- const settings = readSettings(this.props);
67
- element.volume = settings.volume;
68
- element.playbackRate = settings.playbackRate;
69
- element.loop = settings.loop;
70
- if (settings.pan !== 0) setMediaPan(element, settings.pan);
71
-
72
- const handle = actor.runtime.sound;
73
- if (!handle || handle.file !== file) {
74
- actor.runtime.sound = makeHandle(file, element, () => readSettings(this.props));
75
- if (this.props.playOnStart) void actor.runtime.sound.play();
76
- } else {
77
- handle.settings = settings;
78
- }
79
- }
80
-
81
- static Inspector({ component, setComponent, override }) {
82
- return (
83
- <Panel title="Sound" overridden={override?.anyOverridden()}>
84
- <SelectField
85
- label="File"
86
- value={component.file}
87
- onChange={(file) => setComponent({ file })}
88
- options={mediaFilesOfKind('audio')}
89
- {...overrideProps(override, 'file')}
90
- />
91
- <AutoFields
92
- defaultProps={Sound.defaultProps}
93
- meta={Sound.propertyMeta}
94
- component={component}
95
- setComponent={setComponent}
96
- only={['volume', 'playbackRate', 'pan', 'loop', 'polyphonic', 'playOnStart']}
97
- override={override}
98
- />
99
- </Panel>
100
- );
101
- }
102
- }
103
-
104
- function clamp(value, min, max, fallback) {
105
- return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
106
- }
107
-
108
- function readSettings(props) {
109
- return {
110
- volume: clamp(props.volume, 0, 1, 1),
111
- // The same ceiling the classic editor used: past 10x a sound is a click.
112
- playbackRate: clamp(props.playbackRate, 0.01, 10, 1),
113
- pan: clamp(props.pan, -1, 1, 0),
114
- loop: Boolean(props.loop),
115
- polyphonic: Boolean(props.polyphonic),
116
- };
117
- }
118
-
119
- // What a behavior gets at `actor.runtime.sound`. Plain methods over the element,
120
- // so game code never has to know an <audio> is involved.
121
- //
122
- // `play()` returns a promise that resolves when the sound ends, so a behavior
123
- // can `await` one before doing the next thing. A looping sound resolves right
124
- // away rather than never -- awaiting something that by definition doesn't end
125
- // would hang the caller forever, and that is never what was meant.
126
- function makeHandle(file, element, currentSettings) {
127
- const handle = {
128
- file,
129
- element,
130
- settings: currentSettings(),
131
- play() {
132
- const s = handle.settings;
133
- if (s.polyphonic) {
134
- return playOneShot(file, { volume: s.volume, playbackRate: s.playbackRate, pan: s.pan });
135
- }
136
- try {
137
- element.currentTime = 0;
138
- } catch {
139
- // Not seekable yet (still loading) -- it plays from the start anyway.
140
- }
141
- playMedia(element);
142
- if (s.loop) return Promise.resolve();
143
- return new Promise((resolve) => {
144
- const done = () => resolve();
145
- element.addEventListener('ended', done, { once: true });
146
- element.addEventListener('error', done, { once: true });
147
- });
148
- },
149
- resume() {
150
- playMedia(element);
151
- },
152
- pause() {
153
- element.pause();
154
- },
155
- stop() {
156
- stopMedia(element);
157
- },
158
- get playing() {
159
- return !element.paused;
160
- },
161
- };
162
- return handle;
163
- }
@@ -1,95 +0,0 @@
1
- import React from 'react';
2
- import { playTone, WAVEFORMS } from '../engine/tone';
3
- import { Panel, SelectField } from '../engine/ui';
4
- import { AutoFields, overrideProps } from '../engine/autoInspector';
5
- import { PAN, POSITIVE, UNIT } from '../engine/propertyRanges';
6
-
7
- // Plays a synthesized note -- no audio file involved. This is the cheapest sound
8
- // a deck can make: a blip on a pickup, a thud on a miss, a rising scale as a
9
- // counter climbs. Nothing is downloaded and nothing is stored.
10
- //
11
- // actor.runtime.tone.play(); // the note as configured
12
- // actor.runtime.tone.play({ note: 72 }); // ...or override it per play
13
- // await actor.runtime.tone.play(); // wait for the release to finish
14
- //
15
- // `note` is a MIDI number: 60 is middle C, 72 an octave up, +1 a semitone. The
16
- // envelope is attack (silence up to full) then release (full down to silence),
17
- // so the note's whole length is attack + release seconds.
18
- export class Tone {
19
- static behaviorName = 'Tone';
20
-
21
- static defaultProps = {
22
- note: 60,
23
- waveform: 'square',
24
- attack: 0,
25
- release: 0.3,
26
- volume: 0.5,
27
- pan: 0,
28
- playOnStart: false,
29
- };
30
-
31
- // `note` is MIDI, which is genuinely 0..127. `attack`/`release` are seconds,
32
- // so they get a floor but no ceiling.
33
- static propertyMeta = {
34
- note: { min: 0, max: 127, step: 1 },
35
- attack: POSITIVE,
36
- release: POSITIVE,
37
- volume: UNIT,
38
- pan: PAN,
39
- };
40
-
41
- constructor(props) {
42
- this.props = props;
43
- }
44
-
45
- update(actor) {
46
- if (!actor.runtime || actor.runtime.collected) return;
47
- const handle = actor.runtime.tone;
48
- if (!handle) {
49
- actor.runtime.tone = makeHandle(() => this.props);
50
- if (this.props.playOnStart) void actor.runtime.tone.play();
51
- } else {
52
- handle.props = this.props;
53
- }
54
- }
55
-
56
- static Inspector({ component, setComponent, override }) {
57
- return (
58
- <Panel title="Tone" overridden={override?.anyOverridden()}>
59
- <SelectField
60
- label="Waveform"
61
- value={component.waveform}
62
- onChange={(waveform) => setComponent({ waveform })}
63
- options={WAVEFORMS}
64
- {...overrideProps(override, 'waveform')}
65
- />
66
- <AutoFields
67
- defaultProps={Tone.defaultProps}
68
- meta={Tone.propertyMeta}
69
- component={component}
70
- setComponent={setComponent}
71
- only={['note', 'attack', 'release', 'volume', 'pan', 'playOnStart']}
72
- override={override}
73
- />
74
- </Panel>
75
- );
76
- }
77
- }
78
-
79
- // `actor.runtime.tone`. `play(overrides)` takes the same fields as the props, so
80
- // one Tone actor can cover a whole scale without a component per note.
81
- function makeHandle(currentProps) {
82
- const handle = {
83
- props: currentProps(),
84
- voice: null,
85
- play(overrides) {
86
- handle.voice = playTone({ ...handle.props, ...overrides });
87
- return handle.voice.finished;
88
- },
89
- stop() {
90
- handle.voice?.stop();
91
- handle.voice = null;
92
- },
93
- };
94
- return handle;
95
- }
@@ -1,112 +0,0 @@
1
- // A synthesized note: one oscillator through an attack/release envelope. This
2
- // is the sound a deck can make with no audio file at all -- a blip on a pickup,
3
- // a beep on a wrong answer -- and it costs nothing to ship.
4
- //
5
- // Deliberately one voice and two envelope stages, matching what the classic
6
- // Castle editor's "tone" sample offers. Anything richer is an instrument, and an
7
- // instrument belongs in a music system, not here.
8
-
9
- import { getAudioContext, resumeAudioContext } from './audioContext';
10
-
11
- export const WAVEFORMS = ['square', 'sawtooth', 'sine', 'triangle', 'noise'];
12
-
13
- // Voices currently sounding, so play mode can be silenced on Stop.
14
- const voices = new Set();
15
-
16
- // MIDI note number to Hz. 69 is A440, 12 notes to the octave.
17
- export function noteToFrequency(note) {
18
- const midi = Number.isFinite(note) ? note : 60;
19
- return 440 * Math.pow(2, (midi - 69) / 12);
20
- }
21
-
22
- // A short burst of white noise, made once and reused. Noise is the one waveform
23
- // an oscillator can't produce, and it's what a percussive hit wants.
24
- let noiseBuffer = null;
25
- function getNoiseBuffer(ctx) {
26
- if (noiseBuffer && noiseBuffer.sampleRate === ctx.sampleRate) return noiseBuffer;
27
- const frames = Math.floor(ctx.sampleRate * 2);
28
- const buffer = ctx.createBuffer(1, frames, ctx.sampleRate);
29
- const data = buffer.getChannelData(0);
30
- for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
31
- noiseBuffer = buffer;
32
- return buffer;
33
- }
34
-
35
- function makeSource(ctx, waveform, frequency) {
36
- if (waveform === 'noise') {
37
- const source = ctx.createBufferSource();
38
- source.buffer = getNoiseBuffer(ctx);
39
- source.loop = true;
40
- return source;
41
- }
42
- const osc = ctx.createOscillator();
43
- osc.type = WAVEFORMS.includes(waveform) ? waveform : 'square';
44
- osc.frequency.value = frequency;
45
- return osc;
46
- }
47
-
48
- // Play one note. Returns `{ stop, finished }` -- `finished` resolves when the
49
- // release has run out, so a caller can wait for it the way a Sound can.
50
- export function playTone({ note = 60, waveform = 'square', attack = 0, release = 0.4, volume = 1, pan = 0 } = {}) {
51
- const ctx = getAudioContext();
52
- if (!ctx) return { stop() {}, finished: Promise.resolve() };
53
- resumeAudioContext();
54
-
55
- const level = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : 1;
56
- const rise = Math.max(0, Number.isFinite(attack) ? attack : 0);
57
- // A note with no release at all clicks; give it a floor short enough to still
58
- // read as instant.
59
- const fall = Math.max(0.01, Number.isFinite(release) ? release : 0.4);
60
- const start = ctx.currentTime;
61
- const end = start + rise + fall;
62
-
63
- const source = makeSource(ctx, waveform, noteToFrequency(note));
64
- const gain = ctx.createGain();
65
- gain.gain.setValueAtTime(0, start);
66
- gain.gain.linearRampToValueAtTime(level, start + rise);
67
- gain.gain.linearRampToValueAtTime(0, end);
68
-
69
- let tail = gain;
70
- if (pan && ctx.createStereoPanner) {
71
- const panner = ctx.createStereoPanner();
72
- panner.pan.value = Math.min(1, Math.max(-1, pan));
73
- gain.connect(panner);
74
- tail = panner;
75
- }
76
- source.connect(gain);
77
- tail.connect(ctx.destination);
78
- source.start(start);
79
- source.stop(end);
80
-
81
- const voice = { source, gain };
82
- voices.add(voice);
83
- const finished = new Promise((resolve) => {
84
- source.addEventListener('ended', () => {
85
- voices.delete(voice);
86
- resolve();
87
- });
88
- });
89
- return {
90
- stop() {
91
- voices.delete(voice);
92
- try {
93
- source.stop();
94
- } catch {
95
- // Already stopped -- nothing to do.
96
- }
97
- },
98
- finished,
99
- };
100
- }
101
-
102
- // Silence every sounding note. Play mode ending must not leave one ringing.
103
- export function stopAllTones() {
104
- for (const voice of voices) {
105
- try {
106
- voice.source.stop();
107
- } catch {
108
- // Already stopped.
109
- }
110
- }
111
- voices.clear();
112
- }
File without changes
File without changes