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