Moved all widgets and logic from gui app to Flutter package

- Implemented DebugToolsScreen for navigation to asset galleries.
- Created GameScreen to manage gameplay and renderer integrations.
- Added NoGameDataScreen to handle scenarios with missing game data.
- Developed SpriteGallery for visual browsing of sprite assets.
- Introduced VgaGallery for displaying VGA images from game data.
- Added GalleryGameSelector widget for selecting game variants in galleries.
- Created Wolf3dApp as the main application shell for managing game states.
- Implemented WolfMenuShell for consistent menu layouts across screens.
- Enhanced Wolf3d class to support debug mode and related functionalities.
- Updated pubspec.yaml to include window_manager dependency.
- Added tests for game screen lifecycle and debug mode functionalities.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 18:44:32 +01:00
parent cbe2633ceb
commit 5a2681e89b
21 changed files with 963 additions and 590 deletions
@@ -0,0 +1,17 @@
library;
import 'package:flutter/foundation.dart';
/// Whether desktop window-management APIs are expected to work on this host.
bool get supportsDesktopWindowing {
if (kIsWeb) {
return false;
}
return switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
}
@@ -0,0 +1,42 @@
library;
import 'package:flutter/services.dart';
/// Coordinates host application shutdown for gameplay sessions.
class GameAppLifecycleManager {
/// Creates a lifecycle manager.
GameAppLifecycleManager({
required this.shutdownAudio,
this.appExitHandler,
});
/// Callback used to stop and dispose shared audio resources.
final Future<void> Function() shutdownAudio;
/// Optional host-specific app exit callback.
final Future<void> Function()? appExitHandler;
Future<void>? _quitFuture;
/// Shuts down audio and then exits the host app once.
Future<void> quitApplication() async {
final Future<void>? existing = _quitFuture;
if (existing != null) {
await existing;
return;
}
final Future<void> quit = () async {
await shutdownAudio();
final Future<void> Function()? handler = appExitHandler;
if (handler != null) {
await handler();
} else {
await SystemNavigator.pop();
}
}();
_quitFuture = quit;
await quit;
}
}
@@ -0,0 +1,36 @@
library;
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import 'package:wolf_3d_flutter/managers/desktop_windowing_support.dart'
as support;
/// Handles host display/window operations for gameplay screens.
class GameDisplayManager {
/// Creates a display manager.
GameDisplayManager({WindowManager? windowing})
: windowing = windowing ?? windowManager;
/// Window manager instance used for desktop host operations.
final WindowManager windowing;
/// Toggles desktop fullscreen state when supported by the host platform.
///
/// This no-ops on unsupported targets and safely ignores missing plugin
/// hosts to keep gameplay input resilient in embedded/test environments.
Future<void> toggleFullscreen() async {
if (!supportsDesktopWindowing) {
return;
}
try {
final bool isFullScreen = await windowing.isFullScreen();
await windowing.setFullScreen(!isFullScreen);
} on MissingPluginException {
// No-op on hosts where the window manager plugin is unavailable.
}
}
/// Whether runtime desktop window management APIs are expected to work.
bool get supportsDesktopWindowing => support.supportsDesktopWindowing;
}
@@ -0,0 +1,42 @@
library;
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Coordinates gameplay persistence concerns for Flutter hosts.
class GamePersistenceManager {
/// Creates persistence manager dependencies with overridable adapters.
GamePersistenceManager({
RendererSettingsPersistence? rendererSettingsPersistence,
SaveGamePersistence? saveGamePersistence,
}) : rendererSettingsPersistence =
rendererSettingsPersistence ?? DefaultRendererSettingsPersistence(),
saveGamePersistence =
saveGamePersistence ?? DefaultSaveGamePersistence();
/// Persists and restores runtime renderer settings.
final RendererSettingsPersistence rendererSettingsPersistence;
/// Persists slot-based save game snapshots.
final SaveGamePersistence saveGamePersistence;
/// Loads previously persisted renderer settings.
Future<WolfRendererSettings?> loadRendererSettings() {
return rendererSettingsPersistence.load();
}
/// Loads persisted renderer settings and applies them to [engine].
Future<WolfRendererSettings?> restoreRendererSettings(
WolfEngine engine,
) async {
final WolfRendererSettings? saved = await loadRendererSettings();
if (saved != null) {
engine.updateRendererSettings(saved);
}
return saved;
}
/// Saves current renderer settings.
Future<void> saveRendererSettings(WolfRendererSettings settings) {
return rendererSettingsPersistence.save(settings);
}
}
@@ -0,0 +1,44 @@
library;
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Renderer presentation mode used by Flutter host widgets.
enum GameRendererMode {
/// Software pixel renderer presented via decoded framebuffer images.
software,
/// Text-mode renderer for debugging and retro terminal aesthetics.
ascii,
/// GLSL renderer with optional CRT-style post processing.
hardware,
}
/// Maps engine renderer settings to host renderer presentation mode.
GameRendererMode gameRendererModeFromSettings(WolfRendererSettings settings) {
return switch (settings.mode) {
WolfRendererMode.hardware => GameRendererMode.hardware,
WolfRendererMode.software => GameRendererMode.software,
WolfRendererMode.ascii || WolfRendererMode.sixel => GameRendererMode.ascii,
};
}
/// Falls back to software mode when GLSL rendering is unavailable at runtime.
void handleGlslUnavailable({
required bool isMounted,
required GameRendererMode rendererMode,
required WolfEngine? engine,
}) {
if (!isMounted || rendererMode != GameRendererMode.hardware) {
return;
}
final WolfEngine? activeEngine = engine;
if (activeEngine == null) {
return;
}
activeEngine.updateRendererSettings(
activeEngine.rendererSettings.copyWith(mode: WolfRendererMode.software),
);
}
@@ -0,0 +1,217 @@
library;
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_flutter/managers/game_renderer_mode_manager.dart';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
/// Semantic actions that host-level shortcuts can trigger.
///
/// These intents are intentionally UI-host focused (windowing, app shell), not
/// engine gameplay actions. The engine continues to receive input through
/// [Wolf3dFlutterInput].
enum HostShortcutIntent {
/// Toggle desktop fullscreen on/off.
toggleFullscreen,
}
/// Declarative mapping from a key pattern to a host shortcut intent.
///
/// [matches] identifies whether a key event should trigger this binding.
/// [suppressedActions] are one-frame engine actions that should be blocked
/// when the binding is consumed (for example, blocking `interact` on Alt+Enter
/// so Enter does not activate menu selections).
class HostShortcutBinding {
/// Predicate that returns true when this shortcut should fire.
final bool Function(KeyEvent event) matches;
/// Host operation to perform when [matches] succeeds.
final HostShortcutIntent intent;
/// Engine actions to suppress for a single input update tick.
final Set<WolfInputAction> suppressedActions;
/// Creates a host shortcut binding with optional suppressed engine actions.
const HostShortcutBinding({
required this.matches,
required this.intent,
this.suppressedActions = const <WolfInputAction>{},
});
}
/// Ordered set of host shortcut bindings.
///
/// The first binding whose [HostShortcutBinding.matches] returns true wins.
/// This keeps behavior deterministic when multiple shortcuts could overlap.
class HostShortcutRegistry {
/// Ordered bindings consulted for each incoming key-down event.
final List<HostShortcutBinding> bindings;
/// Creates a registry with explicit [bindings].
const HostShortcutRegistry({
required this.bindings,
});
/// Returns the first binding that matches [event], or null when none do.
HostShortcutBinding? match(KeyEvent event) {
for (final HostShortcutBinding binding in bindings) {
if (binding.matches(event)) {
return binding;
}
}
return null;
}
/// Default host shortcuts used by [GameScreenInputManager].
///
/// Alt+Enter toggles fullscreen and suppresses the engine `interact` action
/// for one frame so Enter does not also activate menu/game interactions.
static const HostShortcutRegistry defaults = HostShortcutRegistry(
bindings: <HostShortcutBinding>[
HostShortcutBinding(
matches: isAltEnterShortcut,
intent: HostShortcutIntent.toggleFullscreen,
suppressedActions: <WolfInputAction>{WolfInputAction.interact},
),
],
);
}
/// Optional imperative host shortcut override.
///
/// Return true when the event was fully handled. The handler receives the
/// shared [Wolf3dFlutterInput] so it can suppress engine actions as needed.
typedef HostShortcutHandler =
bool Function(
KeyEvent event,
Wolf3dFlutterInput input,
);
/// Coordinates keyboard shortcut processing for gameplay renderer hosts.
class GameScreenInputManager {
/// Creates an input/shortcut manager for [input].
const GameScreenInputManager({
required this.input,
this.hostShortcutHandler,
this.hostShortcutRegistry = HostShortcutRegistry.defaults,
required this.onToggleFullscreen,
});
/// Shared engine input adapter.
final Wolf3dFlutterInput input;
/// Optional imperative host shortcut override.
final HostShortcutHandler? hostShortcutHandler;
/// Declarative fallback host shortcut registry.
final HostShortcutRegistry hostShortcutRegistry;
/// Callback invoked when fullscreen intent is triggered.
final Future<void> Function() onToggleFullscreen;
/// Handles key-down shortcut and renderer actions for [engine].
void handleRendererKeyEvent({
required KeyEvent event,
required WolfEngine engine,
required bool isAsciiRenderer,
required bool isHardwareRenderer,
required void Function() onFpsToggled,
}) {
if (event is! KeyDownEvent) {
return;
}
// Host shortcuts must be processed before game actions so they can
// suppress overlapping keys (for example Alt+Enter consuming Enter).
if (_handleHostShortcut(event)) {
return;
}
if (event.logicalKey == input.rendererToggleKey) {
engine.cycleRendererMode();
return;
}
if (event.logicalKey == input.fpsToggleKey) {
onFpsToggled();
return;
}
if (event.logicalKey == input.asciiThemeCycleKey) {
if (isAsciiRenderer) {
engine.cycleAsciiTheme();
} else if (isHardwareRenderer) {
engine.toggleHardwareEffects();
}
}
}
/// Handles key-down events for a host screen with nullable [engine].
///
/// This owns host-side renderer mode branching so host widgets can wire key
/// events directly without private forwarding methods.
void handleRendererKeyEventForHost({
required KeyEvent event,
required WolfEngine? engine,
required GameRendererMode rendererMode,
required void Function(WolfEngine engine) onFpsToggled,
}) {
final WolfEngine? activeEngine = engine;
if (activeEngine == null) {
return;
}
handleRendererKeyEvent(
event: event,
engine: activeEngine,
isAsciiRenderer: rendererMode == GameRendererMode.ascii,
isHardwareRenderer: rendererMode == GameRendererMode.hardware,
onFpsToggled: () => onFpsToggled(activeEngine),
);
}
bool _handleHostShortcut(KeyEvent event) {
final HostShortcutHandler? customHandler = hostShortcutHandler;
if (customHandler != null) {
// Custom handlers take full precedence to support future menu-driven
// rebinding/override systems without modifying this screen.
return customHandler(event, input);
}
final HostShortcutBinding? binding = hostShortcutRegistry.match(event);
if (binding == null) {
return false;
}
// Suppress conflicting gameplay/menu actions for one update frame.
for (final WolfInputAction action in binding.suppressedActions) {
input.suppressActionOnce(action);
}
switch (binding.intent) {
case HostShortcutIntent.toggleFullscreen:
unawaited(onToggleFullscreen());
}
return true;
}
}
/// Returns true when [event] is Enter/NumpadEnter while Alt is pressed.
bool isAltEnterShortcut(KeyEvent event) {
final bool isEnter =
event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.numpadEnter;
if (!isEnter) {
return false;
}
final Set<LogicalKeyboardKey> pressedKeys =
HardwareKeyboard.instance.logicalKeysPressed;
return pressedKeys.contains(LogicalKeyboardKey.altLeft) ||
pressedKeys.contains(LogicalKeyboardKey.altRight) ||
pressedKeys.contains(LogicalKeyboardKey.alt);
}
@@ -0,0 +1,383 @@
/// Debug browser for SFX and music assets with playback controls.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
class _AudioRow {
final int id;
final List<String> aliases;
const _AudioRow({required this.id, required this.aliases});
String get subtitle {
if (aliases.isEmpty) {
return 'No known alias';
}
return aliases.join(', ');
}
}
/// Displays all decoded SFX and music tracks for the selected game data.
class AudioGallery extends StatefulWidget {
/// Shared app facade used to access game assets and the audio backend.
final Wolf3d wolf3d;
const AudioGallery({super.key, required this.wolf3d});
@override
State<AudioGallery> createState() => _AudioGalleryState();
}
class _AudioGalleryState extends State<AudioGallery> {
late WolfensteinData _selectedGame;
int? _playingMusicTrackIndex;
int _gridColumnsForWidth(double width) {
if (width >= 1400) return 6;
if (width >= 1100) return 5;
if (width >= 800) return 4;
if (width >= 560) return 3;
return 2;
}
@override
void initState() {
super.initState();
_selectedGame =
widget.wolf3d.maybeActiveGame ?? widget.wolf3d.availableGames.first;
}
@override
void dispose() {
// Ensure debug playback does not continue after closing the gallery.
unawaited(widget.wolf3d.audio.stopAllAudio());
super.dispose();
}
Map<int, List<String>> _buildSfxAliases() {
final Map<int, Set<String>> aliasesById = {};
for (final key in SoundEffect.values) {
final ref = _selectedGame.registry.sfx.resolve(key);
if (ref == null) {
continue;
}
aliasesById
.putIfAbsent(ref.slotIndex, () => <String>{})
.add(_readableKeyName(key.name));
}
return aliasesById.map(
(id, aliases) => MapEntry(id, aliases.toList()..sort()),
);
}
Map<int, List<String>> _buildMusicAliases() {
final Map<int, Set<String>> aliasesById = {};
for (final key in Music.values) {
final route = _selectedGame.registry.music.resolve(key);
if (route == null) {
continue;
}
aliasesById
.putIfAbsent(route.trackIndex, () => <String>{})
.add(_readableKeyName(key.name));
}
return aliasesById.map(
(id, aliases) => MapEntry(id, aliases.toList()..sort()),
);
}
String _readableKeyName(String raw) {
if (raw.isEmpty) {
return raw;
}
return raw.replaceAllMapped(
RegExp(r'([a-z0-9])([A-Z])'),
(match) => '${match.group(1)} ${match.group(2)}',
);
}
Future<void> _stopAllAudioPlayback() async {
await widget.wolf3d.audio.stopAllAudio();
if (!mounted) {
return;
}
setState(() {
_playingMusicTrackIndex = null;
});
}
Future<void> _selectGame(WolfensteinData game) async {
if (identical(_selectedGame, game)) {
return;
}
await _stopAllAudioPlayback();
widget.wolf3d.setActiveGame(game);
if (!mounted) {
return;
}
setState(() {
_selectedGame = game;
_playingMusicTrackIndex = null;
});
}
void _playSfx(int id) {
widget.wolf3d.audio.playSoundEffectId(id);
}
Future<void> _toggleMusic(int trackIndex) async {
if (_playingMusicTrackIndex == trackIndex) {
await _stopAllAudioPlayback();
return;
}
final engineAudio = widget.wolf3d.audio;
if (engineAudio is! DebugMusicPlayer) {
return;
}
final debugAudio = engineAudio as DebugMusicPlayer;
if (trackIndex < 0 || trackIndex >= _selectedGame.music.length) {
return;
}
await debugAudio.playMusic(_selectedGame.music[trackIndex]);
if (!mounted) {
return;
}
setState(() {
_playingMusicTrackIndex = trackIndex;
});
}
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('Audio Gallery'),
actions: [
if (widget.wolf3d.availableGames.length > 1)
GalleryGameSelector(
wolf3d: widget.wolf3d,
selectedGame: _selectedGame,
onSelected: (game) {
unawaited(_selectGame(game));
},
),
],
bottom: const TabBar(
tabs: [
Tab(icon: Icon(Icons.graphic_eq), text: 'SFX'),
Tab(icon: Icon(Icons.music_note), text: 'Music'),
],
),
),
body: TabBarView(
children: [
_buildSfxTab(),
_buildMusicTab(),
],
),
),
);
}
Widget _buildSfxTab() {
final aliasesById = _buildSfxAliases();
final rows = List<_AudioRow>.generate(
_selectedGame.sounds.length,
(id) => _AudioRow(id: id, aliases: aliasesById[id] ?? const []),
);
if (rows.isEmpty) {
return const Center(child: Text('No SFX available in this game data.'));
}
return Column(
children: [
const ListTile(
leading: Icon(Icons.info_outline),
title: Text('Tap any SFX to play it once'),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final columns = _gridColumnsForWidth(constraints.maxWidth);
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2.8,
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _playSfx(row.id),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
CircleAvatar(child: Text('${row.id}')),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SFX ${row.id}',
style: Theme.of(
context,
).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
row.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
),
),
const Icon(Icons.play_arrow),
],
),
),
),
);
},
);
},
),
),
],
);
}
Widget _buildMusicTab() {
final aliasesById = _buildMusicAliases();
final rows = List<_AudioRow>.generate(
_selectedGame.music.length,
(id) => _AudioRow(id: id, aliases: aliasesById[id] ?? const []),
);
if (rows.isEmpty) {
return const Center(
child: Text('No music tracks available in this game data.'),
);
}
return Column(
children: [
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Tap a track to play, tap again to stop'),
subtitle: Text(
_playingMusicTrackIndex == null
? 'No track currently playing'
: 'Playing track ${_playingMusicTrackIndex!}',
),
trailing: FilledButton.icon(
onPressed: _playingMusicTrackIndex == null
? null
: () {
unawaited(_stopAllAudioPlayback());
},
icon: const Icon(Icons.stop),
label: const Text('Stop'),
),
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final columns = _gridColumnsForWidth(constraints.maxWidth);
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 2.8,
),
itemCount: rows.length,
itemBuilder: (context, index) {
final row = rows[index];
final bool isPlaying = _playingMusicTrackIndex == row.id;
return Card(
color: isPlaying
? Theme.of(context).colorScheme.primaryContainer
: null,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
unawaited(_toggleMusic(row.id));
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
child: Row(
children: [
CircleAvatar(child: Text('${row.id}')),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Track ${row.id}',
style: Theme.of(
context,
).textTheme.titleSmall,
),
const SizedBox(height: 2),
Text(
row.subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.bodySmall,
),
],
),
),
Icon(isPlaying ? Icons.stop : Icons.play_arrow),
],
),
),
),
);
},
);
},
),
),
],
);
}
}
@@ -0,0 +1,79 @@
/// Debug tools launcher for art and asset inspection screens.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Presents debug-only navigation shortcuts for asset galleries.
class DebugToolsScreen extends StatelessWidget {
/// Shared app facade used to access active game assets.
final Wolf3d wolf3d;
/// Creates the debug tools screen for [wolf3d].
const DebugToolsScreen({super.key, required this.wolf3d});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Debug Tools'),
automaticallyImplyLeading: true,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'Asset Galleries',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
Card(
child: ListTile(
leading: const Icon(Icons.library_music),
title: const Text('Audio Gallery'),
subtitle: const Text('Browse and test SFX and music tracks.'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => AudioGallery(wolf3d: wolf3d),
),
);
},
),
),
Card(
child: ListTile(
leading: const Icon(Icons.image_search),
title: const Text('Sprite Gallery'),
subtitle: const Text('Browse decoded sprite frames.'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SpriteGallery(wolf3d: wolf3d),
),
);
},
),
),
Card(
child: ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('VGA Gallery'),
subtitle: const Text('Browse decoded VGA images.'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => VgaGallery(wolf3d: wolf3d),
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,253 @@
/// Active gameplay screen for the Flutter host.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_flutter/renderer/wolf_3d_ascii_renderer.dart';
import 'package:wolf_3d_flutter/renderer/wolf_3d_flutter_renderer.dart';
import 'package:wolf_3d_flutter/renderer/wolf_3d_glsl_renderer.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Launches a [WolfEngine] via [Wolf3d] and exposes renderer/input integrations.
class GameScreen extends StatefulWidget {
/// Shared application facade owning the engine, audio, and input.
final Wolf3d wolf3d;
/// Optional host-level shortcut override.
///
/// Return `true` when the event was consumed. Handlers may call
/// [Wolf3dFlutterInput.suppressActionOnce] to keep actions from reaching the
/// engine update loop.
final HostShortcutHandler? hostShortcutHandler;
/// 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,
});
@override
State<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends State<GameScreen> {
WolfEngine? _engine;
late final GameAppLifecycleManager _appLifecycleManager;
late final GameDisplayManager _displayManager;
late final GameScreenInputManager _inputManager;
late final GamePersistenceManager _persistenceManager;
/// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
GameRendererMode _rendererMode = GameRendererMode.hardware;
@override
void initState() {
super.initState();
_appLifecycleManager = GameAppLifecycleManager(
shutdownAudio: widget.wolf3d.shutdownAudio,
appExitHandler: widget.appExitHandler,
);
_displayManager = GameDisplayManager();
_persistenceManager = GamePersistenceManager();
_inputManager = GameScreenInputManager(
input: widget.wolf3d.input,
hostShortcutHandler: widget.hostShortcutHandler,
hostShortcutRegistry: widget.hostShortcutRegistry,
onToggleFullscreen: _displayManager.toggleFullscreen,
);
if (widget.skipEngineBootstrapForTest) {
return;
}
const Set<WolfRendererMode> supportedModes = <WolfRendererMode>{
WolfRendererMode.hardware,
WolfRendererMode.software,
WolfRendererMode.ascii,
};
final engine = widget.wolf3d.launchEngine(
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: supportedModes,
supportsAsciiThemes: true,
supportsHardwareEffects: true,
supportsBloom: true,
supportsFpsCounter: true,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.hardware,
),
onRendererSettingsChanged: (settings) {
unawaited(_persistenceManager.saveRendererSettings(settings));
if (mounted) {
setState(() {
_rendererMode = gameRendererModeFromSettings(settings);
});
}
},
onGameWon: () {
_engine?.difficulty = null;
widget.wolf3d.clearActiveDifficulty();
Navigator.of(context).pop();
},
onQuit: () {
unawaited(_appLifecycleManager.quitApplication());
},
saveGamePersistence: _persistenceManager.saveGamePersistence,
);
_engine = engine;
_rendererMode = gameRendererModeFromSettings(engine.rendererSettings);
unawaited(_persistenceManager.restoreRendererSettings(engine));
}
@override
void dispose() {
unawaited(widget.wolf3d.shutdownAudio());
super.dispose();
}
@override
Widget build(BuildContext context) {
final engine = _engine;
if (engine == null) {
return const Scaffold(body: SizedBox.shrink());
}
final WolfRendererSettings settings = engine.rendererSettings;
final Widget renderer = switch (_rendererMode) {
GameRendererMode.software => WolfFlutterRenderer(
engine: engine,
onKeyEvent: _handleRendererKeyEvent,
),
GameRendererMode.ascii => WolfAsciiRenderer(
engine: engine,
theme: settings.asciiThemeId == WolfRendererSettings.asciiThemeQuadrant
? AsciiThemes.quadrant
: AsciiThemes.blocks,
onKeyEvent: _handleRendererKeyEvent,
),
GameRendererMode.hardware => WolfGlslRenderer(
engine: engine,
effectsEnabled: settings.hardwareEffectsEnabled,
bloomEnabled: settings.bloomEnabled,
onKeyEvent: _handleRendererKeyEvent,
onUnavailable: _handleGlslUnavailable,
),
};
return PopScope(
canPop: engine.difficulty != null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && engine.difficulty == null) {
widget.wolf3d.input.queueBackAction();
}
},
child: Scaffold(
floatingActionButton:
widget.wolf3d.isDebugEnabled && engine.difficulty != null
? FloatingActionButton(
onPressed: _openDebugTools,
tooltip: 'Open Debug Tools',
child: const Icon(Icons.bug_report),
)
: null,
body: LayoutBuilder(
builder: (context, constraints) {
return Listener(
onPointerDown: (event) {
widget.wolf3d.input.onPointerDown(event);
},
onPointerUp: widget.wolf3d.input.onPointerUp,
onPointerMove: widget.wolf3d.input.onPointerMove,
onPointerHover: widget.wolf3d.input.onPointerMove,
child: Stack(
children: [
renderer,
if (!engine.isInitialized)
Container(
color: Colors.black,
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: Colors.teal),
SizedBox(height: 20),
Text(
'GET PSYCHED!',
style: TextStyle(
color: Colors.teal,
fontFamily: 'monospace',
),
),
],
),
),
),
// A second full-screen overlay keeps the presentation simple while
// the engine is still warming up or decoding the first frame.
if (!engine.isInitialized)
Container(
color: Colors.black,
child: const Center(
child: CircularProgressIndicator(color: Colors.teal),
),
),
],
),
);
},
),
),
);
}
void _handleRendererKeyEvent(KeyEvent event) {
_inputManager.handleRendererKeyEventForHost(
event: event,
engine: _engine,
rendererMode: _rendererMode,
onFpsToggled: (WolfEngine activeEngine) {
setState(() => activeEngine.toggleFpsCounter());
},
);
}
void _handleGlslUnavailable() {
handleGlslUnavailable(
isMounted: mounted,
rendererMode: _rendererMode,
engine: _engine,
);
}
void _openDebugTools() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => DebugToolsScreen(wolf3d: widget.wolf3d),
),
);
}
}
@@ -0,0 +1,60 @@
library;
import 'package:flutter/material.dart';
/// Fallback screen shown when no Wolf3D game data files are discovered.
class NoGameDataScreen extends StatelessWidget {
const NoGameDataScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF140000),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF590002),
border: Border.all(color: const Color(0xFFB00000), width: 2),
),
child: const Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WOLF3D DATA NOT FOUND',
style: TextStyle(
color: Color(0xFFFFF700),
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 16),
Text(
'No game files were discovered.\n\n'
'Add Wolfenstein 3D data files to one of these locations:\n'
'- packages/wolf_3d_assets/assets/retail\n'
'- packages/wolf_3d_assets/assets/shareware\n'
'- or a discoverable local game-data folder.\n\n'
'Restart the app after adding the files.',
style: TextStyle(
color: Colors.white,
fontSize: 15,
height: 1.4,
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,154 @@
/// Visual browser for decoded sprite assets and their inferred gameplay roles.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
import 'package:wolf_3d_flutter/renderer/wolf_3d_asset_painter.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Displays every sprite frame in the active game along with enemy metadata.
class SpriteGallery extends StatefulWidget {
/// Shared application facade used to access the active game's sprite set.
final Wolf3d wolf3d;
/// Creates the sprite gallery for [wolf3d].
const SpriteGallery({super.key, required this.wolf3d});
@override
State<SpriteGallery> createState() => _SpriteGalleryState();
}
class _SpriteGalleryState extends State<SpriteGallery> {
late WolfensteinData _selectedGame;
@override
void initState() {
super.initState();
_selectedGame =
widget.wolf3d.maybeActiveGame ?? widget.wolf3d.availableGames.first;
}
bool get isShareware => _selectedGame.version == GameVersion.shareware;
List<Sprite> get _sprites => _selectedGame.sprites;
void _selectGame(WolfensteinData game) {
if (identical(_selectedGame, game)) {
return;
}
widget.wolf3d.setActiveGame(game);
setState(() {
_selectedGame = game;
});
}
String _buildSpriteLabel(int index) {
String label = 'Sprite Index: $index';
for (final enemy in EnemyType.values) {
// The gallery infers likely ownership from sprite index ranges so
// debugging art packs does not require cross-referencing source.
if (enemy.claimsSpriteIndex(index, isShareware: isShareware)) {
final EnemyAnimation? animation = enemy.getAnimationFromSprite(
index,
isShareware: isShareware,
);
label += '\n${enemy.name}';
if (animation != null) {
label += '\n${animation.name}';
}
break;
}
}
return label;
}
void _showImagePreviewDialog(BuildContext context, int index) {
final Sprite sprite = _sprites[index];
showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(
'${_buildSpriteLabel(index)}\n64 x 64',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close'),
),
],
content: Center(
child: AspectRatio(
aspectRatio: 1,
child: WolfAssetPainter.sprite(sprite),
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Sprite Gallery'),
automaticallyImplyLeading: true,
actions: [
if (widget.wolf3d.availableGames.length > 1)
GalleryGameSelector(
wolf3d: widget.wolf3d,
selectedGame: _selectedGame,
onSelected: _selectGame,
),
],
),
backgroundColor: Colors.black,
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _sprites.length,
itemBuilder: (context, index) {
final String label = _buildSpriteLabel(index);
return Card(
color: Colors.blueGrey,
child: InkWell(
onTap: () => _showImagePreviewDialog(context, index),
child: Column(
spacing: 8,
children: [
Text(
label,
style: const TextStyle(color: Colors.white, fontSize: 10),
textAlign: TextAlign.center,
),
Expanded(
child: Center(
child: AspectRatio(
aspectRatio: 1,
child: WolfAssetPainter.sprite(_sprites[index]),
),
),
),
],
),
),
);
},
),
);
}
}
@@ -0,0 +1,128 @@
/// Visual browser for decoded VGA pictures and UI art.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/renderer/wolf_3d_asset_painter.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Shows each VGA image extracted from the currently selected game data set.
class VgaGallery extends StatefulWidget {
/// Shared app facade used to access available game data sets.
final Wolf3d wolf3d;
/// Creates the gallery for the currently selected or browsed game.
const VgaGallery({super.key, required this.wolf3d});
@override
State<VgaGallery> createState() => _VgaGalleryState();
}
class _VgaGalleryState extends State<VgaGallery> {
late WolfensteinData _selectedGame;
List<VgaImage> get _images => _selectedGame.vgaImages;
@override
void initState() {
super.initState();
_selectedGame =
widget.wolf3d.maybeActiveGame ?? widget.wolf3d.availableGames.first;
}
void _selectGame(WolfensteinData game) {
if (identical(_selectedGame, game)) {
return;
}
widget.wolf3d.setActiveGame(game);
setState(() {
_selectedGame = game;
});
}
void _showImagePreviewDialog(BuildContext context, int index) {
final VgaImage image = _images[index];
showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(
'Index: $index ${image.width} x ${image.height}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Close'),
),
],
content: Center(
child: AspectRatio(
aspectRatio: image.width / image.height,
child: WolfAssetPainter.vga(image),
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('VGA Image Gallery'),
actions: [
if (widget.wolf3d.availableGames.length > 1)
GalleryGameSelector(
wolf3d: widget.wolf3d,
selectedGame: _selectedGame,
onSelected: _selectGame,
),
],
),
backgroundColor: Colors.black,
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemCount: _images.length,
itemBuilder: (context, index) {
return Card(
color: Colors.blueGrey,
child: InkWell(
onTap: () => _showImagePreviewDialog(context, index),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8,
children: [
Text(
'Index: $index\n${_images[index].width} x ${_images[index].height}',
style: const TextStyle(color: Colors.white, fontSize: 12),
textAlign: TextAlign.center,
),
Expanded(
child: Center(
child: AspectRatio(
aspectRatio:
_images[index].width / _images[index].height,
child: WolfAssetPainter.vga(_images[index]),
),
),
),
],
),
),
);
},
),
);
}
}
@@ -0,0 +1,61 @@
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Human-readable title for a game variant in gallery selectors.
String formatGalleryGameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
/// Selects which discovered game data set gallery screens should display.
class GalleryGameSelector extends StatelessWidget {
final Wolf3d wolf3d;
final WolfensteinData selectedGame;
final ValueChanged<WolfensteinData> onSelected;
const GalleryGameSelector({
super.key,
required this.wolf3d,
required this.selectedGame,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(right: 12),
child: Center(
child: DropdownButtonHideUnderline(
child: DropdownButton<WolfensteinData>(
value: selectedGame,
borderRadius: BorderRadius.circular(8),
dropdownColor: Theme.of(context).colorScheme.surface,
iconEnabledColor: Theme.of(context).colorScheme.onSurface,
onChanged: (WolfensteinData? game) {
if (game != null) {
onSelected(game);
}
},
items: wolf3d.availableGames.map((WolfensteinData game) {
return DropdownMenuItem<WolfensteinData>(
value: game,
child: Text(formatGalleryGameTitle(game.version)),
);
}).toList(),
),
),
),
);
}
}
@@ -0,0 +1,22 @@
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
/// Minimal app shell that binds a prepared [Wolf3d] instance to host screens.
class Wolf3dApp extends StatelessWidget {
/// Shared initialized facade that owns game data, input, and audio services.
final Wolf3d wolf3d;
const Wolf3dApp({
super.key,
required this.wolf3d,
});
@override
Widget build(BuildContext context) {
return wolf3d.availableGames.isEmpty
? const NoGameDataScreen()
: GameScreen(wolf3d: wolf3d);
}
}
@@ -0,0 +1,95 @@
/// Shared shell for Wolf3D-style menu screens.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/renderer/wolf_3d_asset_painter.dart';
/// Provides a common menu layout with panel framing and optional bottom art.
class WolfMenuShell extends StatelessWidget {
/// Full-screen background color behind the panel.
final Color backgroundColor;
/// Solid panel fill used for the menu content area.
final Color panelColor;
/// Optional heading shown above the panel (text or image).
final Widget? header;
/// Primary menu content rendered inside the panel.
final Widget panelChild;
/// Optional centered VGA image anchored near the bottom of the screen.
final VgaImage? bottomSprite;
/// Width of the menu panel.
final double panelWidth;
/// Padding applied around [panelChild] inside the panel.
final EdgeInsets panelPadding;
/// Scale factor for [bottomSprite].
final double bottomSpriteScale;
/// Distance from the bottom edge for [bottomSprite].
final double bottomOffset;
/// Vertical spacing between [header] and the panel.
final double headerSpacing;
const WolfMenuShell({
super.key,
required this.backgroundColor,
required this.panelColor,
required this.panelChild,
this.header,
this.bottomSprite,
this.panelWidth = 520,
this.panelPadding = const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
this.bottomSpriteScale = 3,
this.bottomOffset = 20,
this.headerSpacing = 14,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: ColoredBox(color: backgroundColor),
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (header case final Widget value) value,
if (header != null) SizedBox(height: headerSpacing),
Container(
width: panelWidth,
padding: panelPadding,
color: panelColor,
child: panelChild,
),
],
),
),
if (bottomSprite != null)
Positioned(
left: 0,
right: 0,
bottom: bottomOffset,
child: Center(
child: SizedBox(
width: bottomSprite!.width * bottomSpriteScale,
height: bottomSprite!.height * bottomSpriteScale,
child: WolfAssetPainter.vga(bottomSprite),
),
),
),
],
);
}
}
@@ -12,6 +12,30 @@ import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
export 'package:wolf_3d_dart/wolf_3d_audio.dart' show DebugMusicPlayer;
export 'audio/wolf3d_platform_audio.dart' show Wolf3dPlatformAudio;
export 'managers/desktop_windowing_support.dart' show supportsDesktopWindowing;
export 'managers/game_app_lifecycle_manager.dart' show GameAppLifecycleManager;
export 'managers/game_display_manager.dart' show GameDisplayManager;
export 'managers/game_persistence_manager.dart' show GamePersistenceManager;
export 'managers/game_renderer_mode_manager.dart'
show GameRendererMode, gameRendererModeFromSettings, handleGlslUnavailable;
export 'managers/game_screen_input_manager.dart'
show
HostShortcutBinding,
HostShortcutHandler,
HostShortcutIntent,
HostShortcutRegistry,
GameScreenInputManager,
isAltEnterShortcut;
export 'screens/audio_gallery.dart' show AudioGallery;
export 'screens/debug_tools_screen.dart' show DebugToolsScreen;
export 'screens/game_screen.dart' show GameScreen;
export 'screens/no_game_data_screen.dart' show NoGameDataScreen;
export 'screens/sprite_gallery.dart' show SpriteGallery;
export 'screens/vga_gallery.dart' show VgaGallery;
export 'widgets/gallery_game_selector.dart'
show GalleryGameSelector, formatGalleryGameTitle;
export 'widgets/wolf3d_app.dart' show Wolf3dApp;
export 'widgets/wolf_menu_shell.dart' show WolfMenuShell;
/// Coordinates asset discovery, audio initialization, and input reuse for apps.
class Wolf3d {
@@ -37,6 +61,17 @@ class Wolf3d {
/// Shared Flutter input adapter reused by gameplay screens.
final Wolf3dFlutterInput input = Wolf3dFlutterInput();
bool _debugEnabled = false;
/// Whether host-level debug affordances should be visible.
bool get isDebugEnabled => _debugEnabled;
/// Enables host-level debug affordances such as debug navigation UI.
Wolf3d enableDebug() {
_debugEnabled = true;
return this;
}
/// The currently selected game data set.
///
/// Throws a [StateError] until [setActiveGame] has been called.
@@ -194,7 +229,12 @@ class Wolf3d {
}
/// Initializes the engine by loading available game data.
Future<Wolf3d> init({String? directory}) async {
///
/// Set [debug] to `true` to explicitly enable host-level debug affordances.
Future<Wolf3d> init({String? directory, bool debug = false}) async {
if (debug) {
_debugEnabled = true;
}
await audio.init();
availableGames.clear();