feat: Add app exit handler and corresponding tests for audio shutdown

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 17:48:59 +01:00
parent a7353e45b3
commit ae3b0deb04
2 changed files with 182 additions and 23 deletions
+81 -21
View File
@@ -125,11 +125,23 @@ class GameScreen extends StatefulWidget {
/// Declarative host shortcut registry used when [hostShortcutHandler] is null. /// Declarative host shortcut registry used when [hostShortcutHandler] is null.
final HostShortcutRegistry hostShortcutRegistry; final HostShortcutRegistry hostShortcutRegistry;
/// Optional host app-exit hook.
///
/// Defaults to [SystemNavigator.pop] in production, but tests can inject a
/// fake callback to assert quit behavior.
final Future<void> Function()? appExitHandler;
/// Skips engine bootstrap for lightweight widget tests.
@visibleForTesting
final bool skipEngineBootstrapForTest;
/// Creates a gameplay screen driven by [wolf3d]. /// Creates a gameplay screen driven by [wolf3d].
const GameScreen({ const GameScreen({
required this.wolf3d, required this.wolf3d,
this.hostShortcutHandler, this.hostShortcutHandler,
this.hostShortcutRegistry = HostShortcutRegistry.defaults, this.hostShortcutRegistry = HostShortcutRegistry.defaults,
this.appExitHandler,
this.skipEngineBootstrapForTest = false,
super.key, super.key,
}); });
@@ -138,11 +150,12 @@ class GameScreen extends StatefulWidget {
} }
class _GameScreenState extends State<GameScreen> { class _GameScreenState extends State<GameScreen> {
late final WolfEngine _engine; WolfEngine? _engine;
final DefaultRendererSettingsPersistence _persistence = final DefaultRendererSettingsPersistence _persistence =
DefaultRendererSettingsPersistence(); DefaultRendererSettingsPersistence();
final DefaultSaveGamePersistence _savePersistence = final DefaultSaveGamePersistence _savePersistence =
DefaultSaveGamePersistence(); DefaultSaveGamePersistence();
Future<void>? _quitFuture;
/// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum. /// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
RendererMode _rendererMode = RendererMode.hardware; RendererMode _rendererMode = RendererMode.hardware;
@@ -150,12 +163,17 @@ class _GameScreenState extends State<GameScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.skipEngineBootstrapForTest) {
return;
}
const Set<WolfRendererMode> supportedModes = <WolfRendererMode>{ const Set<WolfRendererMode> supportedModes = <WolfRendererMode>{
WolfRendererMode.hardware, WolfRendererMode.hardware,
WolfRendererMode.software, WolfRendererMode.software,
WolfRendererMode.ascii, WolfRendererMode.ascii,
}; };
_engine = widget.wolf3d.launchEngine(
final engine = widget.wolf3d.launchEngine(
rendererCapabilities: const WolfRendererCapabilities( rendererCapabilities: const WolfRendererCapabilities(
supportedModes: supportedModes, supportedModes: supportedModes,
supportsAsciiThemes: true, supportsAsciiThemes: true,
@@ -175,7 +193,7 @@ class _GameScreenState extends State<GameScreen> {
} }
}, },
onGameWon: () { onGameWon: () {
_engine.difficulty = null; _engine?.difficulty = null;
widget.wolf3d.clearActiveDifficulty(); widget.wolf3d.clearActiveDifficulty();
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
@@ -184,14 +202,21 @@ class _GameScreenState extends State<GameScreen> {
}, },
saveGamePersistence: _savePersistence, saveGamePersistence: _savePersistence,
); );
_syncRendererModeFrom(_engine.rendererSettings);
_engine = engine;
_syncRendererModeFrom(engine.rendererSettings);
_loadPersistedSettings(); _loadPersistedSettings();
} }
Future<void> _loadPersistedSettings() async { Future<void> _loadPersistedSettings() async {
final engine = _engine;
if (engine == null) {
return;
}
final WolfRendererSettings? saved = await _persistence.load(); final WolfRendererSettings? saved = await _persistence.load();
if (saved != null && mounted) { if (saved != null && mounted) {
_engine.updateRendererSettings(saved); engine.updateRendererSettings(saved);
} }
} }
@@ -217,21 +242,45 @@ class _GameScreenState extends State<GameScreen> {
} }
Future<void> _quitApplication() async { Future<void> _quitApplication() async {
final existing = _quitFuture;
if (existing != null) {
await existing;
return;
}
final quit = () async {
await widget.wolf3d.shutdownAudio(); await widget.wolf3d.shutdownAudio();
final handler = widget.appExitHandler;
if (handler != null) {
await handler();
} else {
await SystemNavigator.pop(); await SystemNavigator.pop();
} }
}();
_quitFuture = quit;
await quit;
}
@visibleForTesting
Future<void> debugQuitForTest() => _quitApplication();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final engine = _engine;
if (engine == null) {
return const Scaffold(body: SizedBox.shrink());
}
return PopScope( return PopScope(
canPop: _engine.difficulty != null, canPop: engine.difficulty != null,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop && _engine.difficulty == null) { if (!didPop && engine.difficulty == null) {
widget.wolf3d.input.queueBackAction(); widget.wolf3d.input.queueBackAction();
} }
}, },
child: Scaffold( child: Scaffold(
floatingActionButton: kDebugMode && _engine.difficulty != null floatingActionButton: kDebugMode && engine.difficulty != null
? FloatingActionButton( ? FloatingActionButton(
onPressed: _openDebugTools, onPressed: _openDebugTools,
tooltip: 'Open Debug Tools', tooltip: 'Open Debug Tools',
@@ -251,7 +300,7 @@ class _GameScreenState extends State<GameScreen> {
children: [ children: [
_buildRenderer(), _buildRenderer(),
if (!_engine.isInitialized) if (!engine.isInitialized)
Container( Container(
color: Colors.black, color: Colors.black,
child: const Center( child: const Center(
@@ -261,7 +310,7 @@ class _GameScreenState extends State<GameScreen> {
CircularProgressIndicator(color: Colors.teal), CircularProgressIndicator(color: Colors.teal),
SizedBox(height: 20), SizedBox(height: 20),
Text( Text(
"GET PSYCHED!", 'GET PSYCHED!',
style: TextStyle( style: TextStyle(
color: Colors.teal, color: Colors.teal,
fontFamily: 'monospace', fontFamily: 'monospace',
@@ -274,7 +323,7 @@ class _GameScreenState extends State<GameScreen> {
// A second full-screen overlay keeps the presentation simple while // A second full-screen overlay keeps the presentation simple while
// the engine is still warming up or decoding the first frame. // the engine is still warming up or decoding the first frame.
if (!_engine.isInitialized) if (!engine.isInitialized)
Container( Container(
color: Colors.black, color: Colors.black,
child: const Center( child: const Center(
@@ -291,13 +340,18 @@ class _GameScreenState extends State<GameScreen> {
} }
Widget _buildRenderer() { Widget _buildRenderer() {
final engine = _engine;
if (engine == null) {
return const SizedBox.shrink();
}
// Keep all renderers behind the same engine so mode switching does not // Keep all renderers behind the same engine so mode switching does not
// reset level state or audio playback. // reset level state or audio playback.
final WolfRendererSettings settings = _engine.rendererSettings; final WolfRendererSettings settings = engine.rendererSettings;
switch (_rendererMode) { switch (_rendererMode) {
case RendererMode.software: case RendererMode.software:
return WolfFlutterRenderer( return WolfFlutterRenderer(
engine: _engine, engine: engine,
onKeyEvent: _handleRendererKeyEvent, onKeyEvent: _handleRendererKeyEvent,
); );
case RendererMode.ascii: case RendererMode.ascii:
@@ -306,13 +360,13 @@ class _GameScreenState extends State<GameScreen> {
? AsciiThemes.quadrant ? AsciiThemes.quadrant
: AsciiThemes.blocks; : AsciiThemes.blocks;
return WolfAsciiRenderer( return WolfAsciiRenderer(
engine: _engine, engine: engine,
theme: theme, theme: theme,
onKeyEvent: _handleRendererKeyEvent, onKeyEvent: _handleRendererKeyEvent,
); );
case RendererMode.hardware: case RendererMode.hardware:
return WolfGlslRenderer( return WolfGlslRenderer(
engine: _engine, engine: engine,
effectsEnabled: settings.hardwareEffectsEnabled, effectsEnabled: settings.hardwareEffectsEnabled,
bloomEnabled: settings.bloomEnabled, bloomEnabled: settings.bloomEnabled,
onKeyEvent: _handleRendererKeyEvent, onKeyEvent: _handleRendererKeyEvent,
@@ -333,20 +387,20 @@ class _GameScreenState extends State<GameScreen> {
} }
if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) { if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) {
_engine.cycleRendererMode(); _engine?.cycleRendererMode();
return; return;
} }
if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) { if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) {
setState(() => _engine.toggleFpsCounter()); setState(() => _engine?.toggleFpsCounter());
return; return;
} }
if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) { if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) {
if (_rendererMode == RendererMode.ascii) { if (_rendererMode == RendererMode.ascii) {
_engine.cycleAsciiTheme(); _engine?.cycleAsciiTheme();
} else if (_rendererMode == RendererMode.hardware) { } else if (_rendererMode == RendererMode.hardware) {
_engine.toggleHardwareEffects(); _engine?.toggleHardwareEffects();
} }
} }
} }
@@ -355,8 +409,14 @@ class _GameScreenState extends State<GameScreen> {
if (!mounted || _rendererMode != RendererMode.hardware) { if (!mounted || _rendererMode != RendererMode.hardware) {
return; return;
} }
_engine.updateRendererSettings(
_engine.rendererSettings.copyWith(mode: WolfRendererMode.software), final engine = _engine;
if (engine == null) {
return;
}
engine.updateRendererSettings(
engine.rendererSettings.copyWith(mode: WolfRendererMode.software),
); );
} }
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/game_screen.dart';
class _CountingAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
int stopAllAudioCallCount = 0;
int disposeCallCount = 0;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
Future<void> stopAllAudio() async {
stopAllAudioCallCount++;
await Future<void>.delayed(const Duration(milliseconds: 1));
}
@override
void stopMusic() {}
@override
void dispose() {
disposeCallCount++;
}
}
void main() {
testWidgets('quit path shuts down audio once and exits once', (tester) async {
final audio = _CountingAudio();
final wolf3d = Wolf3d(audioBackend: audio);
int appExitCallCount = 0;
await tester.pumpWidget(
MaterialApp(
home: GameScreen(
wolf3d: wolf3d,
skipEngineBootstrapForTest: true,
appExitHandler: () async {
appExitCallCount++;
},
),
),
);
final dynamic state = tester.state(find.byType(GameScreen));
await Future.wait<void>([
state.debugQuitForTest() as Future<void>,
state.debugQuitForTest() as Future<void>,
state.debugQuitForTest() as Future<void>,
]);
expect(audio.stopAllAudioCallCount, 1);
expect(audio.disposeCallCount, 1);
expect(appExitCallCount, 1);
});
testWidgets('dispose path shuts down audio', (tester) async {
final audio = _CountingAudio();
final wolf3d = Wolf3d(audioBackend: audio);
await tester.pumpWidget(
MaterialApp(
home: GameScreen(
wolf3d: wolf3d,
skipEngineBootstrapForTest: true,
appExitHandler: () async {},
),
),
);
await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink()));
await tester.pump(const Duration(milliseconds: 10));
expect(audio.stopAllAudioCallCount, 1);
expect(audio.disposeCallCount, 1);
});
}