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 = {};
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);
}
}

View File

@@ -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<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.
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;

View File

@@ -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<dynamic> {
@@ -391,6 +408,8 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
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<dynamic> {
_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<dynamic> {
final List<String> 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<dynamic> {
.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<dynamic> {
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<dynamic> {
'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<dynamic> {
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<dynamic> {
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<dynamic> {
);
}
_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<dynamic> {
_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<dynamic> {
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<String> 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<dynamic> {
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<dynamic> {
_writeLeftClipped(
rowY,
rows[i],
isSelected ? selectedTextColor : unselectedTextColor,
colorForRow(i, isSelected),
panelColor,
textWidth,
textLeft,
@@ -816,7 +906,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
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<dynamic> {
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<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 ---
void _applyDamageFlash() {
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).
prepareFrame(engine);
if (engine.difficulty == null) {
if (engine.isMenuOpen) {
drawMenu(engine);
if (engine.showFpsCounter) {
drawFpsOverlay(engine);

View File

@@ -21,15 +21,17 @@ import 'menu_font.dart';
/// terminal is too small.
class SixelRenderer extends CliRendererBackend<String> {
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<String> {
// 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<String> {
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<String> {
scale: 1,
);
}
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
@@ -434,13 +477,13 @@ class SixelRenderer extends CliRendererBackend<String> {
);
}
}
_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<String> {
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<String> {
);
}
_drawMenuFooterArt(art);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
@@ -498,7 +535,11 @@ class SixelRenderer extends CliRendererBackend<String> {
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<String> {
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<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.
int _rgbToPaletteIndex(int 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
/// visual reference for terminal renderers.
class SoftwareRenderer extends RendererBackend<FrameBuffer> {
static const int _menuFooterBottomMargin = 1;
static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12;
@@ -147,13 +148,28 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
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<FrameBuffer> {
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<FrameBuffer> {
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<FrameBuffer> {
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<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(
String text,
int startX320,

View File

@@ -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) {