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 <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-24 23:35:56 +01:00
parent 5c309c2240
commit d393ca98ec
40 changed files with 1327 additions and 296 deletions
+11
View File
@@ -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
@@ -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;
}
@@ -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;
@@ -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,
});
@@ -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;
@@ -42,51 +42,76 @@ mixin _MenuManagerSelectionMixin on _MenuManagerBase {
/// Immutable snapshot of the current main-menu rows.
List<WolfMenuMainEntry> get mainMenuEntries {
return List<WolfMenuMainEntry>.unmodifiable(
<WolfMenuMainEntry>[
_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<WolfMenuMainEntry> entries = <WolfMenuMainEntry>[
_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<WolfMenuMainEntry>.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.
@@ -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.
@@ -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(),
);
}
@@ -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 {
@@ -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<MenuPicKey, int> _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,
};
}
}
@@ -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;
}
@@ -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
@@ -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.
///
@@ -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.
///
@@ -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();
}
@@ -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();
}
@@ -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<dynamic> {
);
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<dynamic> {
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<dynamic> {
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<dynamic> {
}
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<String> 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<dynamic> {
if (cursor != null) {
_blitVgaImageAscii(
cursor,
38,
72,
(rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2,
);
}
@@ -780,7 +800,11 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
}
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<dynamic> {
}
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<dynamic> {
_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<dynamic> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
_drawScaledColumnBand(_mainMenuBandFirstColumn!);
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.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<int> column) {
@@ -1190,6 +1226,18 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
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<dynamic> {
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<dynamic> {
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<dynamic> {
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);
}
}
}
}
@@ -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<int> firstColumn(VgaImage image) {
final List<int> column = List<int>.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<int> sourceByRow = List<int>.filled(sampledRows, 0);
final List<bool> isBlackByRow = List<bool>.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<bool> isBlackByRow) {
if (isBlackByRow.isEmpty) {
return '';
}
int runStart = 0;
bool runIsBlack = isBlackByRow[0];
final List<String> chunks = <String>[];
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<int> 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;
}
}
@@ -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<String> {
);
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<String> {
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<String> {
}
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<String> {
}
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<String> {
}
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<String> {
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> {
);
}
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<String> {
_fillRect320(0, mainBarTop, 320, 22, barColor);
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
_drawScaledColumnBand(_mainMenuBandFirstColumn!);
}
List<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.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<int> column) {
@@ -1128,6 +1172,18 @@ class SixelRenderer extends CliRendererBackend<String> {
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<String> {
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<String> {
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);
}
}
}
}
@@ -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<FrameBuffer> {
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<FrameBuffer> {
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<FrameBuffer> {
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<FrameBuffer> {
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<FrameBuffer> {
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<FrameBuffer> {
_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<FrameBuffer> {
final List<int>? 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<FrameBuffer> {
}
}
void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) {
_mainMenuBandFirstColumn = _cacheFirstColumn(optionsLabel);
final List<int> 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<int> _cacheFirstColumn(VgaImage image) {
final List<int> column = List<int>.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(
@@ -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;
@@ -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<SmallAmmoCollectible>());
},
);
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: <WolfensteinData>[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,
),
],
),
],
);
}
@@ -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,
);
});
});
}
@@ -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
});
}
@@ -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() {
@@ -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() {
@@ -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';
@@ -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<SpearMenuPresentationModule>());
});
});
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<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);
});
});
}
@@ -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),
<int>[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 47 → 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 46 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<List<int>> 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<int> 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);
}