From d393ca98ec5750b6f282fcd621e66864d59d1ddf Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 24 Mar 2026 23:35:56 +0100 Subject: [PATCH] Refactor menu rendering and asset registry structure - Updated SoftwareRenderer to incorporate MenuHeaderBand for handling spear variant menus and improved backdrop drawing. - Refactored asset registry imports to organize menu-related assets under a dedicated menu structure. - Enhanced game session snapshot tests to validate menu theme restoration for spear variant games. - Added tests for classic menu presentation module to ensure palette consistency with canonical constants. - Implemented tests for spear asset registry to verify correct menu VGA index resolutions. - Created unit tests for MenuHeaderBand to validate functionality in rendering menu headers and sidebars. - Adjusted HUD module imports to align with new menu structure. Signed-off-by: Hans Kokx --- apps/wolf_3d_gui/lib/main.dart | 4 + packages/wolf_3d_dart/README.md | 11 + .../lib/src/engine/wolf_3d_engine.dart | 28 +++ .../lib/src/engine/wolf_3d_engine_base.dart | 97 +++++++- .../lib/src/menu/manager/menu_manager.dart | 2 + .../manager/menu_manager_intro_mixin.dart | 2 + .../manager/menu_manager_selection_mixin.dart | 105 +++++---- .../lib/src/menu/wolf_menu_presentation.dart | 4 +- .../menu/spear/spear_asset_registry.dart | 21 ++ .../spear}/spear_demo_asset_registry.dart | 10 +- .../spear}/spear_demo_entity_module.dart | 0 .../spear}/spear_demo_hud_module.dart | 0 .../spear}/spear_demo_menu_module.dart | 0 .../spear}/spear_demo_sfx_module.dart | 0 .../menu/spear/spear_menu_module.dart | 53 +++++ .../spear/spear_menu_presentation_module.dart | 28 +++ .../classic_menu_presentation_module.dart | 45 ++-- .../wolf}/retail_asset_registry.dart | 8 +- .../{ => menu/wolf}/retail_entity_module.dart | 0 .../{ => menu/wolf}/retail_hud_module.dart | 0 .../{ => menu/wolf}/retail_menu_module.dart | 0 .../wolf}/shareware_asset_registry.dart | 8 +- .../wolf}/shareware_entity_module.dart | 0 .../{ => menu/wolf}/shareware_hud_module.dart | 0 .../wolf}/shareware_menu_module.dart | 0 .../spear_menu_presentation_module.dart | 12 - .../lib/src/registry/registry_resolver.dart | 8 +- .../lib/src/rendering/ascii_renderer.dart | 170 ++++++++++---- .../lib/src/rendering/menu_header_band.dart | 162 +++++++++++++ .../lib/src/rendering/sixel_renderer.dart | 170 ++++++++++---- .../lib/src/rendering/software_renderer.dart | 171 +++++++++----- .../wolf_3d_dart/lib/wolf_3d_data_types.dart | 10 +- .../engine/game_session_snapshot_test.dart | 198 +++++++++++++--- .../test/menu/menu_manager_test.dart | 17 ++ ...classic_menu_presentation_module_test.dart | 17 ++ .../registry/shareware_hud_module_test.dart | 2 +- .../registry/shareware_menu_module_test.dart | 2 +- .../registry/spear_demo_registry_test.dart | 2 +- .../test/registry/spear_registry_test.dart | 41 ++++ .../test/rendering/menu_header_band_test.dart | 215 ++++++++++++++++++ 40 files changed, 1327 insertions(+), 296 deletions(-) create mode 100644 packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_asset_registry.dart rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/spear}/spear_demo_asset_registry.dart (59%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/spear}/spear_demo_entity_module.dart (100%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/spear}/spear_demo_hud_module.dart (100%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/spear}/spear_demo_menu_module.dart (100%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/spear}/spear_demo_sfx_module.dart (100%) create mode 100644 packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_menu_module.dart create mode 100644 packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/classic_menu_presentation_module.dart (82%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/retail_asset_registry.dart (72%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/retail_entity_module.dart (100%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/retail_hud_module.dart (100%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/retail_menu_module.dart (100%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/shareware_asset_registry.dart (82%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/shareware_entity_module.dart (100%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/shareware_hud_module.dart (100%) rename packages/wolf_3d_dart/lib/src/registry/built_in/{ => menu/wolf}/shareware_menu_module.dart (100%) delete 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/rendering/menu_header_band.dart create mode 100644 packages/wolf_3d_dart/test/registry/classic_menu_presentation_module_test.dart create mode 100644 packages/wolf_3d_dart/test/registry/spear_registry_test.dart create mode 100644 packages/wolf_3d_dart/test/rendering/menu_header_band_test.dart diff --git a/apps/wolf_3d_gui/lib/main.dart b/apps/wolf_3d_gui/lib/main.dart index 9662ce0..3f16a3e 100644 --- a/apps/wolf_3d_gui/lib/main.dart +++ b/apps/wolf_3d_gui/lib/main.dart @@ -12,6 +12,10 @@ void main() async { debug: kDebugMode, ).init(); + if (kDebugMode) { + wolf3d.enableMenuHeaderBandDebugLogging(prefix: '[wolf_3d_gui]'); + } + runApp( MaterialApp( home: Wolf3dGuiApp(engine: wolf3d), diff --git a/packages/wolf_3d_dart/README.md b/packages/wolf_3d_dart/README.md index fa8ce89..05a4ebc 100644 --- a/packages/wolf_3d_dart/README.md +++ b/packages/wolf_3d_dart/README.md @@ -238,6 +238,17 @@ The presentation module should treat its image-returning methods as optional hoo - return a `VgaImage` when that surface has variant-specific art, - return `null` when the presentation intentionally has no image for that concept, + +### Palette Conversion Guardrail + +When mapping target RGB menu tones to a VGA palette index (for example, preserving Wolf classic dark-red theme), resolve nearest colors from `ColorPalette.argbFromVgaIndex()` values. + +Do not use `ColorPalette.findClosestPaletteIndex()` for this specific workflow, because its channel interpretation is legacy-oriented and can produce hue-swapped matches (for example, red targets resolving to blue-ish indices). + +In short: + +- For variant-defined menu colors: use explicit palette indices from `MenuPresentationModule`. +- For host-defined fallback RGB tones: find nearest VGA index by comparing RGB distance against `argbFromVgaIndex()` output. - use `mappedPic(...)` only for legacy numeric menu art lookups that still matter for a renderer path. ### Wiring A Fully Custom Registry diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine.dart index 4d77533..cbd8c15 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine.dart @@ -1,3 +1,4 @@ +import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_input.dart'; @@ -37,6 +38,33 @@ class Wolf3dEngine { /// Enables host-level debug affordances such as debug navigation UI. Wolf3dEngine enableDebug() { _debugEnabled = true; + enableMenuHeaderBandDebugLogging(); + return this; + } + + /// Routes shared menu header band diagnostics to [logger]. + /// + /// Pass `null` to disable menu header band diagnostics. + Wolf3dEngine setMenuHeaderBandDebugLogger( + void Function(String message)? logger, + ) { + MenuHeaderBand.debugLogger = logger; + return this; + } + + /// Enables menu header band diagnostics with an optional [prefix]. + Wolf3dEngine enableMenuHeaderBandDebugLogging({ + String prefix = '[MENU_HEADER_BAND]', + }) { + MenuHeaderBand.debugLogger = (String message) { + print('$prefix $message'); + }; + return this; + } + + /// Disables menu header band diagnostics. + Wolf3dEngine disableMenuHeaderBandDebugLogging() { + MenuHeaderBand.debugLogger = null; return this; } diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index ef1851e..c8a9352 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -8,6 +8,7 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; import 'package:wolf_3d_dart/wolf_3d_input.dart'; +import 'package:wolf_3d_dart/wolf_3d_menu.dart'; /// The core orchestration class for the Wolfenstein 3D engine. /// @@ -61,7 +62,7 @@ class WolfEngine { if (_availableGames.isEmpty) { throw StateError('WolfEngine requires at least one game data set.'); } - menuManager.menuBackgroundRgb = menuBackgroundRgb; + _applyMenuPresentationTheme(); _normalizeRendererSettings(); _syncRendererMenuModel(); } @@ -88,10 +89,10 @@ class WolfEngine { WolfensteinData get data => _availableGames[_currentGameIndex]; /// Desired menu background color in 24-bit RGB. - final int menuBackgroundRgb; + int menuBackgroundRgb; /// Desired menu panel color in 24-bit RGB. - final int menuPanelRgb; + int menuPanelRgb; /// The active difficulty level, affecting enemy spawning and behavior. Difficulty? difficulty; @@ -231,6 +232,7 @@ class WolfEngine { void init() { _currentGameIndex = 0; audio.activeGame = data; + _applyMenuPresentationTheme(); onGameSelected?.call(data); _currentEpisodeIndex = startingEpisode ?? 0; @@ -244,6 +246,7 @@ class WolfEngine { hasResumableGame: false, hasLoadableSave: _hasLoadableSave, initialGameIsRetail: data.version == GameVersion.retail, + initialGameIsSpear: _isSpearVariant(data.version), ); if (_availableGames.length == 1) { @@ -428,6 +431,8 @@ class WolfEngine { _currentGameIndex = snapshot.currentGameIndex; audio.activeGame = data; + _applyMenuPresentationTheme(); + menuManager.setCurrentGameVersion(data.version); onGameSelected?.call(data); _currentEpisodeIndex = snapshot.currentEpisodeIndex; @@ -744,6 +749,72 @@ class WolfEngine { ); } + void _applyMenuPresentationTheme() { + if (!_isSpearVariant(data.version)) { + menuBackgroundRgb = _paletteMappedRgb24(0x890000); + menuPanelRgb = _paletteMappedRgb24(0x590002); + menuManager.menuBackgroundRgb = menuBackgroundRgb; + return; + } + + final presentation = WolfMenuPresentation(data); + final int resolvedBackgroundIndex = _resolvedMenuColorIndex( + presentation.backgroundIndex, + ); + menuBackgroundRgb = _rgb24FromVgaIndex(resolvedBackgroundIndex); + menuPanelRgb = 0x000359; + menuManager.menuBackgroundRgb = menuBackgroundRgb; + } + + int _paletteMappedRgb24(int rgb) { + final int index = _closestVgaIndexForRgb24(rgb); + return _rgb24FromVgaIndex(index); + } + + int _closestVgaIndexForRgb24(int rgb24) { + final int targetR = (rgb24 >> 16) & 0xFF; + final int targetG = (rgb24 >> 8) & 0xFF; + final int targetB = rgb24 & 0xFF; + + int bestIndex = 0; + int bestDistance = 0x7FFFFFFF; + + for (int index = 0; index < 256; index++) { + final int argb = ColorPalette.argbFromVgaIndex(index); + final int r = (argb >> 16) & 0xFF; + final int g = (argb >> 8) & 0xFF; + final int b = argb & 0xFF; + final int dr = targetR - r; + final int dg = targetG - g; + final int db = targetB - b; + final int distance = (dr * dr) + (dg * dg) + (db * db); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + } + + return bestIndex; + } + + int _resolvedMenuColorIndex(int paletteIndex) { + if (_isSpearVariant(data.version)) { + return paletteIndex; + } + if (paletteIndex >= 0x20 && paletteIndex <= 0x2F) { + return paletteIndex + 0x70; + } + return paletteIndex; + } + + int _rgb24FromVgaIndex(int paletteIndex) { + final int argb = ColorPalette.argbFromVgaIndex(paletteIndex); + final int r = (argb >> 16) & 0xFF; + final int g = (argb >> 8) & 0xFF; + final int b = argb & 0xFF; + return (r << 16) | (g << 8) | b; + } + /// The primary heartbeat of the engine. /// /// Updates all world subsystems based on the [elapsed] time. @@ -913,6 +984,8 @@ class WolfEngine { if (menuResult.selectedIndex != null) { _currentGameIndex = menuResult.selectedIndex!; audio.activeGame = data; + _applyMenuPresentationTheme(); + menuManager.setCurrentGameVersion(data.version); onGameSelected?.call(data); _currentEpisodeIndex = 0; onEpisodeSelected?.call(null); @@ -949,7 +1022,11 @@ class WolfEngine { void _tickDifficultySelectionMenu(EngineInput input) { final menuResult = menuManager.updateDifficultySelection(input); if (menuResult.goBack) { - menuManager.startTransition(WolfMenuScreen.episodeSelect); + if (_isSingleEpisodeFlowForCurrentGame) { + menuManager.startTransition(WolfMenuScreen.mainMenu); + } else { + menuManager.startTransition(WolfMenuScreen.episodeSelect); + } return; } @@ -1021,9 +1098,21 @@ class WolfEngine { void _beginNewGameMenuFlow() { onEpisodeSelected?.call(null); menuManager.clearEpisodeSelection(); + if (_isSingleEpisodeFlowForCurrentGame) { + menuManager.startTransition(WolfMenuScreen.difficultySelect); + return; + } menuManager.startTransition(WolfMenuScreen.episodeSelect); } + bool get _isSingleEpisodeFlowForCurrentGame => + _isSpearVariant(data.version) || data.episodes.length <= 1; + + bool _isSpearVariant(GameVersion version) { + return version == GameVersion.spearOfDestiny || + version == GameVersion.spearOfDestinyDemo; + } + void _openPauseMenu() { if (!_hasActiveSession) { return; 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 index ee74985..1bfa4db 100644 --- a/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/manager/menu_manager.dart @@ -49,6 +49,7 @@ abstract class _MenuManagerBase { bool _showResumeOption = false; bool _hasLoadableSave = false; int _gameCount = 1; + bool _isSpearVariant = false; bool _prevUp = false; bool _prevDown = false; @@ -129,6 +130,7 @@ abstract class _MenuManagerBase { bool hasResumableGame = false, bool hasLoadableSave = false, bool initialGameIsRetail = false, + bool initialGameIsSpear = false, WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade, }); 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 index cae0e07..3e96873 100644 --- 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 @@ -140,6 +140,7 @@ mixin _MenuManagerIntroMixin on _MenuManagerBase { bool hasResumableGame = false, bool hasLoadableSave = false, bool initialGameIsRetail = false, + bool initialGameIsSpear = false, WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade, }) { _gameCount = gameCount; @@ -153,6 +154,7 @@ mixin _MenuManagerIntroMixin on _MenuManagerBase { : Difficulty.values .indexOf(initialDifficulty) .clamp(0, Difficulty.values.length - 1); + _isSpearVariant = initialGameIsSpear; _introLandingMenu = WolfMenuScreen.mainMenu; if (gameCount > 1) { _activeMenu = WolfMenuScreen.gameSelect; 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 index 1d2e5c1..21cab42 100644 --- 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 @@ -42,51 +42,76 @@ mixin _MenuManagerSelectionMixin on _MenuManagerBase { /// 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', - ), + final List entries = [ + _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', + ), + ]; + + if (!_isSpearVariant) { + entries.add( _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'), - ], + ); + } + + entries.add( + _mainMenuEntry( + action: _showResumeOption + ? WolfMenuMainAction.endGame + : WolfMenuMainAction.viewScores, + label: _showResumeOption ? 'END GAME' : 'VIEW SCORES', + ), ); + entries.add( + _mainMenuEntry( + action: _showResumeOption + ? WolfMenuMainAction.backToGame + : WolfMenuMainAction.backToDemo, + label: _showResumeOption ? 'BACK TO GAME' : 'BACK TO DEMO', + ), + ); + entries.add(_mainMenuEntry(action: WolfMenuMainAction.quit, label: 'QUIT')); + + return List.unmodifiable(entries); + } + + /// Updates menu variant flags from the selected game version. + void setCurrentGameVersion(GameVersion version) { + _isSpearVariant = + version == GameVersion.spearOfDestiny || + version == GameVersion.spearOfDestinyDemo; + _selectedMainIndex = clampIndex(_selectedMainIndex, mainMenuEntries.length); + if (!_isSelectableMainIndex(_selectedMainIndex)) { + _selectedMainIndex = findSelectableIndex( + _selectedMainIndex, + mainMenuEntries.length, + _isSelectableMainIndex, + ); + } } /// Whether the main menu can return to the game-selection step. 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 index cc8b469..1020fd9 100644 --- a/packages/wolf_3d_dart/lib/src/menu/wolf_menu_presentation.dart +++ b/packages/wolf_3d_dart/lib/src/menu/wolf_menu_presentation.dart @@ -1,5 +1,5 @@ -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/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_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. diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_asset_registry.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_asset_registry.dart new file mode 100644 index 0000000..f044d2c --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_asset_registry.dart @@ -0,0 +1,21 @@ +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/menu/spear/spear_demo_entity_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_hud_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_sfx_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart'; + +/// Built-in [AssetRegistry] for full Spear of Destiny (`.SOD`). +class SpearAssetRegistry extends AssetRegistry { + SpearAssetRegistry() + : super( + sfx: const SpearDemoSfxModule(), + music: const BuiltInMusicModule(GameVersion.spearOfDestiny), + entities: const SpearDemoEntityModule(), + hud: const SpearDemoHudModule(), + menu: const SpearMenuPicModule(), + menuPresentation: const SpearMenuPresentationModule(), + ); +} 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/menu/spear/spear_demo_asset_registry.dart similarity index 59% rename from packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_asset_registry.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_asset_registry.dart index c7c5de6..960cec8 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/menu/spear/spear_demo_asset_registry.dart @@ -1,11 +1,11 @@ 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/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'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_entity_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_hud_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_menu_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_sfx_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart'; /// Built-in [AssetRegistry] for Spear of Destiny demo/shareware (`.SDM`). class SpearDemoAssetRegistry extends AssetRegistry { diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_entity_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_entity_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_entity_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_entity_module.dart diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_hud_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_hud_module.dart diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_menu_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_menu_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_menu_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_menu_module.dart diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_sfx_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_sfx_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_sfx_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_demo_sfx_module.dart diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_menu_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_menu_module.dart new file mode 100644 index 0000000..ed7811b --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_menu_module.dart @@ -0,0 +1,53 @@ +import 'package:wolf_3d_dart/src/data_types/difficulty.dart'; +import 'package:wolf_3d_dart/src/registry/keys/menu_pic_key.dart'; +import 'package:wolf_3d_dart/src/registry/modules/menu_pic_module.dart'; + +/// Built-in menu-picture module for full Spear of Destiny releases (`.SOD`). +/// +/// Picture indices are derived from `GFXV_SOD.H` (`chunkId - STARTPICS`). +class SpearMenuPicModule extends MenuPicModule { + const SpearMenuPicModule(); + + static final Map _indices = { + MenuPicKey.title: 76, // TITLE1PIC + MenuPicKey.credits: 89, // CREDITSPIC + MenuPicKey.pg13: 88, // PG13PIC + + MenuPicKey.controlBackground: 12, // C_CONTROLPIC + MenuPicKey.footer: 1, // C_MOUSELBACKPIC + MenuPicKey.heading: 0, // C_BACKDROPPIC + MenuPicKey.optionsLabel: 13, // C_OPTIONSPIC + MenuPicKey.customizeLabel: 6, // C_CUSTOMIZEPIC + + MenuPicKey.cursorActive: 2, // C_CURSOR1PIC + MenuPicKey.cursorInactive: 3, // C_CURSOR2PIC + MenuPicKey.markerSelected: 5, // C_SELECTEDPIC + MenuPicKey.markerUnselected: 4, // C_NOTSELECTEDPIC + + MenuPicKey.difficultyBaby: 18, // C_BABYMODEPIC + MenuPicKey.difficultyEasy: 19, // C_EASYPIC + MenuPicKey.difficultyNormal: 20, // C_NORMALPIC + MenuPicKey.difficultyHard: 21, // C_HARDPIC + }; + + @override + MenuPicRef? resolve(MenuPicKey key) { + final int? index = _indices[key]; + return index != null ? MenuPicRef(index) : null; + } + + @override + MenuPicKey episodeKey(int episodeIndex) { + return MenuPicKey.episode1; + } + + @override + MenuPicKey difficultyKey(Difficulty difficulty) { + return switch (difficulty) { + Difficulty.baby => MenuPicKey.difficultyBaby, + Difficulty.easy => MenuPicKey.difficultyEasy, + Difficulty.medium => MenuPicKey.difficultyNormal, + Difficulty.hard => MenuPicKey.difficultyHard, + }; + } +} \ No newline at end of file diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart new file mode 100644 index 0000000..11c383e --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart @@ -0,0 +1,28 @@ +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/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(); + + /// Spear VGA background color (`BKGDCOLOR` in `WL_MENU.H`). + @override + int get backgroundIndex => 0x9D; + + /// Spear panel fill color (`BORD2COLOR` in `WL_MENU.H`). + @override + int get panelIndex => 0x93; + + /// Spear panel border color (`BORDCOLOR` in `WL_MENU.H`). + @override + int get borderIndex => 0x99; + + /// Spear disabled/deactivated text color (`DEACTIVE` in `WL_MENU.H`). + @override + int get disabledTextIndex => 0x9B; +} 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/menu/wolf/classic_menu_presentation_module.dart similarity index 82% rename from packages/wolf_3d_dart/lib/src/registry/built_in/classic_menu_presentation_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart index a4da1a0..e09f1d6 100644 --- 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/menu/wolf/classic_menu_presentation_module.dart @@ -1,4 +1,3 @@ -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'; @@ -11,49 +10,45 @@ 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. + /// Classic menu background (`BKGDCOLOR` in `WL_MENU.H`). @override - int get backgroundIndex => 111; + int get backgroundIndex => 0x2D; - /// Classic VGA palette index for panel fills. + /// Classic panel fill (`BORD2COLOR` in `WL_MENU.H`). @override - int get panelIndex => 103; + int get panelIndex => 0x23; - /// Classic VGA palette index for panel borders. + /// Classic panel border (`BORDCOLOR` in `WL_MENU.H`). @override - int get borderIndex => 87; + int get borderIndex => 0x29; - /// Classic VGA palette index for emphasis text. + /// Highlight text (`HIGHLIGHT` in `WL_MENU.H`). @override - int get emphasisIndex => 10; + int get emphasisIndex => 0x13; - /// Classic VGA palette index for warnings. + /// Read-screen highlight (`READHCOLOR` in `WL_MENU.H`). @override - int get warningIndex => 14; + int get warningIndex => 0x47; - /// Classic VGA palette index for muted text. + /// Read-screen body text (`READCOLOR` in `WL_MENU.H`). @override - int get mutedIndex => 8; + int get mutedIndex => 0x4A; - /// Classic VGA palette index for selected row text. + /// Selected menu text (`HIGHLIGHT` in `WL_MENU.H`). @override - int get selectedTextIndex => 19; + int get selectedTextIndex => 0x13; - /// Classic VGA palette index for unselected row text. + /// Unselected menu text (`TEXTCOLOR` in `WL_MENU.H`). @override - int get unselectedTextIndex => 23; + int get unselectedTextIndex => 0x17; - /// Classic VGA palette index for disabled row text. + /// Disabled menu text (`DEACTIVE` in `WL_MENU.H`). @override - int get disabledTextIndex => 4; + int get disabledTextIndex => 0x2B; - /// Classic heading palette index computed from the target yellow tone. + /// Header/read highlight (`READHCOLOR` in `WL_MENU.H`). @override - int get headerTextIndex => - ColorPalette.findClosestPaletteIndex(_headerTargetRgb); + int get headerTextIndex => 0x47; /// Controls/customize panel background art. @override 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/menu/wolf/retail_asset_registry.dart similarity index 72% rename from packages/wolf_3d_dart/lib/src/registry/built_in/retail_asset_registry.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/retail_asset_registry.dart index 0d057cf..e7c8dbe 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/menu/wolf/retail_asset_registry.dart @@ -2,10 +2,10 @@ 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'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_entity_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_hud_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_menu_module.dart'; /// The canonical [AssetRegistry] for all retail Wolf3D releases. /// diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_entity_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/retail_entity_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/retail_entity_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/retail_entity_module.dart diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_hud_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/retail_hud_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/retail_hud_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/retail_hud_module.dart diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_menu_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/retail_menu_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/retail_menu_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/retail_menu_module.dart 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/menu/wolf/shareware_asset_registry.dart similarity index 82% rename from packages/wolf_3d_dart/lib/src/registry/built_in/shareware_asset_registry.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/shareware_asset_registry.dart index 127a685..408f559 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/menu/wolf/shareware_asset_registry.dart @@ -2,10 +2,10 @@ 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'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_entity_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_hud_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_menu_module.dart'; /// The [AssetRegistry] for the Wolfenstein 3D v1.4 Shareware release. /// diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_entity_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/shareware_entity_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/shareware_entity_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/shareware_entity_module.dart diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_hud_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/shareware_hud_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/shareware_hud_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/shareware_hud_module.dart 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/menu/wolf/shareware_menu_module.dart similarity index 100% rename from packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart rename to packages/wolf_3d_dart/lib/src/registry/built_in/menu/wolf/shareware_menu_module.dart 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 deleted file mode 100644 index d8e0c2b..0000000 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_menu_presentation_module.dart +++ /dev/null @@ -1,12 +0,0 @@ -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/registry_resolver.dart b/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart index 6fdce0f..0aa7f1d 100644 --- a/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart +++ b/packages/wolf_3d_dart/lib/src/registry/registry_resolver.dart @@ -1,9 +1,10 @@ import 'package:wolf_3d_dart/src/data/data_version.dart'; 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/retail_asset_registry.dart'; -import 'package:wolf_3d_dart/src/registry/built_in/shareware_asset_registry.dart'; -import 'package:wolf_3d_dart/src/registry/built_in/spear_demo_asset_registry.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_asset_registry.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_demo_asset_registry.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/retail_asset_registry.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_asset_registry.dart'; /// The input used by [AssetRegistryResolver] to select or build a registry. class RegistrySelectionContext { @@ -62,6 +63,7 @@ class BuiltInAssetRegistryResolver implements AssetRegistryResolver { case GameVersion.shareware: return SharewareAssetRegistry(); case GameVersion.spearOfDestiny: + return SpearAssetRegistry(); case GameVersion.spearOfDestinyDemo: return SpearDemoAssetRegistry(); } 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 fd4b754..260f936 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -4,6 +4,7 @@ import 'dart:math' as math; import 'package:arcane_helper_utils/arcane_helper_utils.dart'; import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart'; +import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; @@ -547,13 +548,18 @@ class AsciiRenderer extends CliRendererBackend { ); final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb); final menu = WolfMenuPresentation(engine.data); + final bool isSpearVariant = MenuHeaderBand.isSpearVariant( + engine.data.version, + ); 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) { + if (isSpearVariant && menu.heading != null) { + _drawTiledMenuBackdrop(menu.heading!, bgColor); + } else if (_usesTerminalLayout) { _fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor); } else { _fillRect(0, 0, width, height, activeTheme.solid, bgColor); @@ -561,7 +567,7 @@ class AsciiRenderer extends CliRendererBackend { final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { - _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); + _mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel); } if (engine.menuManager.activeMenu == WolfMenuScreen.introSplash) { @@ -575,7 +581,11 @@ class AsciiRenderer extends CliRendererBackend { final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); - _drawMainMenuOptionsSideBars(optionsLabel, optionsX); + _drawMainMenuOptionsSideBars( + optionsLabel, + optionsX, + debugContext: 'ascii/mainMenu', + ); _blitVgaImageAscii(optionsLabel, optionsX, 0); } else { _drawHeaderBarStack( @@ -630,37 +640,47 @@ class AsciiRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) { - _drawHeaderBarStack( - headingY200: _headerHeadingY, - backgroundColor: bgColor, - barColor: ColorPalette.vga32Bit[0], - ); - _fillRect320(28, 58, 264, 104, panelColor); + final optionsLabel = menu.optionsLabel; + if (optionsLabel != null) { + final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars( + optionsLabel, + optionsX, + debugContext: 'ascii/gameSelect', + ); + _blitVgaImageAscii(optionsLabel, optionsX, 0); + } else { + _drawHeaderBarStack( + headingY200: _headerHeadingY, + backgroundColor: bgColor, + barColor: ColorPalette.vga32Bit[0], + ); + _drawMenuTextCentered( + 'SELECT GAME', + _headerHeadingY, + headingColor, + scale: menuTypography.headingScale, + ); + } + _fillRect320(68, 52, 178, 136, panelColor); final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - const int rowYStart = 84; - const int rowStep = 18; + const int rowYStart = 55; + const int rowStep = 13; final List rows = engine.availableGames - .map((game) => _gameTitle(game.version)) + .map((game) => MenuHeaderBand.gameTitle(game.version)) .toList(growable: false); - _drawMenuTextCentered( - 'SELECT GAME', - _headerHeadingY, - headingColor, - scale: menuTypography.headingScale, - ); - _drawSelectableMenuRows( typography: menuTypography, rows: rows, selectedIndex: engine.menuManager.selectedGameIndex, rowYStart200: rowYStart, rowStep200: rowStep, - textX320: 70, - panelX320: 28, - panelW320: 264, + textX320: 100, + panelX320: 68, + panelW320: 178, colorForRow: (int _, bool isSelected) { return isSelected ? selectedTextColor : unselectedTextColor; }, @@ -669,7 +689,7 @@ class AsciiRenderer extends CliRendererBackend { if (cursor != null) { _blitVgaImageAscii( cursor, - 38, + 72, (rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2, ); } @@ -780,7 +800,11 @@ class AsciiRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) { - _drawCustomizeMenuHeader(menu, headingColor, bgColor); + _drawCustomizeMenuHeader( + menu, + headingColor, + bgColor, + ); final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); @@ -884,7 +908,11 @@ class AsciiRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) { - _drawCustomizeMenuHeader(menu, headingColor, bgColor); + _drawCustomizeMenuHeader( + menu, + headingColor, + bgColor, + ); _fillRect320(56, 52, 208, 120, panelColor); _drawMenuTextCentered( engine.menuManager.rendererOptionsTitle, @@ -1018,19 +1046,6 @@ class AsciiRenderer extends CliRendererBackend { _applyMenuTransition(engine.menuManager, bgColor); } - String _gameTitle(GameVersion version) { - switch (version) { - case GameVersion.shareware: - return 'SHAREWARE'; - case GameVersion.retail: - return 'RETAIL'; - case GameVersion.spearOfDestiny: - return 'SPEAR OF DESTINY'; - case GameVersion.spearOfDestinyDemo: - return 'SOD DEMO'; - } - } - void _drawIntroSplash( WolfEngine engine, WolfMenuPresentation menu, @@ -1172,17 +1187,38 @@ class AsciiRenderer extends CliRendererBackend { } } - void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) { - _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); - _drawScaledColumnBand(_mainMenuBandFirstColumn!); - } - - List _cacheFirstColumn(VgaImage image) { - final List column = List.filled(image.height, 0); - for (int y = 0; y < image.height; y++) { - column[y] = image.decodePixel(0, y); - } - return column; + void _drawMainMenuOptionsSideBars( + VgaImage optionsLabel, + int optionsX320, { + required String debugContext, + }) { + _mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel); + MenuHeaderBand.applyFromHeadingImage( + image: optionsLabel, + imageX320: optionsX320, + debugContext: debugContext, + fillSideEdgesRow: + ( + int y200, + int leftWidth320, + int rightStartX320, + int paletteIndex, + ) { + final int color = ColorPalette.vga32Bit[paletteIndex]; + if (leftWidth320 > 0) { + _fillRect320(0, y200, leftWidth320, 1, color); + } + if (rightStartX320 < 320) { + _fillRect320( + rightStartX320, + y200, + 320 - rightStartX320, + 1, + color, + ); + } + }, + ); } void _drawScaledColumnBand(List column) { @@ -1190,6 +1226,18 @@ class AsciiRenderer extends CliRendererBackend { return; } + int firstBlack = -1; + int lastBlack = -1; + for (int y = 0; y < column.length; y++) { + if (column[y] == 0) { + firstBlack = firstBlack == -1 ? y : firstBlack; + lastBlack = y; + } + } + if (firstBlack == -1) { + return; + } + final int maxDrawHeight = _usesTerminalLayout ? _terminalPixelHeight : height; @@ -1198,6 +1246,9 @@ class AsciiRenderer extends CliRendererBackend { for (int dy = 0; dy < destHeight; dy++) { final int srcY = (dy / scaleY).toInt().clamp(0, column.length - 1); + if (srcY < firstBlack || srcY > lastBlack) { + continue; + } final int fillColor = ColorPalette.vga32Bit[column[srcY]]; if (_usesTerminalLayout) { @@ -1606,6 +1657,11 @@ class AsciiRenderer extends CliRendererBackend { final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel; if (heading != null) { final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars( + heading, + headingX, + debugContext: 'ascii/customizeHeader', + ); _blitVgaImageAscii(heading, headingX, 0); return; } @@ -2516,4 +2572,20 @@ class AsciiRenderer extends CliRendererBackend { int _rgbToPaletteColor(int rgb) { return ColorPalette.vga32Bit[ColorPalette.findClosestPaletteIndex(rgb)]; } + + void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) { + if (image.width <= 0 || image.height <= 0) { + if (_usesTerminalLayout) { + _fillTerminalRect(0, 0, width, _terminalPixelHeight, fallbackColor); + } else { + _fillRect(0, 0, width, height, activeTheme.solid, fallbackColor); + } + return; + } + for (int y = 0; y < 200; y += image.height) { + for (int x = 0; x < 320; x += image.width) { + _blitVgaImageAscii(image, x, y); + } + } + } } diff --git a/packages/wolf_3d_dart/lib/src/rendering/menu_header_band.dart b/packages/wolf_3d_dart/lib/src/rendering/menu_header_band.dart new file mode 100644 index 0000000..329445b --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rendering/menu_header_band.dart @@ -0,0 +1,162 @@ +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +abstract class MenuHeaderBand { + static void Function(String message)? debugLogger; + + static bool isSpearVariant(GameVersion version) { + switch (version) { + case GameVersion.spearOfDestiny: + case GameVersion.spearOfDestinyDemo: + return true; + case GameVersion.shareware: + case GameVersion.retail: + return false; + } + } + + static List firstColumn(VgaImage image) { + final List column = List.filled(image.height, 0); + final int sampleX = image.width > 1 ? 1 : 0; + for (int y = 0; y < image.height; y++) { + column[y] = image.decodePixel(sampleX, y); + } + + final int effectiveRows = _effectiveBandRowCount(column); + if (effectiveRows == column.length) { + return column; + } + return column.sublist(0, effectiveRows); + } + + static String gameTitle(GameVersion version) { + switch (version) { + case GameVersion.shareware: + return 'SHAREWARE'; + case GameVersion.retail: + return 'RETAIL'; + case GameVersion.spearOfDestiny: + return 'SPEAR OF DESTINY'; + case GameVersion.spearOfDestinyDemo: + return 'SOD DEMO'; + } + } + + static void applyFromHeadingImage({ + required VgaImage image, + required int imageX320, + required void Function( + int y200, + int leftWidth320, + int rightStartX320, + int paletteIndex, + ) + fillSideEdgesRow, + int maxRows = 200, + String? debugContext, + }) { + if (image.width <= 0 || image.height <= 0) { + return; + } + + final int sampledRows = image.height < maxRows ? image.height : maxRows; + if (sampledRows <= 0) { + return; + } + + final int sampleX = image.width > 1 ? 1 : 0; + final List sourceByRow = List.filled(sampledRows, 0); + final List isBlackByRow = List.filled(sampledRows, false); + for (int y = 0; y < sampledRows; y++) { + final int sourceIndex = image.decodePixel(sampleX, y); + sourceByRow[y] = sourceIndex; + isBlackByRow[y] = sourceIndex == 0; + } + + final int rowCount = _effectiveBandRowCount(sourceByRow); + if (rowCount <= 0) { + return; + } + + final int leftWidth320 = imageX320.clamp(0, 320); + final int rightStartX320 = (imageX320 + image.width).clamp(0, 320); + + // Extend rows that fall inside the span bounded by the first and last + // black row. Leading non-black rows (coloured image border at the top) + // and trailing non-black rows let the background show through. Interior + // non-black rows (e.g. a decorative stripe sandwiched between two black + // sections) are extended with their actual palette colour. + int firstBlack = -1; + int lastBlack = -1; + for (int y = 0; y < rowCount; y++) { + if (sourceByRow[y] == 0) { + firstBlack = firstBlack == -1 ? y : firstBlack; + lastBlack = y; + } + } + if (firstBlack == -1) { + return; + } + for (int y = firstBlack; y <= lastBlack; y++) { + fillSideEdgesRow(y, leftWidth320, rightStartX320, sourceByRow[y]); + } + + final debug = debugLogger; + if (debug != null) { + final String label = debugContext ?? 'header-band'; + final String runsText = _summarizeRunsByBlackState(isBlackByRow); + debug( + '$label rows=$rowCount left=$leftWidth320 right=$rightStartX320 runs=$runsText', + ); + } + } + + static String _summarizeRunsByBlackState(List isBlackByRow) { + if (isBlackByRow.isEmpty) { + return ''; + } + + int runStart = 0; + bool runIsBlack = isBlackByRow[0]; + final List chunks = []; + + for (int y = 1; y < isBlackByRow.length; y++) { + if (isBlackByRow[y] == runIsBlack) { + continue; + } + chunks.add( + '[$runStart-${y - 1}:${runIsBlack ? 'black' : 'non-black'}]', + ); + runStart = y; + runIsBlack = isBlackByRow[y]; + } + chunks.add( + '[$runStart-${isBlackByRow.length - 1}:${runIsBlack ? 'black' : 'non-black'}]', + ); + return chunks.join(', '); + } + + static int _effectiveBandRowCount(List sampledByRow) { + if (sampledByRow.isEmpty) { + return 0; + } + + int lastBlack = -1; + for (int y = sampledByRow.length - 1; y >= 0; y--) { + if (sampledByRow[y] == 0) { + lastBlack = y; + break; + } + } + + if (lastBlack == -1) { + return sampledByRow.length; + } + + final int trailingRows = sampledByRow.length - (lastBlack + 1); + const int maxTrailingRows = 3; + final int keptTrailingRows = trailingRows > maxTrailingRows + ? maxTrailingRows + : trailingRows; + return lastBlack + 1 + keptTrailingRows; + } +} 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 18c957a..b5ce3a1 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -9,6 +9,7 @@ import 'dart:typed_data'; import 'package:wolf_3d_dart/src/input/cli_input.dart'; import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart'; +import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; @@ -477,18 +478,25 @@ class SixelRenderer extends CliRendererBackend { ); final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb); final menu = WolfMenuPresentation(engine.data); + final bool isSpearVariant = MenuHeaderBand.isSpearVariant( + engine.data.version, + ); 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; + if (isSpearVariant && menu.heading != null) { + _drawTiledMenuBackdrop(menu.heading!, bgColor); + } else { + for (int i = 0; i < _screen.length; i++) { + _screen[i] = bgColor; + } } final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { - _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); + _mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel); } // Draw footer first so menu panels can clip overlap in the center. _drawMenuFooterArt(menu); @@ -504,7 +512,11 @@ class SixelRenderer extends CliRendererBackend { final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); - _drawMainMenuOptionsSideBars(optionsLabel, optionsX); + _drawMainMenuOptionsSideBars( + optionsLabel, + optionsX, + debugContext: 'sixel/mainMenu', + ); _blitVgaImage(optionsLabel, optionsX, 0); } else { _drawHeaderBarStack( @@ -546,31 +558,43 @@ class SixelRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) { - _drawHeaderBarStack( - headingY200: _headerHeadingY, - backgroundColor: bgColor, - barColor: 0, - ); - _fillRect320(28, 58, 264, 104, panelColor); - _drawMenuTextCentered( - 'SELECT GAME', - _headerHeadingY, - headingIndex, - scale: 2, - ); + final optionsLabel = menu.optionsLabel; + if (optionsLabel != null) { + final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars( + optionsLabel, + optionsX, + debugContext: 'sixel/gameSelect', + ); + _blitVgaImage(optionsLabel, optionsX, 0); + } else { + _drawHeaderBarStack( + headingY200: _headerHeadingY, + backgroundColor: bgColor, + barColor: 0, + ); + _drawMenuTextCentered( + 'SELECT GAME', + _headerHeadingY, + headingIndex, + scale: 2, + ); + } + _fillRect320(68, 52, 178, 136, panelColor); + final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - const int rowYStart = 84; - const int rowStep = 18; + const int rowYStart = 55; + const int rowStep = 13; for (int i = 0; i < engine.availableGames.length; i++) { final bool isSelected = i == engine.menuManager.selectedGameIndex; if (isSelected && cursor != null) { - _blitVgaImage(cursor, 38, (rowYStart + (i * rowStep)) - 2); + _blitVgaImage(cursor, 72, (rowYStart + (i * rowStep)) - 2); } _drawMenuText( - _gameTitle(engine.availableGames[i].version), - 70, + MenuHeaderBand.gameTitle(engine.availableGames[i].version), + 100, rowYStart + (i * rowStep), isSelected ? selectedTextIndex : unselectedTextIndex, scale: 1, @@ -633,7 +657,11 @@ class SixelRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) { - _drawCustomizeMenuHeader(menu, headingIndex, bgColor); + _drawCustomizeMenuHeader( + menu, + headingIndex, + bgColor, + ); final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); @@ -726,7 +754,11 @@ class SixelRenderer extends CliRendererBackend { } if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) { - _drawCustomizeMenuHeader(menu, headingIndex, bgColor); + _drawCustomizeMenuHeader( + menu, + headingIndex, + bgColor, + ); _fillRect320(56, 52, 208, 120, panelColor); _drawMenuTextCentered( engine.menuManager.rendererOptionsTitle, @@ -845,6 +877,11 @@ class SixelRenderer extends CliRendererBackend { final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel; if (heading != null) { final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars( + heading, + headingX, + debugContext: 'sixel/customizeHeader', + ); _blitVgaImage(heading, headingX, 0); return; } @@ -882,19 +919,6 @@ class SixelRenderer extends CliRendererBackend { ); } - String _gameTitle(GameVersion version) { - switch (version) { - case GameVersion.shareware: - return 'SHAREWARE'; - case GameVersion.retail: - return 'RETAIL'; - case GameVersion.spearOfDestiny: - return 'SPEAR OF DESTINY'; - case GameVersion.spearOfDestinyDemo: - return 'SOD DEMO'; - } - } - void _drawIntroSplash(WolfEngine engine, WolfMenuPresentation menu) { final image = switch (engine.menuManager.currentIntroSlide) { WolfIntroSlide.retailWarning => null, @@ -1110,17 +1134,37 @@ class SixelRenderer extends CliRendererBackend { _fillRect320(0, mainBarTop, 320, 22, barColor); } - void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) { - _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); - _drawScaledColumnBand(_mainMenuBandFirstColumn!); - } - - List _cacheFirstColumn(VgaImage image) { - final List column = List.filled(image.height, 0); - for (int y = 0; y < image.height; y++) { - column[y] = image.decodePixel(0, y); - } - return column; + void _drawMainMenuOptionsSideBars( + VgaImage optionsLabel, + int optionsX320, { + required String debugContext, + }) { + _mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel); + MenuHeaderBand.applyFromHeadingImage( + image: optionsLabel, + imageX320: optionsX320, + debugContext: debugContext, + fillSideEdgesRow: + ( + int y200, + int leftWidth320, + int rightStartX320, + int paletteIndex, + ) { + if (leftWidth320 > 0) { + _fillRect320(0, y200, leftWidth320, 1, paletteIndex); + } + if (rightStartX320 < 320) { + _fillRect320( + rightStartX320, + y200, + 320 - rightStartX320, + 1, + paletteIndex, + ); + } + }, + ); } void _drawScaledColumnBand(List column) { @@ -1128,6 +1172,18 @@ class SixelRenderer extends CliRendererBackend { return; } + int firstBlack = -1; + int lastBlack = -1; + for (int y = 0; y < column.length; y++) { + if (column[y] == 0) { + firstBlack = firstBlack == -1 ? y : firstBlack; + lastBlack = y; + } + } + if (firstBlack == -1) { + return; + } + final double scaleY = height / 200.0; final int destHeight = math.max(1, (column.length * scaleY).toInt()); @@ -1141,8 +1197,10 @@ class SixelRenderer extends CliRendererBackend { 0, column.length - 1, ); - final int paletteIndex = column[srcY]; - final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex; + if (srcY < firstBlack || srcY > lastBlack) { + continue; + } + final int fillIndex = column[srcY]; final int rowStart = drawY * width; for (int drawX = 0; drawX < width; drawX++) { _screen[rowStart + drawX] = fillIndex; @@ -1544,4 +1602,18 @@ class SixelRenderer extends CliRendererBackend { int _rgbToPaletteIndex(int rgb) { return ColorPalette.findClosestPaletteIndex(rgb); } + + void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) { + if (image.width <= 0 || image.height <= 0) { + for (int i = 0; i < _screen.length; i++) { + _screen[i] = fallbackColor; + } + return; + } + for (int y = 0; y < 200; y += image.height) { + for (int x = 0; x < 320; x += image.width) { + _blitVgaImage(image, x, y); + } + } + } } 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 911b6a3..d7dcc9f 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; import 'package:wolf_3d_dart/src/rendering/fizzle_fade.dart'; import 'package:wolf_3d_dart/src/rendering/menu_font.dart'; +import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart'; import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; @@ -255,18 +256,25 @@ class SoftwareRenderer extends RendererBackend { final int bgColor = _rgbToFrameColor(engine.menuManager.menuBackgroundRgb); final int panelColor = _rgbToFrameColor(engine.menuPanelRgb); final menu = WolfMenuPresentation(engine.data); + final bool isSpearVariant = MenuHeaderBand.isSpearVariant( + engine.data.version, + ); 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; + if (isSpearVariant && menu.heading != null) { + _drawTiledMenuBackdrop(menu.heading!, bgColor); + } else { + for (int i = 0; i < _buffer.pixels.length; i++) { + _buffer.pixels[i] = bgColor; + } } final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { - _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); + _mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel); } // Draw footer first so menu panels can clip overlap in the center. _drawCenteredMenuFooter(menu); @@ -494,7 +502,11 @@ class SoftwareRenderer extends RendererBackend { final optionsLabel = menu.optionsLabel; if (optionsLabel != null) { final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); - _drawMainMenuOptionsSideBars(optionsLabel, optionsX); + _drawMainMenuOptionsSideBars( + optionsLabel, + optionsX, + debugContext: 'software/mainMenu', + ); _blitVgaImage(optionsLabel, optionsX, 0); } else { _drawHeaderBarStack( @@ -560,6 +572,11 @@ class SoftwareRenderer extends RendererBackend { final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel; if (heading != null) { final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars( + heading, + headingX, + debugContext: 'software/changeView', + ); _blitVgaImage(heading, headingX, 0); } else { _drawCanonicalMenuTextCentered( @@ -706,6 +723,11 @@ class SoftwareRenderer extends RendererBackend { final VgaImage? heading = menu.customizeLabel ?? menu.optionsLabel; if (heading != null) { final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars( + heading, + headingX, + debugContext: 'software/rendererOptions', + ); _blitVgaImage(heading, headingX, 0); } else { _drawCanonicalMenuTextCentered( @@ -772,42 +794,52 @@ class SoftwareRenderer extends RendererBackend { int selectedTextColor, int unselectedTextColor, ) { - _drawHeaderBarStack( - headingY200: _headerHeadingY, - backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb), - barColor: ColorPalette.vga32Bit[0], - ); + final optionsLabel = menu.optionsLabel; + if (optionsLabel != null) { + final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars( + optionsLabel, + optionsX, + debugContext: 'software/gameSelect', + ); + _blitVgaImage(optionsLabel, optionsX, 0); + } else { + _drawHeaderBarStack( + headingY200: _headerHeadingY, + backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb), + barColor: ColorPalette.vga32Bit[0], + ); + _drawCanonicalMenuTextCentered( + 'SELECT GAME', + _headerHeadingY, + headingColor, + scale: 2, + ); + } - const int panelX = 28; - const int panelY = 58; - const int panelW = 264; - const int panelH = 104; + const int panelX = 68; + const int panelY = 52; + const int panelW = 178; + const int panelH = 136; _fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor); - _drawCanonicalMenuTextCentered( - 'SELECT GAME', - _headerHeadingY, - headingColor, - scale: 2, - ); - final cursor = menu.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); - const int rowYStart = 78; - const int rowStep = 20; - const int textX = 70; + const int rowYStart = 55; + const int rowStep = 13; + const int textX = 100; final int selectedIndex = engine.menuManager.selectedGameIndex; for (int i = 0; i < engine.availableGames.length; i++) { final bool isSelected = i == selectedIndex; final int y = rowYStart + (i * rowStep); if (isSelected && cursor != null) { - _blitVgaImage(cursor, panelX + 10, y - 2); + _blitVgaImage(cursor, panelX + 4, y - 2); } _drawCanonicalMenuText( - _gameTitle(engine.availableGames[i].version), + MenuHeaderBand.gameTitle(engine.availableGames[i].version), textX, y, isSelected ? selectedTextColor : unselectedTextColor, @@ -1052,19 +1084,6 @@ class SoftwareRenderer extends RendererBackend { _buffer.pixels[(y * width) + x] = color; } - String _gameTitle(GameVersion version) { - switch (version) { - case GameVersion.shareware: - return 'SHAREWARE'; - case GameVersion.retail: - return 'RETAIL'; - case GameVersion.spearOfDestiny: - return 'SPEAR OF DESTINY'; - case GameVersion.spearOfDestinyDemo: - return 'SOD DEMO'; - } - } - void _applyMenuTransition(MenuManager menuManager, int coverColor) { switch (menuManager.transitionEffect) { case WolfTransitionEffect.none: @@ -1215,10 +1234,24 @@ class SoftwareRenderer extends RendererBackend { final List? cachedColumn = _mainMenuBandFirstColumn; if (cachedColumn != null && cachedColumn.isNotEmpty) { final int bandHeight = cachedColumn.length.clamp(0, 200); + int firstBlack = -1; + int lastBlack = -1; for (int y = 0; y < bandHeight; y++) { - final int paletteIndex = cachedColumn[y]; - final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex; - _fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]); + if (cachedColumn[y] == 0) { + firstBlack = firstBlack == -1 ? y : firstBlack; + lastBlack = y; + } + } + if (firstBlack != -1) { + for (int y = firstBlack; y <= lastBlack; y++) { + _fillCanonicalRect( + 0, + y, + 320, + 1, + ColorPalette.vga32Bit[cachedColumn[y]], + ); + } } return; } @@ -1266,22 +1299,52 @@ class SoftwareRenderer extends RendererBackend { } } - void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) { - _mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel); - final List firstColumn = _mainMenuBandFirstColumn!; - for (int y = 0; y < optionsLabel.height; y++) { - final int paletteIndex = firstColumn[y]; - final int fillIndex = paletteIndex == 0 ? 0 : paletteIndex; - _fillCanonicalRect(0, y, 320, 1, ColorPalette.vga32Bit[fillIndex]); + void _drawTiledMenuBackdrop(VgaImage image, int fallbackColor) { + if (image.width <= 0 || image.height <= 0) { + for (int i = 0; i < _buffer.pixels.length; i++) { + _buffer.pixels[i] = fallbackColor; + } + return; + } + for (int y = 0; y < 200; y += image.height) { + for (int x = 0; x < 320; x += image.width) { + _blitVgaImage(image, x, y); + } } } - List _cacheFirstColumn(VgaImage image) { - final List column = List.filled(image.height, 0); - for (int y = 0; y < image.height; y++) { - column[y] = image.decodePixel(0, y); - } - return column; + void _drawMainMenuOptionsSideBars( + VgaImage optionsLabel, + int optionsX320, { + required String debugContext, + }) { + _mainMenuBandFirstColumn = MenuHeaderBand.firstColumn(optionsLabel); + MenuHeaderBand.applyFromHeadingImage( + image: optionsLabel, + imageX320: optionsX320, + debugContext: debugContext, + fillSideEdgesRow: + ( + int y200, + int leftWidth320, + int rightStartX320, + int paletteIndex, + ) { + final int color = ColorPalette.vga32Bit[paletteIndex]; + if (leftWidth320 > 0) { + _fillCanonicalRect(0, y200, leftWidth320, 1, color); + } + if (rightStartX320 < 320) { + _fillCanonicalRect( + rightStartX320, + y200, + 320 - rightStartX320, + 1, + color, + ); + } + }, + ); } void _drawCanonicalMenuText( 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 b78735e..77cc0ca 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_data_types.dart @@ -26,12 +26,12 @@ export 'src/data_types/wolf_level.dart' show WolfLevel; export 'src/data_types/wolfenstein_data.dart' show WolfensteinData; // Registry public surface export 'src/registry/asset_registry.dart' show AssetRegistry; -export 'src/registry/built_in/retail_asset_registry.dart' - show RetailAssetRegistry; -export 'src/registry/built_in/shareware_asset_registry.dart' - show SharewareAssetRegistry; -export 'src/registry/built_in/spear_demo_asset_registry.dart' +export 'src/registry/built_in/menu/spear/spear_demo_asset_registry.dart' show SpearDemoAssetRegistry; +export 'src/registry/built_in/menu/wolf/retail_asset_registry.dart' + show RetailAssetRegistry; +export 'src/registry/built_in/menu/wolf/shareware_asset_registry.dart' + show SharewareAssetRegistry; export 'src/registry/keys/entity_key.dart' show EntityKey; export 'src/registry/keys/hud_key.dart' show HudKey; export 'src/registry/keys/menu_pic_key.dart' show MenuPicKey; diff --git a/packages/wolf_3d_dart/test/engine/game_session_snapshot_test.dart b/packages/wolf_3d_dart/test/engine/game_session_snapshot_test.dart index ec67758..3bf99e6 100644 --- a/packages/wolf_3d_dart/test/engine/game_session_snapshot_test.dart +++ b/packages/wolf_3d_dart/test/engine/game_session_snapshot_test.dart @@ -1,10 +1,12 @@ import 'dart:typed_data'; import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_asset_registry.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; import 'package:wolf_3d_dart/wolf_3d_input.dart'; +import 'package:wolf_3d_dart/wolf_3d_menu.dart'; void main() { test( @@ -122,6 +124,104 @@ void main() { expect(engine.entities.last, isA()); }, ); + + test('restoreSaveState applies menu theme from restored active game', () { + final engine = _buildEngineWithTwoGames(); + engine.init(); + + final GameSessionSnapshot snapshot = engine.captureSaveState(); + final GameSessionSnapshot restoredSnapshot = GameSessionSnapshot( + currentGameIndex: 1, + currentEpisodeIndex: snapshot.currentEpisodeIndex, + currentLevelIndex: snapshot.currentLevelIndex, + returnLevelIndex: snapshot.returnLevelIndex, + difficulty: snapshot.difficulty, + timeAliveMs: snapshot.timeAliveMs, + lastAcousticAlertTime: snapshot.lastAcousticAlertTime, + isMapOverlayVisible: snapshot.isMapOverlayVisible, + isMenuOverlayVisible: snapshot.isMenuOverlayVisible, + player: snapshot.player, + currentLevel: snapshot.currentLevel, + areaGrid: snapshot.areaGrid, + areasByPlayer: snapshot.areasByPlayer, + entities: snapshot.entities, + doors: snapshot.doors, + pushwalls: snapshot.pushwalls, + ); + + engine.restoreSaveState(restoredSnapshot); + + final presentation = WolfMenuPresentation(engine.data); + final bool isSpear = + engine.data.version == GameVersion.spearOfDestiny || + engine.data.version == GameVersion.spearOfDestinyDemo; + + final int expectedBackground = isSpear + ? _rgb24FromVgaIndex( + _resolvedMenuColorIndex( + presentation.backgroundIndex, + engine.data.version, + ), + ) + : _paletteMappedRgb24(0x890000); + + final int expectedPanel = isSpear + ? 0x000359 + : _paletteMappedRgb24(0x590002); + expect(engine.currentGameIndex, 1); + expect(engine.menuBackgroundRgb, expectedBackground); + expect(engine.menuPanelRgb, expectedPanel); + expect(engine.menuManager.menuBackgroundRgb, expectedBackground); + }); +} + +int _rgb24FromVgaIndex(int paletteIndex) { + final int argb = ColorPalette.argbFromVgaIndex(paletteIndex); + final int r = (argb >> 16) & 0xFF; + final int g = (argb >> 8) & 0xFF; + final int b = argb & 0xFF; + return (r << 16) | (g << 8) | b; +} + +int _resolvedMenuColorIndex(int paletteIndex, GameVersion version) { + final bool isSpear = + version == GameVersion.spearOfDestiny || + version == GameVersion.spearOfDestinyDemo; + if (!isSpear && paletteIndex >= 0x20 && paletteIndex <= 0x2F) { + return paletteIndex + 0x70; + } + return paletteIndex; +} + +int _paletteMappedRgb24(int rgb) { + final int index = _closestVgaIndexForRgb24(rgb); + return _rgb24FromVgaIndex(index); +} + +int _closestVgaIndexForRgb24(int rgb24) { + final int targetR = (rgb24 >> 16) & 0xFF; + final int targetG = (rgb24 >> 8) & 0xFF; + final int targetB = rgb24 & 0xFF; + + int bestIndex = 0; + int bestDistance = 0x7FFFFFFF; + + for (int index = 0; index < 256; index++) { + final int argb = ColorPalette.argbFromVgaIndex(index); + final int r = (argb >> 16) & 0xFF; + final int g = (argb >> 8) & 0xFF; + final int b = argb & 0xFF; + final int dr = targetR - r; + final int dg = targetG - g; + final int db = targetB - b; + final int distance = (dr * dr) + (dg * dg) + (db * db); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = index; + } + } + + return bestIndex; } class _TestInput extends Wolf3dInput { @@ -162,6 +262,35 @@ class _SilentAudio implements EngineAudio { } WolfEngine _buildEngine() { + final data = _buildTestData(version: GameVersion.retail); + + return WolfEngine( + data: data, + difficulty: Difficulty.medium, + startingEpisode: 0, + frameBuffer: FrameBuffer(64, 64), + input: _TestInput(), + onGameWon: () {}, + engineAudio: _SilentAudio(), + ); +} + +WolfEngine _buildEngineWithTwoGames() { + final retail = _buildTestData(version: GameVersion.retail); + final spear = _buildTestData(version: GameVersion.spearOfDestiny); + + return WolfEngine( + availableGames: [retail, spear], + difficulty: Difficulty.medium, + startingEpisode: 0, + frameBuffer: FrameBuffer(64, 64), + input: _TestInput(), + onGameWon: () {}, + engineAudio: _SilentAudio(), + ); +} + +WolfensteinData _buildTestData({required GameVersion version}) { final wallGrid = _buildGrid(); final objectGrid = _buildGrid(); _fillBoundaries(wallGrid, 2); @@ -171,43 +300,38 @@ WolfEngine _buildEngine() { wallGrid[2][3] = 90; wallGrid[4][4] = 5; - return WolfEngine( - data: WolfensteinData( - version: GameVersion.retail, - dataVersion: DataVersion.unknown, - registry: RetailAssetRegistry(), - walls: [ - _solidSprite(1), - _solidSprite(1), - _solidSprite(2), - _solidSprite(2), - ], - sprites: List.generate(436, (_) => _solidSprite(255)), - sounds: List.generate(200, (_) => PcmSound(Uint8List(1))), - adLibSounds: const [], - music: const [], - vgaImages: const [], - episodes: [ - Episode( - name: 'Episode 1', - levels: [ - WolfLevel( - name: 'Level 1', - wallGrid: wallGrid, - areaGrid: List.generate(64, (_) => List.filled(64, -1)), - objectGrid: objectGrid, - music: Music.level01, - ), - ], - ), - ], - ), - difficulty: Difficulty.medium, - startingEpisode: 0, - frameBuffer: FrameBuffer(64, 64), - input: _TestInput(), - onGameWon: () {}, - engineAudio: _SilentAudio(), + return WolfensteinData( + version: version, + dataVersion: DataVersion.unknown, + registry: switch (version) { + GameVersion.spearOfDestiny => SpearAssetRegistry(), + _ => RetailAssetRegistry(), + }, + walls: [ + _solidSprite(1), + _solidSprite(1), + _solidSprite(2), + _solidSprite(2), + ], + sprites: List.generate(436, (_) => _solidSprite(255)), + sounds: List.generate(200, (_) => PcmSound(Uint8List(1))), + adLibSounds: const [], + music: const [], + vgaImages: const [], + episodes: [ + Episode( + name: 'Episode 1', + levels: [ + WolfLevel( + name: 'Level 1', + wallGrid: wallGrid, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), + objectGrid: objectGrid, + music: Music.level01, + ), + ], + ), + ], ); } diff --git a/packages/wolf_3d_dart/test/menu/menu_manager_test.dart b/packages/wolf_3d_dart/test/menu/menu_manager_test.dart index 3a93cea..a4bc060 100644 --- a/packages/wolf_3d_dart/test/menu/menu_manager_test.dart +++ b/packages/wolf_3d_dart/test/menu/menu_manager_test.dart @@ -159,6 +159,23 @@ void main() { expect(manager.transitionEffect, WolfTransitionEffect.none); expect(manager.activeMenu, WolfMenuScreen.difficultySelect); }); + + test('spear variant main menu omits READ THIS row', () { + final manager = MenuManager(); + + manager.beginSelectionFlow( + gameCount: 1, + initialGameIsSpear: true, + ); + manager.showMainMenu(hasResumableGame: false, hasLoadableSave: true); + + expect( + manager.mainMenuEntries.any( + (entry) => entry.action == WolfMenuMainAction.readThis, + ), + isFalse, + ); + }); }); } diff --git a/packages/wolf_3d_dart/test/registry/classic_menu_presentation_module_test.dart b/packages/wolf_3d_dart/test/registry/classic_menu_presentation_module_test.dart new file mode 100644 index 0000000..58beed7 --- /dev/null +++ b/packages/wolf_3d_dart/test/registry/classic_menu_presentation_module_test.dart @@ -0,0 +1,17 @@ +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/classic_menu_presentation_module.dart'; + +void main() { + test('classic menu palette matches canonical WL_MENU.H constants', () { + const module = ClassicMenuPresentationModule(); + + expect(module.backgroundIndex, 0x2D); // BKGDCOLOR + expect(module.panelIndex, 0x23); // BORD2COLOR + expect(module.borderIndex, 0x29); // BORDCOLOR + expect(module.disabledTextIndex, 0x2B); // DEACTIVE + + expect(module.unselectedTextIndex, 0x17); // TEXTCOLOR + expect(module.selectedTextIndex, 0x13); // HIGHLIGHT + expect(module.headerTextIndex, 0x47); // READHCOLOR + }); +} diff --git a/packages/wolf_3d_dart/test/registry/shareware_hud_module_test.dart b/packages/wolf_3d_dart/test/registry/shareware_hud_module_test.dart index 2c6f9aa..ed1b08e 100644 --- a/packages/wolf_3d_dart/test/registry/shareware_hud_module_test.dart +++ b/packages/wolf_3d_dart/test/registry/shareware_hud_module_test.dart @@ -1,5 +1,5 @@ import 'package:test/test.dart'; -import 'package:wolf_3d_dart/src/registry/built_in/shareware_hud_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_hud_module.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; void main() { diff --git a/packages/wolf_3d_dart/test/registry/shareware_menu_module_test.dart b/packages/wolf_3d_dart/test/registry/shareware_menu_module_test.dart index 8481fd2..00d2e1d 100644 --- a/packages/wolf_3d_dart/test/registry/shareware_menu_module_test.dart +++ b/packages/wolf_3d_dart/test/registry/shareware_menu_module_test.dart @@ -1,5 +1,5 @@ import 'package:test/test.dart'; -import 'package:wolf_3d_dart/src/registry/built_in/shareware_menu_module.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/wolf/shareware_menu_module.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; void main() { 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 743451b..578d300 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,5 +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/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart'; diff --git a/packages/wolf_3d_dart/test/registry/spear_registry_test.dart b/packages/wolf_3d_dart/test/registry/spear_registry_test.dart new file mode 100644 index 0000000..bf8a4b9 --- /dev/null +++ b/packages/wolf_3d_dart/test/registry/spear_registry_test.dart @@ -0,0 +1,41 @@ +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_asset_registry.dart'; +import 'package:wolf_3d_dart/src/registry/built_in/menu/spear/spear_menu_presentation_module.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +void main() { + group('SpearAssetRegistry', () { + test('resolves full SOD menu VGA indices', () { + final SpearAssetRegistry registry = SpearAssetRegistry(); + + expect(registry.menu.resolve(MenuPicKey.title)?.pictureIndex, 76); + expect(registry.menu.resolve(MenuPicKey.pg13)?.pictureIndex, 88); + expect(registry.menu.resolve(MenuPicKey.credits)?.pictureIndex, 89); + expect( + registry.menu.resolve(MenuPicKey.controlBackground)?.pictureIndex, + 12, + ); + expect(registry.menu.resolve(MenuPicKey.optionsLabel)?.pictureIndex, 13); + expect(registry.menuPresentation, isA()); + }); + }); + + group('BuiltInAssetRegistryResolver full SOD selection', () { + test('uses full Spear registry for game-version fallback', () { + const BuiltInAssetRegistryResolver resolver = + BuiltInAssetRegistryResolver(); + + final AssetRegistry registry = resolver.resolve( + const RegistrySelectionContext( + gameVersion: GameVersion.spearOfDestiny, + dataVersion: DataVersion.unknown, + ), + ); + + expect(registry, isA()); + expect(registry.menu.resolve(MenuPicKey.title)?.pictureIndex, 76); + expect(registry.menu.resolve(MenuPicKey.pg13)?.pictureIndex, 88); + expect(registry.menu.resolve(MenuPicKey.credits)?.pictureIndex, 89); + }); + }); +} diff --git a/packages/wolf_3d_dart/test/rendering/menu_header_band_test.dart b/packages/wolf_3d_dart/test/rendering/menu_header_band_test.dart new file mode 100644 index 0000000..7dc6334 --- /dev/null +++ b/packages/wolf_3d_dart/test/rendering/menu_header_band_test.dart @@ -0,0 +1,215 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +void main() { + test('shared Spear variant utility is correct', () { + expect( + MenuHeaderBand.isSpearVariant(GameVersion.retail), + isFalse, + ); + expect( + MenuHeaderBand.isSpearVariant(GameVersion.shareware), + isFalse, + ); + expect( + MenuHeaderBand.isSpearVariant(GameVersion.spearOfDestiny), + isTrue, + ); + expect( + MenuHeaderBand.isSpearVariant(GameVersion.spearOfDestinyDemo), + isTrue, + ); + }); + + test( + 'black rows and rows sandwiched between them are extended to screen sides', + () { + // Interior column (x=1): row 0 → 9 (leading non-black, skip), + // row 1 → 0 (black, fill), + // row 2 → 0 (black, fill), + // row 3 → 4 (trailing, skip), row 4 → 3 (trailing, skip). + final image = _imageFromRows([ + [5, 9, 9, 5], + [6, 0, 8, 5], + [6, 0, 8, 0], + [7, 4, 4, 7], + [7, 3, 3, 7], + ]); + + final List<(int, int, int, int)> edgeRows = <(int, int, int, int)>[]; + + MenuHeaderBand.applyFromHeadingImage( + image: image, + imageX320: 10, + fillSideEdgesRow: + ( + int y200, + int leftWidth320, + int rightStartX320, + int paletteIndex, + ) { + edgeRows.add((y200, leftWidth320, rightStartX320, paletteIndex)); + }, + ); + + // Only rows 1 and 2 have interior-column value 0 (black) → side fills. + expect( + edgeRows, + <(int, int, int, int)>[ + (1, 10, 14, 0), + (2, 10, 14, 0), + ], + ); + }, + ); + + test('no side fills when all interior rows are non-black', () { + final image = _imageFromRows([ + [9, 1, 1, 3], + [8, 2, 2, 3], + [7, 4, 5, 6], + [6, 6, 6, 6], + [5, 7, 7, 2], + ]); + + final List<(int, int, int, int)> edgeRows = <(int, int, int, int)>[]; + + MenuHeaderBand.applyFromHeadingImage( + image: image, + imageX320: 50, + fillSideEdgesRow: + ( + int y200, + int leftWidth320, + int rightStartX320, + int paletteIndex, + ) { + edgeRows.add((y200, leftWidth320, rightStartX320, paletteIndex)); + }, + ); + + expect(edgeRows, isEmpty); + }); + + test('algorithm trims long trailing rows after final black run', () { + final image = _imageFromRows([ + [9, 1, 1, 3], + [8, 2, 2, 3], + [7, 0, 0, 7], + [7, 0, 0, 7], + [7, 0, 0, 7], + [6, 6, 6, 6], + [5, 5, 5, 5], + [4, 4, 4, 4], + [3, 3, 3, 3], + [2, 2, 2, 2], + [1, 1, 1, 1], + ]); + + final List<(int, int, int, int)> edgeRows = <(int, int, int, int)>[]; + + MenuHeaderBand.applyFromHeadingImage( + image: image, + imageX320: 50, + fillSideEdgesRow: + ( + int y200, + int leftWidth320, + int rightStartX320, + int paletteIndex, + ) { + edgeRows.add((y200, leftWidth320, rightStartX320, paletteIndex)); + }, + ); + + // Rows 2-4 are black (interior x=1 → 0). Rows 5-10 are non-black trailing; + // effectiveBandRowCount trims to lastBlack(4)+1+3 = 8 rows processed. + // Only the 3 black rows produce fills (no interior non-black rows here). + expect(edgeRows.length, 3); + expect( + edgeRows, + <(int, int, int, int)>[ + (2, 50, 54, 0), + (3, 50, 54, 0), + (4, 50, 54, 0), + ], + ); + + // firstColumn is trimmed to 8 entries (rows 0-7). + expect( + MenuHeaderBand.firstColumn(image), + [1, 2, 0, 0, 0, 6, 5, 4], + ); + }); + + test('interior non-black rows sandwiched between black rows are extended', () { + // Simulates a heading image with a decorative non-black stripe between + // two black sections (e.g. Wolf3D row 32). + // Interior column (x=1): row 0 → 5 (leading non-black, skip), + // row 1 → 0 (first black, fill with palette 0), + // row 2 → 7 (interior non-black stripe, fill with palette 7), + // row 3 → 0 (last black, fill with palette 0), + // rows 4–7 → trailing non-black (4 rows > maxTrailing=3, + // so effectiveBandRowCount trims to 7 rows). + final image = _imageFromRows([ + [9, 5, 5, 9], + [6, 0, 0, 6], + [6, 7, 7, 6], + [6, 0, 0, 6], + [8, 3, 3, 8], + [8, 4, 4, 8], + [8, 2, 2, 8], + [8, 1, 1, 8], + ]); + + final List<(int, int, int, int)> edgeRows = <(int, int, int, int)>[]; + + MenuHeaderBand.applyFromHeadingImage( + image: image, + imageX320: 10, + fillSideEdgesRow: + ( + int y200, + int leftWidth320, + int rightStartX320, + int paletteIndex, + ) { + edgeRows.add((y200, leftWidth320, rightStartX320, paletteIndex)); + }, + ); + + // Rows 1 and 3 are black → fill with palette 0. + // Row 2 is interior non-black between two black rows → fill with palette 7. + // Row 0 is before firstBlack → skip (background shows through at top). + // Rows 4–6 are after lastBlack → skip (background shows through at bottom). + expect( + edgeRows, + <(int, int, int, int)>[ + (1, 10, 14, 0), + (2, 10, 14, 7), + (3, 10, 14, 0), + ], + ); + }); +} + +VgaImage _imageFromRows(List> rows) { + const int width = 4; + final int height = rows.length; + final Uint8List pixels = Uint8List(width * height); + + for (int y = 0; y < height; y++) { + final List row = rows[y]; + if (row.length != width) { + throw ArgumentError('Each row must have width=$width entries.'); + } + for (int x = 0; x < width; x++) { + pixels[(x * height) + y] = row[x]; + } + } + + return VgaImage(width: width, height: height, pixels: pixels); +}