From 5c309c2240c1d81ac9e9403f02d4ce56b6b311e4 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 24 Mar 2026 18:45:34 +0100 Subject: [PATCH] Refactor menu structure and add Flutter-specific input and persistence layers - Moved menu-related classes to a new structure under `src/menu/`. - Introduced `WolfMenuPresentation` to handle menu art and mappings. - Added `MenuManager` tests to ensure menu state reflects game status. - Implemented `FlutterRendererSettingsPersistence` and `FlutterSaveGamePersistence` for managing settings and save files on desktop platforms. - Created `Wolf3dFlutterInput` to handle keyboard and mouse input in a Flutter environment. - Updated README to reflect new package structure and usage instructions. Signed-off-by: Hans Kokx --- README.md | 1 - apps/wolf_3d_gui/lib/no_game_data_screen.dart | 58 +- packages/wolf_3d_dart/README.md | 217 +++- .../lib/src/data/io/discovery_io.dart | 3 +- .../lib/src/data/wolfenstein_loader.dart | 11 +- .../lib/src/menu/manager/menu_manager.dart | 165 +++ .../menu/manager/menu_manager_entries.dart | 62 + .../src/menu/manager/menu_manager_enums.dart | 19 + .../manager/menu_manager_intro_mixin.dart | 324 +++++ .../menu_manager_navigation_mixin.dart | 233 ++++ .../manager/menu_manager_selection_mixin.dart | 379 ++++++ .../lib/src/menu/menu_manager.dart | 1087 +---------------- .../lib/src/menu/wolf_menu_pic.dart | 40 + .../lib/src/menu/wolf_menu_presentation.dart | 153 +++ .../lib/src/registry/asset_registry.dart | 8 +- .../classic_menu_presentation_module.dart | 197 +++ .../built_in/retail_asset_registry.dart | 2 + .../built_in/shareware_asset_registry.dart | 2 + .../built_in/shareware_menu_module.dart | 4 +- .../built_in/spear_demo_asset_registry.dart | 2 + .../spear_menu_presentation_module.dart | 12 + .../modules/menu_presentation_module.dart | 78 ++ .../lib/src/rendering/ascii_renderer.dart | 56 +- .../lib/src/rendering/sixel_renderer.dart | 72 +- .../lib/src/rendering/software_renderer.dart | 80 +- .../wolf_3d_dart/lib/wolf_3d_data_types.dart | 2 + packages/wolf_3d_dart/lib/wolf_3d_menu.dart | 217 +--- .../test/menu/menu_manager_test.dart | 167 +++ .../registry/spear_demo_registry_test.dart | 2 + .../wolf_classic_menu_art_mapping_test.dart | 8 +- packages/wolf_3d_flutter/README.md | 14 +- .../lib/engine/wolf3d_flutter_engine.dart | 122 ++ .../{ => input}/wolf_3d_input_flutter.dart | 0 .../managers/game_screen_input_manager.dart | 2 +- ...renderer_settings_persistence_flutter.dart | 0 .../save_game_persistence_flutter.dart | 0 .../wolf_3d_flutter/lib/wolf_3d_flutter.dart | 122 +- 37 files changed, 2356 insertions(+), 1565 deletions(-) create mode 100644 packages/wolf_3d_dart/lib/src/menu/manager/menu_manager.dart create mode 100644 packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_entries.dart create mode 100644 packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_enums.dart create mode 100644 packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_intro_mixin.dart create mode 100644 packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_navigation_mixin.dart create mode 100644 packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_selection_mixin.dart create mode 100644 packages/wolf_3d_dart/lib/src/menu/wolf_menu_pic.dart create mode 100644 packages/wolf_3d_dart/lib/src/menu/wolf_menu_presentation.dart create mode 100644 packages/wolf_3d_dart/lib/src/registry/built_in/classic_menu_presentation_module.dart create mode 100644 packages/wolf_3d_dart/lib/src/registry/built_in/spear_menu_presentation_module.dart create mode 100644 packages/wolf_3d_dart/lib/src/registry/modules/menu_presentation_module.dart create mode 100644 packages/wolf_3d_dart/test/menu/menu_manager_test.dart create mode 100644 packages/wolf_3d_flutter/lib/engine/wolf3d_flutter_engine.dart rename packages/wolf_3d_flutter/lib/{ => input}/wolf_3d_input_flutter.dart (100%) rename packages/wolf_3d_flutter/lib/{ => persistence}/renderer_settings_persistence_flutter.dart (100%) rename packages/wolf_3d_flutter/lib/{ => persistence}/save_game_persistence_flutter.dart (100%) diff --git a/README.md b/README.md index d2e95de..7b6105f 100644 --- a/README.md +++ b/README.md @@ -82,5 +82,4 @@ cd apps/wolf_3d_gui && flutter test ## Related Docs -- [`TODO.md`](TODO.md) — high-level project roadmap - App/package READMEs listed above for module-specific setup and architecture diff --git a/apps/wolf_3d_gui/lib/no_game_data_screen.dart b/apps/wolf_3d_gui/lib/no_game_data_screen.dart index 97e7147..37dea33 100644 --- a/apps/wolf_3d_gui/lib/no_game_data_screen.dart +++ b/apps/wolf_3d_gui/lib/no_game_data_screen.dart @@ -8,6 +8,8 @@ import 'game_data_picker_manager.dart'; /// GUI-host fallback screen shown when no Wolf3D game data is discovered. class NoGameDataScreen extends StatelessWidget { + static const WolfMenuPresentation _menu = WolfMenuPresentation.classic(); + /// Creates the no-data screen with app-owned setup actions. const NoGameDataScreen({ super.key, @@ -67,22 +69,22 @@ class NoGameDataScreen extends StatelessWidget { static Color _stateColor(GameDataVersionState state) { switch (state) { case GameDataVersionState.ready: - return Color(WolfMenuPalette.emphasisColor); + return Color(_menu.emphasisColor); case GameDataVersionState.checksumWarning: - return Color(WolfMenuPalette.warningColor); + return Color(_menu.warningColor); case GameDataVersionState.incomplete: - return Color(WolfMenuPalette.mutedColor); + return Color(_menu.mutedColor); } } static Color _fileStateColor(GameDataFileState state) { switch (state) { case GameDataFileState.ready: - return Color(WolfMenuPalette.emphasisColor); + return Color(_menu.emphasisColor); case GameDataFileState.warning: - return Color(WolfMenuPalette.warningColor); + return Color(_menu.warningColor); case GameDataFileState.missing: - return Color(WolfMenuPalette.mutedColor); + return Color(_menu.mutedColor); } } @@ -92,7 +94,7 @@ class NoGameDataScreen extends StatelessWidget { scanResult?.readyVersions ?? []; return Scaffold( - backgroundColor: Color(WolfMenuPalette.backgroundColor), + backgroundColor: Color(_menu.backgroundColor), body: LayoutBuilder( builder: (BuildContext context, BoxConstraints viewportConstraints) { return SingleChildScrollView( @@ -106,9 +108,9 @@ class NoGameDataScreen extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 640), child: DecoratedBox( decoration: BoxDecoration( - color: Color(WolfMenuPalette.panelColor), + color: Color(_menu.panelColor), border: Border.all( - color: Color(WolfMenuPalette.borderColor), + color: Color(_menu.borderColor), width: 2, ), ), @@ -121,7 +123,7 @@ class NoGameDataScreen extends StatelessWidget { Text( 'WOLF3D DATA NOT FOUND', style: TextStyle( - color: Color(WolfMenuPalette.titleColor), + color: Color(_menu.titleColor), fontSize: 24, fontWeight: FontWeight.bold, ), @@ -131,7 +133,7 @@ class NoGameDataScreen extends StatelessWidget { 'No game files were discovered.\n\n' 'Select a game-data directory, or select one or more game-data files.', style: TextStyle( - color: Color(WolfMenuPalette.bodyColor), + color: Color(_menu.bodyColor), fontSize: 15, height: 1.4, ), @@ -140,7 +142,7 @@ class NoGameDataScreen extends StatelessWidget { Text( 'A complete version can be loaded directly or imported into the app config folder.', style: TextStyle( - color: Color(WolfMenuPalette.emphasisColor), + color: Color(_menu.emphasisColor), fontSize: 14, height: 1.35, fontWeight: FontWeight.w600, @@ -179,7 +181,7 @@ class NoGameDataScreen extends StatelessWidget { child: Text( 'Scanning selected locations...', style: TextStyle( - color: Color(WolfMenuPalette.bodyColor), + color: Color(_menu.bodyColor), fontSize: 13, height: 1.3, ), @@ -188,8 +190,8 @@ class NoGameDataScreen extends StatelessWidget { if (scanResult != null) ...[ const SizedBox(height: 18), _ScanSummary( - bodyColor: Color(WolfMenuPalette.bodyColor), - mutedColor: Color(WolfMenuPalette.mutedColor), + bodyColor: Color(_menu.bodyColor), + mutedColor: Color(_menu.mutedColor), scanResult: scanResult!, ), const SizedBox(height: 12), @@ -197,15 +199,11 @@ class NoGameDataScreen extends StatelessWidget { (GameDataVersionAnalysis analysis) => Padding( padding: const EdgeInsets.only(bottom: 12), child: _VersionCard( - panelColor: Color( - WolfMenuPalette.backgroundColor, - ), - borderColor: Color( - WolfMenuPalette.borderColor, - ), - titleColor: Color(WolfMenuPalette.titleColor), - bodyColor: Color(WolfMenuPalette.bodyColor), - mutedColor: Color(WolfMenuPalette.mutedColor), + panelColor: Color(_menu.backgroundColor), + borderColor: Color(_menu.borderColor), + titleColor: Color(_menu.titleColor), + bodyColor: Color(_menu.bodyColor), + mutedColor: Color(_menu.mutedColor), analysis: analysis, ), ), @@ -217,18 +215,18 @@ class NoGameDataScreen extends StatelessWidget { initialValue: selectedReadyVersion ?? readyVersions.first.version, - dropdownColor: Color(WolfMenuPalette.panelColor), + dropdownColor: Color(_menu.panelColor), style: TextStyle( - color: Color(WolfMenuPalette.bodyColor), + color: Color(_menu.bodyColor), ), decoration: InputDecoration( labelText: 'Complete version', labelStyle: TextStyle( - color: Color(WolfMenuPalette.bodyColor), + color: Color(_menu.bodyColor), ), enabledBorder: UnderlineInputBorder( borderSide: BorderSide( - color: Color(WolfMenuPalette.borderColor), + color: Color(_menu.borderColor), ), ), ), @@ -275,7 +273,7 @@ class NoGameDataScreen extends StatelessWidget { child: Text( pickerError!.trim(), style: TextStyle( - color: Color(WolfMenuPalette.emphasisColor), + color: Color(_menu.emphasisColor), fontSize: 13, height: 1.3, fontWeight: FontWeight.w600, @@ -289,7 +287,7 @@ class NoGameDataScreen extends StatelessWidget { child: Text( 'Configured data directory: ${configuredDataDirectory!.trim()}', style: TextStyle( - color: Color(WolfMenuPalette.bodyColor), + color: Color(_menu.bodyColor), fontSize: 13, height: 1.3, ), diff --git a/packages/wolf_3d_dart/README.md b/packages/wolf_3d_dart/README.md index 267451f..fa8ce89 100644 --- a/packages/wolf_3d_dart/README.md +++ b/packages/wolf_3d_dart/README.md @@ -22,7 +22,7 @@ Primary entry libraries in `lib/`: - `wolf_3d_renderer.dart` — rendering/backends integration points. - `wolf_3d_audio.dart` — audio interfaces and host backends. - `wolf_3d_input.dart` — input abstractions. -- `wolf_3d_menu.dart` — menu models/managers and shared `WolfMenuPalette` color accessors for hosts. +- `wolf_3d_menu.dart` — menu models/managers and the registry-backed `WolfMenuPresentation` helpers for hosts. - `wolf_3d_host.dart` — host-level glue contracts. Implementation details live under `lib/src/`. @@ -50,6 +50,221 @@ dart test - This package owns deterministic engine/frame progression and shared game logic. - Frame-buffer sizing is controlled by hosts through engine APIs. - Rendering code is maintained under `lib/src/rendering/`. +- Menu coordination is split under `lib/src/menu/manager/`; public consumers should prefer `lib/wolf_3d_menu.dart` or the internal barrel at `lib/src/menu/menu_manager.dart` instead of reaching into individual implementation files. +- Menu presentation is selected through `AssetRegistry.menuPresentation`, which keeps retail/shareware/Spear variants and user-defined menu overrides aligned with the rest of the registry system. + +## Custom Menus + +Custom menu support is split across two registry modules: + +- `MenuPicModule` maps symbolic menu keys such as `MenuPicKey.title` or an episode selection entry to concrete VGA picture indices in `WolfensteinData.vgaImages`. +- `MenuPresentationModule` defines the palette indices and higher-level menu art lookups that renderers and hosts consume. + +That split is intentional: + +- `MenuPicModule` answers "which image index represents this menu asset for this game/mod?" +- `MenuPresentationModule` answers "which colors and optional art should the UI use?" + +In practice, most custom variants will either: + +- reuse an existing `MenuPicModule` and only change colors/presentation, or +- provide both a custom `MenuPicModule` and a matching `MenuPresentationModule` when the menu art layout itself changes. + +### Using Menu Presentation From Loaded Data + +Once game data has been loaded, bind menu presentation through the active registry: + +```dart +final WolfMenuPresentation menu = WolfMenuPresentation(data); + +final int panelColor = menu.panelColor; +final VgaImage? title = menu.title; +final VgaImage? episode1 = menu.episodeOption(0); +``` + +This is the normal path for renderers and any UI that should track the active game variant automatically. + +### Fallback Presentation Before Data Loads + +Host-owned screens that appear before game data discovery can still use menu-consistent colors: + +```dart +const WolfMenuPresentation classicMenu = WolfMenuPresentation.classic(); +const WolfMenuPresentation spearMenu = WolfMenuPresentation.spear(); +``` + +Those fallback constructors expose colors without requiring a loaded `WolfensteinData` instance. Art getters return `null` until real data is attached. + +### Implementing A Custom MenuPicModule + +Use `MenuPicModule` when your mod changes which VGA pictures back the classic menu keys: + +```dart +class ModMenuPics extends MenuPicModule { + const ModMenuPics(); + + @override + MenuPicRef? resolve(MenuPicKey key) { + switch (key) { + case MenuPicKey.title: + return const MenuPicRef(140); + case MenuPicKey.optionTitle: + return const MenuPicRef(141); + case MenuPicKey.customizeTitle: + return const MenuPicRef(142); + default: + return null; + } + } + + @override + MenuPicKey episodeKey(int episodeIndex) { + switch (episodeIndex) { + case 0: + return MenuPicKey.episode1; + case 1: + return MenuPicKey.episode2; + case 2: + return MenuPicKey.episode3; + default: + return MenuPicKey.episode1; + } + } + + @override + MenuPicKey difficultyKey(Difficulty difficulty) { + switch (difficulty) { + case Difficulty.easy: + return MenuPicKey.skill1; + case Difficulty.medium: + return MenuPicKey.skill2; + case Difficulty.hard: + return MenuPicKey.skill3; + case Difficulty.expert: + return MenuPicKey.skill4; + } + } +} +``` + +Returning `null` from `resolve` means that the key is not provided by that module. + +### Implementing A Custom MenuPresentationModule + +Use `MenuPresentationModule` when you want to change menu colors, point existing menu concepts at different art, or selectively omit optional art: + +### Custom Menu Presentation Example + +```dart +class ModMenuPresentation extends MenuPresentationModule { + const ModMenuPresentation(); + + @override + int get backgroundIndex => 111; + + @override + int get panelIndex => 97; + + @override + int get borderIndex => 87; + + @override + int get emphasisIndex => 10; + + @override + int get warningIndex => 14; + + @override + int get mutedIndex => 8; + + @override + int get selectedTextIndex => 19; + + @override + int get unselectedTextIndex => 23; + + @override + int get disabledTextIndex => 4; + + @override + int get headerTextIndex => 15; + + @override + VgaImage? controlBackground(WolfensteinData data) => null; + + @override + VgaImage? title(WolfensteinData data) => null; + + @override + VgaImage? heading(WolfensteinData data) => null; + + @override + VgaImage? selectedMarker(WolfensteinData data) => null; + + @override + VgaImage? unselectedMarker(WolfensteinData data) => null; + + @override + VgaImage? optionsLabel(WolfensteinData data) => null; + + @override + VgaImage? customizeLabel(WolfensteinData data) => null; + + @override + VgaImage? credits(WolfensteinData data) => null; + + @override + VgaImage? episodeOption(WolfensteinData data, int episodeIndex) => null; + + @override + VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty) => + null; + + @override + VgaImage? mappedPic(WolfensteinData data, int index) => null; +} + +final registry = AssetRegistry( + sfx: mySfxModule, + music: myMusicModule, + entities: myEntityModule, + hud: myHudModule, + menu: myMenuPicModule, + menuPresentation: const ModMenuPresentation(), +); +``` + +The presentation module should treat its image-returning methods as optional hooks: + +- return a `VgaImage` when that surface has variant-specific art, +- return `null` when the presentation intentionally has no image for that concept, +- use `mappedPic(...)` only for legacy numeric menu art lookups that still matter for a renderer path. + +### Wiring A Fully Custom Registry + +To ship a complete custom menu variant, provide both modules through `AssetRegistry` when loading data: + +```dart +final registry = AssetRegistry( + sfx: mySfxModule, + music: myMusicModule, + entities: myEntityModule, + hud: myHudModule, + menu: const ModMenuPics(), + menuPresentation: const ModMenuPresentation(), +); +``` + +If your menu art still follows the built-in retail/shareware layout, you may not need a custom `MenuPicModule`. In that case, keep the built-in module and only swap `menuPresentation`. + +### Choosing The Right Extension Point + +- Change colors only: implement `MenuPresentationModule`. +- Change symbolic menu art mapping: implement `MenuPicModule`. +- Change both colors and art layout: implement both modules. +- Build a host setup screen before data loads: use `WolfMenuPresentation.classic()` or `WolfMenuPresentation.spear()`. + +For most host code, prefer the public `wolf_3d_menu.dart` surface instead of importing internal files directly. ## Non-Goals diff --git a/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart b/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart index 1abbca6..96fdaa7 100644 --- a/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart +++ b/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart @@ -2,11 +2,10 @@ import 'dart:developer'; import 'dart:io'; import 'dart:typed_data'; +import 'package:wolf_3d_dart/src/data/md5_hash.dart'; import 'package:wolf_3d_dart/src/data/wl_parser.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; -import '../md5_hash.dart'; - /// dart:io implementation for directory discovery with version integrity checks. Future> discoverInDirectory({ String? directoryPath, diff --git a/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart b/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart index 44d746b..996a714 100644 --- a/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart +++ b/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart @@ -1,12 +1,11 @@ import 'dart:typed_data'; -import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; - -import 'io/discovery_stub.dart' - if (dart.library.io) 'io/discovery_io.dart' +import 'package:wolf_3d_dart/src/data/io/discovery_stub.dart' + if (dart.library.io) 'package:wolf_3d_dart/src/data/io/discovery_io.dart' as platform; -import 'md5_hash.dart'; -import 'wl_parser.dart'; +import 'package:wolf_3d_dart/src/data/md5_hash.dart'; +import 'package:wolf_3d_dart/src/data/wl_parser.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; /// The main entry point for loading Wolfenstein 3D data. /// diff --git a/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager.dart new file mode 100644 index 0000000..ee74985 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager.dart @@ -0,0 +1,165 @@ +library; + +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +import 'menu_manager_entries.dart'; +import 'menu_manager_enums.dart'; + +part 'menu_manager_intro_mixin.dart'; +part 'menu_manager_navigation_mixin.dart'; +part 'menu_manager_selection_mixin.dart'; + +enum _WolfIntroPhase { fadeIn, hold, fadeOut } + +abstract class _MenuManagerBase { + static const int transitionDurationMs = 280; + static const int introFadeDurationMs = 280; + static const int introRetailBackgroundRgb = 0xA00000; + static const int introPg13BackgroundRgb = 0x33A2E8; + static const int introTitleBackgroundRgb = 0x000000; + + WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect; + WolfMenuScreen? _transitionTarget; + WolfTransitionEffect _transitionEffect = WolfTransitionEffect.normalFade; + int _transitionElapsedMs = 0; + bool _transitionSwappedMenu = false; + WolfMenuScreen _introLandingMenu = WolfMenuScreen.mainMenu; + WolfTransitionEffect _introEffect = WolfTransitionEffect.normalFade; + int _introSlideIndex = 0; + int _introElapsedMs = 0; + _WolfIntroPhase _introPhase = _WolfIntroPhase.fadeIn; + bool _introAdvanceRequested = false; + List _introSlides = [ + WolfIntroSlide.pg13, + WolfIntroSlide.title, + ]; + + int _selectedMainIndex = 0; + int _selectedGameIndex = 0; + int _selectedEpisodeIndex = 0; + int _selectedDifficultyIndex = 0; + int _selectedChangeViewIndex = 0; + int _selectedRendererOptionIndex = 0; + String _rendererOptionsTitle = 'CUSTOMIZE'; + List _changeViewEntries = + const []; + List _rendererOptionEntries = + const []; + bool _showResumeOption = false; + bool _hasLoadableSave = false; + int _gameCount = 1; + + bool _prevUp = false; + bool _prevDown = false; + bool _prevConfirm = false; + bool _prevBack = false; + + int _menuBackgroundRgb = 0x890000; + + bool get isTransitioning => _transitionTarget != null; + + bool get isIntroSplashActive => _activeMenu == WolfMenuScreen.introSplash; + + int get changeViewItemCount => + _changeViewEntries.length + _rendererOptionEntries.length; + + void consumeEdgeState(EngineInput input) { + _prevUp = input.isMovingForward; + _prevDown = input.isMovingBackward; + _prevConfirm = input.isInteracting || input.isFiring; + _prevBack = input.isBack; + } + + void resetEdgeState() { + _prevUp = false; + _prevDown = false; + _prevConfirm = false; + _prevBack = false; + } + + int findSelectableIndex( + int startIndex, + int itemCount, + bool Function(int index) selectable, + ) { + if (itemCount <= 0) { + return 0; + } + for (int offset = 0; offset < itemCount; offset++) { + final int index = (startIndex + offset) % itemCount; + if (selectable(index)) { + return index; + } + } + return clampIndex(startIndex, itemCount); + } + + int moveSelectableIndex( + int currentIndex, + int itemCount, + int delta, + bool Function(int index) selectable, + ) { + if (itemCount <= 0) { + return 0; + } + int index = currentIndex; + for (int step = 0; step < itemCount; step++) { + index = (index + delta + itemCount) % itemCount; + if (selectable(index)) { + return index; + } + } + return currentIndex; + } + + int clampIndex(int index, int itemCount) { + if (itemCount <= 0) { + return 0; + } + return index.clamp(0, itemCount - 1); + } + + void beginSelectionFlow({ + required int gameCount, + int initialGameIndex = 0, + int initialEpisodeIndex = 0, + Difficulty? initialDifficulty, + bool hasResumableGame = false, + bool hasLoadableSave = false, + bool initialGameIsRetail = false, + WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade, + }); + + int _defaultMainMenuIndex(); +} + +/// Coordinates menu state, splash sequencing, and selection updates. +/// +/// Hosts and renderers interact with this type through the stable +/// `src/menu/menu_manager.dart` barrel, while the implementation remains split +/// across focused files under `src/menu/manager/`. +class MenuManager extends _MenuManagerBase + with + _MenuManagerIntroMixin, + _MenuManagerSelectionMixin, + _MenuManagerNavigationMixin { + static const int transitionDurationMs = _MenuManagerBase.transitionDurationMs; + static const int introFadeDurationMs = _MenuManagerBase.introFadeDurationMs; + + /// Whether to show the alternate cursor frame at [elapsedMs]. + bool isCursorAltFrame(int elapsedMs) => ((elapsedMs ~/ 220) % 2) == 1; +} + +class _MenuAction { + const _MenuAction({ + required this.index, + required this.confirmed, + required this.goBack, + }); + + final int index; + final bool confirmed; + final bool goBack; +} diff --git a/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_entries.dart b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_entries.dart new file mode 100644 index 0000000..3fc87ed --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_entries.dart @@ -0,0 +1,62 @@ +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +/// Logical actions exposed by the main menu. +enum WolfMenuMainAction { + newGame, + sound, + control, + loadGame, + saveGame, + changeView, + readThis, + viewScores, + endGame, + backToGame, + backToDemo, + quit, +} + +/// Immutable description of a main-menu row. +class WolfMenuMainEntry { + const WolfMenuMainEntry({ + required this.action, + required this.label, + this.isEnabled = true, + }); + + final WolfMenuMainAction action; + final String label; + final bool isEnabled; +} + +/// Immutable description of a renderer row in the change-view menu. +class WolfMenuRendererEntry { + const WolfMenuRendererEntry({ + required this.mode, + required this.label, + required this.hasOptions, + this.isEnabled = true, + this.isChecked = false, + }); + + final WolfRendererMode mode; + final String label; + final bool hasOptions; + final bool isEnabled; + final bool isChecked; +} + +/// Immutable description of a renderer-specific option row. +class WolfMenuRendererOptionEntry { + const WolfMenuRendererOptionEntry({ + required this.id, + required this.label, + this.isEnabled = true, + this.isChecked = false, + }); + + final WolfRendererOptionId id; + final String label; + final bool isEnabled; + final bool isChecked; +} diff --git a/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_enums.dart b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_enums.dart new file mode 100644 index 0000000..78ec3a7 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_enums.dart @@ -0,0 +1,19 @@ +/// Menu screens handled by [MenuManager]. +enum WolfMenuScreen { + introSplash, + mainMenu, + gameSelect, + episodeSelect, + difficultySelect, + changeView, + rendererOptions, +} + +/// Splash slides shown before the control-panel menu. +enum WolfIntroSlide { retailWarning, pg13, title } + +/// Visual effect used when entering or leaving a menu surface. +enum WolfTransitionEffect { none, normalFade, fizzleFade } + +/// Phase of a two-stage menu transition effect. +enum WolfTransitionPhase { idle, covering, revealing } diff --git a/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_intro_mixin.dart b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_intro_mixin.dart new file mode 100644 index 0000000..cae0e07 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_intro_mixin.dart @@ -0,0 +1,324 @@ +part of 'menu_manager.dart'; + +mixin _MenuManagerIntroMixin on _MenuManagerBase { + /// The currently visible intro slide. + WolfIntroSlide get currentIntroSlide { + if (_introSlides.isEmpty) { + return WolfIntroSlide.title; + } + final int index = _introSlideIndex.clamp(0, _introSlides.length - 1); + return _introSlides[index]; + } + + /// Whether the retail warning card is currently visible. + bool get isIntroRetailWarningSlide => + currentIntroSlide == WolfIntroSlide.retailWarning; + + /// Whether the PG-13 splash is currently visible. + bool get isIntroPg13Slide => currentIntroSlide == WolfIntroSlide.pg13; + + /// Whether the title splash is currently visible. + bool get isIntroTitleSlide => currentIntroSlide == WolfIntroSlide.title; + + /// Background RGB used for the active intro slide. + int get introBackgroundRgb { + switch (currentIntroSlide) { + case WolfIntroSlide.retailWarning: + return _MenuManagerBase.introRetailBackgroundRgb; + case WolfIntroSlide.pg13: + return _MenuManagerBase.introPg13BackgroundRgb; + case WolfIntroSlide.title: + return _MenuManagerBase.introTitleBackgroundRgb; + } + } + + /// Overlay alpha for the current intro transition. + double get introOverlayAlpha { + if (!isIntroSplashActive) { + return 0.0; + } + switch (_introPhase) { + case _WolfIntroPhase.fadeIn: + return (1.0 - (_introElapsedMs / _MenuManagerBase.introFadeDurationMs)) + .clamp(0.0, 1.0); + case _WolfIntroPhase.hold: + return 0.0; + case _WolfIntroPhase.fadeOut: + return (_introElapsedMs / _MenuManagerBase.introFadeDurationMs).clamp( + 0.0, + 1.0, + ); + } + } + + /// Effect currently applied to the intro overlay. + WolfTransitionEffect get introOverlayEffect { + if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) { + return WolfTransitionEffect.none; + } + return _introEffect; + } + + /// Phase currently applied to the intro overlay. + WolfTransitionPhase get introOverlayPhase { + if (!isIntroSplashActive) { + return WolfTransitionPhase.idle; + } + switch (_introPhase) { + case _WolfIntroPhase.fadeIn: + return WolfTransitionPhase.revealing; + case _WolfIntroPhase.hold: + return WolfTransitionPhase.idle; + case _WolfIntroPhase.fadeOut: + return WolfTransitionPhase.covering; + } + } + + /// Normalized progress for the current intro overlay phase. + double get introOverlayPhaseProgress { + if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) { + return 0.0; + } + return (_introElapsedMs / _MenuManagerBase.introFadeDurationMs).clamp( + 0.0, + 1.0, + ); + } + + /// Fade alpha for active menu transitions, in the range `0.0..1.0`. + double get transitionAlpha { + if (!isTransitioning) { + return 0.0; + } + final int half = _MenuManagerBase.transitionDurationMs ~/ 2; + if (_transitionElapsedMs <= half) { + return (_transitionElapsedMs / half).clamp(0.0, 1.0); + } + final int fadeInElapsed = _transitionElapsedMs - half; + return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0); + } + + /// Effect applied to the active transition. + WolfTransitionEffect get transitionEffect { + if (!isTransitioning) { + return WolfTransitionEffect.none; + } + return _transitionEffect; + } + + /// Phase of the current menu transition. + WolfTransitionPhase get transitionPhase { + if (!isTransitioning) { + return WolfTransitionPhase.idle; + } + final int half = _MenuManagerBase.transitionDurationMs ~/ 2; + if (_transitionElapsedMs < half) { + return WolfTransitionPhase.covering; + } + return WolfTransitionPhase.revealing; + } + + /// Normalized progress for the current menu transition phase. + double get transitionPhaseProgress { + if (!isTransitioning) { + return 0.0; + } + final int half = _MenuManagerBase.transitionDurationMs ~/ 2; + if (_transitionElapsedMs < half) { + return (_transitionElapsedMs / half).clamp(0.0, 1.0); + } + return ((_transitionElapsedMs - half) / half).clamp(0.0, 1.0); + } + + /// Resets menu state for startup and optionally begins the intro sequence. + @override + void beginSelectionFlow({ + required int gameCount, + int initialGameIndex = 0, + int initialEpisodeIndex = 0, + Difficulty? initialDifficulty, + bool hasResumableGame = false, + bool hasLoadableSave = false, + bool initialGameIsRetail = false, + WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade, + }) { + _gameCount = gameCount; + _showResumeOption = hasResumableGame; + _hasLoadableSave = hasLoadableSave; + _selectedMainIndex = _defaultMainMenuIndex(); + _selectedGameIndex = clampIndex(initialGameIndex, gameCount); + _selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex; + _selectedDifficultyIndex = initialDifficulty == null + ? 0 + : Difficulty.values + .indexOf(initialDifficulty) + .clamp(0, Difficulty.values.length - 1); + _introLandingMenu = WolfMenuScreen.mainMenu; + if (gameCount > 1) { + _activeMenu = WolfMenuScreen.gameSelect; + _introEffect = introEffect; + _introElapsedMs = 0; + _introPhase = _WolfIntroPhase.fadeIn; + _introSlideIndex = 0; + _introSlides = [ + WolfIntroSlide.pg13, + WolfIntroSlide.title, + ]; + } else { + _startIntroSequence( + includeRetailWarning: initialGameIsRetail, + effect: introEffect, + ); + } + _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + resetEdgeState(); + } + + /// Starts the intro splash flow and lands on [landingMenu] when complete. + void beginIntroSplash({ + WolfMenuScreen landingMenu = WolfMenuScreen.mainMenu, + bool includeRetailWarning = false, + WolfTransitionEffect effect = WolfTransitionEffect.normalFade, + }) { + _introLandingMenu = landingMenu; + _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + _startIntroSequence( + includeRetailWarning: includeRetailWarning, + effect: effect, + ); + resetEdgeState(); + } + + /// Starts a transition from the current menu to [target]. + void startTransition( + WolfMenuScreen target, { + WolfTransitionEffect effect = WolfTransitionEffect.normalFade, + }) { + if (_activeMenu == target) { + return; + } + _transitionTarget = target; + _transitionEffect = effect; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + resetEdgeState(); + } + + /// Advances active splash or menu transitions by [deltaMs]. + void tickTransition(int deltaMs) { + if (isIntroSplashActive) { + _tickIntro(deltaMs); + return; + } + + if (!isTransitioning) { + return; + } + _transitionElapsedMs += deltaMs; + final int half = _MenuManagerBase.transitionDurationMs ~/ 2; + if (!_transitionSwappedMenu && _transitionElapsedMs >= half) { + _activeMenu = _transitionTarget!; + _transitionSwappedMenu = true; + } + if (_transitionElapsedMs >= _MenuManagerBase.transitionDurationMs) { + _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + } + } + + /// Consumes input for the intro splash screen. + void updateIntroSplash(EngineInput input) { + if (!isIntroSplashActive) { + return; + } + + final bool confirmNow = input.isInteracting; + if (confirmNow && !_prevConfirm) { + if (_introPhase == _WolfIntroPhase.fadeOut) { + } else if (_introPhase == _WolfIntroPhase.hold) { + _introPhase = _WolfIntroPhase.fadeOut; + _introElapsedMs = 0; + } else { + _introAdvanceRequested = true; + } + } + + consumeEdgeState(input); + } + + void _startIntroSequence({ + required bool includeRetailWarning, + required WolfTransitionEffect effect, + }) { + _activeMenu = WolfMenuScreen.introSplash; + _introEffect = effect; + _introSlides = includeRetailWarning + ? [ + WolfIntroSlide.retailWarning, + WolfIntroSlide.pg13, + WolfIntroSlide.title, + ] + : [WolfIntroSlide.pg13, WolfIntroSlide.title]; + _introSlideIndex = 0; + _introElapsedMs = 0; + _introPhase = _WolfIntroPhase.fadeIn; + _introAdvanceRequested = false; + } + + void _tickIntro(int deltaMs) { + if (!isIntroSplashActive) { + return; + } + + _introElapsedMs += deltaMs; + + switch (_introPhase) { + case _WolfIntroPhase.fadeIn: + if (_introElapsedMs >= _MenuManagerBase.introFadeDurationMs) { + _introElapsedMs = 0; + if (_introAdvanceRequested) { + _introPhase = _WolfIntroPhase.fadeOut; + _introAdvanceRequested = false; + } else { + _introPhase = _WolfIntroPhase.hold; + } + } + break; + case _WolfIntroPhase.hold: + _introElapsedMs = 0; + if (_introAdvanceRequested) { + _introPhase = _WolfIntroPhase.fadeOut; + _introAdvanceRequested = false; + } + break; + case _WolfIntroPhase.fadeOut: + if (_introElapsedMs >= _MenuManagerBase.introFadeDurationMs) { + _advanceIntroSlide(); + } + break; + } + } + + void _advanceIntroSlide() { + if (_introSlideIndex < _introSlides.length - 1) { + _introSlideIndex += 1; + _introElapsedMs = 0; + _introPhase = _WolfIntroPhase.fadeIn; + _introAdvanceRequested = false; + return; + } + + _activeMenu = _introLandingMenu; + _introElapsedMs = 0; + _introPhase = _WolfIntroPhase.fadeIn; + _introAdvanceRequested = false; + } +} diff --git a/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_navigation_mixin.dart b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_navigation_mixin.dart new file mode 100644 index 0000000..7b87afd --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_navigation_mixin.dart @@ -0,0 +1,233 @@ +part of 'menu_manager.dart'; + +mixin _MenuManagerNavigationMixin on _MenuManagerSelectionMixin { + /// Updates main-menu navigation and returns the selected action, if any. + ({WolfMenuMainAction? selected, bool goBack}) updateMainMenu( + EngineInput input, + ) { + if (isTransitioning) { + consumeEdgeState(input); + return (selected: null, goBack: false); + } + + final _MenuAction action = _updateLinearSelection( + input, + currentIndex: selectedMainIndex, + itemCount: mainMenuEntries.length, + isSelectableIndex: _isSelectableMainIndex, + ); + _selectedMainIndex = action.index; + return ( + selected: action.confirmed + ? mainMenuEntries[_selectedMainIndex].action + : null, + goBack: action.goBack, + ); + } + + /// Updates change-view navigation and returns either a mode or option choice. + ({ + WolfRendererMode? selectedMode, + WolfRendererOptionId? selectedOption, + bool goBack, + }) + updateChangeViewMenu(EngineInput input) { + if (isTransitioning) { + consumeEdgeState(input); + return ( + selectedMode: null, + selectedOption: null, + goBack: false, + ); + } + + final _MenuAction action = _updateLinearSelection( + input, + currentIndex: selectedChangeViewIndex, + itemCount: changeViewItemCount, + isSelectableIndex: _isSelectableChangeViewIndex, + ); + _selectedChangeViewIndex = action.index; + + if (!action.confirmed) { + return ( + selectedMode: null, + selectedOption: null, + goBack: action.goBack, + ); + } + + if (_selectedChangeViewIndex < _changeViewEntries.length) { + final WolfMenuRendererEntry entry = + _changeViewEntries[_selectedChangeViewIndex]; + return ( + selectedMode: entry.mode, + selectedOption: null, + goBack: action.goBack, + ); + } + + final int optionIndex = + _selectedChangeViewIndex - _changeViewEntries.length; + if (optionIndex < 0 || optionIndex >= _rendererOptionEntries.length) { + return ( + selectedMode: null, + selectedOption: null, + goBack: action.goBack, + ); + } + _selectedRendererOptionIndex = optionIndex; + + return ( + selectedMode: null, + selectedOption: _rendererOptionEntries[optionIndex].id, + goBack: action.goBack, + ); + } + + /// Updates renderer-option navigation and returns the selected option, if any. + ({WolfRendererOptionId? selectedOption, bool goBack}) + updateRendererOptionsMenu(EngineInput input) { + if (isTransitioning) { + consumeEdgeState(input); + return (selectedOption: null, goBack: false); + } + + final _MenuAction action = _updateLinearSelection( + input, + currentIndex: selectedRendererOptionIndex, + itemCount: _rendererOptionEntries.length, + isSelectableIndex: _isSelectableRendererOptionIndex, + ); + _selectedRendererOptionIndex = action.index; + + return ( + selectedOption: action.confirmed && _rendererOptionEntries.isNotEmpty + ? _rendererOptionEntries[_selectedRendererOptionIndex].id + : null, + goBack: action.goBack, + ); + } + + /// Updates difficulty selection and returns the selected difficulty, if any. + ({Difficulty? selected, bool goBack}) updateDifficultySelection( + EngineInput input, + ) { + if (isTransitioning) { + consumeEdgeState(input); + return (selected: null, goBack: false); + } + + final bool upNow = input.isMovingForward; + final bool downNow = input.isMovingBackward; + final bool confirmNow = input.isInteracting || input.isFiring; + final bool backNow = input.isBack; + + if (upNow && !_prevUp) { + _selectedDifficultyIndex = + (_selectedDifficultyIndex - 1 + Difficulty.values.length) % + Difficulty.values.length; + } + + if (downNow && !_prevDown) { + _selectedDifficultyIndex = + (_selectedDifficultyIndex + 1) % Difficulty.values.length; + } + + Difficulty? selected; + if (confirmNow && !_prevConfirm) { + selected = Difficulty.values[_selectedDifficultyIndex]; + } + + final bool goBack = backNow && !_prevBack; + + _prevUp = upNow; + _prevDown = downNow; + _prevConfirm = confirmNow; + _prevBack = backNow; + + return (selected: selected, goBack: goBack); + } + + /// Updates game selection and returns the selected row index, if any. + ({int? selectedIndex, bool goBack}) updateGameSelection( + EngineInput input, { + required int gameCount, + }) { + if (isTransitioning) { + consumeEdgeState(input); + return (selectedIndex: null, goBack: false); + } + final _MenuAction action = _updateLinearSelection( + input, + currentIndex: selectedGameIndex, + itemCount: gameCount, + ); + _selectedGameIndex = action.index; + return ( + selectedIndex: action.confirmed ? _selectedGameIndex : null, + goBack: action.goBack, + ); + } + + /// Updates episode selection and returns the selected row index, if any. + ({int? selectedIndex, bool goBack}) updateEpisodeSelection( + EngineInput input, { + required int episodeCount, + }) { + if (isTransitioning) { + consumeEdgeState(input); + return (selectedIndex: null, goBack: false); + } + final _MenuAction action = _updateLinearSelection( + input, + currentIndex: selectedEpisodeIndex, + itemCount: episodeCount, + ); + _selectedEpisodeIndex = action.index; + return ( + selectedIndex: action.confirmed ? _selectedEpisodeIndex : null, + goBack: action.goBack, + ); + } + + _MenuAction _updateLinearSelection( + EngineInput input, { + required int currentIndex, + required int itemCount, + bool Function(int index)? isSelectableIndex, + }) { + final bool upNow = input.isMovingForward; + final bool downNow = input.isMovingBackward; + final bool confirmNow = input.isInteracting || input.isFiring; + final bool backNow = input.isBack; + + int nextIndex = clampIndex(currentIndex, itemCount); + final bool Function(int index) selectable = + isSelectableIndex ?? ((_) => true); + + if (itemCount > 0 && !selectable(nextIndex)) { + nextIndex = findSelectableIndex(nextIndex, itemCount, selectable); + } + + if (itemCount > 0) { + if (upNow && !_prevUp) { + nextIndex = moveSelectableIndex(nextIndex, itemCount, -1, selectable); + } + + if (downNow && !_prevDown) { + nextIndex = moveSelectableIndex(nextIndex, itemCount, 1, selectable); + } + } + + final bool confirmed = confirmNow && !_prevConfirm && selectable(nextIndex); + final bool goBack = backNow && !_prevBack; + + _prevUp = upNow; + _prevDown = downNow; + _prevConfirm = confirmNow; + _prevBack = backNow; + + return _MenuAction(index: nextIndex, confirmed: confirmed, goBack: goBack); + } +} diff --git a/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_selection_mixin.dart b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_selection_mixin.dart new file mode 100644 index 0000000..1d2e5c1 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager_selection_mixin.dart @@ -0,0 +1,379 @@ +part of 'menu_manager.dart'; + +mixin _MenuManagerSelectionMixin on _MenuManagerBase { + /// Index of the selected main-menu row. + int get selectedMainIndex => _selectedMainIndex; + + /// Index of the selected game row. + int get selectedGameIndex => _selectedGameIndex; + + /// Index of the selected episode row. + int get selectedEpisodeIndex => _selectedEpisodeIndex; + + /// Index of the selected row within the change-view menu. + int get selectedChangeViewIndex => _selectedChangeViewIndex; + + /// Index of the selected renderer-options row. + int get selectedRendererOptionIndex => _selectedRendererOptionIndex; + + /// Index of the selected difficulty row. + int get selectedDifficultyIndex => _selectedDifficultyIndex; + + /// Title shown above renderer-specific options. + String get rendererOptionsTitle => _rendererOptionsTitle; + + /// Renderer entries shown in the change-view menu. + List get changeViewEntries => + List.unmodifiable(_changeViewEntries); + + /// Renderer option entries shown in the customize menu. + List get rendererOptionEntries => + List.unmodifiable(_rendererOptionEntries); + + /// The currently active menu screen. + WolfMenuScreen get activeMenu => _activeMenu; + + /// Background RGB used by menu renderers. + int get menuBackgroundRgb => _menuBackgroundRgb; + + set menuBackgroundRgb(int value) { + _menuBackgroundRgb = value; + } + + /// Immutable snapshot of the current main-menu rows. + List get mainMenuEntries { + return List.unmodifiable( + [ + _mainMenuEntry( + action: WolfMenuMainAction.newGame, + label: 'NEW GAME', + ), + _mainMenuEntry( + action: WolfMenuMainAction.sound, + label: 'SOUND', + ), + _mainMenuEntry( + action: WolfMenuMainAction.control, + label: 'CONTROL', + ), + _mainMenuEntry( + action: WolfMenuMainAction.loadGame, + label: 'LOAD GAME', + ), + _mainMenuEntry( + action: WolfMenuMainAction.saveGame, + label: 'SAVE GAME', + ), + _mainMenuEntry( + action: WolfMenuMainAction.changeView, + label: 'CHANGE VIEW', + ), + _mainMenuEntry( + action: WolfMenuMainAction.readThis, + label: 'READ THIS!', + ), + _mainMenuEntry( + action: _showResumeOption + ? WolfMenuMainAction.endGame + : WolfMenuMainAction.viewScores, + label: _showResumeOption ? 'END GAME' : 'VIEW SCORES', + ), + _mainMenuEntry( + action: _showResumeOption + ? WolfMenuMainAction.backToGame + : WolfMenuMainAction.backToDemo, + label: _showResumeOption ? 'BACK TO GAME' : 'BACK TO DEMO', + ), + _mainMenuEntry(action: WolfMenuMainAction.quit, label: 'QUIT'), + ], + ); + } + + /// Whether the main menu can return to the game-selection step. + bool get canGoBackToGameSelection => !_showResumeOption && _gameCount > 1; + + /// Resets state for a fresh difficulty-only selection flow. + void beginDifficultySelection({Difficulty? initialDifficulty}) { + beginSelectionFlow( + gameCount: 1, + initialGameIndex: 0, + initialEpisodeIndex: 0, + initialDifficulty: initialDifficulty, + ); + _activeMenu = WolfMenuScreen.difficultySelect; + } + + /// Rebuilds the main menu for the current runtime state. + void showMainMenu({ + required bool hasResumableGame, + bool? hasLoadableSave, + }) { + _showResumeOption = hasResumableGame; + if (hasLoadableSave != null) { + _hasLoadableSave = hasLoadableSave; + } + final int itemCount = mainMenuEntries.length; + if (itemCount == 0) { + _selectedMainIndex = 0; + } else { + _selectedMainIndex = _defaultMainMenuIndex(); + } + _activeMenu = WolfMenuScreen.mainMenu; + _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + _introEffect = WolfTransitionEffect.normalFade; + _introElapsedMs = 0; + resetEdgeState(); + } + + /// Updates whether the LOAD GAME row is selectable. + void setLoadGameAvailable(bool isAvailable) { + if (_hasLoadableSave == isAvailable) { + return; + } + _hasLoadableSave = isAvailable; + final int itemCount = mainMenuEntries.length; + if (itemCount <= 0 || !_isSelectableMainIndex(_selectedMainIndex)) { + _selectedMainIndex = findSelectableIndex( + clampIndex(_selectedMainIndex, itemCount), + itemCount, + _isSelectableMainIndex, + ); + } + } + + /// Replaces the renderer rows displayed in the change-view menu. + void setChangeViewEntries(List entries) { + final WolfRendererMode? previouslySelectedMode = + (_selectedChangeViewIndex >= 0 && + _selectedChangeViewIndex < _changeViewEntries.length) + ? _changeViewEntries[_selectedChangeViewIndex].mode + : null; + + _changeViewEntries = List.unmodifiable(entries); + + final int itemCount = changeViewItemCount; + if (itemCount == 0) { + _selectedChangeViewIndex = 0; + return; + } + + if (previouslySelectedMode != null) { + final int modeIndex = _changeViewEntries.indexWhere( + (entry) => entry.mode == previouslySelectedMode, + ); + if (modeIndex >= 0 && _isSelectableChangeViewIndex(modeIndex)) { + _selectedChangeViewIndex = modeIndex; + return; + } + } + + _selectedChangeViewIndex = findSelectableIndex( + clampIndex(_selectedChangeViewIndex, itemCount), + itemCount, + _isSelectableChangeViewIndex, + ); + } + + /// Replaces the renderer-specific option rows displayed in the customize menu. + void setRendererOptionEntries({ + required String title, + required List entries, + }) { + final bool wasSelectingOption = + _selectedChangeViewIndex >= _changeViewEntries.length; + final WolfRendererOptionId? previousOption = + (_selectedRendererOptionIndex >= 0 && + _selectedRendererOptionIndex < _rendererOptionEntries.length) + ? _rendererOptionEntries[_selectedRendererOptionIndex].id + : null; + + _rendererOptionsTitle = title; + _rendererOptionEntries = List.unmodifiable( + entries, + ); + + final int totalCount = changeViewItemCount; + if (_rendererOptionEntries.isEmpty || totalCount == 0) { + _selectedRendererOptionIndex = 0; + if (_changeViewEntries.isNotEmpty) { + _selectedChangeViewIndex = findSelectableIndex( + 0, + _changeViewEntries.length, + _isSelectableChangeViewIndex, + ); + } + return; + } + + if (previousOption != null) { + final int previousIndex = _rendererOptionEntries.indexWhere( + (entry) => entry.id == previousOption, + ); + if (previousIndex >= 0 && + _isSelectableRendererOptionIndex(previousIndex)) { + _selectedRendererOptionIndex = previousIndex; + if (wasSelectingOption) { + _selectedChangeViewIndex = _changeViewEntries.length + previousIndex; + } + return; + } + } + + _selectedRendererOptionIndex = findSelectableIndex( + clampIndex(_selectedRendererOptionIndex, _rendererOptionEntries.length), + _rendererOptionEntries.length, + _isSelectableRendererOptionIndex, + ); + + if (wasSelectingOption) { + _selectedChangeViewIndex = + _changeViewEntries.length + _selectedRendererOptionIndex; + } else { + _selectedChangeViewIndex = findSelectableIndex( + clampIndex(_selectedChangeViewIndex, totalCount), + totalCount, + _isSelectableChangeViewIndex, + ); + } + } + + /// Switches the active menu to the renderer-selection screen. + void showChangeViewMenu() { + _activeMenu = WolfMenuScreen.changeView; + _selectedChangeViewIndex = changeViewItemCount == 0 + ? 0 + : findSelectableIndex( + 0, + changeViewItemCount, + _isSelectableChangeViewIndex, + ); + _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + resetEdgeState(); + } + + /// Switches the active menu to the renderer-options screen. + void showRendererOptionsMenu() { + _activeMenu = WolfMenuScreen.rendererOptions; + _selectedRendererOptionIndex = _rendererOptionEntries.isEmpty + ? 0 + : findSelectableIndex( + 0, + _rendererOptionEntries.length, + _isSelectableRendererOptionIndex, + ); + _transitionTarget = null; + _transitionEffect = WolfTransitionEffect.normalFade; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + resetEdgeState(); + } + + /// Clears the selected episode back to the first row. + void clearEpisodeSelection() { + _selectedEpisodeIndex = 0; + } + + /// Consumes the current input snapshot as the new edge baseline. + void absorbInputState(EngineInput input) { + consumeEdgeState(input); + } + + /// Stores the current episode selection, clamped to the available row count. + void setSelectedEpisodeIndex(int index, int episodeCount) { + _selectedEpisodeIndex = clampIndex(index, episodeCount); + } + + /// Stores the current game selection, clamped to the available row count. + void setSelectedGameIndex(int index, int gameCount) { + _selectedGameIndex = clampIndex(index, gameCount); + } + + @override + int _defaultMainMenuIndex() { + final WolfMenuMainAction target = _showResumeOption + ? WolfMenuMainAction.backToGame + : WolfMenuMainAction.newGame; + final int found = mainMenuEntries.indexWhere( + (entry) => entry.action == target, + ); + return found >= 0 + ? found + : findSelectableIndex( + 0, + mainMenuEntries.length, + _isSelectableMainIndex, + ); + } + + bool _isSelectableMainIndex(int index) { + if (index < 0 || index >= mainMenuEntries.length) { + return false; + } + return mainMenuEntries[index].isEnabled; + } + + bool _isSelectableChangeViewIndex(int index) { + if (index < 0) { + return false; + } + if (index < _changeViewEntries.length) { + return _changeViewEntries[index].isEnabled; + } + final int optionIndex = index - _changeViewEntries.length; + if (optionIndex >= 0 && optionIndex < _rendererOptionEntries.length) { + return _rendererOptionEntries[optionIndex].isEnabled; + } + return false; + } + + bool _isSelectableRendererOptionIndex(int index) { + if (index < 0 || index >= _rendererOptionEntries.length) { + return false; + } + return _rendererOptionEntries[index].isEnabled; + } + + bool _isWiredMainMenuAction(WolfMenuMainAction action) { + switch (action) { + case WolfMenuMainAction.newGame: + case WolfMenuMainAction.loadGame: + case WolfMenuMainAction.saveGame: + case WolfMenuMainAction.endGame: + case WolfMenuMainAction.backToGame: + case WolfMenuMainAction.backToDemo: + case WolfMenuMainAction.quit: + case WolfMenuMainAction.changeView: + return true; + case WolfMenuMainAction.sound: + case WolfMenuMainAction.control: + case WolfMenuMainAction.readThis: + case WolfMenuMainAction.viewScores: + return false; + } + } + + WolfMenuMainEntry _mainMenuEntry({ + required WolfMenuMainAction action, + required String label, + }) { + bool isEnabled = _isWiredMainMenuAction(action); + if (action == WolfMenuMainAction.loadGame) { + isEnabled = isEnabled && _hasLoadableSave; + } + if (action == WolfMenuMainAction.saveGame) { + isEnabled = isEnabled && _showResumeOption; + } + + return WolfMenuMainEntry( + action: action, + label: label, + isEnabled: isEnabled, + ); + } +} diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart index 7c38dbc..ce0b4e4 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -1,1084 +1,5 @@ -import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; -import 'package:wolf_3d_dart/wolf_3d_engine.dart'; +library; -enum WolfMenuScreen { - introSplash, - mainMenu, - gameSelect, - episodeSelect, - difficultySelect, - changeView, - rendererOptions, -} - -enum WolfIntroSlide { retailWarning, pg13, title } - -enum WolfTransitionEffect { none, normalFade, fizzleFade } - -enum WolfTransitionPhase { idle, covering, revealing } - -enum _WolfIntroPhase { fadeIn, hold, fadeOut } - -enum WolfMenuMainAction { - newGame, - sound, - control, - loadGame, - saveGame, - changeView, - readThis, - viewScores, - endGame, - backToGame, - backToDemo, - quit, -} - -class WolfMenuMainEntry { - const WolfMenuMainEntry({ - required this.action, - required this.label, - this.isEnabled = true, - }); - - final WolfMenuMainAction action; - final String label; - final bool isEnabled; -} - -class WolfMenuRendererEntry { - const WolfMenuRendererEntry({ - required this.mode, - required this.label, - required this.hasOptions, - this.isEnabled = true, - this.isChecked = false, - }); - - final WolfRendererMode mode; - final String label; - final bool hasOptions; - final bool isEnabled; - final bool isChecked; -} - -class WolfMenuRendererOptionEntry { - const WolfMenuRendererOptionEntry({ - required this.id, - required this.label, - this.isEnabled = true, - this.isChecked = false, - }); - - final WolfRendererOptionId id; - final String label; - final bool isEnabled; - final bool isChecked; -} - -bool _isWiredMainMenuAction(WolfMenuMainAction action) { - switch (action) { - case WolfMenuMainAction.newGame: - case WolfMenuMainAction.loadGame: - case WolfMenuMainAction.saveGame: - case WolfMenuMainAction.endGame: - case WolfMenuMainAction.backToGame: - case WolfMenuMainAction.backToDemo: - case WolfMenuMainAction.quit: - return true; - case WolfMenuMainAction.changeView: - return true; - case WolfMenuMainAction.sound: - case WolfMenuMainAction.control: - case WolfMenuMainAction.readThis: - case WolfMenuMainAction.viewScores: - return false; - } -} - -/// Handles menu-only input state such as selection movement and edge triggers. -class MenuManager { - static const int transitionDurationMs = 280; - static const int introFadeDurationMs = 280; - static const int _introRetailBackgroundRgb = 0xA00000; - static const int _introPg13BackgroundRgb = 0x33A2E8; - static const int _introTitleBackgroundRgb = 0x000000; - - WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect; - WolfMenuScreen? _transitionTarget; - WolfTransitionEffect _transitionEffect = WolfTransitionEffect.normalFade; - int _transitionElapsedMs = 0; - bool _transitionSwappedMenu = false; - WolfMenuScreen _introLandingMenu = WolfMenuScreen.mainMenu; - WolfTransitionEffect _introEffect = WolfTransitionEffect.normalFade; - int _introSlideIndex = 0; - int _introElapsedMs = 0; - _WolfIntroPhase _introPhase = _WolfIntroPhase.fadeIn; - bool _introAdvanceRequested = false; - List _introSlides = [ - WolfIntroSlide.pg13, - WolfIntroSlide.title, - ]; - - int _selectedMainIndex = 0; - int _selectedGameIndex = 0; - int _selectedEpisodeIndex = 0; - int _selectedDifficultyIndex = 0; - int _selectedChangeViewIndex = 0; - int _selectedRendererOptionIndex = 0; - String _rendererOptionsTitle = 'CUSTOMIZE'; - List _changeViewEntries = - const []; - List _rendererOptionEntries = - const []; - bool _showResumeOption = false; - bool _hasLoadableSave = false; - int _gameCount = 1; - - bool _prevUp = false; - bool _prevDown = false; - bool _prevConfirm = false; - bool _prevBack = false; - - /// Universal menu background color in 24-bit RGB used by menu screens. - int menuBackgroundRgb = 0x890000; - - WolfMenuScreen get activeMenu => _activeMenu; - - bool get isTransitioning => _transitionTarget != null; - - bool get isIntroSplashActive => _activeMenu == WolfMenuScreen.introSplash; - - WolfIntroSlide get currentIntroSlide { - if (_introSlides.isEmpty) { - return WolfIntroSlide.title; - } - final int index = _introSlideIndex.clamp(0, _introSlides.length - 1); - return _introSlides[index]; - } - - bool get isIntroRetailWarningSlide => - currentIntroSlide == WolfIntroSlide.retailWarning; - - bool get isIntroPg13Slide => currentIntroSlide == WolfIntroSlide.pg13; - - bool get isIntroTitleSlide => currentIntroSlide == WolfIntroSlide.title; - - int get introBackgroundRgb { - switch (currentIntroSlide) { - case WolfIntroSlide.retailWarning: - return _introRetailBackgroundRgb; - case WolfIntroSlide.pg13: - return _introPg13BackgroundRgb; - case WolfIntroSlide.title: - return _introTitleBackgroundRgb; - } - } - - double get introOverlayAlpha { - if (!isIntroSplashActive) { - return 0.0; - } - switch (_introPhase) { - case _WolfIntroPhase.fadeIn: - return (1.0 - (_introElapsedMs / introFadeDurationMs)).clamp(0.0, 1.0); - case _WolfIntroPhase.hold: - return 0.0; - case _WolfIntroPhase.fadeOut: - return (_introElapsedMs / introFadeDurationMs).clamp(0.0, 1.0); - } - } - - WolfTransitionEffect get introOverlayEffect { - if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) { - return WolfTransitionEffect.none; - } - return _introEffect; - } - - WolfTransitionPhase get introOverlayPhase { - if (!isIntroSplashActive) { - return WolfTransitionPhase.idle; - } - switch (_introPhase) { - case _WolfIntroPhase.fadeIn: - return WolfTransitionPhase.revealing; - case _WolfIntroPhase.hold: - return WolfTransitionPhase.idle; - case _WolfIntroPhase.fadeOut: - return WolfTransitionPhase.covering; - } - } - - double get introOverlayPhaseProgress { - if (!isIntroSplashActive || _introPhase == _WolfIntroPhase.hold) { - return 0.0; - } - return (_introElapsedMs / introFadeDurationMs).clamp(0.0, 1.0); - } - - /// Returns the fade alpha during transitions (0.0..1.0). - double get transitionAlpha { - if (!isTransitioning) { - return 0.0; - } - final int half = transitionDurationMs ~/ 2; - if (_transitionElapsedMs <= half) { - return (_transitionElapsedMs / half).clamp(0.0, 1.0); - } - final int fadeInElapsed = _transitionElapsedMs - half; - return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0); - } - - WolfTransitionEffect get transitionEffect { - if (!isTransitioning) { - return WolfTransitionEffect.none; - } - return _transitionEffect; - } - - WolfTransitionPhase get transitionPhase { - if (!isTransitioning) { - return WolfTransitionPhase.idle; - } - final int half = transitionDurationMs ~/ 2; - if (_transitionElapsedMs < half) { - return WolfTransitionPhase.covering; - } - return WolfTransitionPhase.revealing; - } - - double get transitionPhaseProgress { - if (!isTransitioning) { - return 0.0; - } - final int half = transitionDurationMs ~/ 2; - if (_transitionElapsedMs < half) { - return (_transitionElapsedMs / half).clamp(0.0, 1.0); - } - return ((_transitionElapsedMs - half) / half).clamp(0.0, 1.0); - } - - int get selectedMainIndex => _selectedMainIndex; - - int get selectedGameIndex => _selectedGameIndex; - - int get selectedEpisodeIndex => _selectedEpisodeIndex; - - int get selectedChangeViewIndex => _selectedChangeViewIndex; - - int get selectedRendererOptionIndex => _selectedRendererOptionIndex; - - String get rendererOptionsTitle => _rendererOptionsTitle; - - List get changeViewEntries => - List.unmodifiable(_changeViewEntries); - - List get rendererOptionEntries => - List.unmodifiable(_rendererOptionEntries); - - List get mainMenuEntries { - return List.unmodifiable( - [ - _mainMenuEntry( - action: WolfMenuMainAction.newGame, - label: 'NEW GAME', - ), - _mainMenuEntry( - action: WolfMenuMainAction.sound, - label: 'SOUND', - ), - _mainMenuEntry( - action: WolfMenuMainAction.control, - label: 'CONTROL', - ), - _mainMenuEntry( - action: WolfMenuMainAction.loadGame, - label: 'LOAD GAME', - ), - _mainMenuEntry( - action: WolfMenuMainAction.saveGame, - label: 'SAVE GAME', - ), - _mainMenuEntry( - action: WolfMenuMainAction.changeView, - label: 'CHANGE VIEW', - ), - _mainMenuEntry( - action: WolfMenuMainAction.readThis, - label: 'READ THIS!', - ), - _mainMenuEntry( - action: _showResumeOption - ? WolfMenuMainAction.endGame - : WolfMenuMainAction.viewScores, - label: _showResumeOption ? 'END GAME' : 'VIEW SCORES', - ), - _mainMenuEntry( - action: _showResumeOption - ? WolfMenuMainAction.backToGame - : WolfMenuMainAction.backToDemo, - label: _showResumeOption ? 'BACK TO GAME' : 'BACK TO DEMO', - ), - _mainMenuEntry(action: WolfMenuMainAction.quit, label: 'QUIT'), - ], - ); - } - - /// Current selected difficulty row index. - int get selectedDifficultyIndex => _selectedDifficultyIndex; - - /// Resets menu state for startup, optionally skipping game selection. - void beginSelectionFlow({ - required int gameCount, - int initialGameIndex = 0, - int initialEpisodeIndex = 0, - Difficulty? initialDifficulty, - bool hasResumableGame = false, - bool hasLoadableSave = false, - bool initialGameIsRetail = false, - WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade, - }) { - _gameCount = gameCount; - _showResumeOption = hasResumableGame; - _hasLoadableSave = hasLoadableSave; - _selectedMainIndex = _defaultMainMenuIndex(); - _selectedGameIndex = _clampIndex(initialGameIndex, gameCount); - _selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex; - _selectedDifficultyIndex = initialDifficulty == null - ? 0 - : Difficulty.values - .indexOf(initialDifficulty) - .clamp(0, Difficulty.values.length - 1); - _introLandingMenu = WolfMenuScreen.mainMenu; - if (gameCount > 1) { - _activeMenu = WolfMenuScreen.gameSelect; - _introEffect = introEffect; - _introElapsedMs = 0; - _introPhase = _WolfIntroPhase.fadeIn; - _introSlideIndex = 0; - _introSlides = [ - WolfIntroSlide.pg13, - WolfIntroSlide.title, - ]; - } else { - _startIntroSequence( - includeRetailWarning: initialGameIsRetail, - effect: introEffect, - ); - } - _transitionTarget = null; - _transitionEffect = WolfTransitionEffect.normalFade; - _transitionElapsedMs = 0; - _transitionSwappedMenu = false; - _resetEdgeState(); - } - - /// Starts the intro splash sequence and lands on [landingMenu] when done. - void beginIntroSplash({ - WolfMenuScreen landingMenu = WolfMenuScreen.mainMenu, - bool includeRetailWarning = false, - WolfTransitionEffect effect = WolfTransitionEffect.normalFade, - }) { - _introLandingMenu = landingMenu; - _transitionTarget = null; - _transitionEffect = WolfTransitionEffect.normalFade; - _transitionElapsedMs = 0; - _transitionSwappedMenu = false; - _startIntroSequence( - includeRetailWarning: includeRetailWarning, - effect: effect, - ); - _resetEdgeState(); - } - - /// Resets menu navigation state for a new difficulty selection flow. - void beginDifficultySelection({Difficulty? initialDifficulty}) { - beginSelectionFlow( - gameCount: 1, - initialGameIndex: 0, - initialEpisodeIndex: 0, - initialDifficulty: initialDifficulty, - ); - _activeMenu = WolfMenuScreen.difficultySelect; - } - - void showMainMenu({ - required bool hasResumableGame, - bool? hasLoadableSave, - }) { - _showResumeOption = hasResumableGame; - if (hasLoadableSave != null) { - _hasLoadableSave = hasLoadableSave; - } - final int itemCount = mainMenuEntries.length; - if (itemCount == 0) { - _selectedMainIndex = 0; - } else { - _selectedMainIndex = _defaultMainMenuIndex(); - } - _activeMenu = WolfMenuScreen.mainMenu; - _transitionTarget = null; - _transitionEffect = WolfTransitionEffect.normalFade; - _transitionElapsedMs = 0; - _transitionSwappedMenu = false; - _introEffect = WolfTransitionEffect.normalFade; - _introElapsedMs = 0; - _resetEdgeState(); - } - - void setLoadGameAvailable(bool isAvailable) { - if (_hasLoadableSave == isAvailable) { - return; - } - _hasLoadableSave = isAvailable; - final int itemCount = mainMenuEntries.length; - if (itemCount <= 0 || !_isSelectableMainIndex(_selectedMainIndex)) { - _selectedMainIndex = _findSelectableIndex( - _clampIndex(_selectedMainIndex, itemCount), - itemCount, - _isSelectableMainIndex, - ); - } - } - - int get _changeViewItemCount => - _changeViewEntries.length + _rendererOptionEntries.length; - - void setChangeViewEntries(List entries) { - final WolfRendererMode? previouslySelectedMode = - (_selectedChangeViewIndex >= 0 && - _selectedChangeViewIndex < _changeViewEntries.length) - ? _changeViewEntries[_selectedChangeViewIndex].mode - : null; - - _changeViewEntries = List.unmodifiable(entries); - - final int itemCount = _changeViewItemCount; - if (itemCount == 0) { - _selectedChangeViewIndex = 0; - return; - } - - if (previouslySelectedMode != null) { - final int modeIndex = _changeViewEntries.indexWhere( - (entry) => entry.mode == previouslySelectedMode, - ); - if (modeIndex >= 0 && _isSelectableChangeViewIndex(modeIndex)) { - _selectedChangeViewIndex = modeIndex; - return; - } - } - - _selectedChangeViewIndex = _findSelectableIndex( - _clampIndex(_selectedChangeViewIndex, itemCount), - itemCount, - _isSelectableChangeViewIndex, - ); - } - - void setRendererOptionEntries({ - required String title, - required List entries, - }) { - final bool wasSelectingOption = - _selectedChangeViewIndex >= _changeViewEntries.length; - final WolfRendererOptionId? previousOption = - (_selectedRendererOptionIndex >= 0 && - _selectedRendererOptionIndex < _rendererOptionEntries.length) - ? _rendererOptionEntries[_selectedRendererOptionIndex].id - : null; - - _rendererOptionsTitle = title; - _rendererOptionEntries = List.unmodifiable( - entries, - ); - - final int totalCount = _changeViewItemCount; - if (_rendererOptionEntries.isEmpty || totalCount == 0) { - _selectedRendererOptionIndex = 0; - if (_changeViewEntries.isNotEmpty) { - _selectedChangeViewIndex = _findSelectableIndex( - 0, - _changeViewEntries.length, - _isSelectableChangeViewIndex, - ); - } - return; - } - - if (previousOption != null) { - final int previousIndex = _rendererOptionEntries.indexWhere( - (entry) => entry.id == previousOption, - ); - if (previousIndex >= 0 && - _isSelectableRendererOptionIndex(previousIndex)) { - _selectedRendererOptionIndex = previousIndex; - if (wasSelectingOption) { - _selectedChangeViewIndex = _changeViewEntries.length + previousIndex; - } - return; - } - } - - _selectedRendererOptionIndex = _findSelectableIndex( - _clampIndex(_selectedRendererOptionIndex, _rendererOptionEntries.length), - _rendererOptionEntries.length, - _isSelectableRendererOptionIndex, - ); - - if (wasSelectingOption) { - _selectedChangeViewIndex = - _changeViewEntries.length + _selectedRendererOptionIndex; - } else { - _selectedChangeViewIndex = _findSelectableIndex( - _clampIndex(_selectedChangeViewIndex, totalCount), - totalCount, - _isSelectableChangeViewIndex, - ); - } - } - - void showChangeViewMenu() { - _activeMenu = WolfMenuScreen.changeView; - _selectedChangeViewIndex = _changeViewItemCount == 0 - ? 0 - : _findSelectableIndex( - 0, - _changeViewItemCount, - _isSelectableChangeViewIndex, - ); - _transitionTarget = null; - _transitionEffect = WolfTransitionEffect.normalFade; - _transitionElapsedMs = 0; - _transitionSwappedMenu = false; - _resetEdgeState(); - } - - void showRendererOptionsMenu() { - _activeMenu = WolfMenuScreen.rendererOptions; - _selectedRendererOptionIndex = _rendererOptionEntries.isEmpty - ? 0 - : _findSelectableIndex( - 0, - _rendererOptionEntries.length, - _isSelectableRendererOptionIndex, - ); - _transitionTarget = null; - _transitionEffect = WolfTransitionEffect.normalFade; - _transitionElapsedMs = 0; - _transitionSwappedMenu = false; - _resetEdgeState(); - } - - /// Starts a menu transition. Input is locked until it completes. - /// - /// Hosts can reuse this fade timing for future pre-menu splash/image - /// sequences so transitions feel consistent across the whole app. - void startTransition( - WolfMenuScreen target, { - WolfTransitionEffect effect = WolfTransitionEffect.normalFade, - }) { - if (_activeMenu == target) { - return; - } - _transitionTarget = target; - _transitionEffect = effect; - _transitionElapsedMs = 0; - _transitionSwappedMenu = false; - _resetEdgeState(); - } - - /// Advances transition timers and swaps menu at midpoint. - void tickTransition(int deltaMs) { - if (isIntroSplashActive) { - _tickIntro(deltaMs); - return; - } - - if (!isTransitioning) { - return; - } - _transitionElapsedMs += deltaMs; - final int half = transitionDurationMs ~/ 2; - if (!_transitionSwappedMenu && _transitionElapsedMs >= half) { - _activeMenu = _transitionTarget!; - _transitionSwappedMenu = true; - } - if (_transitionElapsedMs >= transitionDurationMs) { - _transitionTarget = null; - _transitionEffect = WolfTransitionEffect.normalFade; - _transitionElapsedMs = 0; - _transitionSwappedMenu = false; - } - } - - void updateIntroSplash(EngineInput input) { - if (!isIntroSplashActive) { - return; - } - - final bool confirmNow = input.isInteracting; - if (confirmNow && !_prevConfirm) { - if (_introPhase == _WolfIntroPhase.fadeOut) { - // Ignore repeat confirms while already transitioning out. - } else if (_introPhase == _WolfIntroPhase.hold) { - _introPhase = _WolfIntroPhase.fadeOut; - _introElapsedMs = 0; - } else { - // Queue advance while fade-in is still in progress. - _introAdvanceRequested = true; - } - } - - _consumeEdgeState(input); - } - - void _startIntroSequence({ - required bool includeRetailWarning, - required WolfTransitionEffect effect, - }) { - _activeMenu = WolfMenuScreen.introSplash; - _introEffect = effect; - _introSlides = includeRetailWarning - ? [ - WolfIntroSlide.retailWarning, - WolfIntroSlide.pg13, - WolfIntroSlide.title, - ] - : [WolfIntroSlide.pg13, WolfIntroSlide.title]; - _introSlideIndex = 0; - _introElapsedMs = 0; - _introPhase = _WolfIntroPhase.fadeIn; - _introAdvanceRequested = false; - } - - void _tickIntro(int deltaMs) { - if (!isIntroSplashActive) { - return; - } - - _introElapsedMs += deltaMs; - - switch (_introPhase) { - case _WolfIntroPhase.fadeIn: - if (_introElapsedMs >= introFadeDurationMs) { - _introElapsedMs = 0; - if (_introAdvanceRequested) { - _introPhase = _WolfIntroPhase.fadeOut; - _introAdvanceRequested = false; - } else { - _introPhase = _WolfIntroPhase.hold; - } - } - break; - case _WolfIntroPhase.hold: - // Hold indefinitely until the user confirms. - _introElapsedMs = 0; - if (_introAdvanceRequested) { - _introPhase = _WolfIntroPhase.fadeOut; - _introAdvanceRequested = false; - } - break; - case _WolfIntroPhase.fadeOut: - if (_introElapsedMs >= introFadeDurationMs) { - _advanceIntroSlide(); - } - break; - } - } - - void _advanceIntroSlide() { - if (_introSlideIndex < _introSlides.length - 1) { - _introSlideIndex += 1; - _introElapsedMs = 0; - _introPhase = _WolfIntroPhase.fadeIn; - _introAdvanceRequested = false; - return; - } - - _activeMenu = _introLandingMenu; - _introElapsedMs = 0; - _introPhase = _WolfIntroPhase.fadeIn; - _introAdvanceRequested = false; - } - - void clearEpisodeSelection() { - _selectedEpisodeIndex = 0; - } - - /// Consumes current input as the new edge baseline. - void absorbInputState(EngineInput input) { - _consumeEdgeState(input); - } - - void setSelectedEpisodeIndex(int index, int episodeCount) { - _selectedEpisodeIndex = _clampIndex(index, episodeCount); - } - - void setSelectedGameIndex(int index, int gameCount) { - _selectedGameIndex = _clampIndex(index, gameCount); - } - - ({WolfMenuMainAction? selected, bool goBack}) updateMainMenu( - EngineInput input, - ) { - if (isTransitioning) { - _consumeEdgeState(input); - return (selected: null, goBack: false); - } - - final _MenuAction action = _updateLinearSelection( - input, - currentIndex: _selectedMainIndex, - itemCount: mainMenuEntries.length, - isSelectableIndex: _isSelectableMainIndex, - ); - _selectedMainIndex = action.index; - return ( - selected: action.confirmed - ? mainMenuEntries[_selectedMainIndex].action - : null, - goBack: action.goBack, - ); - } - - ({ - WolfRendererMode? selectedMode, - WolfRendererOptionId? selectedOption, - bool goBack, - }) - updateChangeViewMenu(EngineInput input) { - if (isTransitioning) { - _consumeEdgeState(input); - return ( - selectedMode: null, - selectedOption: null, - goBack: false, - ); - } - - final _MenuAction action = _updateLinearSelection( - input, - currentIndex: _selectedChangeViewIndex, - itemCount: _changeViewItemCount, - isSelectableIndex: _isSelectableChangeViewIndex, - ); - _selectedChangeViewIndex = action.index; - - if (!action.confirmed) { - return ( - selectedMode: null, - selectedOption: null, - goBack: action.goBack, - ); - } - - if (_selectedChangeViewIndex < _changeViewEntries.length) { - final WolfMenuRendererEntry entry = - _changeViewEntries[_selectedChangeViewIndex]; - return ( - selectedMode: entry.mode, - selectedOption: null, - goBack: action.goBack, - ); - } - - final int optionIndex = - _selectedChangeViewIndex - _changeViewEntries.length; - if (optionIndex < 0 || optionIndex >= _rendererOptionEntries.length) { - return ( - selectedMode: null, - selectedOption: null, - goBack: action.goBack, - ); - } - _selectedRendererOptionIndex = optionIndex; - - return ( - selectedMode: null, - selectedOption: _rendererOptionEntries[optionIndex].id, - goBack: action.goBack, - ); - } - - ({WolfRendererOptionId? selectedOption, bool goBack}) - updateRendererOptionsMenu(EngineInput input) { - if (isTransitioning) { - _consumeEdgeState(input); - return (selectedOption: null, goBack: false); - } - - final _MenuAction action = _updateLinearSelection( - input, - currentIndex: _selectedRendererOptionIndex, - itemCount: _rendererOptionEntries.length, - isSelectableIndex: _isSelectableRendererOptionIndex, - ); - _selectedRendererOptionIndex = action.index; - - return ( - selectedOption: action.confirmed && _rendererOptionEntries.isNotEmpty - ? _rendererOptionEntries[_selectedRendererOptionIndex].id - : null, - goBack: action.goBack, - ); - } - - /// Returns a menu action snapshot for this frame. - ({Difficulty? selected, bool goBack}) updateDifficultySelection( - EngineInput input, - ) { - if (isTransitioning) { - _consumeEdgeState(input); - return (selected: null, goBack: false); - } - - final upNow = input.isMovingForward; - final downNow = input.isMovingBackward; - final confirmNow = input.isInteracting || input.isFiring; - final backNow = input.isBack; - - if (upNow && !_prevUp) { - _selectedDifficultyIndex = - (_selectedDifficultyIndex - 1 + Difficulty.values.length) % - Difficulty.values.length; - } - - if (downNow && !_prevDown) { - _selectedDifficultyIndex = - (_selectedDifficultyIndex + 1) % Difficulty.values.length; - } - - Difficulty? selected; - if (confirmNow && !_prevConfirm) { - selected = Difficulty.values[_selectedDifficultyIndex]; - } - - final bool goBack = backNow && !_prevBack; - - _prevUp = upNow; - _prevDown = downNow; - _prevConfirm = confirmNow; - _prevBack = backNow; - - return (selected: selected, goBack: goBack); - } - - ({int? selectedIndex, bool goBack}) updateGameSelection( - EngineInput input, { - required int gameCount, - }) { - if (isTransitioning) { - _consumeEdgeState(input); - return (selectedIndex: null, goBack: false); - } - final _MenuAction action = _updateLinearSelection( - input, - currentIndex: _selectedGameIndex, - itemCount: gameCount, - ); - _selectedGameIndex = action.index; - return ( - selectedIndex: action.confirmed ? _selectedGameIndex : null, - goBack: action.goBack, - ); - } - - ({int? selectedIndex, bool goBack}) updateEpisodeSelection( - EngineInput input, { - required int episodeCount, - }) { - if (isTransitioning) { - _consumeEdgeState(input); - return (selectedIndex: null, goBack: false); - } - final _MenuAction action = _updateLinearSelection( - input, - currentIndex: _selectedEpisodeIndex, - itemCount: episodeCount, - ); - _selectedEpisodeIndex = action.index; - return ( - selectedIndex: action.confirmed ? _selectedEpisodeIndex : null, - goBack: action.goBack, - ); - } - - _MenuAction _updateLinearSelection( - EngineInput input, { - required int currentIndex, - required int itemCount, - bool Function(int index)? isSelectableIndex, - }) { - final upNow = input.isMovingForward; - final downNow = input.isMovingBackward; - final confirmNow = input.isInteracting || input.isFiring; - final backNow = input.isBack; - - int nextIndex = _clampIndex(currentIndex, itemCount); - final bool Function(int index) selectable = - isSelectableIndex ?? ((_) => true); - - if (itemCount > 0 && !selectable(nextIndex)) { - nextIndex = _findSelectableIndex(nextIndex, itemCount, selectable); - } - - if (itemCount > 0) { - if (upNow && !_prevUp) { - nextIndex = _moveSelectableIndex(nextIndex, itemCount, -1, selectable); - } - - if (downNow && !_prevDown) { - nextIndex = _moveSelectableIndex(nextIndex, itemCount, 1, selectable); - } - } - - final bool confirmed = confirmNow && !_prevConfirm && selectable(nextIndex); - final bool goBack = backNow && !_prevBack; - - _prevUp = upNow; - _prevDown = downNow; - _prevConfirm = confirmNow; - _prevBack = backNow; - - return _MenuAction(index: nextIndex, confirmed: confirmed, goBack: goBack); - } - - void _consumeEdgeState(EngineInput input) { - _prevUp = input.isMovingForward; - _prevDown = input.isMovingBackward; - _prevConfirm = input.isInteracting || input.isFiring; - _prevBack = input.isBack; - } - - void _resetEdgeState() { - _prevUp = false; - _prevDown = false; - _prevConfirm = false; - _prevBack = false; - } - - int _defaultMainMenuIndex() { - final WolfMenuMainAction target = _showResumeOption - ? WolfMenuMainAction.backToGame - : WolfMenuMainAction.newGame; - final int found = mainMenuEntries.indexWhere( - (entry) => entry.action == target, - ); - return found >= 0 - ? found - : _findSelectableIndex( - 0, - mainMenuEntries.length, - _isSelectableMainIndex, - ); - } - - bool get canGoBackToGameSelection => !_showResumeOption && _gameCount > 1; - - bool _isSelectableMainIndex(int index) { - if (index < 0 || index >= mainMenuEntries.length) { - return false; - } - return mainMenuEntries[index].isEnabled; - } - - bool _isSelectableChangeViewIndex(int index) { - if (index < 0) { - return false; - } - if (index < _changeViewEntries.length) { - return _changeViewEntries[index].isEnabled; - } - final int optionIndex = index - _changeViewEntries.length; - if (optionIndex >= 0 && optionIndex < _rendererOptionEntries.length) { - return _rendererOptionEntries[optionIndex].isEnabled; - } - return false; - } - - bool _isSelectableRendererOptionIndex(int index) { - if (index < 0 || index >= _rendererOptionEntries.length) { - return false; - } - return _rendererOptionEntries[index].isEnabled; - } - - WolfMenuMainEntry _mainMenuEntry({ - required WolfMenuMainAction action, - required String label, - }) { - bool isEnabled = _isWiredMainMenuAction(action); - if (action == WolfMenuMainAction.loadGame) { - isEnabled = isEnabled && _hasLoadableSave; - } - if (action == WolfMenuMainAction.saveGame) { - isEnabled = isEnabled && _showResumeOption; - } - - return WolfMenuMainEntry( - action: action, - label: label, - isEnabled: isEnabled, - ); - } - - int _findSelectableIndex( - int startIndex, - int itemCount, - bool Function(int index) selectable, - ) { - if (itemCount <= 0) { - return 0; - } - for (int offset = 0; offset < itemCount; offset++) { - final int index = (startIndex + offset) % itemCount; - if (selectable(index)) { - return index; - } - } - return _clampIndex(startIndex, itemCount); - } - - int _moveSelectableIndex( - int currentIndex, - int itemCount, - int delta, - bool Function(int index) selectable, - ) { - if (itemCount <= 0) { - return 0; - } - int index = currentIndex; - for (int step = 0; step < itemCount; step++) { - index = (index + delta + itemCount) % itemCount; - if (selectable(index)) { - return index; - } - } - return currentIndex; - } - - int _clampIndex(int index, int itemCount) { - if (itemCount <= 0) { - return 0; - } - return index.clamp(0, itemCount - 1); - } - - /// Whether to show the alternate cursor frame at [elapsedMs]. - bool isCursorAltFrame(int elapsedMs) => ((elapsedMs ~/ 220) % 2) == 1; -} - -class _MenuAction { - const _MenuAction({ - required this.index, - required this.confirmed, - required this.goBack, - }); - - final int index; - final bool confirmed; - final bool goBack; -} +export 'manager/menu_manager.dart' show MenuManager; +export 'manager/menu_manager_entries.dart'; +export 'manager/menu_manager_enums.dart'; diff --git a/packages/wolf_3d_dart/lib/src/menu/wolf_menu_pic.dart b/packages/wolf_3d_dart/lib/src/menu/wolf_menu_pic.dart new file mode 100644 index 0000000..001969e --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/wolf_menu_pic.dart @@ -0,0 +1,40 @@ +/// Known VGA picture indexes used by the original Wolf3D control-panel menus. +/// +/// Values below are picture-table indexes (not raw chunk ids). +/// For example, `C_CONTROLPIC` is chunk 26 in `GFXV_WL6.H`, so its picture +/// index is `26 - STARTPICS(3) = 23`. +abstract class WolfMenuPic { + static const int hBj = 0; // H_BJPIC + static const int hTopWindow = 3; // H_TOPWINDOWPIC + static const int cOptions = 7; // C_OPTIONSPIC + static const int cCursor1 = 8; // C_CURSOR1PIC + static const int cCursor2 = 9; // C_CURSOR2PIC + static const int cNotSelected = 10; // C_NOTSELECTEDPIC + static const int cSelected = 11; // C_SELECTEDPIC + static const int cBabyMode = 16; // C_BABYMODEPIC + static const int cEasy = 17; // C_EASYPIC + static const int cNormal = 18; // C_NORMALPIC + static const int cHard = 19; // C_HARDPIC + static const int cControl = 23; // C_CONTROLPIC + static const int cCustomize = 24; // C_CUSTOMIZEPIC + static const int cEpisode1 = 27; // C_EPISODE1PIC + static const int cEpisode2 = 28; // C_EPISODE2PIC + static const int cEpisode3 = 29; // C_EPISODE3PIC + static const int cEpisode4 = 30; // C_EPISODE4PIC + static const int cEpisode5 = 31; // C_EPISODE5PIC + static const int cEpisode6 = 32; // C_EPISODE6PIC + static const int statusBar = 83; // STATUSBARPIC + static const int title = 84; // TITLEPIC + static const int pg13 = 85; // PG13PIC + static const int credits = 86; // CREDITSPIC + static const int highScores = 87; // HIGHSCORESPIC + + static const List episodePics = [ + cEpisode1, + cEpisode2, + cEpisode3, + cEpisode4, + cEpisode5, + cEpisode6, + ]; +} diff --git a/packages/wolf_3d_dart/lib/src/menu/wolf_menu_presentation.dart b/packages/wolf_3d_dart/lib/src/menu/wolf_menu_presentation.dart new file mode 100644 index 0000000..cc8b469 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/wolf_menu_presentation.dart @@ -0,0 +1,153 @@ +import 'package:wolf_3d_dart/src/registry/built_in/classic_menu_presentation_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/spear_menu_presentation_module.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +/// Bound access to the active menu presentation for a loaded data set. +/// +/// Renderers should construct this from [WolfensteinData] and consume its +/// color and art accessors instead of hard-coding variant-specific menu rules. +class WolfMenuPresentation { + /// Loaded game data used to resolve art assets. + /// + /// This is `null` for host-owned fallback presentations such as setup + /// screens that need menu colors before any game data has been loaded. + final WolfensteinData? data; + + /// Presentation module that supplies colors and symbolic art lookups. + final MenuPresentationModule _module; + + /// Binds the active menu presentation from [data.registry]. + factory WolfMenuPresentation(WolfensteinData data) { + return WolfMenuPresentation.module( + data.registry.menuPresentation, + data: data, + ); + } + + /// Creates a presentation from an explicit module without loaded game data. + /// + /// This is useful for host UI that wants menu-consistent colors before any + /// game assets have been discovered. + const WolfMenuPresentation.module(this._module, {this.data}); + + /// Classic fallback presentation for host-owned UI outside a loaded game. + const WolfMenuPresentation.classic() + : this.module(const ClassicMenuPresentationModule()); + + /// Spear fallback presentation for host-owned UI outside a loaded game. + const WolfMenuPresentation.spear() + : this.module(const SpearMenuPresentationModule()); + + /// VGA palette index used for menu background fills and header band accents. + int get backgroundIndex => _module.backgroundIndex; + + /// VGA palette index used for menu panel fills. + int get panelIndex => _module.panelIndex; + + /// VGA palette index used for menu panel borders and separators. + int get borderIndex => _module.borderIndex; + + /// VGA palette index used for emphasized or affirmative UI text. + int get emphasisIndex => _module.emphasisIndex; + + /// VGA palette index used for warnings and cautionary text. + int get warningIndex => _module.warningIndex; + + /// VGA palette index used for subdued or de-emphasized text. + int get mutedIndex => _module.mutedIndex; + + /// VGA palette index used for the selected menu row text. + int get selectedTextIndex => _module.selectedTextIndex; + + /// VGA palette index used for normal menu row text. + int get unselectedTextIndex => _module.unselectedTextIndex; + + /// VGA palette index used for disabled menu row text. + int get disabledTextIndex => _module.disabledTextIndex; + + /// VGA palette index used for headings. + int get headerTextIndex => _module.headerTextIndex; + + /// Background color resolved to `0xAARRGGBB`. + int get backgroundColor => ColorPalette.argbFromVgaIndex(backgroundIndex); + + /// Panel fill color resolved to `0xAARRGGBB`. + int get panelColor => ColorPalette.argbFromVgaIndex(panelIndex); + + /// Border color resolved to `0xAARRGGBB`. + int get borderColor => ColorPalette.argbFromVgaIndex(borderIndex); + + /// Heading color resolved to `0xAARRGGBB`. + int get titleColor => ColorPalette.argbFromVgaIndex(headerTextIndex); + + /// Standard body text color resolved to `0xAARRGGBB`. + int get bodyColor => ColorPalette.argbFromVgaIndex(unselectedTextIndex); + + /// Emphasis color resolved to `0xAARRGGBB`. + int get emphasisColor => ColorPalette.argbFromVgaIndex(emphasisIndex); + + /// Warning color resolved to `0xAARRGGBB`. + int get warningColor => ColorPalette.argbFromVgaIndex(warningIndex); + + /// Muted color resolved to `0xAARRGGBB`. + int get mutedColor => ColorPalette.argbFromVgaIndex(mutedIndex); + + /// Selected text color resolved through the VGA 32-bit table. + int get selectedTextColor => ColorPalette.vga32Bit[selectedTextIndex]; + + /// Normal text color resolved through the VGA 32-bit table. + int get unselectedTextColor => ColorPalette.vga32Bit[unselectedTextIndex]; + + /// Disabled text color resolved through the VGA 32-bit table. + int get disabledTextColor => ColorPalette.vga32Bit[disabledTextIndex]; + + /// Heading text color resolved through the VGA 32-bit table. + int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex]; + + /// Background image used by the controls/customize panel, if available. + VgaImage? get controlBackground => + data == null ? null : _module.controlBackground(data!); + + /// Title splash image, if this presentation exposes one. + VgaImage? get title => data == null ? null : _module.title(data!); + + /// Main menu heading art, if available. + VgaImage? get heading => data == null ? null : _module.heading(data!); + + /// Selected checkbox or marker image, if available. + VgaImage? get selectedMarker => + data == null ? null : _module.selectedMarker(data!); + + /// Unselected checkbox or marker image, if available. + VgaImage? get unselectedMarker => + data == null ? null : _module.unselectedMarker(data!); + + /// Main menu options banner image, if available. + VgaImage? get optionsLabel => + data == null ? null : _module.optionsLabel(data!); + + /// Customize/options heading image, if available. + VgaImage? get customizeLabel => + data == null ? null : _module.customizeLabel(data!); + + /// Credits image, if available. + VgaImage? get credits => data == null ? null : _module.credits(data!); + + /// Episode selection art for the zero-based [episodeIndex], if available. + VgaImage? episodeOption(int episodeIndex) { + return data == null ? null : _module.episodeOption(data!, episodeIndex); + } + + /// Difficulty selection art for [difficulty], if available. + VgaImage? difficultyOption(Difficulty difficulty) { + return data == null ? null : _module.difficultyOption(data!, difficulty); + } + + /// Legacy numeric art lookup for classic renderer code paths. + /// + /// Returns `null` when no loaded data is attached or when the requested art + /// does not exist in the active presentation. + VgaImage? mappedPic(int index) { + return data == null ? null : _module.mappedPic(data!, index); + } +} diff --git a/packages/wolf_3d_dart/lib/src/registry/asset_registry.dart b/packages/wolf_3d_dart/lib/src/registry/asset_registry.dart index 3c8fd01..10c82b8 100644 --- a/packages/wolf_3d_dart/lib/src/registry/asset_registry.dart +++ b/packages/wolf_3d_dart/lib/src/registry/asset_registry.dart @@ -1,6 +1,7 @@ import 'package:wolf_3d_dart/src/registry/modules/entity_asset_module.dart'; import 'package:wolf_3d_dart/src/registry/modules/hud_module.dart'; import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart'; +import 'package:wolf_3d_dart/src/registry/modules/menu_presentation_module.dart'; import 'package:wolf_3d_dart/src/registry/modules/music_module.dart'; import 'package:wolf_3d_dart/src/registry/modules/sfx_module.dart'; @@ -14,9 +15,10 @@ import 'package:wolf_3d_dart/src/registry/modules/sfx_module.dart'; /// data.registry.entities.resolve(EntityKey.guard) /// data.registry.hud.faceForHealth(player.health) /// data.registry.menu.resolve(MenuPicKey.title) +/// data.registry.menuPresentation.headerTextIndex /// ``` /// -/// To provide a fully custom asset layout, implement all five module +/// To provide a fully custom asset layout, implement all six module /// interfaces and pass them to this constructor, then supply the resulting /// [AssetRegistry] to [WolfensteinLoader.loadFromBytes]. class AssetRegistry { @@ -26,6 +28,7 @@ class AssetRegistry { required this.entities, required this.hud, required this.menu, + required this.menuPresentation, }); /// Sound-effect slot resolution. @@ -42,4 +45,7 @@ class AssetRegistry { /// Menu VGA picture index resolution. final MenuPicModule menu; + + /// Menu presentation and color routing. + final MenuPresentationModule menuPresentation; } diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/classic_menu_presentation_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/classic_menu_presentation_module.dart new file mode 100644 index 0000000..a4da1a0 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/classic_menu_presentation_module.dart @@ -0,0 +1,197 @@ +import 'package:wolf_3d_dart/src/data_types/color_palette.dart'; +import 'package:wolf_3d_dart/src/data_types/difficulty.dart'; +import 'package:wolf_3d_dart/src/data_types/image.dart'; +import 'package:wolf_3d_dart/src/data_types/wolfenstein_data.dart'; +import 'package:wolf_3d_dart/src/menu/wolf_menu_pic.dart'; +import 'package:wolf_3d_dart/src/registry/keys/menu_pic_key.dart'; +import 'package:wolf_3d_dart/src/registry/modules/menu_presentation_module.dart'; + +/// Built-in menu presentation that mirrors the classic Wolf3D UI. +class ClassicMenuPresentationModule extends MenuPresentationModule { + /// Creates the classic Wolf3D menu presentation. + const ClassicMenuPresentationModule(); + + /// Approximate RGB target used to derive the classic menu heading color. + static const int _headerTargetRgb = 0xFFF700; + + /// Classic VGA palette index for menu background fills. + @override + int get backgroundIndex => 111; + + /// Classic VGA palette index for panel fills. + @override + int get panelIndex => 103; + + /// Classic VGA palette index for panel borders. + @override + int get borderIndex => 87; + + /// Classic VGA palette index for emphasis text. + @override + int get emphasisIndex => 10; + + /// Classic VGA palette index for warnings. + @override + int get warningIndex => 14; + + /// Classic VGA palette index for muted text. + @override + int get mutedIndex => 8; + + /// Classic VGA palette index for selected row text. + @override + int get selectedTextIndex => 19; + + /// Classic VGA palette index for unselected row text. + @override + int get unselectedTextIndex => 23; + + /// Classic VGA palette index for disabled row text. + @override + int get disabledTextIndex => 4; + + /// Classic heading palette index computed from the target yellow tone. + @override + int get headerTextIndex => + ColorPalette.findClosestPaletteIndex(_headerTargetRgb); + + /// Controls/customize panel background art. + @override + VgaImage? controlBackground(WolfensteinData data) => + _imageForKey(data, MenuPicKey.controlBackground); + + /// Title splash art. + @override + VgaImage? title(WolfensteinData data) => _imageForKey(data, MenuPicKey.title); + + /// Main menu heading art. + @override + VgaImage? heading(WolfensteinData data) => + _imageForKey(data, MenuPicKey.heading); + + /// Selected marker art. + @override + VgaImage? selectedMarker(WolfensteinData data) => + _imageForKey(data, MenuPicKey.markerSelected); + + /// Unselected marker art. + @override + VgaImage? unselectedMarker(WolfensteinData data) => + _imageForKey(data, MenuPicKey.markerUnselected); + + /// Main options banner art. + @override + VgaImage? optionsLabel(WolfensteinData data) => + _imageForKey(data, MenuPicKey.optionsLabel); + + /// Customize heading art. + @override + VgaImage? customizeLabel(WolfensteinData data) => + _imageForKey(data, MenuPicKey.customizeLabel); + + /// Credits art. + @override + VgaImage? credits(WolfensteinData data) => + _imageForKey(data, MenuPicKey.credits); + + /// Episode selection art resolved through the active registry mapping. + @override + VgaImage? episodeOption(WolfensteinData data, int episodeIndex) { + if (episodeIndex < 0) { + return null; + } + return _imageForKey(data, data.registry.menu.episodeKey(episodeIndex)); + } + + /// Difficulty art resolved through the active registry mapping. + @override + VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty) { + return _imageForKey(data, data.registry.menu.difficultyKey(difficulty)); + } + + /// Resolves legacy numeric IDs through symbolic keys first. + @override + VgaImage? mappedPic(WolfensteinData data, int index) { + final key = _legacyKeyForIndex(index); + if (key != null) { + return _imageForKey(data, key); + } + return _pic(data, index); + } + + /// Loads a symbolic menu picture through the active registry. + VgaImage? _imageForKey(WolfensteinData data, MenuPicKey key) { + final ref = data.registry.menu.resolve(key); + if (ref == null) { + return null; + } + return _pic(data, ref.pictureIndex); + } + + /// Safely returns the VGA image at [index] when it contains usable pixels. + VgaImage? _pic(WolfensteinData data, int index) { + if (index < 0 || index >= data.vgaImages.length) { + return null; + } + final image = data.vgaImages[index]; + if (image.width <= 0 || image.height <= 0) { + return null; + } + return image; + } + + /// Maps classic numeric menu picture IDs to symbolic menu keys. + /// + /// This preserves the old renderer-facing numbering scheme while routing the + /// actual picture resolution through the registry layer. + MenuPicKey? _legacyKeyForIndex(int index) { + switch (index) { + case WolfMenuPic.hTopWindow: + return MenuPicKey.heading; + case WolfMenuPic.cOptions: + return MenuPicKey.optionsLabel; + case WolfMenuPic.cCursor1: + return MenuPicKey.cursorActive; + case WolfMenuPic.cCursor2: + return MenuPicKey.cursorInactive; + case WolfMenuPic.cNotSelected: + return MenuPicKey.markerUnselected; + case WolfMenuPic.cSelected: + return MenuPicKey.markerSelected; + case 15: + return MenuPicKey.footer; + case WolfMenuPic.cBabyMode: + return MenuPicKey.difficultyBaby; + case WolfMenuPic.cEasy: + return MenuPicKey.difficultyEasy; + case WolfMenuPic.cNormal: + return MenuPicKey.difficultyNormal; + case WolfMenuPic.cHard: + return MenuPicKey.difficultyHard; + case WolfMenuPic.cControl: + return MenuPicKey.controlBackground; + case WolfMenuPic.cCustomize: + return MenuPicKey.customizeLabel; + case WolfMenuPic.cEpisode1: + return MenuPicKey.episode1; + case WolfMenuPic.cEpisode2: + return MenuPicKey.episode2; + case WolfMenuPic.cEpisode3: + return MenuPicKey.episode3; + case WolfMenuPic.cEpisode4: + return MenuPicKey.episode4; + case WolfMenuPic.cEpisode5: + return MenuPicKey.episode5; + case WolfMenuPic.cEpisode6: + return MenuPicKey.episode6; + case WolfMenuPic.title: + return MenuPicKey.title; + case WolfMenuPic.pg13: + return MenuPicKey.pg13; + case WolfMenuPic.credits: + return MenuPicKey.credits; + default: + return null; + } + } +} diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_asset_registry.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/retail_asset_registry.dart index 5496dae..0d057cf 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_asset_registry.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/retail_asset_registry.dart @@ -2,6 +2,7 @@ import 'package:wolf_3d_dart/src/data_types/game_version.dart'; import 'package:wolf_3d_dart/src/registry/asset_registry.dart'; import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/built_in_sfx_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/classic_menu_presentation_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/retail_entity_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/retail_hud_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/retail_menu_module.dart'; @@ -19,5 +20,6 @@ class RetailAssetRegistry extends AssetRegistry { entities: const RetailEntityModule(), hud: const RetailHudModule(), menu: const RetailMenuPicModule(), + menuPresentation: const ClassicMenuPresentationModule(), ); } diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart index 8a5cc00..127a685 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart @@ -2,6 +2,7 @@ import 'package:wolf_3d_dart/src/data_types/game_version.dart'; import 'package:wolf_3d_dart/src/registry/asset_registry.dart'; import 'package:wolf_3d_dart/src/registry/built_in/built_in_music_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/built_in_sfx_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/classic_menu_presentation_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/shareware_entity_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/shareware_hud_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/shareware_menu_module.dart'; @@ -27,6 +28,7 @@ class SharewareAssetRegistry extends AssetRegistry { menu: SharewareMenuPicModule( useOriginalWl1Map: strictOriginalShareware, ), + menuPresentation: const ClassicMenuPresentationModule(), ); /// Convenience accessor to the menu module for post-load initialisation. diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart index f6dbe8e..4c6a5f1 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart @@ -6,9 +6,9 @@ import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart'; /// /// Shareware VGAGRAPH contains fewer pictures than the retail version, so /// the episode/difficulty/control-panel art sits at a shifted position in -/// the VGA image list. The exact shift is computed at resolve time by +/// the VGA image list. The exact shift is computed at resolve time by /// scanning the loaded image list for the landmark STATUSBARPIC, mirroring -/// the runtime heuristic in the original [WolfClassicMenuArt._indexOffset]. +/// the same runtime heuristic used by the built-in classic menu presentation. /// /// Offset determination is deferred until the first [resolve] call and /// cached for subsequent lookups. If the landmark cannot be found the diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_asset_registry.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_asset_registry.dart index 24ab45d..c7c5de6 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_asset_registry.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_asset_registry.dart @@ -5,6 +5,7 @@ import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_entity_module.dart import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_hud_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_menu_module.dart'; import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_sfx_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/spear_menu_presentation_module.dart'; /// Built-in [AssetRegistry] for Spear of Destiny demo/shareware (`.SDM`). class SpearDemoAssetRegistry extends AssetRegistry { @@ -15,5 +16,6 @@ class SpearDemoAssetRegistry extends AssetRegistry { entities: const SpearDemoEntityModule(), hud: const SpearDemoHudModule(), menu: const SpearDemoMenuPicModule(), + menuPresentation: const SpearMenuPresentationModule(), ); } diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_menu_presentation_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_menu_presentation_module.dart new file mode 100644 index 0000000..d8e0c2b --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_menu_presentation_module.dart @@ -0,0 +1,12 @@ +import 'package:wolf_3d_dart/src/registry/built_in/classic_menu_presentation_module.dart'; + +/// Built-in menu presentation for Spear variants. +/// +/// Spear currently reuses the classic control-panel palette and layout rules, +/// but keeping it as a distinct concrete type gives Spear-specific releases and +/// user mods a stable place to diverge without changing retail/shareware +/// defaults. +class SpearMenuPresentationModule extends ClassicMenuPresentationModule { + /// Creates the default Spear menu presentation. + const SpearMenuPresentationModule(); +} diff --git a/packages/wolf_3d_dart/lib/src/registry/modules/menu_presentation_module.dart b/packages/wolf_3d_dart/lib/src/registry/modules/menu_presentation_module.dart new file mode 100644 index 0000000..71b521c --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/modules/menu_presentation_module.dart @@ -0,0 +1,78 @@ +import 'package:wolf_3d_dart/src/data_types/difficulty.dart'; +import 'package:wolf_3d_dart/src/data_types/image.dart'; +import 'package:wolf_3d_dart/src/data_types/wolfenstein_data.dart'; + +/// Provides the visual presentation for Wolf3D menus. +/// +/// A presentation module owns both menu text colors and the symbolic art +/// lookups needed by renderers. Pair it with a [MenuPicModule] inside an +/// [AssetRegistry] to support built-in variants or fully custom user-defined +/// menus. +abstract class MenuPresentationModule { + /// Creates a menu presentation module. + const MenuPresentationModule(); + + /// VGA palette index used for menu background fills and header band accents. + int get backgroundIndex; + + /// VGA palette index used for menu panel fills. + int get panelIndex; + + /// VGA palette index used for panel borders and separators. + int get borderIndex; + + /// VGA palette index used for emphasized UI text. + int get emphasisIndex; + + /// VGA palette index used for warnings and cautionary text. + int get warningIndex; + + /// VGA palette index used for subdued UI text. + int get mutedIndex; + + /// VGA palette index used for the selected menu row text. + int get selectedTextIndex; + + /// VGA palette index used for normal menu row text. + int get unselectedTextIndex; + + /// VGA palette index used for disabled menu row text. + int get disabledTextIndex; + + /// VGA palette index used for headings and title text. + int get headerTextIndex; + + /// Returns the controls/customize panel background image, if supported. + VgaImage? controlBackground(WolfensteinData data); + + /// Returns the title splash image, if supported. + VgaImage? title(WolfensteinData data); + + /// Returns the primary heading art for the main menu, if supported. + VgaImage? heading(WolfensteinData data); + + /// Returns the selected marker image, if supported. + VgaImage? selectedMarker(WolfensteinData data); + + /// Returns the unselected marker image, if supported. + VgaImage? unselectedMarker(WolfensteinData data); + + /// Returns the main options banner image, if supported. + VgaImage? optionsLabel(WolfensteinData data); + + /// Returns the customize/options heading image, if supported. + VgaImage? customizeLabel(WolfensteinData data); + + /// Returns the credits image, if supported. + VgaImage? credits(WolfensteinData data); + + /// Returns episode selection art for zero-based [episodeIndex], if supported. + VgaImage? episodeOption(WolfensteinData data, int episodeIndex); + + /// Returns difficulty selection art for [difficulty], if supported. + VgaImage? difficultyOption(WolfensteinData data, Difficulty difficulty); + + /// Legacy numeric lookup retained for renderer code that still reasons in + /// original VGA picture IDs. + VgaImage? mappedPic(WolfensteinData data, int index); +} diff --git a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart index 73e785a..fd4b754 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -546,10 +546,11 @@ class AsciiRenderer extends CliRendererBackend { engine.menuManager.menuBackgroundRgb, ); final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb); - final int headingColor = WolfMenuPalette.headerTextColor; - final int selectedTextColor = WolfMenuPalette.selectedTextColor; - final int unselectedTextColor = WolfMenuPalette.unselectedTextColor; - final int disabledTextColor = WolfMenuPalette.disabledTextColor; + final menu = WolfMenuPresentation(engine.data); + final int headingColor = menu.headerTextColor; + final int selectedTextColor = menu.selectedTextColor; + final int unselectedTextColor = menu.unselectedTextColor; + final int disabledTextColor = menu.disabledTextColor; final _AsciiMenuTypography menuTypography = _resolveMenuTypography(); if (_usesTerminalLayout) { @@ -558,21 +559,20 @@ class AsciiRenderer extends CliRendererBackend { _fillRect(0, 0, width, height, activeTheme.solid, bgColor); } - final art = WolfClassicMenuArt(engine.data); - final optionsLabel = art.optionsLabel; + final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); } if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) { - _drawIntroSplash(engine, art, menuTypography); + _drawIntroSplash(engine, menu, menuTypography); return; } if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) { _fillRect320(68, 52, 178, 136, panelColor); - final optionsLabel = art.optionsLabel; + final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); _drawMainMenuOptionsSideBars(optionsLabel, optionsX); @@ -591,7 +591,7 @@ class AsciiRenderer extends CliRendererBackend { ); } - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 55; @@ -637,7 +637,7 @@ class AsciiRenderer extends CliRendererBackend { ); _fillRect320(28, 58, 264, 104, panelColor); - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 84; @@ -687,7 +687,7 @@ class AsciiRenderer extends CliRendererBackend { ); _fillRect320(12, 18, 296, 168, panelColor); - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 24; @@ -720,7 +720,7 @@ class AsciiRenderer extends CliRendererBackend { // Keep episode icons visible in compact ASCII layouts so this screen // still communicates the same visual affordances as full-size menus. for (int i = 0; i < engine.data.episodes.length; i++) { - final image = art.episodeOption(i); + final image = menu.episodeOption(i); if (image != null) { _blitVgaImageAscii(image, 40, rowYStart + (i * rowStep)); } @@ -751,7 +751,7 @@ class AsciiRenderer extends CliRendererBackend { if (isSelected && cursor != null) { _blitVgaImageAscii(cursor, 16, y + 2); } - final image = art.episodeOption(i); + final image = menu.episodeOption(i); if (image != null) { _blitVgaImageAscii(image, 40, y); } @@ -780,12 +780,12 @@ class AsciiRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) { - _drawCustomizeMenuHeader(art, headingColor, bgColor); - final cursor = art.mappedPic( + _drawCustomizeMenuHeader(menu, headingColor, bgColor); + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - final selectedMarker = art.selectedMarker; - final unselectedMarker = art.unselectedMarker; + final selectedMarker = menu.selectedMarker; + final unselectedMarker = menu.unselectedMarker; const int rowYStart = 64; const int rowStep = 16; const int cursorX = 62; @@ -884,7 +884,7 @@ class AsciiRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) { - _drawCustomizeMenuHeader(art, headingColor, bgColor); + _drawCustomizeMenuHeader(menu, headingColor, bgColor); _fillRect320(56, 52, 208, 120, panelColor); _drawMenuTextCentered( engine.menuManager.rendererOptionsTitle, @@ -893,11 +893,11 @@ class AsciiRenderer extends CliRendererBackend { scale: 1, ); - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - final selectedMarker = art.selectedMarker; - final unselectedMarker = art.unselectedMarker; + final selectedMarker = menu.selectedMarker; + final unselectedMarker = menu.unselectedMarker; const int rowYStart = 68; const int rowStep = 20; const int cursorX = 62; @@ -947,14 +947,14 @@ class AsciiRenderer extends CliRendererBackend { _fillRect320(28, 70, 264, 82, panelColor); - final face = art.difficultyOption( + final face = menu.difficultyOption( Difficulty.values[selectedDifficultyIndex], ); if (face != null) { _blitVgaImageAscii(face, 28 + 264 - face.width - 18, 92); } - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const rowYStart = 86; @@ -1033,13 +1033,13 @@ class AsciiRenderer extends CliRendererBackend { void _drawIntroSplash( WolfEngine engine, - WolfClassicMenuArt art, + WolfMenuPresentation menu, _AsciiMenuTypography menuTypography, ) { final image = switch (engine.menuManager.currentIntroSlide) { WolfIntroSlide.retailWarning => null, - WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13), - WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title), + WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13), + WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title), }; int splashBg = _rgbToPaletteColor(engine.menuManager.menuBackgroundRgb); @@ -1593,7 +1593,7 @@ class AsciiRenderer extends CliRendererBackend { } void _drawCustomizeMenuHeader( - WolfClassicMenuArt art, + WolfMenuPresentation menu, int headingColor, int backgroundColor, ) { @@ -1603,7 +1603,7 @@ class AsciiRenderer extends CliRendererBackend { barColor: ColorPalette.vga32Bit[0], ); - final VgaImage? heading = art.customizeLabel ?? art.optionsLabel; + final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel; if (heading != null) { final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319); _blitVgaImageAscii(heading, headingX, 0); diff --git a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart index 21783be..18c957a 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -476,32 +476,32 @@ class SixelRenderer extends CliRendererBackend { engine.menuManager.menuBackgroundRgb, ); final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb); - final int headingIndex = WolfMenuPalette.headerTextIndex; - final int selectedTextIndex = WolfMenuPalette.selectedTextIndex; - final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex; - final int disabledTextIndex = WolfMenuPalette.disabledTextIndex; + final menu = WolfMenuPresentation(engine.data); + final int headingIndex = menu.headerTextIndex; + final int selectedTextIndex = menu.selectedTextIndex; + final int unselectedTextIndex = menu.unselectedTextIndex; + final int disabledTextIndex = menu.disabledTextIndex; for (int i = 0; i < _screen.length; i++) { _screen[i] = bgColor; } - final art = WolfClassicMenuArt(engine.data); - final optionsLabel = art.optionsLabel; + final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); } // Draw footer first so menu panels can clip overlap in the center. - _drawMenuFooterArt(art); + _drawMenuFooterArt(menu); if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) { - _drawIntroSplash(engine, art); + _drawIntroSplash(engine, menu); return; } if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) { _fillRect320(68, 52, 178, 136, panelColor); - final optionsLabel = art.optionsLabel; + final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); _drawMainMenuOptionsSideBars(optionsLabel, optionsX); @@ -520,7 +520,7 @@ class SixelRenderer extends CliRendererBackend { ); } - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 55; @@ -558,7 +558,7 @@ class SixelRenderer extends CliRendererBackend { headingIndex, scale: 2, ); - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 84; @@ -593,7 +593,7 @@ class SixelRenderer extends CliRendererBackend { headingIndex, scale: 2, ); - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 30; @@ -604,7 +604,7 @@ class SixelRenderer extends CliRendererBackend { if (isSelected && cursor != null) { _blitVgaImage(cursor, 16, y + 2); } - final image = art.episodeOption(i); + final image = menu.episodeOption(i); if (image != null) { _blitVgaImage(image, 40, y); } @@ -633,12 +633,12 @@ class SixelRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) { - _drawCustomizeMenuHeader(art, headingIndex, bgColor); - final cursor = art.mappedPic( + _drawCustomizeMenuHeader(menu, headingIndex, bgColor); + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - final selectedMarker = art.selectedMarker; - final unselectedMarker = art.unselectedMarker; + final selectedMarker = menu.selectedMarker; + final unselectedMarker = menu.unselectedMarker; const int rowYStart = 66; const int rowStep = 18; const int cursorX = 62; @@ -726,7 +726,7 @@ class SixelRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) { - _drawCustomizeMenuHeader(art, headingIndex, bgColor); + _drawCustomizeMenuHeader(menu, headingIndex, bgColor); _fillRect320(56, 52, 208, 120, panelColor); _drawMenuTextCentered( engine.menuManager.rendererOptionsTitle, @@ -735,11 +735,11 @@ class SixelRenderer extends CliRendererBackend { scale: 1, ); - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - final selectedMarker = art.selectedMarker; - final unselectedMarker = art.unselectedMarker; + final selectedMarker = menu.selectedMarker; + final unselectedMarker = menu.unselectedMarker; const int rowYStart = 68; const int rowStep = 20; const int cursorX = 62; @@ -781,7 +781,12 @@ class SixelRenderer extends CliRendererBackend { ); _fillRect320(28, 70, 264, 82, panelColor); if (_useCompactMenuLayout) { - _drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor); + _drawCompactMenu( + selectedDifficultyIndex, + headingIndex, + panelColor, + menu, + ); _applyMenuTransition(engine.menuManager, bgColor); return; } @@ -793,14 +798,14 @@ class SixelRenderer extends CliRendererBackend { scale: _menuHeadingScale, ); - final face = art.difficultyOption( + final face = menu.difficultyOption( Difficulty.values[selectedDifficultyIndex], ); if (face != null) { _blitVgaImage(face, 28 + 264 - face.width - 10, 92); } - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const rowYStart = 86; @@ -827,7 +832,7 @@ class SixelRenderer extends CliRendererBackend { } void _drawCustomizeMenuHeader( - WolfClassicMenuArt art, + WolfMenuPresentation menu, int headingIndex, int backgroundColor, ) { @@ -837,7 +842,7 @@ class SixelRenderer extends CliRendererBackend { barColor: 0, ); - final VgaImage? heading = art.customizeLabel ?? art.optionsLabel; + final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel; if (heading != null) { final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319); _blitVgaImage(heading, headingX, 0); @@ -865,8 +870,8 @@ class SixelRenderer extends CliRendererBackend { _drawMenuTextCentered(text, y200 + 2, textColor, scale: 1); } - void _drawMenuFooterArt(WolfClassicMenuArt art) { - final bottom = art.mappedPic(15); + void _drawMenuFooterArt(WolfMenuPresentation menu) { + final bottom = menu.mappedPic(15); if (bottom == null) { return; } @@ -890,11 +895,11 @@ class SixelRenderer extends CliRendererBackend { } } - void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) { + void _drawIntroSplash(WolfEngine engine, WolfMenuPresentation menu) { final image = switch (engine.menuManager.currentIntroSlide) { WolfIntroSlide.retailWarning => null, - WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13), - WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title), + WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13), + WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title), }; int splashBg = _rgbToPaletteIndex(engine.menuManager.menuBackgroundRgb); @@ -1157,6 +1162,7 @@ class SixelRenderer extends CliRendererBackend { int selectedDifficultyIndex, int headingIndex, int panelColor, + WolfMenuPresentation menu, ) { _fillRect320(16, 52, 288, 112, panelColor); _drawMenuTextCentered(Difficulty.menuText, 60, headingIndex, scale: 1); @@ -1170,9 +1176,7 @@ class SixelRenderer extends CliRendererBackend { prefix + Difficulty.values[i].title, 42, rowYStart + (i * rowStep), - isSelected - ? WolfMenuPalette.selectedTextIndex - : WolfMenuPalette.unselectedTextIndex, + isSelected ? menu.selectedTextIndex : menu.unselectedTextIndex, scale: 1, ); } diff --git a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart index 62a5614..911b6a3 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -254,31 +254,31 @@ class SoftwareRenderer extends RendererBackend { void drawMenu(WolfEngine engine) { final int bgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb); final int panelColor = _rgbToFrameColor(engine.menuPanelRgb); - final int headingColor = WolfMenuPalette.headerTextColor; - final int selectedTextColor = WolfMenuPalette.selectedTextColor; - final int unselectedTextColor = WolfMenuPalette.unselectedTextColor; - final int disabledTextColor = WolfMenuPalette.disabledTextColor; + final menu = WolfMenuPresentation(engine.data); + final int headingColor = menu.headerTextColor; + final int selectedTextColor = menu.selectedTextColor; + final int unselectedTextColor = menu.unselectedTextColor; + final int disabledTextColor = menu.disabledTextColor; for (int i = 0; i < _buffer.pixels.length; i++) { _buffer.pixels[i] = bgColor; } - final art = WolfClassicMenuArt(engine.data); - final optionsLabel = art.optionsLabel; + final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); } // Draw footer first so menu panels can clip overlap in the center. - _drawCenteredMenuFooter(art); + _drawCenteredMenuFooter(menu); switch (engine.menuManager.activeMenu) { case WolfMenuScreen.introSplash: - _drawIntroSplash(engine, art); + _drawIntroSplash(engine, menu); break; case WolfMenuScreen.mainMenu: _drawMainMenu( engine, - art, + menu, panelColor, headingColor, selectedTextColor, @@ -289,7 +289,7 @@ class SoftwareRenderer extends RendererBackend { case WolfMenuScreen.gameSelect: _drawGameSelectMenu( engine, - art, + menu, panelColor, headingColor, selectedTextColor, @@ -299,7 +299,7 @@ class SoftwareRenderer extends RendererBackend { case WolfMenuScreen.episodeSelect: _drawEpisodeSelectMenu( engine, - art, + menu, panelColor, headingColor, selectedTextColor, @@ -309,7 +309,7 @@ class SoftwareRenderer extends RendererBackend { case WolfMenuScreen.difficultySelect: _drawDifficultyMenu( engine, - art, + menu, panelColor, headingColor, selectedTextColor, @@ -319,7 +319,7 @@ class SoftwareRenderer extends RendererBackend { case WolfMenuScreen.changeView: _drawChangeViewMenu( engine, - art, + menu, panelColor, headingColor, selectedTextColor, @@ -330,7 +330,7 @@ class SoftwareRenderer extends RendererBackend { case WolfMenuScreen.rendererOptions: _drawRendererOptionsMenu( engine, - art, + menu, panelColor, headingColor, selectedTextColor, @@ -343,11 +343,11 @@ class SoftwareRenderer extends RendererBackend { _applyMenuTransition(engine.menuManager, bgColor); } - void _drawIntroSplash(WolfEngine engine, WolfClassicMenuArt art) { + void _drawIntroSplash(WolfEngine engine, WolfMenuPresentation menu) { final image = switch (engine.menuManager.currentIntroSlide) { WolfIntroSlide.retailWarning => null, - WolfIntroSlide.pg13 => art.mappedPic(WolfMenuPic.pg13), - WolfIntroSlide.title => art.mappedPic(WolfMenuPic.title), + WolfIntroSlide.pg13 => menu.mappedPic(WolfMenuPic.pg13), + WolfIntroSlide.title => menu.mappedPic(WolfMenuPic.title), }; int splashBgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb); @@ -478,7 +478,7 @@ class SoftwareRenderer extends RendererBackend { void _drawMainMenu( WolfEngine engine, - WolfClassicMenuArt art, + WolfMenuPresentation menu, int panelColor, int headingColor, int selectedTextColor, @@ -491,7 +491,7 @@ class SoftwareRenderer extends RendererBackend { const int panelH = 136; _fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor); - final optionsLabel = art.optionsLabel; + final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); _drawMainMenuOptionsSideBars(optionsLabel, optionsX); @@ -510,7 +510,7 @@ class SoftwareRenderer extends RendererBackend { ); } - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 55; @@ -538,7 +538,7 @@ class SoftwareRenderer extends RendererBackend { void _drawChangeViewMenu( WolfEngine engine, - WolfClassicMenuArt art, + WolfMenuPresentation menu, int panelColor, int headingColor, int selectedTextColor, @@ -557,7 +557,7 @@ class SoftwareRenderer extends RendererBackend { const int optionsPanelX = 46; const int optionsPanelW = 228; - final VgaImage? heading = art.customizeLabel ?? art.optionsLabel; + final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel; if (heading != null) { final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319); _blitVgaImage(heading, headingX, 0); @@ -570,9 +570,9 @@ class SoftwareRenderer extends RendererBackend { ); } - final VgaImage? selectedMarker = art.selectedMarker; - final VgaImage? unselectedMarker = art.unselectedMarker; - final VgaImage? cursor = art.mappedPic( + final VgaImage? selectedMarker = menu.selectedMarker; + final VgaImage? unselectedMarker = menu.unselectedMarker; + final VgaImage? cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); @@ -684,7 +684,7 @@ class SoftwareRenderer extends RendererBackend { void _drawRendererOptionsMenu( WolfEngine engine, - WolfClassicMenuArt art, + WolfMenuPresentation menu, int panelColor, int headingColor, int selectedTextColor, @@ -703,7 +703,7 @@ class SoftwareRenderer extends RendererBackend { const int panelH = 120; _fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor); - final VgaImage? heading = art.customizeLabel ?? art.optionsLabel; + final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel; if (heading != null) { final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319); _blitVgaImage(heading, headingX, 0); @@ -716,9 +716,9 @@ class SoftwareRenderer extends RendererBackend { ); } - final VgaImage? selectedMarker = art.selectedMarker; - final VgaImage? unselectedMarker = art.unselectedMarker; - final VgaImage? cursor = art.mappedPic( + final VgaImage? selectedMarker = menu.selectedMarker; + final VgaImage? unselectedMarker = menu.unselectedMarker; + final VgaImage? cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); @@ -766,7 +766,7 @@ class SoftwareRenderer extends RendererBackend { void _drawGameSelectMenu( WolfEngine engine, - WolfClassicMenuArt art, + WolfMenuPresentation menu, int panelColor, int headingColor, int selectedTextColor, @@ -791,7 +791,7 @@ class SoftwareRenderer extends RendererBackend { scale: 2, ); - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); @@ -837,7 +837,7 @@ class SoftwareRenderer extends RendererBackend { void _drawEpisodeSelectMenu( WolfEngine engine, - WolfClassicMenuArt art, + WolfMenuPresentation menu, int panelColor, int headingColor, int selectedTextColor, @@ -862,7 +862,7 @@ class SoftwareRenderer extends RendererBackend { scale: 2, ); - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = 30; @@ -879,7 +879,7 @@ class SoftwareRenderer extends RendererBackend { _blitVgaImage(cursor, 16, y + 2); } - final image = art.episodeOption(i); + final image = menu.episodeOption(i); if (image != null) { _blitVgaImage(image, imageX, y); } @@ -904,8 +904,8 @@ class SoftwareRenderer extends RendererBackend { } } - void _drawCenteredMenuFooter(WolfClassicMenuArt art) { - final bottom = art.mappedPic(15); + void _drawCenteredMenuFooter(WolfMenuPresentation menu) { + final bottom = menu.mappedPic(15); if (bottom != null) { final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319); final int y = (200 - bottom.height - _menuFooterBottomMargin).clamp( @@ -954,7 +954,7 @@ class SoftwareRenderer extends RendererBackend { void _drawDifficultyMenu( WolfEngine engine, - WolfClassicMenuArt art, + WolfMenuPresentation menu, int panelColor, int headingColor, int selectedTextColor, @@ -981,14 +981,14 @@ class SoftwareRenderer extends RendererBackend { scale: 2, ); - final face = art.difficultyOption( + final face = menu.difficultyOption( Difficulty.values[selectedDifficultyIndex], ); if (face != null) { _blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22); } - final cursor = art.mappedPic( + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); const int rowYStart = panelY + 16; diff --git a/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart b/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart index b9da144..b78735e 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart @@ -42,6 +42,8 @@ export 'src/registry/modules/entity_asset_module.dart' export 'src/registry/modules/hud_module.dart' show HudModule, HudAssetRef; export 'src/registry/modules/menu_pic_module.dart' show MenuPicModule, MenuPicRef; +export 'src/registry/modules/menu_presentation_module.dart' + show MenuPresentationModule; export 'src/registry/modules/music_module.dart' show MusicModule, MusicRoute; export 'src/registry/modules/sfx_module.dart' show SfxModule, SoundAssetRef; export 'src/registry/registry_resolver.dart' diff --git a/packages/wolf_3d_dart/lib/wolf_3d_menu.dart b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart index 5a31d39..64fb401 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_menu.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart @@ -1,218 +1,5 @@ /// Shared menu helpers for Wolf3D hosts. library; -import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; - -/// Known VGA picture indexes used by the original Wolf3D control-panel menus. -/// -/// Values below are picture-table indexes (not raw chunk ids). -/// For example, `C_CONTROLPIC` is chunk 26 in `GFXV_WL6.H`, so its picture -/// index is `26 - STARTPICS(3) = 23`. -abstract class WolfMenuPic { - static const int hBj = 0; // H_BJPIC - static const int hTopWindow = 3; // H_TOPWINDOWPIC - static const int cOptions = 7; // C_OPTIONSPIC - static const int cCursor1 = 8; // C_CURSOR1PIC - static const int cCursor2 = 9; // C_CURSOR2PIC - static const int cNotSelected = 10; // C_NOTSELECTEDPIC - static const int cSelected = 11; // C_SELECTEDPIC - static const int cBabyMode = 16; // C_BABYMODEPIC - static const int cEasy = 17; // C_EASYPIC - static const int cNormal = 18; // C_NORMALPIC - static const int cHard = 19; // C_HARDPIC - static const int cControl = 23; // C_CONTROLPIC - static const int cCustomize = 24; // C_CUSTOMIZEPIC - static const int cEpisode1 = 27; // C_EPISODE1PIC - static const int cEpisode2 = 28; // C_EPISODE2PIC - static const int cEpisode3 = 29; // C_EPISODE3PIC - static const int cEpisode4 = 30; // C_EPISODE4PIC - static const int cEpisode5 = 31; // C_EPISODE5PIC - static const int cEpisode6 = 32; // C_EPISODE6PIC - static const int statusBar = 83; // STATUSBARPIC - static const int title = 84; // TITLEPIC - static const int pg13 = 85; // PG13PIC - static const int credits = 86; // CREDITSPIC - static const int highScores = 87; // HIGHSCORESPIC - - static const List episodePics = [ - cEpisode1, - cEpisode2, - cEpisode3, - cEpisode4, - cEpisode5, - cEpisode6, - ]; -} - -/// Shared menu text colors resolved from the VGA palette. -/// -/// Keep menu color choices centralized so renderers don't duplicate -/// hard-coded palette slots or RGB conversion logic. -abstract class WolfMenuPalette { - static const int backgroundIndex = 111; - static const int panelIndex = 103; - static const int borderIndex = 87; - static const int emphasisIndex = 10; - static const int warningIndex = 14; - static const int mutedIndex = 8; - - static const int selectedTextIndex = 19; - static const int unselectedTextIndex = 23; - static const int disabledTextIndex = 4; - static const int _headerTargetRgb = 0xFFF700; - - static int? _cachedHeaderTextIndex; - - static int get headerTextIndex => _cachedHeaderTextIndex ??= - ColorPalette.findClosestPaletteIndex(_headerTargetRgb); - - /// Standard ARGB colors (`0xAARRGGBB`) for UI consumers. - static int get backgroundColor => - ColorPalette.argbFromVgaIndex(backgroundIndex); - - static int get panelColor => ColorPalette.argbFromVgaIndex(panelIndex); - - static int get borderColor => ColorPalette.argbFromVgaIndex(borderIndex); - - static int get titleColor => ColorPalette.argbFromVgaIndex(headerTextIndex); - - static int get bodyColor => - ColorPalette.argbFromVgaIndex(unselectedTextIndex); - - static int get emphasisColor => ColorPalette.argbFromVgaIndex(emphasisIndex); - - static int get warningColor => ColorPalette.argbFromVgaIndex(warningIndex); - - static int get mutedColor => ColorPalette.argbFromVgaIndex(mutedIndex); - - static int get selectedTextColor => ColorPalette.vga32Bit[selectedTextIndex]; - - static int get unselectedTextColor => - ColorPalette.vga32Bit[unselectedTextIndex]; - - static int get disabledTextColor => ColorPalette.vga32Bit[disabledTextIndex]; - - static int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex]; -} - -/// Structured accessors for classic Wolf3D menu art. -class WolfClassicMenuArt { - final WolfensteinData data; - - WolfClassicMenuArt(this.data); - - VgaImage? get controlBackground { - return _imageForKey(MenuPicKey.controlBackground); - } - - VgaImage? get title => _imageForKey(MenuPicKey.title); - - VgaImage? get heading => _imageForKey(MenuPicKey.heading); - - VgaImage? get selectedMarker => _imageForKey(MenuPicKey.markerSelected); - - VgaImage? get unselectedMarker => _imageForKey(MenuPicKey.markerUnselected); - - VgaImage? get optionsLabel => _imageForKey(MenuPicKey.optionsLabel); - - VgaImage? get customizeLabel => _imageForKey(MenuPicKey.customizeLabel); - - VgaImage? get credits => _imageForKey(MenuPicKey.credits); - - VgaImage? episodeOption(int episodeIndex) { - if (episodeIndex < 0) { - return null; - } - final key = data.registry.menu.episodeKey(episodeIndex); - return _imageForKey(key); - } - - VgaImage? difficultyOption(Difficulty difficulty) { - final key = data.registry.menu.difficultyKey(difficulty); - return _imageForKey(key); - } - - /// Legacy numeric lookup API retained for existing renderer call sites. - /// - /// Known legacy indices are mapped through symbolic registry keys first. - /// Unknown indices fall back to direct picture-table indexing. - VgaImage? mappedPic(int index) { - final key = _legacyKeyForIndex(index); - if (key != null) { - return _imageForKey(key); - } - return pic(index); - } - - VgaImage? pic(int index) { - if (index < 0 || index >= data.vgaImages.length) { - return null; - } - final image = data.vgaImages[index]; - - if (image.width <= 0 || image.height <= 0) { - return null; - } - - return image; - } - - VgaImage? _imageForKey(MenuPicKey key) { - final ref = data.registry.menu.resolve(key); - if (ref == null) { - return null; - } - return pic(ref.pictureIndex); - } - - MenuPicKey? _legacyKeyForIndex(int index) { - switch (index) { - case WolfMenuPic.hTopWindow: - return MenuPicKey.heading; - case WolfMenuPic.cOptions: - return MenuPicKey.optionsLabel; - case WolfMenuPic.cCursor1: - return MenuPicKey.cursorActive; - case WolfMenuPic.cCursor2: - return MenuPicKey.cursorInactive; - case WolfMenuPic.cNotSelected: - return MenuPicKey.markerUnselected; - case WolfMenuPic.cSelected: - return MenuPicKey.markerSelected; - case 15: - return MenuPicKey.footer; - case WolfMenuPic.cBabyMode: - return MenuPicKey.difficultyBaby; - case WolfMenuPic.cEasy: - return MenuPicKey.difficultyEasy; - case WolfMenuPic.cNormal: - return MenuPicKey.difficultyNormal; - case WolfMenuPic.cHard: - return MenuPicKey.difficultyHard; - case WolfMenuPic.cControl: - return MenuPicKey.controlBackground; - case WolfMenuPic.cCustomize: - return MenuPicKey.customizeLabel; - case WolfMenuPic.cEpisode1: - return MenuPicKey.episode1; - case WolfMenuPic.cEpisode2: - return MenuPicKey.episode2; - case WolfMenuPic.cEpisode3: - return MenuPicKey.episode3; - case WolfMenuPic.cEpisode4: - return MenuPicKey.episode4; - case WolfMenuPic.cEpisode5: - return MenuPicKey.episode5; - case WolfMenuPic.cEpisode6: - return MenuPicKey.episode6; - case WolfMenuPic.title: - return MenuPicKey.title; - case WolfMenuPic.pg13: - return MenuPicKey.pg13; - case WolfMenuPic.credits: - return MenuPicKey.credits; - default: - return null; - } - } -} +export 'src/menu/wolf_menu_pic.dart'; +export 'src/menu/wolf_menu_presentation.dart'; diff --git a/packages/wolf_3d_dart/test/menu/menu_manager_test.dart b/packages/wolf_3d_dart/test/menu/menu_manager_test.dart new file mode 100644 index 0000000..3a93cea --- /dev/null +++ b/packages/wolf_3d_dart/test/menu/menu_manager_test.dart @@ -0,0 +1,167 @@ +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +void main() { + group('MenuManager', () { + test('main menu row enablement reflects resumable and loadable state', () { + final manager = MenuManager(); + + manager.showMainMenu(hasResumableGame: false, hasLoadableSave: false); + + expect( + _entryFor(manager, WolfMenuMainAction.loadGame).isEnabled, + isFalse, + ); + expect( + _entryFor(manager, WolfMenuMainAction.saveGame).isEnabled, + isFalse, + ); + expect( + _entryFor(manager, WolfMenuMainAction.changeView).isEnabled, + isTrue, + ); + + manager.setLoadGameAvailable(true); + + expect(_entryFor(manager, WolfMenuMainAction.loadGame).isEnabled, isTrue); + expect( + _entryFor(manager, WolfMenuMainAction.saveGame).isEnabled, + isFalse, + ); + + manager.showMainMenu(hasResumableGame: true, hasLoadableSave: true); + + expect(_entryFor(manager, WolfMenuMainAction.saveGame).isEnabled, isTrue); + expect(_entryFor(manager, WolfMenuMainAction.endGame).isEnabled, isTrue); + expect( + manager.mainMenuEntries.any( + (entry) => entry.action == WolfMenuMainAction.backToGame, + ), + isTrue, + ); + }); + + test('change-view navigation skips disabled renderer rows and options', () { + final manager = MenuManager(); + + manager.setChangeViewEntries(const [ + WolfMenuRendererEntry( + mode: WolfRendererMode.software, + label: 'SOFTWARE', + hasOptions: false, + isEnabled: false, + ), + WolfMenuRendererEntry( + mode: WolfRendererMode.ascii, + label: 'ASCII', + hasOptions: true, + ), + ]); + manager.setRendererOptionEntries( + title: 'ASCII OPTIONS', + entries: const [ + WolfMenuRendererOptionEntry( + id: WolfRendererOptionId.asciiTheme, + label: 'THEME', + isEnabled: false, + ), + WolfMenuRendererOptionEntry( + id: WolfRendererOptionId.fpsCounter, + label: 'FPS', + ), + ], + ); + + manager.showChangeViewMenu(); + + expect(manager.selectedChangeViewIndex, 1); + + manager.updateChangeViewMenu(const EngineInput(isMovingBackward: true)); + manager.updateChangeViewMenu(const EngineInput()); + + expect(manager.selectedChangeViewIndex, 3); + }); + + test( + 'renderer option selection is preserved by option id when entries refresh', + () { + final manager = MenuManager(); + + manager.setChangeViewEntries(const [ + WolfMenuRendererEntry( + mode: WolfRendererMode.ascii, + label: 'ASCII', + hasOptions: true, + ), + ]); + manager.setRendererOptionEntries( + title: 'ASCII OPTIONS', + entries: const [ + WolfMenuRendererOptionEntry( + id: WolfRendererOptionId.asciiTheme, + label: 'THEME', + ), + WolfMenuRendererOptionEntry( + id: WolfRendererOptionId.fpsCounter, + label: 'FPS', + ), + ], + ); + + manager.showRendererOptionsMenu(); + manager.updateRendererOptionsMenu( + const EngineInput(isMovingBackward: true), + ); + manager.updateRendererOptionsMenu(const EngineInput()); + + expect(manager.selectedRendererOptionIndex, 1); + + manager.setRendererOptionEntries( + title: 'ASCII OPTIONS', + entries: const [ + WolfMenuRendererOptionEntry( + id: WolfRendererOptionId.fpsCounter, + label: 'FPS', + ), + WolfMenuRendererOptionEntry( + id: WolfRendererOptionId.asciiTheme, + label: 'THEME', + ), + ], + ); + + expect(manager.selectedRendererOptionIndex, 0); + expect( + manager.rendererOptionEntries[manager.selectedRendererOptionIndex].id, + WolfRendererOptionId.fpsCounter, + ); + }, + ); + + test('transition locks navigation and reports none when idle', () { + final manager = MenuManager(); + + manager.showMainMenu(hasResumableGame: false); + manager.startTransition(WolfMenuScreen.difficultySelect); + + final result = manager.updateMainMenu( + const EngineInput(isMovingBackward: true, isInteracting: true), + ); + + expect(result.selected, isNull); + expect(result.goBack, isFalse); + expect(manager.transitionEffect, WolfTransitionEffect.normalFade); + + manager.tickTransition(MenuManager.transitionDurationMs); + + expect(manager.isTransitioning, isFalse); + expect(manager.transitionEffect, WolfTransitionEffect.none); + expect(manager.activeMenu, WolfMenuScreen.difficultySelect); + }); + }); +} + +WolfMenuMainEntry _entryFor(MenuManager manager, WolfMenuMainAction action) { + return manager.mainMenuEntries.firstWhere((entry) => entry.action == action); +} diff --git a/packages/wolf_3d_dart/test/registry/spear_demo_registry_test.dart b/packages/wolf_3d_dart/test/registry/spear_demo_registry_test.dart index cccc927..743451b 100644 --- a/packages/wolf_3d_dart/test/registry/spear_demo_registry_test.dart +++ b/packages/wolf_3d_dart/test/registry/spear_demo_registry_test.dart @@ -1,4 +1,5 @@ import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/spear_menu_presentation_module.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; @@ -17,6 +18,7 @@ void main() { expect(registry.hud.resolve(HudKey.statusBar)?.vgaIndex, 73); expect(registry.hud.resolve(HudKey.digit0)?.vgaIndex, 84); expect(registry.hud.resolve(HudKey.pistolIcon)?.vgaIndex, 77); + expect(registry.menuPresentation, isA()); }); test('resolves SDM enemy sprite ranges with +4 SPEAR shift', () { diff --git a/packages/wolf_3d_dart/test/registry/wolf_classic_menu_art_mapping_test.dart b/packages/wolf_3d_dart/test/registry/wolf_classic_menu_art_mapping_test.dart index 09fcb29..2721c30 100644 --- a/packages/wolf_3d_dart/test/registry/wolf_classic_menu_art_mapping_test.dart +++ b/packages/wolf_3d_dart/test/registry/wolf_classic_menu_art_mapping_test.dart @@ -21,14 +21,14 @@ void main() { vgaImages: List.generate(120, (i) => _img(height: i + 1)), ); - final art = WolfClassicMenuArt(data); + final menu = WolfMenuPresentation(data); // Retail cursor constants 8/9 must resolve to shareware cursor IDs 20/21. - expect(art.mappedPic(8)?.height, 21); - expect(art.mappedPic(9)?.height, 22); + expect(menu.mappedPic(8)?.height, 21); + expect(menu.mappedPic(9)?.height, 22); // Retail footer constant 15 must resolve to shareware footer ID 27. - expect(art.mappedPic(15)?.height, 28); + expect(menu.mappedPic(15)?.height, 28); }, ); } diff --git a/packages/wolf_3d_flutter/README.md b/packages/wolf_3d_flutter/README.md index b95b87f..f8d0dc0 100644 --- a/packages/wolf_3d_flutter/README.md +++ b/packages/wolf_3d_flutter/README.md @@ -41,13 +41,25 @@ final Wolf3dFlutterEngine engine = await Wolf3dFlutterEngine( `init()` handles platform setup, audio init, and configured game-data discovery. +The facade itself lives in `lib/engine/wolf3d_flutter_engine.dart` and is re-exported +through the package barrel at `lib/wolf_3d_flutter.dart`. External consumers +should keep importing the barrel unless they have a specific reason to target +the engine library directly. + +The same pattern applies to the Flutter input adapter and the desktop +persistence adapters: they now live in focused subdirectories and are +re-exported through `lib/wolf_3d_flutter.dart`. + For full host wiring examples, see: - `apps/wolf_3d_gui/lib/main.dart` ## Package Structure -- `lib/wolf_3d_flutter.dart` — exports main host facade and public Flutter APIs. +- `lib/wolf_3d_flutter.dart` — barrel export for the public Flutter package surface. +- `lib/engine/wolf3d_flutter_engine.dart` — high-level engine facade and discovery bootstrap. +- `lib/input/` — Flutter-specific input adapters. +- `lib/persistence/` — desktop persistence adapters for saves and renderer settings. - `lib/renderer/` — renderer host widgets. - `lib/managers/` — runtime/session/display/persistence managers. - `lib/audio/` — platform-aware audio backends. diff --git a/packages/wolf_3d_flutter/lib/engine/wolf3d_flutter_engine.dart b/packages/wolf_3d_flutter/lib/engine/wolf3d_flutter_engine.dart new file mode 100644 index 0000000..3afde3a --- /dev/null +++ b/packages/wolf_3d_flutter/lib/engine/wolf3d_flutter_engine.dart @@ -0,0 +1,122 @@ +/// Flutter-specific engine facade for discovery and runtime service wiring. +library; + +import 'package:flutter/foundation.dart'; +import 'package:wolf_3d_dart/wolf_3d_data.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/audio/wolf3d_platform_audio.dart'; +import 'package:wolf_3d_flutter/input/wolf_3d_input_flutter.dart'; +import 'package:wolf_3d_flutter/managers/desktop_windowing_support.dart' + as desktop_windowing_support; +import 'package:wolf_3d_flutter/managers/game_data_directory_persistence.dart'; + +/// Flutter-specific host facade built on top of [Wolf3dEngine]. +/// +/// This type keeps platform-neutral session and engine state in +/// `wolf_3d_dart` while owning Flutter-only concerns such as platform +/// discovery helpers and persisted data-directory lookup. +class Wolf3dFlutterEngine extends Wolf3dEngine { + /// Creates an empty facade that must be initialized with [init]. + Wolf3dFlutterEngine({ + bool debug = false, + EngineAudio? audioBackend, + Wolf3dFlutterInput? inputBackend, + DefaultGameDataDirectoryPersistence? dataDirectoryPersistence, + }) : dataDirectoryPersistence = + dataDirectoryPersistence ?? DefaultGameDataDirectoryPersistence(), + super( + audio: audioBackend ?? Wolf3dPlatformAudio(), + input: inputBackend ?? Wolf3dFlutterInput(), + ) { + if (debug) { + enableDebug(); + } + } + + /// Persists and restores the preferred external game-data directory. + final DefaultGameDataDirectoryPersistence dataDirectoryPersistence; + + /// Last configured or loaded external game-data directory path. + String? configuredDataDirectory; + + /// Shared Flutter input adapter reused by gameplay screens. + @override + Wolf3dFlutterInput get input => super.input as Wolf3dFlutterInput; + + /// Enables host-level debug affordances such as debug navigation UI. + @override + Wolf3dFlutterEngine enableDebug() { + super.enableDebug(); + return this; + } + + /// Initializes the engine by loading available game data. + /// + /// If [directory] is provided, it is persisted and treated as the primary + /// external search root. If omitted, a previously persisted directory is + /// used when available. [additionalDirectories] are scanned after the + /// primary directory and are not persisted. + /// + /// This method scans only configured external directories, deduplicating + /// discovered versions by [GameVersion]. Shared package code does not bundle + /// or import game data on behalf of host applications. + Future init({ + String? directory, + Iterable? additionalDirectories, + }) async { + await desktop_windowing_support.ensureDesktopWindowingInitialized(); + await audio.init(); + availableGames.clear(); + + final String? requestedDirectory = directory?.trim(); + final String? resolvedDirectory = + requestedDirectory != null && requestedDirectory.isNotEmpty + ? requestedDirectory + : await dataDirectoryPersistence.loadDataDirectory(); + configuredDataDirectory = resolvedDirectory; + + if (requestedDirectory != null && requestedDirectory.isNotEmpty) { + await dataDirectoryPersistence.saveDataDirectory(requestedDirectory); + } + + // On non-web platforms, also scan local filesystem locations for + // user-supplied data folders so the host can pick up extra versions. + final Set directoriesToScan = {}; + if (resolvedDirectory != null && resolvedDirectory.isNotEmpty) { + directoriesToScan.add(resolvedDirectory); + } + + if (additionalDirectories != null) { + for (final String directoryPath in additionalDirectories) { + final String trimmedPath = directoryPath.trim(); + if (trimmedPath.isNotEmpty) { + directoriesToScan.add(trimmedPath); + } + } + } + + if (!kIsWeb && directoriesToScan.isNotEmpty) { + for (final String directoryPath in directoriesToScan) { + try { + final externalGames = await WolfensteinLoader.discover( + directoryPath: directoryPath, + recursive: true, + ); + for (final MapEntry entry + in externalGames.entries) { + if (!availableGames.any( + (WolfensteinData g) => g.version == entry.key, + )) { + availableGames.add(entry.value); + } + } + } catch (e) { + debugPrint('External discovery failed: $e'); + } + } + } + + return this; + } +} diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart b/packages/wolf_3d_flutter/lib/input/wolf_3d_input_flutter.dart similarity index 100% rename from packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart rename to packages/wolf_3d_flutter/lib/input/wolf_3d_input_flutter.dart 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 index 56fb9a8..032d1c2 100644 --- a/packages/wolf_3d_flutter/lib/managers/game_screen_input_manager.dart +++ b/packages/wolf_3d_flutter/lib/managers/game_screen_input_manager.dart @@ -5,8 +5,8 @@ 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/input/wolf_3d_input_flutter.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. /// diff --git a/packages/wolf_3d_flutter/lib/renderer_settings_persistence_flutter.dart b/packages/wolf_3d_flutter/lib/persistence/renderer_settings_persistence_flutter.dart similarity index 100% rename from packages/wolf_3d_flutter/lib/renderer_settings_persistence_flutter.dart rename to packages/wolf_3d_flutter/lib/persistence/renderer_settings_persistence_flutter.dart diff --git a/packages/wolf_3d_flutter/lib/save_game_persistence_flutter.dart b/packages/wolf_3d_flutter/lib/persistence/save_game_persistence_flutter.dart similarity index 100% rename from packages/wolf_3d_flutter/lib/save_game_persistence_flutter.dart rename to packages/wolf_3d_flutter/lib/persistence/save_game_persistence_flutter.dart diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index ad2980b..98206b1 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -1,19 +1,11 @@ /// High-level Flutter facade for discovering game data and sharing runtime services. library; -import 'package:flutter/foundation.dart'; -import 'package:wolf_3d_dart/wolf_3d_data.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/audio/wolf3d_platform_audio.dart'; -import 'package:wolf_3d_flutter/managers/desktop_windowing_support.dart' - as desktop_windowing_support; -import 'package:wolf_3d_flutter/managers/game_data_directory_persistence.dart'; -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 'engine/wolf3d_flutter_engine.dart' show Wolf3dFlutterEngine; +export 'input/wolf_3d_input_flutter.dart' show Wolf3dFlutterInput; export 'managers/game_app_lifecycle_manager.dart' show GameAppLifecycleManager; export 'managers/game_data_directory_persistence.dart' show DefaultGameDataDirectoryPersistence; @@ -29,6 +21,10 @@ export 'managers/game_screen_input_manager.dart' HostShortcutRegistry, GameScreenInputManager, isAltEnterShortcut; +export 'persistence/renderer_settings_persistence_flutter.dart' + show FlutterRendererSettingsPersistence; +export 'persistence/save_game_persistence_flutter.dart' + show FlutterSaveGamePersistence; export 'screens/audio_gallery.dart' show AudioGallery; export 'screens/debug_tools_screen.dart' show DebugToolsScreen; export 'screens/game_screen.dart' show GameScreen; @@ -37,109 +33,3 @@ export 'screens/vga_gallery.dart' show VgaGallery; export 'widgets/gallery_game_selector.dart' show GalleryGameSelector, formatGalleryGameTitle; export 'widgets/wolf_menu_shell.dart' show WolfMenuShell; - -/// Flutter-specific host facade built on top of [Wolf3dEngine]. -/// -/// This type keeps platform-neutral session/engine state in the Dart package -/// while owning Flutter-only concerns such as platform discovery helpers. -class Wolf3dFlutterEngine extends Wolf3dEngine { - /// Creates an empty facade that must be initialized with [init]. - Wolf3dFlutterEngine({ - bool debug = false, - EngineAudio? audioBackend, - Wolf3dFlutterInput? inputBackend, - DefaultGameDataDirectoryPersistence? dataDirectoryPersistence, - }) : dataDirectoryPersistence = - dataDirectoryPersistence ?? DefaultGameDataDirectoryPersistence(), - super( - audio: audioBackend ?? Wolf3dPlatformAudio(), - input: inputBackend ?? Wolf3dFlutterInput(), - ) { - if (debug) { - enableDebug(); - } - } - - /// Persists and restores the preferred external game-data directory. - final DefaultGameDataDirectoryPersistence dataDirectoryPersistence; - - /// Last configured/loaded external game-data directory path. - String? configuredDataDirectory; - - /// Shared Flutter input adapter reused by gameplay screens. - @override - Wolf3dFlutterInput get input => super.input as Wolf3dFlutterInput; - - /// Enables host-level debug affordances such as debug navigation UI. - @override - Wolf3dFlutterEngine enableDebug() { - super.enableDebug(); - return this; - } - - /// Initializes the engine by loading available game data. - /// - /// If [directory] is provided, it is persisted and treated as the primary - /// external search root. If omitted, a previously persisted directory is - /// used when available. [additionalDirectories] are scanned after the - /// primary directory and are not persisted. - /// - /// This method scans only configured external directories, deduplicating - /// discovered versions by [GameVersion]. Shared package code does not bundle - /// or import game data on behalf of host applications. - Future init({ - String? directory, - Iterable? additionalDirectories, - }) async { - await desktop_windowing_support.ensureDesktopWindowingInitialized(); - await audio.init(); - availableGames.clear(); - - final String? requestedDirectory = directory?.trim(); - final String? resolvedDirectory = - requestedDirectory != null && requestedDirectory.isNotEmpty - ? requestedDirectory - : await dataDirectoryPersistence.loadDataDirectory(); - configuredDataDirectory = resolvedDirectory; - - if (requestedDirectory != null && requestedDirectory.isNotEmpty) { - await dataDirectoryPersistence.saveDataDirectory(requestedDirectory); - } - - // On non-web platforms, also scan local filesystem locations for - // user-supplied data folders so the host can pick up extra versions. - final Set directoriesToScan = {}; - if (resolvedDirectory != null && resolvedDirectory.isNotEmpty) { - directoriesToScan.add(resolvedDirectory); - } - - if (additionalDirectories != null) { - for (final String directoryPath in additionalDirectories) { - final String trimmedPath = directoryPath.trim(); - if (trimmedPath.isNotEmpty) { - directoriesToScan.add(trimmedPath); - } - } - } - - if (!kIsWeb && directoriesToScan.isNotEmpty) { - for (final String directoryPath in directoriesToScan) { - try { - final externalGames = await WolfensteinLoader.discover( - directoryPath: directoryPath, - recursive: true, - ); - for (var entry in externalGames.entries) { - if (!availableGames.any((g) => g.version == entry.key)) { - availableGames.add(entry.value); - } - } - } catch (e) { - debugPrint("External discovery failed: $e"); - } - } - } - - return this; - } -}