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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user