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:
@@ -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 {
|
||||||
await widget.wolf3d.shutdownAudio();
|
final existing = _quitFuture;
|
||||||
await SystemNavigator.pop();
|
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
|
@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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user