@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.
package/dist/host.esm.js CHANGED
@@ -752,6 +752,12 @@ class AudioManager {
752
752
  _categories;
753
753
  _masterGain = 1.0;
754
754
  _currentMusic = null;
755
+ /** Duck factor (0..1) from duckMusic/unduckMusic. A presentation state, not a player setting. */
756
+ _musicDuck = 1;
757
+ /** Crossfade ramp (0..1) for the track that is fading IN. 1 whenever no fade is running. */
758
+ _musicFade = 1;
759
+ /** Generation counter so a superseded crossfade ramp stops writing over the new track's. */
760
+ _musicFadeToken = 0;
755
761
  _unlocked = false;
756
762
  _unlockHandler = null;
757
763
  constructor(config) {
@@ -810,7 +816,10 @@ class AudioManager {
810
816
  if (this._globalMuted || this._categories[category].muted)
811
817
  return;
812
818
  const { sound } = this._soundModule;
813
- const vol = (options?.volume ?? 1) * this._categories[category].volume * this._masterGain;
819
+ // The master gain lives on the GLOBAL bus (`sound.volumeAll`, see applyVolumes) and @pixi/sound
820
+ // already multiplies it in — folding it in here as well squared it, so a master of 0.5 played
821
+ // sfx at 0.25.
822
+ const vol = (options?.volume ?? 1) * this._categories[category].volume;
814
823
  try {
815
824
  sound.play(alias, {
816
825
  volume: vol,
@@ -832,52 +841,50 @@ class AudioManager {
832
841
  if (!this._initialized || !this._soundModule)
833
842
  return;
834
843
  const { sound } = this._soundModule;
835
- // Stop current music with fade-out, start new music with fade-in
836
- if (this._currentMusic && fadeDuration > 0) {
837
- const prevAlias = this._currentMusic;
838
- this._currentMusic = alias;
839
- if (this._globalMuted || this._categories.music.muted)
840
- return;
841
- // Fade out the previous track
842
- this.fadeVolume(prevAlias, this._categories.music.volume * this._masterGain, 0, fadeDuration, () => {
843
- try {
844
- sound.stop(prevAlias);
845
- }
846
- catch { /* ignore */ }
847
- });
848
- // Start new track at zero volume, fade in
849
- try {
850
- sound.play(alias, {
851
- volume: 0,
852
- loop: true,
844
+ const prevAlias = this._currentMusic;
845
+ const crossfade = !!prevAlias && prevAlias !== alias && fadeDuration > 0;
846
+ // Retire the outgoing track. Its own SOUND-level volume is the only thing still pointing at it,
847
+ // so fading that to 0 is safe — nothing else writes it once `_currentMusic` has moved on.
848
+ if (prevAlias) {
849
+ if (crossfade) {
850
+ const from = this.soundVolumeOf(prevAlias);
851
+ this.fadeVolume(prevAlias, from, 0, fadeDuration, () => {
852
+ try {
853
+ sound.stop(prevAlias);
854
+ }
855
+ catch { /* ignore */ }
853
856
  });
854
- this.fadeVolume(alias, 0, this._categories.music.volume * this._masterGain, fadeDuration);
855
857
  }
856
- catch (e) {
857
- console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
858
- }
859
- }
860
- else {
861
- // No crossfade — instant switch
862
- if (this._currentMusic) {
858
+ else {
863
859
  try {
864
- sound.stop(this._currentMusic);
860
+ sound.stop(prevAlias);
865
861
  }
866
862
  catch { /* ignore */ }
867
863
  }
868
- this._currentMusic = alias;
869
- if (this._globalMuted || this._categories.music.muted)
870
- return;
871
- try {
872
- sound.play(alias, {
873
- volume: this._categories.music.volume * this._masterGain,
874
- loop: true,
875
- });
876
- }
877
- catch (e) {
878
- console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
879
- }
880
864
  }
865
+ this._currentMusic = alias;
866
+ this._musicFadeToken++; // any ramp still running belongs to a track we just replaced
867
+ // Deliberately started even while muted. Global mute is the @pixi/sound CONTEXT mute and a
868
+ // muted music category is a 0 term in `musicGain()` — both already make this inaudible, and
869
+ // both undo themselves the moment the player flips them back. Returning early here instead
870
+ // meant a track begun while muted never existed, so unmuting restored silence until some
871
+ // later mode change happened to switch tracks.
872
+ // The incoming track plays at INSTANCE volume 1 and carries its whole gain on the SOUND layer
873
+ // (`musicGain()`), which is the layer the slider, the duck and this fade all write. Splitting
874
+ // them across layers is what silenced every crossfade: the track was started at instance volume
875
+ // 0 and the ramp then moved the sound layer, whose product with 0 is 0 for the track's life.
876
+ // The gain is written BEFORE play() so the first frame is never at full volume.
877
+ this._musicFade = crossfade ? 0 : 1;
878
+ this.applyMusicGain();
879
+ try {
880
+ sound.play(alias, { volume: 1, loop: true });
881
+ }
882
+ catch (e) {
883
+ console.warn(`[AudioManager] Failed to play music "${alias}":`, e);
884
+ return;
885
+ }
886
+ if (crossfade)
887
+ this.rampMusicFade(fadeDuration);
881
888
  }
882
889
  /**
883
890
  * Stop current music.
@@ -893,6 +900,10 @@ class AudioManager {
893
900
  // ignore
894
901
  }
895
902
  this._currentMusic = null;
903
+ // Retire any running ramp and clear the fade term, so the next track does not inherit a
904
+ // half-finished crossfade and start silent.
905
+ this._musicFadeToken++;
906
+ this._musicFade = 1;
896
907
  }
897
908
  /**
898
909
  * Stop all sounds.
@@ -918,6 +929,9 @@ class AudioManager {
918
929
  */
919
930
  setVolume(category, volume) {
920
931
  this._categories[category].volume = Math.max(0, Math.min(1, volume));
932
+ // applyVolumes() re-pushes the music gain, so moving the Music slider is heard on the track
933
+ // that is ALREADY playing — it used to take effect only at the next playMusic (a mode change).
934
+ // SFX need no push: play() reads the category volume fresh on every call.
921
935
  this.applyVolumes();
922
936
  this.saveState();
923
937
  }
@@ -990,30 +1004,19 @@ class AudioManager {
990
1004
  * @param factor - Volume multiplier (0..1), e.g. 0.3 = 30% of normal
991
1005
  */
992
1006
  duckMusic(factor) {
993
- if (!this._initialized || !this._soundModule || !this._currentMusic)
994
- return;
995
- const { sound } = this._soundModule;
996
- const vol = this._categories.music.volume * factor;
997
- try {
998
- sound.volume(this._currentMusic, vol);
999
- }
1000
- catch {
1001
- // ignore
1002
- }
1007
+ // Held as a FACTOR rather than written as a finished volume: the duck used to write
1008
+ // `category × factor` onto a track whose instance already carried the category volume, so it
1009
+ // ducked to category², and unducking restored category² instead of category. Keeping it as one
1010
+ // term of `musicGain()` also keeps the slider live while ducked.
1011
+ this._musicDuck = Math.max(0, Math.min(1, factor));
1012
+ this.applyMusicGain();
1003
1013
  }
1004
1014
  /**
1005
1015
  * Restore music to normal volume after ducking.
1006
1016
  */
1007
1017
  unduckMusic() {
1008
- if (!this._initialized || !this._soundModule || !this._currentMusic)
1009
- return;
1010
- const { sound } = this._soundModule;
1011
- try {
1012
- sound.volume(this._currentMusic, this._categories.music.volume);
1013
- }
1014
- catch {
1015
- // ignore
1016
- }
1018
+ this._musicDuck = 1;
1019
+ this.applyMusicGain();
1017
1020
  }
1018
1021
  /**
1019
1022
  * Destroy the audio manager and free resources.
@@ -1052,6 +1055,57 @@ class AudioManager {
1052
1055
  };
1053
1056
  requestAnimationFrame(tick);
1054
1057
  }
1058
+ /**
1059
+ * The SOUND-layer gain for the running music track.
1060
+ *
1061
+ * @pixi/sound resolves a playing instance as `instance × sound × global` (WebAudioInstance.
1062
+ * refresh). Each of those three has exactly ONE owner here, which is what keeps the mixer honest:
1063
+ * global — the master gain (`applyVolumes`)
1064
+ * sound — music: this function. sfx: untouched, left at 1.
1065
+ * instance — sfx: the per-call volume × the sfx category. music: always 1.
1066
+ * Everything that can move music volume — the player's slider, the category mute, a big-win duck,
1067
+ * a crossfade — is a term below, so they compose instead of overwriting each other.
1068
+ */
1069
+ musicGain() {
1070
+ const c = this._categories.music;
1071
+ return (c.muted ? 0 : 1) * c.volume * this._musicDuck * this._musicFade;
1072
+ }
1073
+ /** Push `musicGain()` at the current track. Safe before it starts playing and with none playing. */
1074
+ applyMusicGain() {
1075
+ if (!this._soundModule || !this._currentMusic)
1076
+ return;
1077
+ try {
1078
+ this._soundModule.sound.volume(this._currentMusic, this.musicGain());
1079
+ }
1080
+ catch {
1081
+ // ignore — alias not registered yet
1082
+ }
1083
+ }
1084
+ /** Current SOUND-layer volume of `alias`, or 0 when it cannot be read. */
1085
+ soundVolumeOf(alias) {
1086
+ try {
1087
+ return Number(this._soundModule.sound.volume(alias)) || 0;
1088
+ }
1089
+ catch {
1090
+ return 0;
1091
+ }
1092
+ }
1093
+ /** Ramp the crossfade term 0 → 1 over `durationMs`, recomposing the gain each frame so a slider
1094
+ * drag or a duck landing mid-fade is honoured rather than overwritten when the fade ends. */
1095
+ rampMusicFade(durationMs) {
1096
+ const token = this._musicFadeToken;
1097
+ const start = Date.now();
1098
+ const tick = () => {
1099
+ if (token !== this._musicFadeToken)
1100
+ return; // a newer track owns the music now
1101
+ const t = Math.min((Date.now() - start) / durationMs, 1);
1102
+ this._musicFade = t;
1103
+ this.applyMusicGain();
1104
+ if (t < 1)
1105
+ requestAnimationFrame(tick);
1106
+ };
1107
+ requestAnimationFrame(tick);
1108
+ }
1055
1109
  applyVolumes() {
1056
1110
  if (!this._soundModule)
1057
1111
  return;
@@ -1059,6 +1113,7 @@ class AudioManager {
1059
1113
  // Global mute is owned by sound.muteAll()/unmuteAll() (context.muted),
1060
1114
  // not by volumeAll — mixing both leaves mute un-undoable after reload.
1061
1115
  sound.volumeAll = this._masterGain; // master multiplies the global bus
1116
+ this.applyMusicGain(); // category volume/mute reach the RUNNING track
1062
1117
  }
1063
1118
  setupMobileUnlock() {
1064
1119
  if (this._unlocked)