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.
@@ -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
- }