@energy8platform/game-engine 0.34.2 → 0.34.3

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.
@@ -37,6 +37,12 @@ export class AudioManager {
37
37
  private _categories: Record<AudioCategoryName, CategoryState>;
38
38
  private _masterGain = 1.0;
39
39
  private _currentMusic: string | null = null;
40
+ /** Duck factor (0..1) from duckMusic/unduckMusic. A presentation state, not a player setting. */
41
+ private _musicDuck = 1;
42
+ /** Crossfade ramp (0..1) for the track that is fading IN. 1 whenever no fade is running. */
43
+ private _musicFade = 1;
44
+ /** Generation counter so a superseded crossfade ramp stops writing over the new track's. */
45
+ private _musicFadeToken = 0;
40
46
  private _unlocked = false;
41
47
  private _unlockHandler: (() => void) | null = null;
42
48
 
@@ -106,7 +112,10 @@ export class AudioManager {
106
112
  if (this._globalMuted || this._categories[category].muted) return;
107
113
 
108
114
  const { sound } = this._soundModule;
109
- const vol = (options?.volume ?? 1) * this._categories[category].volume * this._masterGain;
115
+ // The master gain lives on the GLOBAL bus (`sound.volumeAll`, see applyVolumes) and @pixi/sound
116
+ // already multiplies it in — folding it in here as well squared it, so a master of 0.5 played
117
+ // sfx at 0.25.
118
+ const vol = (options?.volume ?? 1) * this._categories[category].volume;
110
119
 
111
120
  try {
112
121
  sound.play(alias, {
@@ -129,47 +138,45 @@ export class AudioManager {
129
138
  if (!this._initialized || !this._soundModule) return;
130
139
 
131
140
  const { sound } = this._soundModule;
132
-
133
- // Stop current music with fade-out, start new music with fade-in
134
- if (this._currentMusic && fadeDuration > 0) {
135
- const prevAlias = this._currentMusic;
136
- this._currentMusic = alias;
137
-
138
- if (this._globalMuted || this._categories.music.muted) return;
139
-
140
- // Fade out the previous track
141
- this.fadeVolume(prevAlias, this._categories.music.volume * this._masterGain, 0, fadeDuration, () => {
142
- try { sound.stop(prevAlias); } catch { /* ignore */ }
143
- });
144
-
145
- // Start new track at zero volume, fade in
146
- try {
147
- sound.play(alias, {
148
- volume: 0,
149
- loop: true,
141
+ const prevAlias = this._currentMusic;
142
+ const crossfade = !!prevAlias && prevAlias !== alias && fadeDuration > 0;
143
+
144
+ // Retire the outgoing track. Its own SOUND-level volume is the only thing still pointing at it,
145
+ // so fading that to 0 is safe — nothing else writes it once `_currentMusic` has moved on.
146
+ if (prevAlias) {
147
+ if (crossfade) {
148
+ const from = this.soundVolumeOf(prevAlias);
149
+ this.fadeVolume(prevAlias, from, 0, fadeDuration, () => {
150
+ try { sound.stop(prevAlias); } catch { /* ignore */ }
150
151
  });
151
- this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
152
- } catch (e) {
153
- console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
154
- }
155
- } else {
156
- // No crossfade — instant switch
157
- if (this._currentMusic) {
158
- try { sound.stop(this._currentMusic); } catch { /* ignore */ }
152
+ } else {
153
+ try { sound.stop(prevAlias); } catch { /* ignore */ }
159
154
  }
155
+ }
160
156
 
161
- this._currentMusic = alias;
162
- if (this._globalMuted || this._categories.music.muted) return;
163
-
164
- try {
165
- sound.play(alias, {
166
- volume: this._categories.music.volume * this._masterGain,
167
- loop: true,
168
- });
169
- } catch (e) {
170
- console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
171
- }
157
+ this._currentMusic = alias;
158
+ this._musicFadeToken++; // any ramp still running belongs to a track we just replaced
159
+
160
+ // Deliberately started even while muted. Global mute is the @pixi/sound CONTEXT mute and a
161
+ // muted music category is a 0 term in `musicGain()` — both already make this inaudible, and
162
+ // both undo themselves the moment the player flips them back. Returning early here instead
163
+ // meant a track begun while muted never existed, so unmuting restored silence until some
164
+ // later mode change happened to switch tracks.
165
+
166
+ // The incoming track plays at INSTANCE volume 1 and carries its whole gain on the SOUND layer
167
+ // (`musicGain()`), which is the layer the slider, the duck and this fade all write. Splitting
168
+ // them across layers is what silenced every crossfade: the track was started at instance volume
169
+ // 0 and the ramp then moved the sound layer, whose product with 0 is 0 for the track's life.
170
+ // The gain is written BEFORE play() so the first frame is never at full volume.
171
+ this._musicFade = crossfade ? 0 : 1;
172
+ this.applyMusicGain();
173
+ try {
174
+ sound.play(alias, { volume: 1, loop: true });
175
+ } catch (e) {
176
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
177
+ return;
172
178
  }
179
+ if (crossfade) this.rampMusicFade(fadeDuration);
173
180
  }
174
181
 
175
182
  /**
@@ -184,6 +191,10 @@ export class AudioManager {
184
191
  // ignore
185
192
  }
186
193
  this._currentMusic = null;
194
+ // Retire any running ramp and clear the fade term, so the next track does not inherit a
195
+ // half-finished crossfade and start silent.
196
+ this._musicFadeToken++;
197
+ this._musicFade = 1;
187
198
  }
188
199
 
189
200
  /**
@@ -212,6 +223,9 @@ export class AudioManager {
212
223
  */
213
224
  setVolume(category: AudioCategoryName, volume: number): void {
214
225
  this._categories[category].volume = Math.max(0, Math.min(1, volume));
226
+ // applyVolumes() re-pushes the music gain, so moving the Music slider is heard on the track
227
+ // that is ALREADY playing — it used to take effect only at the next playMusic (a mode change).
228
+ // SFX need no push: play() reads the category volume fresh on every call.
215
229
  this.applyVolumes();
216
230
  this.saveState();
217
231
  }
@@ -291,27 +305,20 @@ export class AudioManager {
291
305
  * @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
292
306
  */
293
307
  duckMusic(factor: number): void {
294
- if (!this._initialized || !this._soundModule || !this._currentMusic) return;
295
- const { sound } = this._soundModule;
296
- const vol = this._categories.music.volume * factor;
297
- try {
298
- sound.volume(this._currentMusic, vol);
299
- } catch {
300
- // ignore
301
- }
308
+ // Held as a FACTOR rather than written as a finished volume: the duck used to write
309
+ // `category × factor` onto a track whose instance already carried the category volume, so it
310
+ // ducked to category², and unducking restored category² instead of category. Keeping it as one
311
+ // term of `musicGain()` also keeps the slider live while ducked.
312
+ this._musicDuck = Math.max(0, Math.min(1, factor));
313
+ this.applyMusicGain();
302
314
  }
303
315
 
304
316
  /**
305
317
  * Restore music to normal volume after ducking.
306
318
  */
307
319
  unduckMusic(): void {
308
- if (!this._initialized || !this._soundModule || !this._currentMusic) return;
309
- const { sound } = this._soundModule;
310
- try {
311
- sound.volume(this._currentMusic, this._categories.music.volume);
312
- } catch {
313
- // ignore
314
- }
320
+ this._musicDuck = 1;
321
+ this.applyMusicGain();
315
322
  }
316
323
 
317
324
  /**
@@ -357,12 +364,63 @@ export class AudioManager {
357
364
  requestAnimationFrame(tick);
358
365
  }
359
366
 
367
+ /**
368
+ * The SOUND-layer gain for the running music track.
369
+ *
370
+ * @pixi/sound resolves a playing instance as `instance × sound × global` (WebAudioInstance.
371
+ * refresh). Each of those three has exactly ONE owner here, which is what keeps the mixer honest:
372
+ * global — the master gain (`applyVolumes`)
373
+ * sound — music: this function. sfx: untouched, left at 1.
374
+ * instance — sfx: the per-call volume × the sfx category. music: always 1.
375
+ * Everything that can move music volume — the player's slider, the category mute, a big-win duck,
376
+ * a crossfade — is a term below, so they compose instead of overwriting each other.
377
+ */
378
+ private musicGain(): number {
379
+ const c = this._categories.music;
380
+ return (c.muted ? 0 : 1) * c.volume * this._musicDuck * this._musicFade;
381
+ }
382
+
383
+ /** Push `musicGain()` at the current track. Safe before it starts playing and with none playing. */
384
+ private applyMusicGain(): void {
385
+ if (!this._soundModule || !this._currentMusic) return;
386
+ try {
387
+ this._soundModule.sound.volume(this._currentMusic, this.musicGain());
388
+ } catch {
389
+ // ignore — alias not registered yet
390
+ }
391
+ }
392
+
393
+ /** Current SOUND-layer volume of `alias`, or 0 when it cannot be read. */
394
+ private soundVolumeOf(alias: string): number {
395
+ try {
396
+ return Number(this._soundModule.sound.volume(alias)) || 0;
397
+ } catch {
398
+ return 0;
399
+ }
400
+ }
401
+
402
+ /** Ramp the crossfade term 0 → 1 over `durationMs`, recomposing the gain each frame so a slider
403
+ * drag or a duck landing mid-fade is honoured rather than overwritten when the fade ends. */
404
+ private rampMusicFade(durationMs: number): void {
405
+ const token = this._musicFadeToken;
406
+ const start = Date.now();
407
+ const tick = (): void => {
408
+ if (token !== this._musicFadeToken) return; // a newer track owns the music now
409
+ const t = Math.min((Date.now() - start) / durationMs, 1);
410
+ this._musicFade = t;
411
+ this.applyMusicGain();
412
+ if (t < 1) requestAnimationFrame(tick);
413
+ };
414
+ requestAnimationFrame(tick);
415
+ }
416
+
360
417
  private applyVolumes(): void {
361
418
  if (!this._soundModule) return;
362
419
  const { sound } = this._soundModule;
363
420
  // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
364
421
  // not by volumeAll — mixing both leaves mute un-undoable after reload.
365
422
  sound.volumeAll = this._masterGain; // master multiplies the global bus
423
+ this.applyMusicGain(); // category volume/mute reach the RUNNING track
366
424
  }
367
425
 
368
426
  private setupMobileUnlock(): void {