From 5a2681e89bde4dd25c9939b3bb02f11d91f8aadb Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 18:44:32 +0100 Subject: [PATCH] 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 --- apps/wolf_3d_gui/lib/main.dart | 82 +-- apps/wolf_3d_gui/lib/screens/game_screen.dart | 502 ------------------ .../managers/desktop_windowing_support.dart | 17 + .../managers/game_app_lifecycle_manager.dart | 42 ++ .../lib/managers/game_display_manager.dart | 36 ++ .../managers/game_persistence_manager.dart | 42 ++ .../managers/game_renderer_mode_manager.dart | 44 ++ .../managers/game_screen_input_manager.dart | 217 ++++++++ .../lib/screens/audio_gallery.dart | 1 - .../lib/screens/debug_tools_screen.dart | 3 - .../lib/screens/game_screen.dart | 253 +++++++++ .../lib/screens/no_game_data_screen.dart | 60 +++ .../lib/screens/sprite_gallery.dart | 1 - .../lib/screens/vga_gallery.dart | 1 - .../lib/widgets}/gallery_game_selector.dart | 4 + .../lib/widgets/wolf3d_app.dart | 22 + .../lib/widgets}/wolf_menu_shell.dart | 2 +- .../wolf_3d_flutter/lib/wolf_3d_flutter.dart | 42 +- packages/wolf_3d_flutter/pubspec.yaml | 1 + .../game_screen_lifecycle_audio_test.dart | 1 - .../test/wolf_3d_flutter_debug_mode_test.dart | 180 +++++++ 21 files changed, 963 insertions(+), 590 deletions(-) delete mode 100644 apps/wolf_3d_gui/lib/screens/game_screen.dart create mode 100644 packages/wolf_3d_flutter/lib/managers/desktop_windowing_support.dart create mode 100644 packages/wolf_3d_flutter/lib/managers/game_app_lifecycle_manager.dart create mode 100644 packages/wolf_3d_flutter/lib/managers/game_display_manager.dart create mode 100644 packages/wolf_3d_flutter/lib/managers/game_persistence_manager.dart create mode 100644 packages/wolf_3d_flutter/lib/managers/game_renderer_mode_manager.dart create mode 100644 packages/wolf_3d_flutter/lib/managers/game_screen_input_manager.dart rename {apps/wolf_3d_gui => packages/wolf_3d_flutter}/lib/screens/audio_gallery.dart (99%) rename {apps/wolf_3d_gui => packages/wolf_3d_flutter}/lib/screens/debug_tools_screen.dart (93%) create mode 100644 packages/wolf_3d_flutter/lib/screens/game_screen.dart create mode 100644 packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart rename {apps/wolf_3d_gui => packages/wolf_3d_flutter}/lib/screens/sprite_gallery.dart (98%) rename {apps/wolf_3d_gui => packages/wolf_3d_flutter}/lib/screens/vga_gallery.dart (98%) rename {apps/wolf_3d_gui/lib/screens => packages/wolf_3d_flutter/lib/widgets}/gallery_game_selector.dart (91%) create mode 100644 packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart rename {apps/wolf_3d_gui/lib/screens => packages/wolf_3d_flutter/lib/widgets}/wolf_menu_shell.dart (97%) rename {apps/wolf_3d_gui => packages/wolf_3d_flutter}/test/game_screen_lifecycle_audio_test.dart (96%) create mode 100644 packages/wolf_3d_flutter/test/wolf_3d_flutter_debug_mode_test.dart diff --git a/apps/wolf_3d_gui/lib/main.dart b/apps/wolf_3d_gui/lib/main.dart index 6a5d18c..2cc7c50 100644 --- a/apps/wolf_3d_gui/lib/main.dart +++ b/apps/wolf_3d_gui/lib/main.dart @@ -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, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart deleted file mode 100644 index aefee99..0000000 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ /dev/null @@ -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 suppressedActions; - - /// Creates a host shortcut binding with optional suppressed engine actions. - const HostShortcutBinding({ - required this.matches, - required this.intent, - this.suppressedActions = const {}, - }); -} - -/// 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 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( - matches: _isAltEnterShortcut, - intent: HostShortcutIntent.toggleFullscreen, - suppressedActions: {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 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 createState() => _GameScreenState(); -} - -class _GameScreenState extends State { - WolfEngine? _engine; - final DefaultRendererSettingsPersistence _persistence = - DefaultRendererSettingsPersistence(); - final DefaultSaveGamePersistence _savePersistence = - DefaultSaveGamePersistence(); - Future? _quitFuture; - - /// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum. - RendererMode _rendererMode = RendererMode.hardware; - - @override - void initState() { - super.initState(); - if (widget.skipEngineBootstrapForTest) { - return; - } - - const Set supportedModes = { - 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 _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 _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 _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 pressedKeys = - HardwareKeyboard.instance.logicalKeysPressed; - return pressedKeys.contains(LogicalKeyboardKey.altLeft) || - pressedKeys.contains(LogicalKeyboardKey.altRight) || - pressedKeys.contains(LogicalKeyboardKey.alt); -} diff --git a/packages/wolf_3d_flutter/lib/managers/desktop_windowing_support.dart b/packages/wolf_3d_flutter/lib/managers/desktop_windowing_support.dart new file mode 100644 index 0000000..8c62c3e --- /dev/null +++ b/packages/wolf_3d_flutter/lib/managers/desktop_windowing_support.dart @@ -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, + }; +} diff --git a/packages/wolf_3d_flutter/lib/managers/game_app_lifecycle_manager.dart b/packages/wolf_3d_flutter/lib/managers/game_app_lifecycle_manager.dart new file mode 100644 index 0000000..21956e8 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/managers/game_app_lifecycle_manager.dart @@ -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 Function() shutdownAudio; + + /// Optional host-specific app exit callback. + final Future Function()? appExitHandler; + + Future? _quitFuture; + + /// Shuts down audio and then exits the host app once. + Future quitApplication() async { + final Future? existing = _quitFuture; + if (existing != null) { + await existing; + return; + } + + final Future quit = () async { + await shutdownAudio(); + final Future Function()? handler = appExitHandler; + if (handler != null) { + await handler(); + } else { + await SystemNavigator.pop(); + } + }(); + + _quitFuture = quit; + await quit; + } +} diff --git a/packages/wolf_3d_flutter/lib/managers/game_display_manager.dart b/packages/wolf_3d_flutter/lib/managers/game_display_manager.dart new file mode 100644 index 0000000..65c2dee --- /dev/null +++ b/packages/wolf_3d_flutter/lib/managers/game_display_manager.dart @@ -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 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; +} diff --git a/packages/wolf_3d_flutter/lib/managers/game_persistence_manager.dart b/packages/wolf_3d_flutter/lib/managers/game_persistence_manager.dart new file mode 100644 index 0000000..42a9555 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/managers/game_persistence_manager.dart @@ -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 loadRendererSettings() { + return rendererSettingsPersistence.load(); + } + + /// Loads persisted renderer settings and applies them to [engine]. + Future restoreRendererSettings( + WolfEngine engine, + ) async { + final WolfRendererSettings? saved = await loadRendererSettings(); + if (saved != null) { + engine.updateRendererSettings(saved); + } + return saved; + } + + /// Saves current renderer settings. + Future saveRendererSettings(WolfRendererSettings settings) { + return rendererSettingsPersistence.save(settings); + } +} diff --git a/packages/wolf_3d_flutter/lib/managers/game_renderer_mode_manager.dart b/packages/wolf_3d_flutter/lib/managers/game_renderer_mode_manager.dart new file mode 100644 index 0000000..d5da436 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/managers/game_renderer_mode_manager.dart @@ -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), + ); +} diff --git a/packages/wolf_3d_flutter/lib/managers/game_screen_input_manager.dart b/packages/wolf_3d_flutter/lib/managers/game_screen_input_manager.dart new file mode 100644 index 0000000..56fb9a8 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/managers/game_screen_input_manager.dart @@ -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 suppressedActions; + + /// Creates a host shortcut binding with optional suppressed engine actions. + const HostShortcutBinding({ + required this.matches, + required this.intent, + this.suppressedActions = const {}, + }); +} + +/// 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 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( + matches: isAltEnterShortcut, + intent: HostShortcutIntent.toggleFullscreen, + suppressedActions: {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 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 pressedKeys = + HardwareKeyboard.instance.logicalKeysPressed; + return pressedKeys.contains(LogicalKeyboardKey.altLeft) || + pressedKeys.contains(LogicalKeyboardKey.altRight) || + pressedKeys.contains(LogicalKeyboardKey.alt); +} diff --git a/apps/wolf_3d_gui/lib/screens/audio_gallery.dart b/packages/wolf_3d_flutter/lib/screens/audio_gallery.dart similarity index 99% rename from apps/wolf_3d_gui/lib/screens/audio_gallery.dart rename to packages/wolf_3d_flutter/lib/screens/audio_gallery.dart index 55c556b..eb83425 100644 --- a/apps/wolf_3d_gui/lib/screens/audio_gallery.dart +++ b/packages/wolf_3d_flutter/lib/screens/audio_gallery.dart @@ -6,7 +6,6 @@ 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; diff --git a/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart b/packages/wolf_3d_flutter/lib/screens/debug_tools_screen.dart similarity index 93% rename from apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart rename to packages/wolf_3d_flutter/lib/screens/debug_tools_screen.dart index 76e1e48..63e1efd 100644 --- a/apps/wolf_3d_gui/lib/screens/debug_tools_screen.dart +++ b/packages/wolf_3d_flutter/lib/screens/debug_tools_screen.dart @@ -3,9 +3,6 @@ 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 { diff --git a/packages/wolf_3d_flutter/lib/screens/game_screen.dart b/packages/wolf_3d_flutter/lib/screens/game_screen.dart new file mode 100644 index 0000000..99c5802 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/screens/game_screen.dart @@ -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 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 createState() => _GameScreenState(); +} + +class _GameScreenState extends State { + 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 supportedModes = { + 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), + ), + ); + } +} diff --git a/packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart b/packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart new file mode 100644 index 0000000..bd8f225 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/screens/no_game_data_screen.dart @@ -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, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/wolf_3d_gui/lib/screens/sprite_gallery.dart b/packages/wolf_3d_flutter/lib/screens/sprite_gallery.dart similarity index 98% rename from apps/wolf_3d_gui/lib/screens/sprite_gallery.dart rename to packages/wolf_3d_flutter/lib/screens/sprite_gallery.dart index eebfc6b..3f9b1dc 100644 --- a/apps/wolf_3d_gui/lib/screens/sprite_gallery.dart +++ b/packages/wolf_3d_flutter/lib/screens/sprite_gallery.dart @@ -6,7 +6,6 @@ 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 { diff --git a/apps/wolf_3d_gui/lib/screens/vga_gallery.dart b/packages/wolf_3d_flutter/lib/screens/vga_gallery.dart similarity index 98% rename from apps/wolf_3d_gui/lib/screens/vga_gallery.dart rename to packages/wolf_3d_flutter/lib/screens/vga_gallery.dart index 46de5eb..d4019e4 100644 --- a/apps/wolf_3d_gui/lib/screens/vga_gallery.dart +++ b/packages/wolf_3d_flutter/lib/screens/vga_gallery.dart @@ -5,7 +5,6 @@ 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 { diff --git a/apps/wolf_3d_gui/lib/screens/gallery_game_selector.dart b/packages/wolf_3d_flutter/lib/widgets/gallery_game_selector.dart similarity index 91% rename from apps/wolf_3d_gui/lib/screens/gallery_game_selector.dart rename to packages/wolf_3d_flutter/lib/widgets/gallery_game_selector.dart index b4f80ab..dd79e85 100644 --- a/apps/wolf_3d_gui/lib/screens/gallery_game_selector.dart +++ b/packages/wolf_3d_flutter/lib/widgets/gallery_game_selector.dart @@ -1,7 +1,10 @@ +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: @@ -15,6 +18,7 @@ String formatGalleryGameTitle(GameVersion version) { } } +/// Selects which discovered game data set gallery screens should display. class GalleryGameSelector extends StatelessWidget { final Wolf3d wolf3d; final WolfensteinData selectedGame; diff --git a/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart b/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart new file mode 100644 index 0000000..746f198 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/widgets/wolf3d_app.dart @@ -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); + } +} diff --git a/apps/wolf_3d_gui/lib/screens/wolf_menu_shell.dart b/packages/wolf_3d_flutter/lib/widgets/wolf_menu_shell.dart similarity index 97% rename from apps/wolf_3d_gui/lib/screens/wolf_menu_shell.dart rename to packages/wolf_3d_flutter/lib/widgets/wolf_menu_shell.dart index e7f1bc5..48f372b 100644 --- a/apps/wolf_3d_gui/lib/screens/wolf_menu_shell.dart +++ b/packages/wolf_3d_flutter/lib/widgets/wolf_menu_shell.dart @@ -65,7 +65,7 @@ class WolfMenuShell extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ?header, + if (header case final Widget value) value, if (header != null) SizedBox(height: headerSpacing), Container( width: panelWidth, diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index 585db39..03087af 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -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 init({String? directory}) async { + /// + /// Set [debug] to `true` to explicitly enable host-level debug affordances. + Future init({String? directory, bool debug = false}) async { + if (debug) { + _debugEnabled = true; + } await audio.init(); availableGames.clear(); diff --git a/packages/wolf_3d_flutter/pubspec.yaml b/packages/wolf_3d_flutter/pubspec.yaml index d5d3e6f..cad57ef 100644 --- a/packages/wolf_3d_flutter/pubspec.yaml +++ b/packages/wolf_3d_flutter/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: sdk: flutter wolf_3d_assets: any audioplayers: ^6.6.0 + window_manager: ^0.5.1 dev_dependencies: flutter_test: diff --git a/apps/wolf_3d_gui/test/game_screen_lifecycle_audio_test.dart b/packages/wolf_3d_flutter/test/game_screen_lifecycle_audio_test.dart similarity index 96% rename from apps/wolf_3d_gui/test/game_screen_lifecycle_audio_test.dart rename to packages/wolf_3d_flutter/test/game_screen_lifecycle_audio_test.dart index 0ce185c..c6c53c5 100644 --- a/apps/wolf_3d_gui/test/game_screen_lifecycle_audio_test.dart +++ b/packages/wolf_3d_flutter/test/game_screen_lifecycle_audio_test.dart @@ -3,7 +3,6 @@ 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 diff --git a/packages/wolf_3d_flutter/test/wolf_3d_flutter_debug_mode_test.dart b/packages/wolf_3d_flutter/test/wolf_3d_flutter_debug_mode_test.dart new file mode 100644 index 0000000..b64d949 --- /dev/null +++ b/packages/wolf_3d_flutter/test/wolf_3d_flutter_debug_mode_test.dart @@ -0,0 +1,180 @@ +import 'dart:typed_data'; + +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'; + +class _NoopAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + Future stopAllAudio() async {} + + @override + void stopMusic() {} + + @override + void dispose() {} +} + +void main() { + group('Wolf3d debug mode', () { + test('is disabled by default', () { + final wolf3d = Wolf3d(audioBackend: _NoopAudio()); + + expect(wolf3d.isDebugEnabled, isFalse); + }); + + test('enableDebug toggles debug mode', () { + final wolf3d = Wolf3d(audioBackend: _NoopAudio()); + + final returned = wolf3d.enableDebug(); + + expect(returned, same(wolf3d)); + expect(wolf3d.isDebugEnabled, isTrue); + }); + + test('init(debug: true) enables debug mode', () async { + final wolf3d = Wolf3d(audioBackend: _NoopAudio()); + + await wolf3d.init(debug: true); + + expect(wolf3d.isDebugEnabled, isTrue); + }); + + testWidgets('GameScreen hides debug FAB when debug mode is disabled', ( + tester, + ) async { + final wolf3d = _TestWolf3d(audioBackend: _NoopAudio()); + + await tester.pumpWidget( + MaterialApp( + home: GameScreen( + wolf3d: wolf3d, + appExitHandler: () async {}, + ), + ), + ); + await tester.pump(); + + expect(find.byType(FloatingActionButton), findsNothing); + expect(find.byIcon(Icons.bug_report), findsNothing); + }); + + testWidgets('GameScreen shows debug FAB when debug mode is enabled', ( + tester, + ) async { + final wolf3d = _TestWolf3d(audioBackend: _NoopAudio())..enableDebug(); + + await tester.pumpWidget( + MaterialApp( + home: GameScreen( + wolf3d: wolf3d, + appExitHandler: () async {}, + ), + ), + ); + await tester.pump(); + + expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byIcon(Icons.bug_report), findsOneWidget); + }); + }); +} + +class _TestWolf3d extends Wolf3d { + _TestWolf3d({required super.audioBackend}); + + @override + WolfEngine launchEngine({ + required void Function() onGameWon, + void Function()? onQuit, + SaveGamePersistence? saveGamePersistence, + WolfRendererCapabilities? rendererCapabilities, + WolfRendererSettings? rendererSettings, + void Function(WolfRendererSettings settings)? onRendererSettingsChanged, + }) { + final engine = WolfEngine( + data: _buildTestData(), + difficulty: Difficulty.easy, + startingEpisode: 0, + frameBuffer: FrameBuffer(64, 64), + engineAudio: audio, + input: input, + onGameWon: onGameWon, + onMenuExit: () {}, + onQuit: onQuit, + saveGamePersistence: saveGamePersistence, + rendererCapabilities: rendererCapabilities, + rendererSettings: const WolfRendererSettings( + mode: WolfRendererMode.software, + ), + onRendererSettingsChanged: onRendererSettingsChanged, + ); + engine.init(); + return engine; + } +} + +WolfensteinData _buildTestData() { + final wallGrid = List.generate(64, (_) => List.filled(64, 0)); + final objectGrid = List.generate(64, (_) => List.filled(64, 0)); + + for (int i = 0; i < 64; i++) { + wallGrid[0][i] = 2; + wallGrid[63][i] = 2; + wallGrid[i][0] = 2; + wallGrid[i][63] = 2; + } + objectGrid[2][2] = MapObject.playerEast; + + return WolfensteinData( + version: GameVersion.retail, + dataVersion: DataVersion.unknown, + registry: RetailAssetRegistry(), + walls: [_sprite(1), _sprite(1), _sprite(2), _sprite(2)], + sprites: List.generate(436, (_) => _sprite(255)), + sounds: List.generate(200, (_) => PcmSound(Uint8List(1))), + adLibSounds: const [], + music: const [], + vgaImages: const [], + episodes: [ + Episode( + name: 'Test Episode', + levels: [ + WolfLevel( + name: 'Test Level', + wallGrid: wallGrid, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), + objectGrid: objectGrid, + music: Music.level01, + ), + ], + ), + ], + ); +} + +Sprite _sprite(int color) => + Sprite(Uint8List.fromList(List.filled(64 * 64, color)));