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
+3 -79
View File
@@ -8,96 +8,20 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/game_screen.dart';
/// Creates the application shell after loading available Wolf3D data sets.
void main() async {
WidgetsFlutterBinding.ensureInitialized();
if (_supportsDesktopWindowing) {
if (supportsDesktopWindowing) {
await windowManager.ensureInitialized();
}
final Wolf3d wolf3d = await Wolf3d().init();
final Wolf3d wolf3d = await Wolf3d().init(debug: kDebugMode);
runApp(
MaterialApp(
darkTheme: ThemeData.dark(useMaterial3: true),
theme: ThemeData.light(useMaterial3: true),
themeMode: ThemeMode.system,
home: wolf3d.availableGames.isEmpty
? const _NoGameDataScreen()
: GameScreen(wolf3d: wolf3d),
home: Wolf3dApp(wolf3d: wolf3d),
),
);
}
/// Whether desktop window-management APIs should be initialized for this host.
bool get _supportsDesktopWindowing {
if (kIsWeb) {
return false;
}
return switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
}
class _NoGameDataScreen extends StatelessWidget {
const _NoGameDataScreen();
@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,
),
),
],
),
),
),
),
),
),
);
}
}
@@ -1,384 +0,0 @@
/// 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';
import 'package:wolf_3d_gui/screens/gallery_game_selector.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),
],
),
),
),
);
},
);
},
),
),
],
);
}
}
@@ -1,82 +0,0 @@
/// Debug tools launcher for art and asset inspection screens.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/audio_gallery.dart';
import 'package:wolf_3d_gui/screens/sprite_gallery.dart';
import 'package:wolf_3d_gui/screens/vga_gallery.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),
),
);
},
),
),
],
),
);
}
}
@@ -1,57 +0,0 @@
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
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';
}
}
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(),
),
),
),
);
}
}
@@ -1,502 +0,0 @@
/// Active gameplay screen for the Flutter host.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:window_manager/window_manager.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_input.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';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
import 'package:wolf_3d_gui/screens/debug_tools_screen.dart';
enum RendererMode {
/// 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,
}
/// 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 [GameScreen].
///
/// 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,
);
/// 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;
final DefaultRendererSettingsPersistence _persistence =
DefaultRendererSettingsPersistence();
final DefaultSaveGamePersistence _savePersistence =
DefaultSaveGamePersistence();
Future<void>? _quitFuture;
/// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
RendererMode _rendererMode = RendererMode.hardware;
@override
void initState() {
super.initState();
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(_persistence.save(settings));
if (mounted) {
setState(() {
_syncRendererModeFrom(settings);
});
}
},
onGameWon: () {
_engine?.difficulty = null;
widget.wolf3d.clearActiveDifficulty();
Navigator.of(context).pop();
},
onQuit: () {
unawaited(_quitApplication());
},
saveGamePersistence: _savePersistence,
);
_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);
}
}
void _syncRendererModeFrom(WolfRendererSettings settings) {
switch (settings.mode) {
case WolfRendererMode.hardware:
_rendererMode = RendererMode.hardware;
break;
case WolfRendererMode.software:
_rendererMode = RendererMode.software;
break;
case WolfRendererMode.ascii:
case WolfRendererMode.sixel:
_rendererMode = RendererMode.ascii;
break;
}
}
@override
void dispose() {
unawaited(widget.wolf3d.shutdownAudio());
super.dispose();
}
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;
}
@override
Widget build(BuildContext context) {
final engine = _engine;
if (engine == null) {
return const Scaffold(body: SizedBox.shrink());
}
return PopScope(
canPop: engine.difficulty != null,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && engine.difficulty == null) {
widget.wolf3d.input.queueBackAction();
}
},
child: Scaffold(
floatingActionButton: kDebugMode && 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: [
_buildRenderer(),
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),
),
),
],
),
);
},
),
),
);
}
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;
switch (_rendererMode) {
case RendererMode.software:
return WolfFlutterRenderer(
engine: engine,
onKeyEvent: _handleRendererKeyEvent,
);
case RendererMode.ascii:
final AsciiTheme theme =
settings.asciiThemeId == WolfRendererSettings.asciiThemeQuadrant
? AsciiThemes.quadrant
: AsciiThemes.blocks;
return WolfAsciiRenderer(
engine: engine,
theme: theme,
onKeyEvent: _handleRendererKeyEvent,
);
case RendererMode.hardware:
return WolfGlslRenderer(
engine: engine,
effectsEnabled: settings.hardwareEffectsEnabled,
bloomEnabled: settings.bloomEnabled,
onKeyEvent: _handleRendererKeyEvent,
onUnavailable: _onGlslUnavailable,
);
}
}
void _handleRendererKeyEvent(KeyEvent event) {
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 == widget.wolf3d.input.rendererToggleKey) {
_engine?.cycleRendererMode();
return;
}
if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) {
setState(() => _engine?.toggleFpsCounter());
return;
}
if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) {
if (_rendererMode == RendererMode.ascii) {
_engine?.cycleAsciiTheme();
} else if (_rendererMode == RendererMode.hardware) {
_engine?.toggleHardwareEffects();
}
}
}
void _onGlslUnavailable() {
if (!mounted || _rendererMode != RendererMode.hardware) {
return;
}
final engine = _engine;
if (engine == null) {
return;
}
engine.updateRendererSettings(
engine.rendererSettings.copyWith(mode: WolfRendererMode.software),
);
}
void _openDebugTools() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => DebugToolsScreen(wolf3d: widget.wolf3d),
),
);
}
bool _handleHostShortcut(KeyEvent event) {
final HostShortcutHandler? customHandler = widget.hostShortcutHandler;
if (customHandler != null) {
// Custom handlers take full precedence to support future menu-driven
// rebinding/override systems without modifying this screen.
return customHandler(event, widget.wolf3d.input);
}
final HostShortcutBinding? binding = widget.hostShortcutRegistry.match(
event,
);
if (binding == null) {
return false;
}
// Suppress conflicting gameplay/menu actions for one update frame.
for (final WolfInputAction action in binding.suppressedActions) {
widget.wolf3d.input.suppressActionOnce(action);
}
switch (binding.intent) {
case HostShortcutIntent.toggleFullscreen:
unawaited(_toggleFullscreen());
}
return true;
}
/// 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 windowManager.isFullScreen();
await windowManager.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 {
if (kIsWeb) {
return false;
}
return switch (defaultTargetPlatform) {
TargetPlatform.linux ||
TargetPlatform.windows ||
TargetPlatform.macOS => true,
_ => false,
};
}
}
/// 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);
}
@@ -1,155 +0,0 @@
/// 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';
import 'package:wolf_3d_gui/screens/gallery_game_selector.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]),
),
),
),
],
),
),
);
},
),
);
}
}
@@ -1,129 +0,0 @@
/// 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';
import 'package:wolf_3d_gui/screens/gallery_game_selector.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]),
),
),
),
],
),
),
);
},
),
);
}
}
@@ -1,95 +0,0 @@
/// 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: [
?header,
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),
),
),
),
],
);
}
}
@@ -1,69 +0,0 @@
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('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);
});
}