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.
|
||||
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 {
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user