WebAudioSound's destroy() method causes crash
Version
- Phaser Version: 3.55.2
- Operating system: Mac OS Ventura 13.3.1(a)
- Browser: Chrome 114.0
Description
First of all, I saw a Issue. That is #5492 Issue. It is similar to my problem.
In my case, the same problem occurs sometimes with other parameter: this.muteNode reported null.
This results in the following error appearing in the JavaScript console:
Uncaught TypeError: Cannot read properties of null (reading 'disconnect') at initialize.destroy
Example Test Code
Here's the code from WebAudioSound.js
destroy: function ()
{
BaseSound.prototype.destroy.call(this);
this.audioBuffer = null;
this.stopAndRemoveBufferSource();
this.muteNode.disconnect();
this.muteNode = null;
this.volumeNode.disconnect();
this.volumeNode = null;
if (this.pannerNode)
{
this.pannerNode.disconnect();
this.pannerNode = null;
}
this.rateUpdates.length = 0;
this.rateUpdates = null;
},
Additional Information
How about update to below code?
destroy: function ()
{
BaseSound.prototype.destroy.call(this);
this.audioBuffer = null;
this.stopAndRemoveBufferSource();
if (this.muteNode)
{
this.muteNode.disconnect();
this.muteNode = null;
}
this.volumeNode.disconnect();
this.volumeNode = null;
if (this.pannerNode)
{
this.pannerNode.disconnect();
this.pannerNode = null;
}
this.rateUpdates.length = 0;
this.rateUpdates = null;
},
I saw Phaser 3.60.0 Change Log that is Sound Manager Bug Fixes.
Destroying a WebAudioSound in the same game step as destroying the Game itself would cause an error when trying to disconnect already disconnected Web Audio nodes. WebAudioSound will now bail out of its destroy sequence if it's already pending removal.
If I update from version 3.55.2 to version 3.60.0, Issue is clear?
@jjongsu try it and see? The issue resolved in 3.60 was specifically to do with destroying the Game instance. Destroying just a Sound instance (while the game is still running) is different. However, I can't see how muteNode etc would ever not be null as it cannot create a WebAudio Sound object without making those. The only thing I can think of would be that the sound was destroyed twice. That could throw the error, but even that shouldn't happen because of the pendingRemove guard.
@photonstorm Thank you for response. In my case, I didn't use sound destroy method. I use scene destroy. when scene is destroyed, sound destroyed. If scene run destroyed in a moment, can error?
Sounds are global in Phaser. They are not tied to a Scene. Destroying a Scene (which is quite a rare event and shouldn't really happen in most games) doesn't touch the Sound Manager, as it belongs to the Game instance, not the Scene. So I don't believe this is relevant.
I would need to see a test case to prove otherwise.
It composed react component that has a modal and phaser game.
It has a error on 'timeout'. In game, check user action time. when no action time is over 15, It's error case. So It show error modal.
But, before show error modal, following error appearing in the JavaScript console:
TypeError: Cannot read properties of null (reading 'disconnect') at initialize.destroy ~~~
I use phaser3 on react component :
export default function GameB() {
const ref = useRef<HTMLDivElement>(null);
const game = useRef<Phaser.Game>();
const [modalError, setModalError] = useState<'timeoutError'>();
useEffect(() => {
if (!ref.current) return;
const scale = window.innerHeight / 1080;
ref.current.style.transform = `scale(${scale})`;
ref.current.addEventListener('timeout', (data: any) => {
setModalError('timeoutError');
});
if (!modalError) {
game.current = new Phaser.Game({
type: Phaser.AUTO,
scale: {
width: 1920,
height: 1080
},
physics: { default: 'arcade' },
plugins: {
global: [
{
key: 'rexGrayScalePipeline',
plugin: GrayScalePipelinePlugin,
start: true
}
]
},
parent: ref.current?.id,
scene: [new Loading(), new Preload(), new Main(), new Role(), new Result()]
});
}
return () => {
game.current?.destroy(true);
};
}, [modalError]);
if (error) throw error;
return (
<div className="flex justify-center items-center overflow-hidden inset-0 fixed">
<div className="w-[1920px] h-[1080px] flex-shrink-0 justify-center" ref={ref} id="phaser-container"></div>
{modalError && <GameModal errorName={modalError} />}
</div>
);
}
timeManager class used in Main scene.
export default class TimeManager {
scene: Main;
time: number = 91;
countdown!: Phaser.GameObjects.Text;
popUp!: NoAction;
activeTime: number = 90;
dim?: Phaser.GameObjects.Rectangle;
startText!: Phaser.GameObjects.Image;
constructor({ scene }: { scene:Main }) {
this.scene = scene;
this.create();
}
private create() {
this.scene.add.image(1540, 50, 'iconClock').setOrigin(0.5, 0.5).setDepth(depth['controller']);
this.countdown = this.scene.add.text(1540, 53, '90').setOrigin(0.5, 0.5).setDepth(depth['controller']);
this.popUp = new NoAction({ scene: this.scene });
this.dim = this.scene.add.rectangle(0, 0, this.scene.game.canvas.width, this.scene.game.canvas.height, 0x0000).setAlpha(0.3).setOrigin(0, 0).setDepth(depth['dim']);
this.startText = this.scene.add.image(this.scene.game.canvas.width / 2, this.scene.game.canvas.height / 2, 'textStart').setDepth(depth['dim']);
this.dim?.on('start', () => {
const event = this.scene.tweens.add({
targets: [this.dim, this.startText],
alpha: 0,
duration: 500,
ease: 'Power1',
onComplete: () => {
event.remove();
this.time >= 90 && this.emit('start');
this.dim?.removeAllListeners('start');
this.dim?.destroy(true);
this.startText.destroy(true);
}
});
});
this.on('run', (time: number) => {
const repeat = time ?? this.time;
if (this.time === 0) return;
if (this.time === 91) {
const delayEvent = this.scene.time.delayedCall(1000, () => {
const timer = this.scene.time.addEvent({
delay: 1000,
repeat: repeat - 1,
callback: () => {
this.time -= 1;
this.emit('update', this.time);
this.emit('syncTime', this.time);
if (this.time === 0) {
timer.destroy();
this.emit('end');
timer.remove();
delayEvent.remove();
}
}
});
});
} else {
const timer = this.scene.time.addEvent({
delay: 1000,
repeat: repeat - 1,
callback: () => {
this.time -= 1;
this.emit('update', this.time);
this.emit('syncTime', this.time);
if (this.time === 0) {
timer.destroy();
this.emit('end');
timer.remove();
}
}
});
}
});
this.on('update', (time: any) => {
if (time === 90 || (time < 90 && this.dim?.alpha === 0.3)) this.dim?.emit('start');
if (time === 10) {
this.countdown.setColor('#BF4326');
this.scene.game.effectSound.play('CCSE12', { loop: true });
}
if (this.activeTime - this.time === 10) this.popUp.group.emit('appear');
if (this.activeTime - this.time >= 15) {
this.scene.scene.pause();
this.emit('message:update');
}
this.countdown.setText(`${time}`);
});
this.on('message:update', () => {
if (this.scene instanceof Main) {
// game log update
this.emit('timeout:post');
}
});
// timeout post;
this.on('timeout:post', () => {
const timeoutEvent = new CustomEvent('timeout', { detail: { gameLog: this.scene.gameLog } });
this.scene.game.canvas.parentElement?.dispatchEvent(timeoutEvent);
});
this.on('setSyncTime', (time: any) => {
this.time = time;
this.emit('update', this.time);
});
this.scene.input.on('pointerdown', () => {
this.activeTime = this.time;
this.popUp.group.emit('disappear');
});
this.scene.input.keyboard.on('keydown', () => {
this.activeTime = this.time;
this.popUp.group.emit('disappear');
});
}
on(ev: string, fn: Function) {
this.countdown.on(ev, fn);
}
emit(ev: string, ...args: any) {
this.countdown.emit(ev, ...args);
}
}
Sorry, but I can't work with this React code. It needs boiling down to something much more simple if we're to look at it I'm afraid.
Also - we've taken the decision to recode the Phaser Audio system from scratch and replace the existing one. This will remove the division between Web Audio and HTML Audio (you'll be able to have both together), along with lots of other required features and API updates. Development work will start in Q2 2024. As a result, we are going to mothball all audio-related issues rather than try to monkey-patch solutions in.