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