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,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]),
),
),
),
],
),
),
);
},
),
);
}
}