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
+83 -23
View File
@@ -125,11 +125,23 @@ class GameScreen extends StatefulWidget {
/// Declarative host shortcut registry used when [hostShortcutHandler] is null.
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].
const GameScreen({
required this.wolf3d,
this.hostShortcutHandler,
this.hostShortcutRegistry = HostShortcutRegistry.defaults,
this.appExitHandler,
this.skipEngineBootstrapForTest = false,
super.key,
});
@@ -138,11 +150,12 @@ class GameScreen extends StatefulWidget {
}
class _GameScreenState extends State<GameScreen> {
late final WolfEngine _engine;
WolfEngine? _engine;
final DefaultRendererSettingsPersistence _persistence =
DefaultRendererSettingsPersistence();
final DefaultSaveGamePersistence _savePersistence =
DefaultSaveGamePersistence();
Future<void>? _quitFuture;
/// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
RendererMode _rendererMode = RendererMode.hardware;
@@ -150,12 +163,17 @@ class _GameScreenState extends State<GameScreen> {
@override
void initState() {
super.initState();
if (widget.skipEngineBootstrapForTest) {
return;
}
const Set<WolfRendererMode> supportedModes = <WolfRendererMode>{
WolfRendererMode.hardware,
WolfRendererMode.software,
WolfRendererMode.ascii,
};
_engine = widget.wolf3d.launchEngine(
final engine = widget.wolf3d.launchEngine(
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: supportedModes,
supportsAsciiThemes: true,
@@ -175,7 +193,7 @@ class _GameScreenState extends State<GameScreen> {
}
},
onGameWon: () {
_engine.difficulty = null;
_engine?.difficulty = null;
widget.wolf3d.clearActiveDifficulty();
Navigator.of(context).pop();
},
@@ -184,14 +202,21 @@ class _GameScreenState extends State<GameScreen> {
},
saveGamePersistence: _savePersistence,
);
_syncRendererModeFrom(_engine.rendererSettings);
_engine = engine;
_syncRendererModeFrom(engine.rendererSettings);
_loadPersistedSettings();
}
Future<void> _loadPersistedSettings() async {
final engine = _engine;
if (engine == null) {
return;
}
final WolfRendererSettings? saved = await _persistence.load();
if (saved != null && mounted) {
_engine.updateRendererSettings(saved);
engine.updateRendererSettings(saved);
}
}
@@ -217,21 +242,45 @@ class _GameScreenState extends State<GameScreen> {
}
Future<void> _quitApplication() async {
await widget.wolf3d.shutdownAudio();
await SystemNavigator.pop();
final existing = _quitFuture;
if (existing != null) {
await existing;
return;
}
final quit = () async {
await widget.wolf3d.shutdownAudio();
final handler = widget.appExitHandler;
if (handler != null) {
await handler();
} else {
await SystemNavigator.pop();
}
}();
_quitFuture = quit;
await quit;
}
@visibleForTesting
Future<void> debugQuitForTest() => _quitApplication();
@override
Widget build(BuildContext context) {
final engine = _engine;
if (engine == null) {
return const Scaffold(body: SizedBox.shrink());
}
return PopScope(
canPop: _engine.difficulty != null,
canPop: engine.difficulty != null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _engine.difficulty == null) {
if (!didPop && engine.difficulty == null) {
widget.wolf3d.input.queueBackAction();
}
},
child: Scaffold(
floatingActionButton: kDebugMode && _engine.difficulty != null
floatingActionButton: kDebugMode && engine.difficulty != null
? FloatingActionButton(
onPressed: _openDebugTools,
tooltip: 'Open Debug Tools',
@@ -251,7 +300,7 @@ class _GameScreenState extends State<GameScreen> {
children: [
_buildRenderer(),
if (!_engine.isInitialized)
if (!engine.isInitialized)
Container(
color: Colors.black,
child: const Center(
@@ -261,7 +310,7 @@ class _GameScreenState extends State<GameScreen> {
CircularProgressIndicator(color: Colors.teal),
SizedBox(height: 20),
Text(
"GET PSYCHED!",
'GET PSYCHED!',
style: TextStyle(
color: Colors.teal,
fontFamily: 'monospace',
@@ -274,7 +323,7 @@ class _GameScreenState extends State<GameScreen> {
// A second full-screen overlay keeps the presentation simple while
// the engine is still warming up or decoding the first frame.
if (!_engine.isInitialized)
if (!engine.isInitialized)
Container(
color: Colors.black,
child: const Center(
@@ -291,13 +340,18 @@ class _GameScreenState extends State<GameScreen> {
}
Widget _buildRenderer() {
final engine = _engine;
if (engine == null) {
return const SizedBox.shrink();
}
// Keep all renderers behind the same engine so mode switching does not
// reset level state or audio playback.
final WolfRendererSettings settings = _engine.rendererSettings;
final WolfRendererSettings settings = engine.rendererSettings;
switch (_rendererMode) {
case RendererMode.software:
return WolfFlutterRenderer(
engine: _engine,
engine: engine,
onKeyEvent: _handleRendererKeyEvent,
);
case RendererMode.ascii:
@@ -306,13 +360,13 @@ class _GameScreenState extends State<GameScreen> {
? AsciiThemes.quadrant
: AsciiThemes.blocks;
return WolfAsciiRenderer(
engine: _engine,
engine: engine,
theme: theme,
onKeyEvent: _handleRendererKeyEvent,
);
case RendererMode.hardware:
return WolfGlslRenderer(
engine: _engine,
engine: engine,
effectsEnabled: settings.hardwareEffectsEnabled,
bloomEnabled: settings.bloomEnabled,
onKeyEvent: _handleRendererKeyEvent,
@@ -333,20 +387,20 @@ class _GameScreenState extends State<GameScreen> {
}
if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) {
_engine.cycleRendererMode();
_engine?.cycleRendererMode();
return;
}
if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) {
setState(() => _engine.toggleFpsCounter());
setState(() => _engine?.toggleFpsCounter());
return;
}
if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) {
if (_rendererMode == RendererMode.ascii) {
_engine.cycleAsciiTheme();
_engine?.cycleAsciiTheme();
} else if (_rendererMode == RendererMode.hardware) {
_engine.toggleHardwareEffects();
_engine?.toggleHardwareEffects();
}
}
}
@@ -355,8 +409,14 @@ class _GameScreenState extends State<GameScreen> {
if (!mounted || _rendererMode != RendererMode.hardware) {
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);
});
}