castle-web-cli 0.4.127 → 0.4.129
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/dist/shell/assets/{index-0CshkxoU.css → index-CAq6f9B5.css} +1 -1
- package/dist/shell/assets/index-DKu9ejyh.js +436 -0
- package/dist/shell/index.html +2 -2
- package/kits/physics-2d/CLAUDE.md +31 -13
- package/kits/physics-2d/behaviors/Joints.jsx +4 -3
- package/kits/physics-2d/behaviors/SoundPlayer.jsx +266 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +3 -3
- package/kits/physics-2d/behaviors/Video.jsx +4 -3
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/engine/audioContext.js +41 -0
- package/kits/physics-2d/engine/autoInspector.jsx +50 -33
- package/kits/physics-2d/engine/media.js +29 -39
- package/kits/physics-2d/engine/sound.js +316 -0
- package/kits/physics-2d/engine/ui.jsx +272 -3
- package/kits/physics-2d/engine/ui.module.css +95 -16
- package/kits/physics-2d/scripts/testsounds.mjs +84 -0
- package/kits/physics-2d/systems/media.js +31 -15
- package/package.json +1 -1
- package/dist/shell/assets/index-Bqkqd5Dv.js +0 -436
- package/kits/physics-2d/behaviors/Sound.jsx +0 -163
- package/kits/physics-2d/behaviors/Tone.jsx +0 -95
- package/kits/physics-2d/engine/tone.js +0 -112
|
@@ -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
|
-
}
|