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 <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 09:58:48 +01:00
parent 536a10d99e
commit 9b053e1c02
8 changed files with 1198 additions and 135 deletions

View File

@@ -150,6 +150,8 @@ class WolfEngine {
final Map<int, ({int x, int y})> _lastPatrolTileByEnemy = {}; final Map<int, ({int x, int y})> _lastPatrolTileByEnemy = {};
int _currentEpisodeIndex = 0; int _currentEpisodeIndex = 0;
bool _isMenuOverlayVisible = false;
bool _hasActiveSession = false;
bool _isPlayerMovingFast = false; bool _isPlayerMovingFast = false;
int _currentLevelIndex = 0; int _currentLevelIndex = 0;
@@ -177,6 +179,7 @@ class WolfEngine {
initialGameIndex: _currentGameIndex, initialGameIndex: _currentGameIndex,
initialEpisodeIndex: _currentEpisodeIndex, initialEpisodeIndex: _currentEpisodeIndex,
initialDifficulty: difficulty, initialDifficulty: difficulty,
hasResumableGame: false,
); );
if (_availableGames.length == 1) { if (_availableGames.length == 1) {
@@ -185,12 +188,19 @@ class WolfEngine {
} }
if (difficulty != null) { if (difficulty != null) {
_loadLevel(); _loadLevel(preservePlayerState: false);
_hasActiveSession = true;
} }
isInitialized = 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. /// Replaces the shared framebuffer when dimensions change.
void setFrameBuffer(int width, int height) { void setFrameBuffer(int width, int height) {
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
@@ -225,7 +235,13 @@ class WolfEngine {
input.update(); input.update();
final currentInput = input.currentInput; final currentInput = input.currentInput;
if (difficulty == null) { if (difficulty != null && !_isMenuOverlayVisible && currentInput.isBack) {
_openPauseMenu();
menuManager.absorbInputState(currentInput);
return;
}
if (isMenuOpen) {
menuManager.tickTransition(delta.inMilliseconds); menuManager.tickTransition(delta.inMilliseconds);
_tickMenu(currentInput); _tickMenu(currentInput);
return; return;
@@ -275,6 +291,9 @@ class WolfEngine {
} }
switch (menuManager.activeMenu) { switch (menuManager.activeMenu) {
case WolfMenuScreen.mainMenu:
_tickMainMenu(input);
break;
case WolfMenuScreen.gameSelect: case WolfMenuScreen.gameSelect:
_tickGameSelectionMenu(input); _tickGameSelectionMenu(input);
break; 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) { void _tickGameSelectionMenu(EngineInput input) {
final menuResult = menuManager.updateGameSelection( final menuResult = menuManager.updateGameSelection(
input, input,
@@ -294,7 +353,7 @@ class WolfEngine {
); );
if (menuResult.goBack) { if (menuResult.goBack) {
_exitTopLevelMenu(); menuManager.startTransition(WolfMenuScreen.mainMenu);
return; return;
} }
@@ -305,7 +364,7 @@ class WolfEngine {
_currentEpisodeIndex = 0; _currentEpisodeIndex = 0;
onEpisodeSelected?.call(null); onEpisodeSelected?.call(null);
menuManager.clearEpisodeSelection(); menuManager.clearEpisodeSelection();
menuManager.startTransition(WolfMenuScreen.episodeSelect); menuManager.startTransition(WolfMenuScreen.mainMenu);
} }
} }
@@ -318,11 +377,7 @@ class WolfEngine {
if (menuResult.goBack) { if (menuResult.goBack) {
onEpisodeSelected?.call(null); onEpisodeSelected?.call(null);
menuManager.clearEpisodeSelection(); menuManager.clearEpisodeSelection();
if (_availableGames.length > 1) { menuManager.startTransition(WolfMenuScreen.mainMenu);
menuManager.startTransition(WolfMenuScreen.gameSelect);
} else {
_exitTopLevelMenu();
}
return; return;
} }
@@ -341,13 +396,47 @@ class WolfEngine {
} }
if (menuResult.selected != null) { if (menuResult.selected != null) {
difficulty = menuResult.selected; _startNewGameSession(menuResult.selected!);
_currentLevelIndex = 0;
_returnLevelIndex = null;
_loadLevel();
} }
} }
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() { void _exitTopLevelMenu() {
if (onMenuExit != null) { if (onMenuExit != null) {
onMenuExit!.call(); onMenuExit!.call();
@@ -357,7 +446,7 @@ class WolfEngine {
} }
/// Wipes the current world state and builds a new floor from map data. /// Wipes the current world state and builds a new floor from map data.
void _loadLevel() { void _loadLevel({required bool preservePlayerState}) {
entities.clear(); entities.clear();
_lastPatrolTileByEnemy.clear(); _lastPatrolTileByEnemy.clear();
@@ -374,17 +463,26 @@ class WolfEngine {
audio.playLevelMusic(activeLevel); audio.playLevelMusic(activeLevel);
// Spawn Player and Entities from the Object Grid // Spawn Player and Entities from the Object Grid
bool playerSpawned = false;
for (int y = 0; y < 64; y++) { for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) { for (int x = 0; x < 64; x++) {
int objId = _objectLevel[y][x]; int objId = _objectLevel[y][x];
// Map IDs 19-22 are Reserved for Player Starts // Map IDs 19-22 are Reserved for Player Starts
if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) { if (objId >= MapObject.playerNorth && objId <= MapObject.playerWest) {
player = Player( playerSpawned = true;
x: x + 0.5, if (preservePlayerState) {
y: y + 0.5, player
angle: MapObject.getAngle(objId), ..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 { } else {
Entity? newEntity = EntityRegistry.spawn( Entity? newEntity = EntityRegistry.spawn(
objId, 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 // Sanitize the level grid to ensure only valid walls/doors remain
for (int y = 0; y < 64; y++) { for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) { for (int x = 0; x < 64; x++) {
@@ -419,6 +521,9 @@ class WolfEngine {
void _onLevelCompleted({bool isSecretExit = false}) { void _onLevelCompleted({bool isSecretExit = false}) {
audio.playSoundEffect(WolfSound.levelDone); audio.playSoundEffect(WolfSound.levelDone);
audio.stopMusic(); audio.stopMusic();
player
..hasGoldKey = false
..hasSilverKey = false;
final currentEpisode = data.episodes[_currentEpisodeIndex]; final currentEpisode = data.episodes[_currentEpisodeIndex];
if (isSecretExit) { if (isSecretExit) {
@@ -439,7 +544,7 @@ class WolfEngine {
_currentLevelIndex > 9) { _currentLevelIndex > 9) {
onGameWon(); onGameWon();
} else { } else {
_loadLevel(); _loadLevel(preservePlayerState: true);
} }
} }

View File

@@ -1,10 +1,53 @@
import 'package:wolf_3d_dart/wolf_3d_data_types.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_engine.dart';
// Keep this enum focused on currently implemented menu screens. A future enum WolfMenuScreen { mainMenu, gameSelect, episodeSelect, difficultySelect }
// top-level "main" menu can be inserted before gameSelect without changing
// transition timing or fade infrastructure. enum WolfMenuMainAction {
enum WolfMenuScreen { gameSelect, episodeSelect, difficultySelect } 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. /// Handles menu-only input state such as selection movement and edge triggers.
class MenuManager { class MenuManager {
@@ -15,9 +58,12 @@ class MenuManager {
int _transitionElapsedMs = 0; int _transitionElapsedMs = 0;
bool _transitionSwappedMenu = false; bool _transitionSwappedMenu = false;
int _selectedMainIndex = 0;
int _selectedGameIndex = 0; int _selectedGameIndex = 0;
int _selectedEpisodeIndex = 0; int _selectedEpisodeIndex = 0;
int _selectedDifficultyIndex = 0; int _selectedDifficultyIndex = 0;
bool _showResumeOption = false;
int _gameCount = 1;
bool _prevUp = false; bool _prevUp = false;
bool _prevDown = false; bool _prevDown = false;
@@ -41,10 +87,60 @@ class MenuManager {
return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0); return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0);
} }
int get selectedMainIndex => _selectedMainIndex;
int get selectedGameIndex => _selectedGameIndex; int get selectedGameIndex => _selectedGameIndex;
int get selectedEpisodeIndex => _selectedEpisodeIndex; int get selectedEpisodeIndex => _selectedEpisodeIndex;
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',
),
_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. /// Current selected difficulty row index.
int get selectedDifficultyIndex => _selectedDifficultyIndex; int get selectedDifficultyIndex => _selectedDifficultyIndex;
@@ -54,7 +150,11 @@ class MenuManager {
int initialGameIndex = 0, int initialGameIndex = 0,
int initialEpisodeIndex = 0, int initialEpisodeIndex = 0,
Difficulty? initialDifficulty, Difficulty? initialDifficulty,
bool hasResumableGame = false,
}) { }) {
_gameCount = gameCount;
_showResumeOption = hasResumableGame;
_selectedMainIndex = _defaultMainMenuIndex();
_selectedGameIndex = _clampIndex(initialGameIndex, gameCount); _selectedGameIndex = _clampIndex(initialGameIndex, gameCount);
_selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex; _selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex;
_selectedDifficultyIndex = initialDifficulty == null _selectedDifficultyIndex = initialDifficulty == null
@@ -64,7 +164,7 @@ class MenuManager {
.clamp(0, Difficulty.values.length - 1); .clamp(0, Difficulty.values.length - 1);
_activeMenu = gameCount > 1 _activeMenu = gameCount > 1
? WolfMenuScreen.gameSelect ? WolfMenuScreen.gameSelect
: WolfMenuScreen.episodeSelect; : WolfMenuScreen.mainMenu;
_transitionTarget = null; _transitionTarget = null;
_transitionElapsedMs = 0; _transitionElapsedMs = 0;
_transitionSwappedMenu = false; _transitionSwappedMenu = false;
@@ -82,6 +182,21 @@ class MenuManager {
_activeMenu = WolfMenuScreen.difficultySelect; _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. /// Starts a menu transition. Input is locked until it completes.
/// ///
/// Hosts can reuse this fade timing for future pre-menu splash/image /// Hosts can reuse this fade timing for future pre-menu splash/image
@@ -131,6 +246,29 @@ class MenuManager {
_selectedGameIndex = _clampIndex(index, gameCount); _selectedGameIndex = _clampIndex(index, gameCount);
} }
({WolfMenuMainAction? selected, bool goBack}) updateMainMenu(
EngineInput input,
) {
if (isTransitioning) {
_consumeEdgeState(input);
return (selected: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: _selectedMainIndex,
itemCount: mainMenuEntries.length,
isSelectableIndex: _isSelectableMainIndex,
);
_selectedMainIndex = action.index;
return (
selected: action.confirmed
? mainMenuEntries[_selectedMainIndex].action
: null,
goBack: action.goBack,
);
}
/// Returns a menu action snapshot for this frame. /// Returns a menu action snapshot for this frame.
({Difficulty? selected, bool goBack}) updateDifficultySelection( ({Difficulty? selected, bool goBack}) updateDifficultySelection(
EngineInput input, EngineInput input,
@@ -215,6 +353,7 @@ class MenuManager {
EngineInput input, { EngineInput input, {
required int currentIndex, required int currentIndex,
required int itemCount, required int itemCount,
bool Function(int index)? isSelectableIndex,
}) { }) {
final upNow = input.isMovingForward; final upNow = input.isMovingForward;
final downNow = input.isMovingBackward; final downNow = input.isMovingBackward;
@@ -222,18 +361,24 @@ class MenuManager {
final backNow = input.isBack; final backNow = input.isBack;
int nextIndex = _clampIndex(currentIndex, itemCount); 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 (itemCount > 0) {
if (upNow && !_prevUp) { if (upNow && !_prevUp) {
nextIndex = (nextIndex - 1 + itemCount) % itemCount; nextIndex = _moveSelectableIndex(nextIndex, itemCount, -1, selectable);
} }
if (downNow && !_prevDown) { 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; final bool goBack = backNow && !_prevBack;
_prevUp = upNow; _prevUp = upNow;
@@ -258,6 +403,78 @@ class MenuManager {
_prevBack = false; _prevBack = false;
} }
int _defaultMainMenuIndex() {
final WolfMenuMainAction target = _showResumeOption
? WolfMenuMainAction.backToGame
: WolfMenuMainAction.newGame;
final int found = mainMenuEntries.indexWhere(
(entry) => entry.action == target,
);
return found >= 0
? found
: _findSelectableIndex(
0,
mainMenuEntries.length,
_isSelectableMainIndex,
);
}
bool get canGoBackToGameSelection => !_showResumeOption && _gameCount > 1;
bool _isSelectableMainIndex(int index) {
if (index < 0 || index >= mainMenuEntries.length) {
return false;
}
return mainMenuEntries[index].isEnabled;
}
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) { int _clampIndex(int index, int itemCount) {
if (itemCount <= 0) { if (itemCount <= 0) {
return 0; return 0;

View File

@@ -80,6 +80,23 @@ enum AsciiRendererMode {
terminalGrid, 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 /// Text-mode renderer that can render to ANSI escape output or a Flutter
/// grid model of colored characters. /// grid model of colored characters.
class AsciiRenderer extends CliRendererBackend<dynamic> { class AsciiRenderer extends CliRendererBackend<dynamic> {
@@ -391,6 +408,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int headingColor = WolfMenuPalette.headerTextColor; final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor; final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor; final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
final int disabledTextColor = WolfMenuPalette.disabledTextColor;
final _AsciiMenuTypography menuTypography = _resolveMenuTypography();
if (_usesTerminalLayout) { if (_usesTerminalLayout) {
_fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor); _fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor);
@@ -398,11 +417,66 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_fillRect(0, 0, width, height, activeTheme.solid, bgColor); _fillRect(0, 0, width, height, activeTheme.solid, bgColor);
} }
_fillRect320(28, 70, 264, 82, panelColor);
final art = WolfClassicMenuArt(engine.data); 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) { if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_fillRect320(28, 58, 264, 104, panelColor);
final cursor = art.mappedPic( final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
); );
@@ -411,50 +485,32 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final List<String> rows = engine.availableGames final List<String> rows = engine.availableGames
.map((game) => _gameTitle(game.version)) .map((game) => _gameTitle(game.version))
.toList(growable: false); .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( _drawMenuTextCentered(
'SELECT GAME', 'SELECT GAME',
48, 48,
headingColor, headingColor,
scale: _fullMenuHeadingScale, scale: menuTypography.headingScale,
); );
for (int i = 0; i < rows.length; i++) { _drawSelectableMenuRows(
final bool isSelected = i == engine.menuManager.selectedGameIndex; typography: menuTypography,
if (isSelected && cursor != null) { rows: rows,
_blitVgaImageAscii(cursor, 38, (rowYStart + (i * rowStep)) - 2); selectedIndex: engine.menuManager.selectedGameIndex,
} rowYStart200: rowYStart,
_drawMenuText( rowStep200: rowStep,
rows[i], textX320: 70,
70, panelX320: 28,
rowYStart + (i * rowStep), panelW320: 264,
isSelected ? selectedTextColor : unselectedTextColor, 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<dynamic> {
.map((episode) => episode.name.replaceAll('\n', ' ')) .map((episode) => episode.name.replaceAll('\n', ' '))
.toList(growable: false); .toList(growable: false);
if (_useMinimalMenuText) { if (menuTypography.usesCompactRows) {
_drawMenuTextCentered( _drawMenuTextCentered(
'WHICH EPISODE TO PLAY?', 'WHICH EPISODE TO PLAY?',
8, 8,
headingColor, headingColor,
scale: 2, scale: menuTypography.headingScale,
); );
_drawMinimalMenuRows( _drawMinimalMenuRows(
rows: rows, rows: rows,
@@ -490,9 +546,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
textX320: 98, textX320: 98,
panelX320: 12, panelX320: 12,
panelW320: 296, panelW320: 296,
selectedTextColor: selectedTextColor,
unselectedTextColor: unselectedTextColor,
panelColor: panelColor, panelColor: panelColor,
colorForRow: (int _, bool isSelected) {
return isSelected ? selectedTextColor : unselectedTextColor;
},
); );
// Keep episode icons visible in compact ASCII layouts so this screen // Keep episode icons visible in compact ASCII layouts so this screen
@@ -520,7 +577,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
'WHICH EPISODE TO PLAY?', 'WHICH EPISODE TO PLAY?',
8, 8,
headingColor, headingColor,
scale: _fullMenuHeadingScale, scale: menuTypography.headingScale,
); );
for (int i = 0; i < engine.data.episodes.length; i++) { for (int i = 0; i < engine.data.episodes.length; i++) {
@@ -560,6 +617,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int selectedDifficultyIndex = final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex; engine.menuManager.selectedDifficultyIndex;
_fillRect320(28, 70, 264, 82, panelColor);
final face = art.difficultyOption( final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex], Difficulty.values[selectedDifficultyIndex],
); );
@@ -573,18 +632,25 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
const rowYStart = 86; const rowYStart = 86;
const rowStep = 15; const rowStep = 15;
if (_useMinimalMenuText) { if (menuTypography.usesCompactRows) {
_drawMenuTextCentered( _drawMenuTextCentered(
Difficulty.menuText, Difficulty.menuText,
48, 48,
headingColor, headingColor,
scale: 2, scale: menuTypography.headingScale,
); );
_drawMinimalMenuText( _drawSelectableMenuRows(
selectedDifficultyIndex, typography: menuTypography,
selectedTextColor, rows: Difficulty.values.map((d) => d.title).toList(growable: false),
unselectedTextColor, selectedIndex: selectedDifficultyIndex,
panelColor, rowYStart200: rowYStart,
rowStep200: rowStep,
textX320: 70,
panelX320: 28,
panelW320: 264,
colorForRow: (int _, bool isSelected) {
return isSelected ? selectedTextColor : unselectedTextColor;
},
); );
if (cursor != null) { if (cursor != null) {
_blitVgaImageAscii( _blitVgaImageAscii(
@@ -594,15 +660,15 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
); );
} }
_drawCenteredMenuFooter(); _drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return; return;
} }
final int headingScale = _fullMenuHeadingScale;
_drawMenuTextCentered( _drawMenuTextCentered(
Difficulty.menuText, Difficulty.menuText,
48, 48,
headingColor, headingColor,
scale: headingScale, scale: menuTypography.headingScale,
); );
for (int i = 0; i < Difficulty.values.length; i++) { for (int i = 0; i < Difficulty.values.length; i++) {
@@ -698,15 +764,23 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_drawMenuText(text, x320, y200, color, scale: scale); _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) { if (!_usesTerminalLayout) {
return 2; return 2;
} }
return projectionWidth < 140 ? 1 : 2; return projectionWidth < 140 ? 1 : 2;
} }
bool get _useMinimalMenuText => _menuGlyphHeightInRows(scale: 1) <= 4;
int _menuGlyphHeightInRows({required int scale}) { int _menuGlyphHeightInRows({required int scale}) {
final double scaleY = final double scaleY =
(_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0; (_usesTerminalLayout ? _terminalPixelHeight : height) / 200.0;
@@ -732,24 +806,41 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
return pixelX.clamp(0, width - 1); return pixelX.clamp(0, width - 1);
} }
void _drawMinimalMenuText( void _drawSelectableMenuRows({
int selectedDifficultyIndex, required _AsciiMenuTypography typography,
int selectedTextColor, required List<String> rows,
int unselectedTextColor, required int selectedIndex,
int panelColor, required int rowYStart200,
) { required int rowStep200,
_drawMinimalMenuRows( required int textX320,
rows: Difficulty.values.map((d) => d.title).toList(growable: false), required int panelX320,
selectedIndex: selectedDifficultyIndex, required int panelW320,
rowYStart200: 86, required int Function(int index, bool isSelected) colorForRow,
rowStep200: 15, }) {
textX320: 70, if (typography.usesCompactRows) {
panelX320: 28, _drawMinimalMenuRows(
panelW320: 264, rows: rows,
selectedTextColor: selectedTextColor, selectedIndex: selectedIndex,
unselectedTextColor: unselectedTextColor, rowYStart200: rowYStart200,
panelColor: panelColor, 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({ void _drawMinimalMenuRows({
@@ -760,9 +851,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
required int textX320, required int textX320,
required int panelX320, required int panelX320,
required int panelW320, required int panelW320,
required int selectedTextColor,
required int unselectedTextColor,
required int panelColor, required int panelColor,
required int Function(int index, bool isSelected) colorForRow,
}) { }) {
final int panelX = final int panelX =
projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt(); projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt();
@@ -780,7 +870,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_writeLeftClipped( _writeLeftClipped(
rowY, rowY,
rows[i], rows[i],
isSelected ? selectedTextColor : unselectedTextColor, colorForRow(i, isSelected),
panelColor, panelColor,
textWidth, textWidth,
textLeft, textLeft,
@@ -816,7 +906,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int boxWidth = math.min(width, textWidth + 2); final int boxWidth = math.min(width, textWidth + 2);
final int boxHeight = 3; final int boxHeight = 3;
final int boxX = ((width - boxWidth) ~/ 2).clamp(0, width - boxWidth); 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) { if (_usesTerminalLayout) {
_fillTerminalRect( _fillTerminalRect(
@@ -871,7 +961,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
final int panelWidth = (textWidth + 12).clamp(1, 320); final int panelWidth = (textWidth + 12).clamp(1, 320);
final int panelHeight = 12; final int panelHeight = 12;
final int panelX = ((320 - panelWidth) ~/ 2).clamp(0, 319); final int panelX = ((320 - panelWidth) ~/ 2).clamp(0, 319);
const int panelY = 184; const int panelY = 188;
_fillRect320(panelX, panelY, panelWidth, panelHeight, hintBackground); _fillRect320(panelX, panelY, panelWidth, panelHeight, hintBackground);
int cursorX = panelX + 6; int cursorX = panelX + 6;
@@ -1286,6 +1376,78 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
} }
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 --- // --- DAMAGE FLASH ---
void _applyDamageFlash() { void _applyDamageFlash() {
for (int y = 0; y < viewHeight; y++) { for (int y = 0; y < viewHeight; y++) {

View File

@@ -77,7 +77,7 @@ abstract class RendererBackend<T>
// 1. Setup the frame (clear screen, draw floor/ceiling). // 1. Setup the frame (clear screen, draw floor/ceiling).
prepareFrame(engine); prepareFrame(engine);
if (engine.difficulty == null) { if (engine.isMenuOpen) {
drawMenu(engine); drawMenu(engine);
if (engine.showFpsCounter) { if (engine.showFpsCounter) {
drawFpsOverlay(engine); drawFpsOverlay(engine);

View File

@@ -21,15 +21,17 @@ import 'menu_font.dart';
/// terminal is too small. /// terminal is too small.
class SixelRenderer extends CliRendererBackend<String> { class SixelRenderer extends CliRendererBackend<String> {
static const double _targetAspectRatio = 4 / 3; static const double _targetAspectRatio = 4 / 3;
static const int _defaultLineHeightPx = 18; static const int _defaultLineHeightPx = 16;
static const double _defaultCellWidthToHeight = 0.55; static const double _defaultCellWidthToHeight = 0.55;
static const int _minimumTerminalColumns = 117; static const int _minimumTerminalColumns = 117;
static const int _minimumTerminalRows = 34; static const int _minimumTerminalRows = 34;
static const double _terminalViewportSafety = 0.90; static const double _terminalViewportSafety = 0.90;
static const int _terminalRowSafetyMargin = 1;
static const int _compactMenuMinWidthPx = 200; static const int _compactMenuMinWidthPx = 200;
static const int _compactMenuMinHeightPx = 130; static const int _compactMenuMinHeightPx = 130;
static const int _maxRenderWidth = 320; static const int _maxRenderWidth = 320;
static const int _maxRenderHeight = 240; static const int _maxRenderHeight = 240;
static const int _menuFooterBottomMargin = 1;
static const String _terminalTealBackground = '\x1b[48;2;0;150;136m'; static const String _terminalTealBackground = '\x1b[48;2;0;150;136m';
late Uint8List _screen; late Uint8List _screen;
@@ -195,10 +197,12 @@ class SixelRenderer extends CliRendererBackend<String> {
// Horizontal: cell-width estimates vary by terminal/font and cause right-shift // Horizontal: cell-width estimates vary by terminal/font and cause right-shift
// clipping, so keep the image at column 0. // 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( final int imageRows = math.max(
1, 1,
(_outputHeight / _defaultLineHeightPx).ceil(), (_outputHeight / _defaultLineHeightPx).ceil() + _terminalRowSafetyMargin,
); );
_offsetColumns = 0; _offsetColumns = 0;
_offsetRows = math.max(0, (terminalRows - imageRows) ~/ 2); _offsetRows = math.max(0, (terminalRows - imageRows) ~/ 2);
@@ -358,15 +362,55 @@ class SixelRenderer extends CliRendererBackend<String> {
final int headingIndex = WolfMenuPalette.headerTextIndex; final int headingIndex = WolfMenuPalette.headerTextIndex;
final int selectedTextIndex = WolfMenuPalette.selectedTextIndex; final int selectedTextIndex = WolfMenuPalette.selectedTextIndex;
final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex; final int unselectedTextIndex = WolfMenuPalette.unselectedTextIndex;
final int disabledTextIndex = WolfMenuPalette.disabledTextIndex;
for (int i = 0; i < _screen.length; i++) { for (int i = 0; i < _screen.length; i++) {
_screen[i] = bgColor; _screen[i] = bgColor;
} }
_fillRect320(28, 70, 264, 82, panelColor);
final art = WolfClassicMenuArt(engine.data); 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) { if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_fillRect320(28, 58, 264, 104, panelColor);
_drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2); _drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2);
final cursor = art.mappedPic( final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8, engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
@@ -386,7 +430,6 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: 1, scale: 1,
); );
} }
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return; return;
} }
@@ -434,13 +477,13 @@ class SixelRenderer extends CliRendererBackend<String> {
); );
} }
} }
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return; return;
} }
final int selectedDifficultyIndex = final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex; engine.menuManager.selectedDifficultyIndex;
_fillRect320(28, 70, 264, 82, panelColor);
if (_useCompactMenuLayout) { if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor); _drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
@@ -454,11 +497,6 @@ class SixelRenderer extends CliRendererBackend<String> {
scale: _menuHeadingScale, scale: _menuHeadingScale,
); );
final bottom = art.mappedPic(15);
if (bottom != null) {
_blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8);
}
final face = art.difficultyOption( final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex], Difficulty.values[selectedDifficultyIndex],
); );
@@ -489,7 +527,6 @@ class SixelRenderer extends CliRendererBackend<String> {
); );
} }
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
} }
@@ -498,7 +535,11 @@ class SixelRenderer extends CliRendererBackend<String> {
if (bottom == null) { if (bottom == null) {
return; 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) { String _gameTitle(GameVersion version) {
@@ -809,8 +850,14 @@ class SixelRenderer extends CliRendererBackend<String> {
int drawY = destStartY + dy; int drawY = destStartY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1); int srcX = ((dx / destWidth) * image.width).toInt().clamp(
int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1); 0,
image.width - 1,
);
int srcY = ((dy / destHeight) * image.height).toInt().clamp(
0,
image.height - 1,
);
int colorByte = image.decodePixel(srcX, srcY); int colorByte = image.decodePixel(srcX, srcY);
if (colorByte != 255) { if (colorByte != 255) {
@@ -842,6 +889,60 @@ class SixelRenderer extends CliRendererBackend<String> {
} }
} }
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. /// Maps an RGB color to the nearest VGA palette index.
int _rgbToPaletteIndex(int rgb) { int _rgbToPaletteIndex(int rgb) {
return ColorPalette.findClosestPaletteIndex(rgb); return ColorPalette.findClosestPaletteIndex(rgb);

View File

@@ -12,6 +12,7 @@ import 'package:wolf_3d_dart/wolf_3d_menu.dart';
/// This is the canonical "modern framebuffer" implementation and serves as a /// This is the canonical "modern framebuffer" implementation and serves as a
/// visual reference for terminal renderers. /// visual reference for terminal renderers.
class SoftwareRenderer extends RendererBackend<FrameBuffer> { class SoftwareRenderer extends RendererBackend<FrameBuffer> {
static const int _menuFooterBottomMargin = 1;
static const int _menuFooterY = 184; static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12; static const int _menuFooterHeight = 12;
@@ -147,13 +148,28 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final int headingColor = WolfMenuPalette.headerTextColor; final int headingColor = WolfMenuPalette.headerTextColor;
final int selectedTextColor = WolfMenuPalette.selectedTextColor; final int selectedTextColor = WolfMenuPalette.selectedTextColor;
final int unselectedTextColor = WolfMenuPalette.unselectedTextColor; final int unselectedTextColor = WolfMenuPalette.unselectedTextColor;
final int disabledTextColor = WolfMenuPalette.disabledTextColor;
for (int i = 0; i < _buffer.pixels.length; i++) { for (int i = 0; i < _buffer.pixels.length; i++) {
_buffer.pixels[i] = bgColor; _buffer.pixels[i] = bgColor;
} }
final art = WolfClassicMenuArt(engine.data); final art = WolfClassicMenuArt(engine.data);
// Draw footer first so menu panels can clip overlap in the center.
_drawCenteredMenuFooter(art);
switch (engine.menuManager.activeMenu) { switch (engine.menuManager.activeMenu) {
case WolfMenuScreen.mainMenu:
_drawMainMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
disabledTextColor,
);
break;
case WolfMenuScreen.gameSelect: case WolfMenuScreen.gameSelect:
_drawGameSelectMenu( _drawGameSelectMenu(
engine, engine,
@@ -186,11 +202,59 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
break; break;
} }
_drawCenteredMenuFooter(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _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( void _drawGameSelectMenu(
WolfEngine engine, WolfEngine engine,
WolfClassicMenuArt art, WolfClassicMenuArt art,
@@ -298,7 +362,10 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
final bottom = art.mappedPic(15); final bottom = art.mappedPic(15);
if (bottom != null) { if (bottom != null) {
final int x = ((320 - bottom.width) ~/ 2).clamp(0, 319); 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); _blitVgaImage(bottom, x, y);
return; return;
} }
@@ -362,13 +429,6 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
scale: 2, 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( final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex], Difficulty.values[selectedDifficultyIndex],
); );
@@ -490,6 +550,28 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
} }
} }
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( void _drawCanonicalMenuText(
String text, String text,
int startX320, int startX320,

View File

@@ -50,6 +50,7 @@ abstract class WolfMenuPic {
abstract class WolfMenuPalette { abstract class WolfMenuPalette {
static const int selectedTextIndex = 19; static const int selectedTextIndex = 19;
static const int unselectedTextIndex = 23; static const int unselectedTextIndex = 23;
static const int disabledTextIndex = 4;
static const int _headerTargetRgb = 0xFFF700; static const int _headerTargetRgb = 0xFFF700;
static int? _cachedHeaderTextIndex; static int? _cachedHeaderTextIndex;
@@ -62,6 +63,8 @@ abstract class WolfMenuPalette {
static int get unselectedTextColor => static int get unselectedTextColor =>
ColorPalette.vga32Bit[unselectedTextIndex]; ColorPalette.vga32Bit[unselectedTextIndex];
static int get disabledTextColor => ColorPalette.vga32Bit[disabledTextIndex];
static int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex]; static int get headerTextColor => ColorPalette.vga32Bit[headerTextIndex];
static int _nearestPaletteIndex(int rgb) { static int _nearestPaletteIndex(int rgb) {

View File

@@ -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<void> debugSoundTest() async {}
@override
Future<void> 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)));
}