From 9b053e1c02fdd662a5d28b2c0b35925be72e0783 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Fri, 20 Mar 2026 09:58:48 +0100 Subject: [PATCH] Refactor menu rendering and state management - Introduced _AsciiMenuTypography and _AsciiMenuRowFont enums to manage typography settings for menu rendering. - Updated AsciiRenderer to utilize new typography settings for main menu and game select screens. - Enhanced SixelRenderer and SoftwareRenderer to support new menu rendering logic, including sidebars for options labels. - Added disabled text color handling in WolfMenuPalette for better visual feedback on menu entries. - Implemented a new method _drawSelectableMenuRows to streamline the drawing of menu rows based on selection state. - Created a comprehensive test suite for level state carry-over and pause menu functionality, ensuring player state is preserved across levels and menus. - Adjusted footer rendering to account for layout changes and improved visual consistency across different renderers. Signed-off-by: Hans Kokx --- .../lib/src/engine/wolf_3d_engine_base.dart | 145 ++++++- .../lib/src/menu/menu_manager.dart | 233 ++++++++++- .../lib/src/rendering/ascii_renderer.dart | 322 ++++++++++---- .../lib/src/rendering/renderer_backend.dart | 2 +- .../lib/src/rendering/sixel_renderer.dart | 133 +++++- .../lib/src/rendering/software_renderer.dart | 102 ++++- packages/wolf_3d_dart/lib/wolf_3d_menu.dart | 3 + .../level_state_and_pause_menu_test.dart | 393 ++++++++++++++++++ 8 files changed, 1198 insertions(+), 135 deletions(-) create mode 100644 packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart 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 c344644..ccd5382 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 @@ -150,6 +150,8 @@ class WolfEngine { final Map _lastPatrolTileByEnemy = {}; int _currentEpisodeIndex = 0; + bool _isMenuOverlayVisible = false; + bool _hasActiveSession = false; bool _isPlayerMovingFast = false; int _currentLevelIndex = 0; @@ -177,6 +179,7 @@ class WolfEngine { initialGameIndex: _currentGameIndex, initialEpisodeIndex: _currentEpisodeIndex, initialDifficulty: difficulty, + hasResumableGame: false, ); if (_availableGames.length == 1) { @@ -185,12 +188,19 @@ class WolfEngine { } if (difficulty != null) { - _loadLevel(); + _loadLevel(preservePlayerState: false); + _hasActiveSession = true; } isInitialized = true; } + /// Whether a menu overlay is currently blocking gameplay updates. + bool get isMenuOpen => difficulty == null || _isMenuOverlayVisible; + + /// Whether the current gameplay session can be resumed from the main menu. + bool get canResumeGame => _hasActiveSession; + /// Replaces the shared framebuffer when dimensions change. void setFrameBuffer(int width, int height) { if (width <= 0 || height <= 0) { @@ -225,7 +235,13 @@ class WolfEngine { input.update(); final currentInput = input.currentInput; - if (difficulty == null) { + if (difficulty != null && !_isMenuOverlayVisible && currentInput.isBack) { + _openPauseMenu(); + menuManager.absorbInputState(currentInput); + return; + } + + if (isMenuOpen) { menuManager.tickTransition(delta.inMilliseconds); _tickMenu(currentInput); return; @@ -275,6 +291,9 @@ class WolfEngine { } switch (menuManager.activeMenu) { + case WolfMenuScreen.mainMenu: + _tickMainMenu(input); + break; case WolfMenuScreen.gameSelect: _tickGameSelectionMenu(input); break; @@ -287,6 +306,46 @@ class WolfEngine { } } + void _tickMainMenu(EngineInput input) { + final menuResult = menuManager.updateMainMenu(input); + + if (menuResult.goBack) { + if (_isMenuOverlayVisible && _hasActiveSession) { + _resumeGame(); + } else if (menuManager.canGoBackToGameSelection) { + menuManager.startTransition(WolfMenuScreen.gameSelect); + } else { + _exitTopLevelMenu(); + } + return; + } + + switch (menuResult.selected) { + case WolfMenuMainAction.newGame: + _beginNewGameMenuFlow(); + break; + case WolfMenuMainAction.endGame: + _endCurrentGame(); + break; + case WolfMenuMainAction.backToGame: + _resumeGame(); + break; + case WolfMenuMainAction.backToDemo: + case WolfMenuMainAction.quit: + _exitTopLevelMenu(); + break; + case WolfMenuMainAction.sound: + case WolfMenuMainAction.control: + case WolfMenuMainAction.loadGame: + case WolfMenuMainAction.saveGame: + case WolfMenuMainAction.changeView: + case WolfMenuMainAction.readThis: + case WolfMenuMainAction.viewScores: + case null: + break; + } + } + void _tickGameSelectionMenu(EngineInput input) { final menuResult = menuManager.updateGameSelection( input, @@ -294,7 +353,7 @@ class WolfEngine { ); if (menuResult.goBack) { - _exitTopLevelMenu(); + menuManager.startTransition(WolfMenuScreen.mainMenu); return; } @@ -305,7 +364,7 @@ class WolfEngine { _currentEpisodeIndex = 0; onEpisodeSelected?.call(null); menuManager.clearEpisodeSelection(); - menuManager.startTransition(WolfMenuScreen.episodeSelect); + menuManager.startTransition(WolfMenuScreen.mainMenu); } } @@ -318,11 +377,7 @@ class WolfEngine { if (menuResult.goBack) { onEpisodeSelected?.call(null); menuManager.clearEpisodeSelection(); - if (_availableGames.length > 1) { - menuManager.startTransition(WolfMenuScreen.gameSelect); - } else { - _exitTopLevelMenu(); - } + menuManager.startTransition(WolfMenuScreen.mainMenu); return; } @@ -341,13 +396,47 @@ class WolfEngine { } if (menuResult.selected != null) { - difficulty = menuResult.selected; - _currentLevelIndex = 0; - _returnLevelIndex = null; - _loadLevel(); + _startNewGameSession(menuResult.selected!); } } + void _beginNewGameMenuFlow() { + onEpisodeSelected?.call(null); + menuManager.clearEpisodeSelection(); + menuManager.startTransition(WolfMenuScreen.episodeSelect); + } + + void _openPauseMenu() { + if (!_hasActiveSession) { + return; + } + _isMenuOverlayVisible = true; + menuManager.showMainMenu(hasResumableGame: true); + } + + void _resumeGame() { + _isMenuOverlayVisible = false; + menuManager.absorbInputState(input.currentInput); + } + + void _startNewGameSession(Difficulty selectedDifficulty) { + difficulty = selectedDifficulty; + _currentLevelIndex = 0; + _returnLevelIndex = null; + _isMenuOverlayVisible = false; + _loadLevel(preservePlayerState: false); + _hasActiveSession = true; + } + + void _endCurrentGame() { + difficulty = null; + _isMenuOverlayVisible = false; + _hasActiveSession = false; + _returnLevelIndex = null; + onEpisodeSelected?.call(null); + menuManager.showMainMenu(hasResumableGame: false); + } + void _exitTopLevelMenu() { if (onMenuExit != null) { onMenuExit!.call(); @@ -357,7 +446,7 @@ class WolfEngine { } /// Wipes the current world state and builds a new floor from map data. - void _loadLevel() { + void _loadLevel({required bool preservePlayerState}) { entities.clear(); _lastPatrolTileByEnemy.clear(); @@ -374,17 +463,26 @@ class WolfEngine { audio.playLevelMusic(activeLevel); // Spawn Player and Entities from the Object Grid + bool playerSpawned = false; for (int y = 0; y < 64; y++) { for (int x = 0; x < 64; x++) { int objId = _objectLevel[y][x]; // Map IDs 19-22 are Reserved for Player Starts if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) { - player = Player( - x: x + 0.5, - y: y + 0.5, - angle: MapObject.getAngle(objId), - ); + playerSpawned = true; + if (preservePlayerState) { + player + ..x = x + 0.5 + ..y = y + 0.5 + ..angle = MapObject.getAngle(objId); + } else { + player = Player( + x: x + 0.5, + y: y + 0.5, + angle: MapObject.getAngle(objId), + ); + } } else { Entity? newEntity = EntityRegistry.spawn( objId, @@ -399,6 +497,10 @@ class WolfEngine { } } + if (!playerSpawned && !preservePlayerState) { + player = Player(x: 1.5, y: 1.5, angle: 0.0); + } + // Sanitize the level grid to ensure only valid walls/doors remain for (int y = 0; y < 64; y++) { for (int x = 0; x < 64; x++) { @@ -419,6 +521,9 @@ class WolfEngine { void _onLevelCompleted({bool isSecretExit = false}) { audio.playSoundEffect(WolfSound.levelDone); audio.stopMusic(); + player + ..hasGoldKey = false + ..hasSilverKey = false; final currentEpisode = data.episodes[_currentEpisodeIndex]; if (isSecretExit) { @@ -439,7 +544,7 @@ class WolfEngine { _currentLevelIndex > 9) { onGameWon(); } else { - _loadLevel(); + _loadLevel(preservePlayerState: true); } } diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart index 25bd1fb..5468bee 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -1,10 +1,53 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; -// Keep this enum focused on currently implemented menu screens. A future -// top-level "main" menu can be inserted before gameSelect without changing -// transition timing or fade infrastructure. -enum WolfMenuScreen { gameSelect, episodeSelect, difficultySelect } +enum WolfMenuScreen { mainMenu, gameSelect, episodeSelect, difficultySelect } + +enum WolfMenuMainAction { + newGame, + sound, + control, + loadGame, + saveGame, + changeView, + readThis, + viewScores, + endGame, + backToGame, + backToDemo, + quit, +} + +class WolfMenuMainEntry { + const WolfMenuMainEntry({ + required this.action, + required this.label, + this.isEnabled = true, + }); + + final WolfMenuMainAction action; + final String label; + final bool isEnabled; +} + +bool _isWiredMainMenuAction(WolfMenuMainAction action) { + switch (action) { + case WolfMenuMainAction.newGame: + case WolfMenuMainAction.endGame: + case WolfMenuMainAction.backToGame: + case WolfMenuMainAction.backToDemo: + case WolfMenuMainAction.quit: + return true; + case WolfMenuMainAction.sound: + case WolfMenuMainAction.control: + case WolfMenuMainAction.loadGame: + case WolfMenuMainAction.saveGame: + case WolfMenuMainAction.changeView: + case WolfMenuMainAction.readThis: + case WolfMenuMainAction.viewScores: + return false; + } +} /// Handles menu-only input state such as selection movement and edge triggers. class MenuManager { @@ -15,9 +58,12 @@ class MenuManager { int _transitionElapsedMs = 0; bool _transitionSwappedMenu = false; + int _selectedMainIndex = 0; int _selectedGameIndex = 0; int _selectedEpisodeIndex = 0; int _selectedDifficultyIndex = 0; + bool _showResumeOption = false; + int _gameCount = 1; bool _prevUp = false; bool _prevDown = false; @@ -41,10 +87,60 @@ class MenuManager { return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0); } + int get selectedMainIndex => _selectedMainIndex; + int get selectedGameIndex => _selectedGameIndex; int get selectedEpisodeIndex => _selectedEpisodeIndex; + List get mainMenuEntries { + return List.unmodifiable( + [ + _mainMenuEntry( + action: WolfMenuMainAction.newGame, + label: 'NEW GAME', + ), + _mainMenuEntry( + action: WolfMenuMainAction.sound, + label: 'SOUND', + ), + _mainMenuEntry( + action: WolfMenuMainAction.control, + label: 'CONTROL', + ), + _mainMenuEntry( + action: WolfMenuMainAction.loadGame, + label: 'LOAD GAME', + ), + _mainMenuEntry( + action: WolfMenuMainAction.saveGame, + label: 'SAVE GAME', + ), + _mainMenuEntry( + action: WolfMenuMainAction.changeView, + label: 'CHANGE VIEW', + ), + _mainMenuEntry( + action: WolfMenuMainAction.readThis, + label: 'READ THIS!', + ), + _mainMenuEntry( + action: _showResumeOption + ? WolfMenuMainAction.endGame + : WolfMenuMainAction.viewScores, + label: _showResumeOption ? 'END GAME' : 'VIEW SCORES', + ), + _mainMenuEntry( + action: _showResumeOption + ? WolfMenuMainAction.backToGame + : WolfMenuMainAction.backToDemo, + label: _showResumeOption ? 'BACK TO GAME' : 'BACK TO DEMO', + ), + _mainMenuEntry(action: WolfMenuMainAction.quit, label: 'QUIT'), + ], + ); + } + /// Current selected difficulty row index. int get selectedDifficultyIndex => _selectedDifficultyIndex; @@ -54,7 +150,11 @@ class MenuManager { int initialGameIndex = 0, int initialEpisodeIndex = 0, Difficulty? initialDifficulty, + bool hasResumableGame = false, }) { + _gameCount = gameCount; + _showResumeOption = hasResumableGame; + _selectedMainIndex = _defaultMainMenuIndex(); _selectedGameIndex = _clampIndex(initialGameIndex, gameCount); _selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex; _selectedDifficultyIndex = initialDifficulty == null @@ -64,7 +164,7 @@ class MenuManager { .clamp(0, Difficulty.values.length - 1); _activeMenu = gameCount > 1 ? WolfMenuScreen.gameSelect - : WolfMenuScreen.episodeSelect; + : WolfMenuScreen.mainMenu; _transitionTarget = null; _transitionElapsedMs = 0; _transitionSwappedMenu = false; @@ -82,6 +182,21 @@ class MenuManager { _activeMenu = WolfMenuScreen.difficultySelect; } + void showMainMenu({required bool hasResumableGame}) { + _showResumeOption = hasResumableGame; + final int itemCount = mainMenuEntries.length; + if (itemCount == 0) { + _selectedMainIndex = 0; + } else { + _selectedMainIndex = _defaultMainMenuIndex(); + } + _activeMenu = WolfMenuScreen.mainMenu; + _transitionTarget = null; + _transitionElapsedMs = 0; + _transitionSwappedMenu = false; + _resetEdgeState(); + } + /// Starts a menu transition. Input is locked until it completes. /// /// Hosts can reuse this fade timing for future pre-menu splash/image @@ -131,6 +246,29 @@ class MenuManager { _selectedGameIndex = _clampIndex(index, gameCount); } + ({WolfMenuMainAction? selected, bool goBack}) updateMainMenu( + EngineInput input, + ) { + if (isTransitioning) { + _consumeEdgeState(input); + return (selected: null, goBack: false); + } + + final _MenuAction action = _updateLinearSelection( + input, + currentIndex: _selectedMainIndex, + itemCount: mainMenuEntries.length, + isSelectableIndex: _isSelectableMainIndex, + ); + _selectedMainIndex = action.index; + return ( + selected: action.confirmed + ? mainMenuEntries[_selectedMainIndex].action + : null, + goBack: action.goBack, + ); + } + /// Returns a menu action snapshot for this frame. ({Difficulty? selected, bool goBack}) updateDifficultySelection( EngineInput input, @@ -215,6 +353,7 @@ class MenuManager { EngineInput input, { required int currentIndex, required int itemCount, + bool Function(int index)? isSelectableIndex, }) { final upNow = input.isMovingForward; final downNow = input.isMovingBackward; @@ -222,18 +361,24 @@ class MenuManager { final backNow = input.isBack; int nextIndex = _clampIndex(currentIndex, itemCount); + final bool Function(int index) selectable = + isSelectableIndex ?? ((_) => true); + + if (itemCount > 0 && !selectable(nextIndex)) { + nextIndex = _findSelectableIndex(nextIndex, itemCount, selectable); + } if (itemCount > 0) { if (upNow && !_prevUp) { - nextIndex = (nextIndex - 1 + itemCount) % itemCount; + nextIndex = _moveSelectableIndex(nextIndex, itemCount, -1, selectable); } if (downNow && !_prevDown) { - nextIndex = (nextIndex + 1) % itemCount; + nextIndex = _moveSelectableIndex(nextIndex, itemCount, 1, selectable); } } - final bool confirmed = confirmNow && !_prevConfirm; + final bool confirmed = confirmNow && !_prevConfirm && selectable(nextIndex); final bool goBack = backNow && !_prevBack; _prevUp = upNow; @@ -258,6 +403,78 @@ class MenuManager { _prevBack = false; } + int _defaultMainMenuIndex() { + final WolfMenuMainAction target = _showResumeOption + ? WolfMenuMainAction.backToGame + : WolfMenuMainAction.newGame; + final int found = mainMenuEntries.indexWhere( + (entry) => entry.action == target, + ); + return found >= 0 + ? found + : _findSelectableIndex( + 0, + mainMenuEntries.length, + _isSelectableMainIndex, + ); + } + + bool get canGoBackToGameSelection => !_showResumeOption && _gameCount > 1; + + bool _isSelectableMainIndex(int index) { + if (index < 0 || index >= mainMenuEntries.length) { + return false; + } + return mainMenuEntries[index].isEnabled; + } + + WolfMenuMainEntry _mainMenuEntry({ + required WolfMenuMainAction action, + required String label, + }) { + return WolfMenuMainEntry( + action: action, + label: label, + isEnabled: _isWiredMainMenuAction(action), + ); + } + + int _findSelectableIndex( + int startIndex, + int itemCount, + bool Function(int index) selectable, + ) { + if (itemCount <= 0) { + return 0; + } + for (int offset = 0; offset < itemCount; offset++) { + final int index = (startIndex + offset) % itemCount; + if (selectable(index)) { + return index; + } + } + return _clampIndex(startIndex, itemCount); + } + + int _moveSelectableIndex( + int currentIndex, + int itemCount, + int delta, + bool Function(int index) selectable, + ) { + if (itemCount <= 0) { + return 0; + } + int index = currentIndex; + for (int step = 0; step < itemCount; step++) { + index = (index + delta + itemCount) % itemCount; + if (selectable(index)) { + return index; + } + } + return currentIndex; + } + int _clampIndex(int index, int itemCount) { if (itemCount <= 0) { return 0; 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 13ea43d..f03bf24 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -80,6 +80,23 @@ enum AsciiRendererMode { terminalGrid, } +enum _AsciiMenuRowFont { + bitmap, + compactText, +} + +class _AsciiMenuTypography { + const _AsciiMenuTypography({ + required this.headingScale, + required this.rowFont, + }); + + final int headingScale; + final _AsciiMenuRowFont rowFont; + + bool get usesCompactRows => rowFont == _AsciiMenuRowFont.compactText; +} + /// Text-mode renderer that can render to ANSI escape output or a Flutter /// grid model of colored characters. class AsciiRenderer extends CliRendererBackend { @@ -391,6 +408,8 @@ class AsciiRenderer extends CliRendererBackend { final int headingColor = WolfMenuPalette.headerTextColor; final int selectedTextColor = WolfMenuPalette.selectedTextColor; final int unselectedTextColor = WolfMenuPalette.unselectedTextColor; + final int disabledTextColor = WolfMenuPalette.disabledTextColor; + final _AsciiMenuTypography menuTypography = _resolveMenuTypography(); if (_usesTerminalLayout) { _fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor); @@ -398,11 +417,66 @@ class AsciiRenderer extends CliRendererBackend { _fillRect(0, 0, width, height, activeTheme.solid, bgColor); } - _fillRect320(28, 70, 264, 82, panelColor); - final art = WolfClassicMenuArt(engine.data); + if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) { + _fillRect320(68, 52, 178, 136, panelColor); + + final optionsLabel = art.optionsLabel; + if (optionsLabel != null) { + final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars(optionsLabel, optionsX); + _blitVgaImageAscii(optionsLabel, optionsX, 0); + } else { + _drawMenuTextCentered( + 'OPTIONS', + 24, + headingColor, + scale: menuTypography.headingScale, + ); + } + + final cursor = art.mappedPic( + engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, + ); + const int rowYStart = 55; + const int rowStep = 13; + final entries = engine.menuManager.mainMenuEntries; + + _drawSelectableMenuRows( + typography: menuTypography, + rows: entries.map((entry) => entry.label).toList(growable: false), + selectedIndex: engine.menuManager.selectedMainIndex, + rowYStart200: rowYStart, + rowStep200: rowStep, + textX320: 100, + panelX320: 68, + panelW320: 178, + colorForRow: (int index, bool isSelected) { + final entry = entries[index]; + if (!entry.isEnabled) { + return disabledTextColor; + } + return isSelected ? selectedTextColor : unselectedTextColor; + }, + ); + + if (cursor != null) { + _blitVgaImageAscii( + cursor, + 72, + (rowYStart + (engine.menuManager.selectedMainIndex * rowStep)) - 2, + ); + } + + _drawCenteredMenuFooter(); + _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + return; + } + if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) { + _fillRect320(28, 58, 264, 104, panelColor); + final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, ); @@ -411,50 +485,32 @@ class AsciiRenderer extends CliRendererBackend { final List rows = engine.availableGames .map((game) => _gameTitle(game.version)) .toList(growable: false); - - if (_useMinimalMenuText) { - _drawMenuTextCentered('SELECT GAME', 48, headingColor, scale: 2); - _drawMinimalMenuRows( - rows: rows, - selectedIndex: engine.menuManager.selectedGameIndex, - rowYStart200: rowYStart, - rowStep200: rowStep, - textX320: 70, - panelX320: 28, - panelW320: 264, - selectedTextColor: selectedTextColor, - unselectedTextColor: unselectedTextColor, - panelColor: panelColor, - ); - if (cursor != null) { - _blitVgaImageAscii( - cursor, - 38, - (rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2, - ); - } - _drawCenteredMenuFooter(); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); - return; - } - _drawMenuTextCentered( 'SELECT GAME', 48, headingColor, - scale: _fullMenuHeadingScale, + scale: menuTypography.headingScale, ); - for (int i = 0; i < rows.length; i++) { - final bool isSelected = i == engine.menuManager.selectedGameIndex; - if (isSelected && cursor != null) { - _blitVgaImageAscii(cursor, 38, (rowYStart + (i * rowStep)) - 2); - } - _drawMenuText( - rows[i], - 70, - rowYStart + (i * rowStep), - isSelected ? selectedTextColor : unselectedTextColor, + _drawSelectableMenuRows( + typography: menuTypography, + rows: rows, + selectedIndex: engine.menuManager.selectedGameIndex, + rowYStart200: rowYStart, + rowStep200: rowStep, + textX320: 70, + panelX320: 28, + panelW320: 264, + colorForRow: (int _, bool isSelected) { + return isSelected ? selectedTextColor : unselectedTextColor; + }, + ); + + if (cursor != null) { + _blitVgaImageAscii( + cursor, + 38, + (rowYStart + (engine.menuManager.selectedGameIndex * rowStep)) - 2, ); } @@ -475,12 +531,12 @@ class AsciiRenderer extends CliRendererBackend { .map((episode) => episode.name.replaceAll('\n', ' ')) .toList(growable: false); - if (_useMinimalMenuText) { + if (menuTypography.usesCompactRows) { _drawMenuTextCentered( 'WHICH EPISODE TO PLAY?', 8, headingColor, - scale: 2, + scale: menuTypography.headingScale, ); _drawMinimalMenuRows( rows: rows, @@ -490,9 +546,10 @@ class AsciiRenderer extends CliRendererBackend { textX320: 98, panelX320: 12, panelW320: 296, - selectedTextColor: selectedTextColor, - unselectedTextColor: unselectedTextColor, panelColor: panelColor, + colorForRow: (int _, bool isSelected) { + return isSelected ? selectedTextColor : unselectedTextColor; + }, ); // Keep episode icons visible in compact ASCII layouts so this screen @@ -520,7 +577,7 @@ class AsciiRenderer extends CliRendererBackend { 'WHICH EPISODE TO PLAY?', 8, headingColor, - scale: _fullMenuHeadingScale, + scale: menuTypography.headingScale, ); for (int i = 0; i < engine.data.episodes.length; i++) { @@ -560,6 +617,8 @@ class AsciiRenderer extends CliRendererBackend { final int selectedDifficultyIndex = engine.menuManager.selectedDifficultyIndex; + _fillRect320(28, 70, 264, 82, panelColor); + final face = art.difficultyOption( Difficulty.values[selectedDifficultyIndex], ); @@ -573,18 +632,25 @@ class AsciiRenderer extends CliRendererBackend { const rowYStart = 86; const rowStep = 15; - if (_useMinimalMenuText) { + if (menuTypography.usesCompactRows) { _drawMenuTextCentered( Difficulty.menuText, 48, headingColor, - scale: 2, + scale: menuTypography.headingScale, ); - _drawMinimalMenuText( - selectedDifficultyIndex, - selectedTextColor, - unselectedTextColor, - panelColor, + _drawSelectableMenuRows( + typography: menuTypography, + rows: Difficulty.values.map((d) => d.title).toList(growable: false), + selectedIndex: selectedDifficultyIndex, + rowYStart200: rowYStart, + rowStep200: rowStep, + textX320: 70, + panelX320: 28, + panelW320: 264, + colorForRow: (int _, bool isSelected) { + return isSelected ? selectedTextColor : unselectedTextColor; + }, ); if (cursor != null) { _blitVgaImageAscii( @@ -594,15 +660,15 @@ class AsciiRenderer extends CliRendererBackend { ); } _drawCenteredMenuFooter(); + _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); return; } - final int headingScale = _fullMenuHeadingScale; _drawMenuTextCentered( Difficulty.menuText, 48, headingColor, - scale: headingScale, + scale: menuTypography.headingScale, ); for (int i = 0; i < Difficulty.values.length; i++) { @@ -698,15 +764,23 @@ class AsciiRenderer extends CliRendererBackend { _drawMenuText(text, x320, y200, color, scale: scale); } - int get _fullMenuHeadingScale { + _AsciiMenuTypography _resolveMenuTypography() { + final bool usesCompactRows = _menuGlyphHeightInRows(scale: 1) <= 4; + return _AsciiMenuTypography( + headingScale: usesCompactRows ? 2 : _defaultMenuHeadingScale, + rowFont: usesCompactRows + ? _AsciiMenuRowFont.compactText + : _AsciiMenuRowFont.bitmap, + ); + } + + int get _defaultMenuHeadingScale { if (!_usesTerminalLayout) { return 2; } return projectionWidth < 140 ? 1 : 2; } - bool get _useMinimalMenuText => _menuGlyphHeightInRows(scale: 1) <= 4; - int _menuGlyphHeightInRows({required int scale}) { final double scaleY = (_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0; @@ -732,24 +806,41 @@ class AsciiRenderer extends CliRendererBackend { return pixelX.clamp(0, width - 1); } - void _drawMinimalMenuText( - int selectedDifficultyIndex, - int selectedTextColor, - int unselectedTextColor, - int panelColor, - ) { - _drawMinimalMenuRows( - rows: Difficulty.values.map((d) => d.title).toList(growable: false), - selectedIndex: selectedDifficultyIndex, - rowYStart200: 86, - rowStep200: 15, - textX320: 70, - panelX320: 28, - panelW320: 264, - selectedTextColor: selectedTextColor, - unselectedTextColor: unselectedTextColor, - panelColor: panelColor, - ); + void _drawSelectableMenuRows({ + required _AsciiMenuTypography typography, + required List rows, + required int selectedIndex, + required int rowYStart200, + required int rowStep200, + required int textX320, + required int panelX320, + required int panelW320, + required int Function(int index, bool isSelected) colorForRow, + }) { + if (typography.usesCompactRows) { + _drawMinimalMenuRows( + rows: rows, + selectedIndex: selectedIndex, + rowYStart200: rowYStart200, + rowStep200: rowStep200, + textX320: textX320, + panelX320: panelX320, + panelW320: panelW320, + panelColor: _rgbToPaletteColor(engine.menuPanelRgb), + colorForRow: colorForRow, + ); + return; + } + + for (int i = 0; i < rows.length; i++) { + final bool isSelected = i == selectedIndex; + _drawMenuText( + rows[i], + textX320, + rowYStart200 + (i * rowStep200), + colorForRow(i, isSelected), + ); + } } void _drawMinimalMenuRows({ @@ -760,9 +851,8 @@ class AsciiRenderer extends CliRendererBackend { required int textX320, required int panelX320, required int panelW320, - required int selectedTextColor, - required int unselectedTextColor, required int panelColor, + required int Function(int index, bool isSelected) colorForRow, }) { final int panelX = projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt(); @@ -780,7 +870,7 @@ class AsciiRenderer extends CliRendererBackend { _writeLeftClipped( rowY, rows[i], - isSelected ? selectedTextColor : unselectedTextColor, + colorForRow(i, isSelected), panelColor, textWidth, textLeft, @@ -816,7 +906,7 @@ class AsciiRenderer extends CliRendererBackend { final int boxWidth = math.min(width, textWidth + 2); final int boxHeight = 3; final int boxX = ((width - boxWidth) ~/ 2).clamp(0, width - boxWidth); - final int boxY = math.max(0, height - boxHeight - 1); + final int boxY = math.max(0, height - boxHeight); if (_usesTerminalLayout) { _fillTerminalRect( @@ -871,7 +961,7 @@ class AsciiRenderer extends CliRendererBackend { final int panelWidth = (textWidth + 12).clamp(1, 320); final int panelHeight = 12; final int panelX = ((320 - panelWidth) ~/ 2).clamp(0, 319); - const int panelY = 184; + const int panelY = 188; _fillRect320(panelX, panelY, panelWidth, panelHeight, hintBackground); int cursorX = panelX + 6; @@ -1286,6 +1376,78 @@ class AsciiRenderer extends CliRendererBackend { } } + void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) { + final int barColor = ColorPalette.vga32Bit[0]; + final int leftWidth = optionsX320.clamp(0, 320); + final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320); + final int rightWidth = (320 - rightStart).clamp(0, 320); + + for (int y = 0; y < optionsLabel.height; y++) { + final int leftEdge = optionsLabel.decodePixel(0, y); + final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y); + if (leftEdge != 0 || rightEdge != 0) { + continue; + } + + if (leftWidth > 0) { + _fillRect320Precise(0, y, leftWidth, y + 1, barColor); + } + if (rightWidth > 0) { + _fillRect320Precise(rightStart, y, 320, y + 1, barColor); + } + } + } + + void _fillRect320Precise( + int startX320, + int startY200, + int endX320, + int endY200, + int color, + ) { + if (endX320 <= startX320 || endY200 <= startY200) { + return; + } + + final double scaleX = + (_usesTerminalLayout ? projectionWidth : width) / 320.0; + final double scaleY = + (_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0; + + final int offsetX = _usesTerminalLayout ? projectionOffsetX : 0; + final int startX = offsetX + (startX320 * scaleX).floor(); + final int endX = offsetX + (endX320 * scaleX).ceil(); + final int startY = (startY200 * scaleY).floor(); + final int endY = (endY200 * scaleY).ceil(); + + if (_usesTerminalLayout) { + for (int y = startY; y < endY; y++) { + if (y < 0 || y >= _terminalPixelHeight) { + continue; + } + for (int x = startX; x < endX; x++) { + if (x < 0 || x >= _terminalSceneWidth) { + continue; + } + _scenePixels[y][x] = color; + } + } + return; + } + + for (int y = startY; y < endY; y++) { + if (y < 0 || y >= height) { + continue; + } + for (int x = startX; x < endX; x++) { + if (x < 0 || x >= width) { + continue; + } + _screen[y][x] = ColoredChar(activeTheme.solid, color); + } + } + } + // --- DAMAGE FLASH --- void _applyDamageFlash() { for (int y = 0; y < viewHeight; y++) { diff --git a/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart b/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart index 705b32b..5259df9 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart @@ -77,7 +77,7 @@ abstract class RendererBackend // 1. Setup the frame (clear screen, draw floor/ceiling). prepareFrame(engine); - if (engine.difficulty == null) { + if (engine.isMenuOpen) { drawMenu(engine); if (engine.showFpsCounter) { drawFpsOverlay(engine); 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 c83cee1..3a6edf3 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -21,15 +21,17 @@ import 'menu_font.dart'; /// terminal is too small. class SixelRenderer extends CliRendererBackend { static const double _targetAspectRatio = 4 / 3; - static const int _defaultLineHeightPx = 18; + static const int _defaultLineHeightPx = 16; static const double _defaultCellWidthToHeight = 0.55; static const int _minimumTerminalColumns = 117; static const int _minimumTerminalRows = 34; static const double _terminalViewportSafety = 0.90; + static const int _terminalRowSafetyMargin = 1; static const int _compactMenuMinWidthPx = 200; static const int _compactMenuMinHeightPx = 130; static const int _maxRenderWidth = 320; static const int _maxRenderHeight = 240; + static const int _menuFooterBottomMargin = 1; static const String _terminalTealBackground = '\x1b[48;2;0;150;136m'; late Uint8List _screen; @@ -195,10 +197,12 @@ class SixelRenderer extends CliRendererBackend { // Horizontal: cell-width estimates vary by terminal/font and cause right-shift // clipping, so keep the image at column 0. - // Vertical: line-height is reliable enough to center correctly. + // Vertical: use a conservative row estimate and keep one spare row so the + // terminal does not scroll the image upward when its actual cell height is + // smaller than our approximation. final int imageRows = math.max( 1, - (_outputHeight / _defaultLineHeightPx).ceil(), + (_outputHeight / _defaultLineHeightPx).ceil() + _terminalRowSafetyMargin, ); _offsetColumns = 0; _offsetRows = math.max(0, (terminalRows - imageRows) ~/ 2); @@ -358,15 +362,55 @@ class SixelRenderer extends CliRendererBackend { final int headingIndex = WolfMenuPalette.headerTextIndex; final int selectedTextIndex = WolfMenuPalette.selectedTextIndex; final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex; + final int disabledTextIndex = WolfMenuPalette.disabledTextIndex; for (int i = 0; i < _screen.length; i++) { _screen[i] = bgColor; } - _fillRect320(28, 70, 264, 82, panelColor); - final art = WolfClassicMenuArt(engine.data); + // Draw footer first so menu panels can clip overlap in the center. + _drawMenuFooterArt(art); + + if (engine.menuManager.activeMenu == WolfMenuScreen.mainMenu) { + _fillRect320(68, 52, 178, 136, panelColor); + + final optionsLabel = art.optionsLabel; + if (optionsLabel != null) { + final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars(optionsLabel, optionsX); + _blitVgaImage(optionsLabel, optionsX, 0); + } else { + _drawMenuTextCentered('OPTIONS', 24, headingIndex, scale: 2); + } + + final cursor = art.mappedPic( + engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, + ); + const int rowYStart = 55; + const int rowStep = 13; + final entries = engine.menuManager.mainMenuEntries; + for (int i = 0; i < entries.length; i++) { + final bool isSelected = i == engine.menuManager.selectedMainIndex; + if (isSelected && cursor != null) { + _blitVgaImage(cursor, 72, (rowYStart + (i * rowStep)) - 2); + } + _drawMenuText( + entries[i].label, + 100, + rowYStart + (i * rowStep), + entries[i].isEnabled + ? (isSelected ? selectedTextIndex : unselectedTextIndex) + : disabledTextIndex, + scale: 1, + ); + } + _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); + return; + } + if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) { + _fillRect320(28, 58, 264, 104, panelColor); _drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2); final cursor = art.mappedPic( engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, @@ -386,7 +430,6 @@ class SixelRenderer extends CliRendererBackend { scale: 1, ); } - _drawMenuFooterArt(art); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); return; } @@ -434,13 +477,13 @@ class SixelRenderer extends CliRendererBackend { ); } } - _drawMenuFooterArt(art); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); return; } final int selectedDifficultyIndex = engine.menuManager.selectedDifficultyIndex; + _fillRect320(28, 70, 264, 82, panelColor); if (_useCompactMenuLayout) { _drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); @@ -454,11 +497,6 @@ class SixelRenderer extends CliRendererBackend { scale: _menuHeadingScale, ); - final bottom = art.mappedPic(15); - if (bottom != null) { - _blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8); - } - final face = art.difficultyOption( Difficulty.values[selectedDifficultyIndex], ); @@ -489,7 +527,6 @@ class SixelRenderer extends CliRendererBackend { ); } - _drawMenuFooterArt(art); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); } @@ -498,7 +535,11 @@ class SixelRenderer extends CliRendererBackend { if (bottom == null) { return; } - _blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8); + _blitVgaImage( + bottom, + (320 - bottom.width) ~/ 2, + 200 - bottom.height - _menuFooterBottomMargin, + ); } String _gameTitle(GameVersion version) { @@ -809,8 +850,14 @@ class SixelRenderer extends CliRendererBackend { int drawY = destStartY + dy; if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { - int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1); - int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1); + int srcX = ((dx / destWidth) * image.width).toInt().clamp( + 0, + image.width - 1, + ); + int srcY = ((dy / destHeight) * image.height).toInt().clamp( + 0, + image.height - 1, + ); int colorByte = image.decodePixel(srcX, srcY); if (colorByte != 255) { @@ -842,6 +889,60 @@ class SixelRenderer extends CliRendererBackend { } } + void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) { + const int barColor = 0; + final int leftWidth = optionsX320.clamp(0, 320); + final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320); + final int rightWidth = (320 - rightStart).clamp(0, 320); + + for (int y = 0; y < optionsLabel.height; y++) { + final int leftEdge = optionsLabel.decodePixel(0, y); + final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y); + if (leftEdge != 0 || rightEdge != 0) { + continue; + } + + if (leftWidth > 0) { + _fillRect320Precise(0, y, leftWidth, y + 1, barColor); + } + if (rightWidth > 0) { + _fillRect320Precise(rightStart, y, 320, y + 1, barColor); + } + } + } + + void _fillRect320Precise( + int startX320, + int startY200, + int endX320, + int endY200, + int colorIndex, + ) { + if (endX320 <= startX320 || endY200 <= startY200) { + return; + } + + final double scaleX = width / 320.0; + final double scaleY = height / 200.0; + final int startX = (startX320 * scaleX).floor(); + final int endX = (endX320 * scaleX).ceil(); + final int startY = (startY200 * scaleY).floor(); + final int endY = (endY200 * scaleY).ceil(); + + for (int y = startY; y < endY; y++) { + if (y < 0 || y >= height) { + continue; + } + final int rowOffset = y * width; + for (int x = startX; x < endX; x++) { + if (x < 0 || x >= width) { + continue; + } + _screen[rowOffset + x] = colorIndex; + } + } + } + /// Maps an RGB color to the nearest VGA palette index. int _rgbToPaletteIndex(int rgb) { return ColorPalette.findClosestPaletteIndex(rgb); 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 d33e6e9..f1ebfd6 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -12,6 +12,7 @@ import 'package:wolf_3d_dart/wolf_3d_menu.dart'; /// This is the canonical "modern framebuffer" implementation and serves as a /// visual reference for terminal renderers. class SoftwareRenderer extends RendererBackend { + static const int _menuFooterBottomMargin = 1; static const int _menuFooterY = 184; static const int _menuFooterHeight = 12; @@ -147,13 +148,28 @@ class SoftwareRenderer extends RendererBackend { final int headingColor = WolfMenuPalette.headerTextColor; final int selectedTextColor = WolfMenuPalette.selectedTextColor; final int unselectedTextColor = WolfMenuPalette.unselectedTextColor; + final int disabledTextColor = WolfMenuPalette.disabledTextColor; for (int i = 0; i < _buffer.pixels.length; i++) { _buffer.pixels[i] = bgColor; } final art = WolfClassicMenuArt(engine.data); + // Draw footer first so menu panels can clip overlap in the center. + _drawCenteredMenuFooter(art); + switch (engine.menuManager.activeMenu) { + case WolfMenuScreen.mainMenu: + _drawMainMenu( + engine, + art, + panelColor, + headingColor, + selectedTextColor, + unselectedTextColor, + disabledTextColor, + ); + break; case WolfMenuScreen.gameSelect: _drawGameSelectMenu( engine, @@ -186,11 +202,59 @@ class SoftwareRenderer extends RendererBackend { break; } - _drawCenteredMenuFooter(art); - _applyMenuFade(engine.menuManager.transitionAlpha, bgColor); } + void _drawMainMenu( + WolfEngine engine, + WolfClassicMenuArt art, + int panelColor, + int headingColor, + int selectedTextColor, + int unselectedTextColor, + int disabledTextColor, + ) { + const int panelX = 68; + const int panelY = 52; + const int panelW = 178; + const int panelH = 136; + _fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor); + + final optionsLabel = art.optionsLabel; + if (optionsLabel != null) { + final int optionsX = ((320 - optionsLabel.width) ~/ 2).clamp(0, 319); + _drawMainMenuOptionsSideBars(optionsLabel, optionsX); + _blitVgaImage(optionsLabel, optionsX, 0); + } else { + _drawCanonicalMenuTextCentered('OPTIONS', 24, headingColor, scale: 2); + } + + final cursor = art.mappedPic( + engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, + ); + const int rowYStart = 55; + const int rowStep = 13; + const int textX = 100; + final entries = engine.menuManager.mainMenuEntries; + final int selectedIndex = engine.menuManager.selectedMainIndex; + + for (int i = 0; i < entries.length; i++) { + final bool isSelected = i == selectedIndex; + final int y = rowYStart + (i * rowStep); + if (isSelected && cursor != null) { + _blitVgaImage(cursor, panelX + 4, y - 2); + } + _drawCanonicalMenuText( + entries[i].label, + textX, + y, + entries[i].isEnabled + ? (isSelected ? selectedTextColor : unselectedTextColor) + : disabledTextColor, + ); + } + } + void _drawGameSelectMenu( WolfEngine engine, WolfClassicMenuArt art, @@ -298,7 +362,10 @@ class SoftwareRenderer extends RendererBackend { final bottom = art.mappedPic(15); if (bottom != null) { final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319); - final int y = (200 - bottom.height - 8).clamp(0, 199); + final int y = (200 - bottom.height - _menuFooterBottomMargin).clamp( + 0, + 199, + ); _blitVgaImage(bottom, x, y); return; } @@ -362,13 +429,6 @@ class SoftwareRenderer extends RendererBackend { scale: 2, ); - final bottom = art.mappedPic(15); - if (bottom != null) { - final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319); - final int y = (200 - bottom.height - 8).clamp(0, 199); - _blitVgaImage(bottom, x, y); - } - final face = art.difficultyOption( Difficulty.values[selectedDifficultyIndex], ); @@ -490,6 +550,28 @@ class SoftwareRenderer extends RendererBackend { } } + void _drawMainMenuOptionsSideBars(VgaImage optionsLabel, int optionsX320) { + final int barColor = ColorPalette.vga32Bit[0]; + final int leftWidth = optionsX320.clamp(0, 320); + final int rightStart = (optionsX320 + optionsLabel.width).clamp(0, 320); + final int rightWidth = (320 - rightStart).clamp(0, 320); + + for (int y = 0; y < optionsLabel.height; y++) { + final int leftEdge = optionsLabel.decodePixel(0, y); + final int rightEdge = optionsLabel.decodePixel(optionsLabel.width - 1, y); + if (leftEdge != 0 || rightEdge != 0) { + continue; + } + + if (leftWidth > 0) { + _fillCanonicalRect(0, y, leftWidth, 1, barColor); + } + if (rightWidth > 0) { + _fillCanonicalRect(rightStart, y, rightWidth, 1, barColor); + } + } + } + void _drawCanonicalMenuText( String text, int startX320, diff --git a/packages/wolf_3d_dart/lib/wolf_3d_menu.dart b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart index db4480c..c0d7ca4 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_menu.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart @@ -50,6 +50,7 @@ abstract class WolfMenuPic { abstract class WolfMenuPalette { static const int selectedTextIndex = 19; static const int unselectedTextIndex = 23; + static const int disabledTextIndex = 4; static const int _headerTargetRgb = 0xFFF700; static int? _cachedHeaderTextIndex; @@ -62,6 +63,8 @@ abstract class WolfMenuPalette { static int get unselectedTextColor => ColorPalette.vga32Bit[unselectedTextIndex]; + static int get disabledTextColor => ColorPalette.vga32Bit[disabledTextIndex]; + static int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex]; static int _nearestPaletteIndex(int rgb) { diff --git a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart new file mode 100644 index 0000000..7337d9a --- /dev/null +++ b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart @@ -0,0 +1,393 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/menu/menu_manager.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'; + +void main() { + group('Level state carry-over', () { + test('preserves player session state between levels but clears keys', () { + final input = _TestInput(); + final engine = _buildEngine(input: input, difficulty: Difficulty.hard); + + engine.init(); + engine.player + ..health = 47 + ..ammo = 33 + ..score = 1200 + ..lives = 5 + ..hasMachineGun = true + ..hasChainGun = true + ..hasGoldKey = true + ..hasSilverKey = true; + engine.player.weapons[WeaponType.machineGun] = MachineGun(); + engine.player.weapons[WeaponType.chainGun] = ChainGun(); + engine.player.currentWeapon = engine.player.weapons[WeaponType.chainGun]!; + + input.isInteracting = true; + engine.tick(const Duration(milliseconds: 16)); + input.isInteracting = false; + + expect(engine.activeLevel.name, 'Level 2'); + expect(engine.player.health, 47); + expect(engine.player.ammo, 33); + expect(engine.player.score, 1200); + expect(engine.player.lives, 5); + expect(engine.player.hasMachineGun, isTrue); + expect(engine.player.hasChainGun, isTrue); + expect(engine.player.currentWeapon.type, WeaponType.chainGun); + expect(engine.player.hasGoldKey, isFalse); + expect(engine.player.hasSilverKey, isFalse); + expect(engine.player.x, closeTo(4.5, 0.001)); + expect(engine.player.y, closeTo(4.5, 0.001)); + }); + }); + + group('Pause and main menu', () { + test( + 'shows game selection before the shared main menu when multiple games exist', + () { + final input = _TestInput(); + final engine = _buildMultiGameEngine(input: input, difficulty: null); + + engine.init(); + + expect(engine.isMenuOpen, isTrue); + expect(engine.menuManager.activeMenu, WolfMenuScreen.gameSelect); + + input.isInteracting = true; + engine.tick(const Duration(milliseconds: 16)); + input.isInteracting = false; + engine.tick(const Duration(milliseconds: 300)); + + expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu); + expect( + engine.menuManager.mainMenuEntries + .map((entry) => entry.label) + .toList(), + [ + 'NEW GAME', + 'SOUND', + 'CONTROL', + 'LOAD GAME', + 'SAVE GAME', + 'CHANGE VIEW', + 'READ THIS!', + 'VIEW SCORES', + 'BACK TO DEMO', + 'QUIT', + ], + ); + expect( + engine.menuManager.mainMenuEntries + .map((entry) => entry.isEnabled) + .toList(), + [true, false, false, false, false, false, false, false, true, true], + ); + + input.isInteracting = true; + engine.tick(const Duration(milliseconds: 16)); + input.isInteracting = false; + engine.tick(const Duration(milliseconds: 300)); + + expect(engine.menuManager.activeMenu, WolfMenuScreen.episodeSelect); + }, + ); + + test('single-game startup opens the shared main menu directly', () { + final input = _TestInput(); + final engine = _buildEngine(input: input, difficulty: null); + + engine.init(); + + expect(engine.isMenuOpen, isTrue); + expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu); + expect( + engine.menuManager.mainMenuEntries.map((entry) => entry.label).toList(), + [ + 'NEW GAME', + 'SOUND', + 'CONTROL', + 'LOAD GAME', + 'SAVE GAME', + 'CHANGE VIEW', + 'READ THIS!', + 'VIEW SCORES', + 'BACK TO DEMO', + 'QUIT', + ], + ); + expect( + engine.menuManager.mainMenuEntries + .map((entry) => entry.isEnabled) + .toList(), + [true, false, false, false, false, false, false, false, true, true], + ); + + input.isInteracting = true; + engine.tick(const Duration(milliseconds: 16)); + input.isInteracting = false; + engine.tick(const Duration(milliseconds: 300)); + + expect(engine.menuManager.activeMenu, WolfMenuScreen.episodeSelect); + }); + + test( + 'escape opens pause menu and back to game resumes without resetting state', + () { + final input = _TestInput(); + final engine = _buildEngine(input: input, difficulty: Difficulty.hard); + + engine.init(); + final double startX = engine.player.x; + final double startY = engine.player.y; + + input.isBack = true; + engine.tick(const Duration(milliseconds: 16)); + input.isBack = false; + + expect(engine.isMenuOpen, isTrue); + expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu); + expect( + engine.menuManager.mainMenuEntries + .map((entry) => entry.label) + .toList(), + [ + 'NEW GAME', + 'SOUND', + 'CONTROL', + 'LOAD GAME', + 'SAVE GAME', + 'CHANGE VIEW', + 'READ THIS!', + 'END GAME', + 'BACK TO GAME', + 'QUIT', + ], + ); + expect( + engine.menuManager.mainMenuEntries + .map((entry) => entry.isEnabled) + .toList(), + [true, false, false, false, false, false, false, true, true, true], + ); + + input.isMovingForward = true; + engine.tick(const Duration(milliseconds: 16)); + expect(engine.player.x, closeTo(startX, 0.001)); + expect(engine.player.y, closeTo(startY, 0.001)); + + input + ..isMovingForward = false + ..isBack = true; + engine.tick(const Duration(milliseconds: 16)); + input.isBack = false; + + expect(engine.isMenuOpen, isFalse); + + input.isMovingForward = true; + engine.tick(const Duration(milliseconds: 16)); + input.isMovingForward = false; + + expect(engine.player.x, greaterThan(startX)); + }, + ); + + test('main menu skips disabled entries during navigation', () { + final manager = MenuManager(); + + manager.showMainMenu(hasResumableGame: false); + + expect(manager.selectedMainIndex, 0); + + manager.updateMainMenu(const EngineInput(isMovingBackward: true)); + manager.updateMainMenu(const EngineInput()); + expect(manager.selectedMainIndex, 8); + + manager.updateMainMenu(const EngineInput(isMovingBackward: true)); + manager.updateMainMenu(const EngineInput()); + expect(manager.selectedMainIndex, 9); + + manager.updateMainMenu(const EngineInput(isMovingBackward: true)); + manager.updateMainMenu(const EngineInput()); + expect(manager.selectedMainIndex, 0); + }); + + test('quit selection triggers top-level menu exit callback', () { + final input = _TestInput(); + int exitCalls = 0; + final engine = _buildEngine( + input: input, + difficulty: null, + onMenuExit: () { + exitCalls++; + }, + ); + + engine.init(); + + input.isMovingBackward = true; + engine.tick(const Duration(milliseconds: 16)); + input.isMovingBackward = false; + engine.tick(const Duration(milliseconds: 16)); + + input.isMovingBackward = true; + engine.tick(const Duration(milliseconds: 16)); + input.isMovingBackward = false; + engine.tick(const Duration(milliseconds: 16)); + + expect(engine.menuManager.selectedMainIndex, 9); + + input.isInteracting = true; + engine.tick(const Duration(milliseconds: 16)); + input.isInteracting = false; + + expect(exitCalls, 1); + }); + }); +} + +WolfEngine _buildMultiGameEngine({ + required _TestInput input, + required Difficulty? difficulty, + void Function()? onMenuExit, +}) { + final WolfensteinData retail = _buildTestData( + gameVersion: GameVersion.retail, + ); + final WolfensteinData shareware = _buildTestData( + gameVersion: GameVersion.shareware, + ); + + return WolfEngine( + availableGames: [retail, shareware], + difficulty: difficulty, + startingEpisode: 0, + frameBuffer: FrameBuffer(64, 64), + input: input, + engineAudio: _SilentAudio(), + onGameWon: () {}, + onMenuExit: onMenuExit, + ); +} + +WolfEngine _buildEngine({ + required _TestInput input, + required Difficulty? difficulty, + void Function()? onMenuExit, +}) { + return WolfEngine( + data: _buildTestData(gameVersion: GameVersion.retail), + difficulty: difficulty, + startingEpisode: 0, + frameBuffer: FrameBuffer(64, 64), + input: input, + engineAudio: _SilentAudio(), + onGameWon: () {}, + onMenuExit: onMenuExit, + ); +} + +WolfensteinData _buildTestData({required GameVersion gameVersion}) { + final levelOneWalls = _buildGrid(); + final levelOneObjects = _buildGrid(); + final levelTwoWalls = _buildGrid(); + final levelTwoObjects = _buildGrid(); + + _fillBoundaries(levelOneWalls, 2); + _fillBoundaries(levelTwoWalls, 2); + + levelOneObjects[2][2] = MapObject.playerEast; + levelOneWalls[2][3] = MapObject.normalElevatorSwitch; + + levelTwoObjects[4][4] = MapObject.playerEast; + + return WolfensteinData( + version: gameVersion, + dataVersion: DataVersion.unknown, + registry: gameVersion == GameVersion.shareware + ? SharewareAssetRegistry() + : 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: levelOneWalls, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), + objectGrid: levelOneObjects, + musicIndex: 0, + ), + WolfLevel( + name: 'Level 2', + wallGrid: levelTwoWalls, + areaGrid: List.generate(64, (_) => List.filled(64, -1)), + objectGrid: levelTwoObjects, + musicIndex: 1, + ), + ], + ), + ], + ); +} + +class _TestInput extends Wolf3dInput { + @override + void update() {} +} + +class _SilentAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void playLevelMusic(WolfLevel level) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(int sfxId) {} + + @override + void stopMusic() {} + + @override + void dispose() {} +} + +SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0)); + +void _fillBoundaries(SpriteMap grid, int wallId) { + for (int i = 0; i < 64; i++) { + grid[0][i] = wallId; + grid[63][i] = wallId; + grid[i][0] = wallId; + grid[i][63] = wallId; + } +} + +Sprite _solidSprite(int colorIndex) { + return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex))); +}