Refactor menu rendering and improve projection sampling

- Updated AsciiRasterizer to support game and episode selection menus with improved layout and cursor handling.
- Enhanced SixelRasterizer and SoftwareRasterizer to modularize menu drawing logic for game and episode selection.
- Introduced new methods for drawing menus and applying fade effects across rasterizers.
- Adjusted wall texture sampling in Rasterizer to anchor to projection height center for consistent rendering.
- Added tests for wall texture sampling behavior to ensure legacy compatibility and new functionality.
- Modified Flutter audio adapter to use nullable access for active game and adjusted game selection logic in the main class.
- Cleaned up input handling in Wolf3dFlutterInput by removing unused menu tap variables.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 20:06:18 +01:00
parent d93f467163
commit 0e143892f0
15 changed files with 1090 additions and 204 deletions

View File

@@ -13,28 +13,50 @@ import 'package:wolf_3d_dart/wolf_3d_input.dart';
/// input systems, and the world state.
class WolfEngine {
WolfEngine({
required this.data,
required this.startingEpisode,
WolfensteinData? data,
List<WolfensteinData>? availableGames,
this.startingEpisode,
required this.onGameWon,
required this.input,
required this.frameBuffer,
this.difficulty,
this.menuBackgroundRgb = 0x890000,
this.menuPanelRgb = 0x590002,
EngineAudio? audio,
}) : audio = audio ?? CliSilentAudio(),
this.onMenuExit,
this.onGameSelected,
this.onEpisodeSelected,
EngineAudio? engineAudio,
}) : assert(
data != null || (availableGames != null && availableGames.isNotEmpty),
'Provide either data or a non-empty availableGames list.',
),
_availableGames = availableGames ?? <WolfensteinData>[data!],
audio = engineAudio ?? CliSilentAudio(),
doorManager = DoorManager(
onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId),
onPlaySound: (sfxId) => engineAudio?.playSoundEffect(sfxId),
),
pushwallManager = PushwallManager(
onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId),
);
onPlaySound: (sfxId) => engineAudio?.playSoundEffect(sfxId),
) {
if (_availableGames.isEmpty) {
throw StateError('WolfEngine requires at least one game data set.');
}
}
/// Total milliseconds elapsed since the engine was initialized.
int _timeAliveMs = 0;
/// The static game data (textures, sounds, maps) parsed from original files.
final WolfensteinData data;
/// The discovered game data sets available for selection.
final List<WolfensteinData> _availableGames;
/// Available game data sets for menu rendering and selection.
List<WolfensteinData> get availableGames =>
List.unmodifiable(_availableGames);
int _currentGameIndex = 0;
/// The currently active game data set.
WolfensteinData get data => _availableGames[_currentGameIndex];
/// Desired menu background color in 24-bit RGB.
final int menuBackgroundRgb;
@@ -52,7 +74,7 @@ class WolfEngine {
int get timeAliveMs => _timeAliveMs;
/// The episode index where the game session begins.
final int startingEpisode;
final int? startingEpisode;
/// Handles music and sound effect playback.
late final EngineAudio audio;
@@ -60,6 +82,15 @@ class WolfEngine {
/// Callback triggered when the final level of an episode is completed.
final void Function() onGameWon;
/// Callback triggered when backing out of the top-level menu.
final void Function()? onMenuExit;
/// Callback triggered whenever the active game changes from menu flow.
final void Function(WolfensteinData game)? onGameSelected;
/// Callback triggered when episode selection changes; `null` means cleared.
final void Function(int? episodeIndex)? onEpisodeSelected;
// --- State Managers ---
/// Manages the state and animation of doors throughout the level.
@@ -107,11 +138,24 @@ class WolfEngine {
/// Initializes the engine, sets the starting episode, and loads the first level.
void init() {
_currentGameIndex = 0;
audio.activeGame = data;
_currentEpisodeIndex = startingEpisode;
onGameSelected?.call(data);
_currentEpisodeIndex = startingEpisode ?? 0;
_currentLevelIndex = 0;
menuManager.beginDifficultySelection(initialDifficulty: difficulty);
menuManager.beginSelectionFlow(
gameCount: _availableGames.length,
initialGameIndex: _currentGameIndex,
initialEpisodeIndex: _currentEpisodeIndex,
initialDifficulty: difficulty,
);
if (_availableGames.length == 1) {
menuManager.setSelectedGameIndex(0, 1);
onEpisodeSelected?.call(null);
}
if (difficulty != null) {
_loadLevel();
@@ -146,7 +190,8 @@ class WolfEngine {
final currentInput = input.currentInput;
if (difficulty == null) {
_tickDifficultyMenu(currentInput);
menuManager.tickTransition(delta.inMilliseconds);
_tickMenu(currentInput);
return;
}
@@ -181,21 +226,94 @@ class WolfEngine {
);
}
void _tickDifficultyMenu(EngineInput input) {
void _tickMenu(EngineInput input) {
if (menuManager.isTransitioning) {
menuManager.absorbInputState(input);
return;
}
switch (menuManager.activeMenu) {
case WolfMenuScreen.gameSelect:
_tickGameSelectionMenu(input);
break;
case WolfMenuScreen.episodeSelect:
_tickEpisodeSelectionMenu(input);
break;
case WolfMenuScreen.difficultySelect:
_tickDifficultySelectionMenu(input);
break;
}
}
void _tickGameSelectionMenu(EngineInput input) {
final menuResult = menuManager.updateGameSelection(
input,
gameCount: _availableGames.length,
);
if (menuResult.goBack) {
_exitTopLevelMenu();
return;
}
if (menuResult.selectedIndex != null) {
_currentGameIndex = menuResult.selectedIndex!;
audio.activeGame = data;
onGameSelected?.call(data);
_currentEpisodeIndex = 0;
onEpisodeSelected?.call(null);
menuManager.clearEpisodeSelection();
menuManager.startTransition(WolfMenuScreen.episodeSelect);
}
}
void _tickEpisodeSelectionMenu(EngineInput input) {
final menuResult = menuManager.updateEpisodeSelection(
input,
episodeCount: data.episodes.length,
);
if (menuResult.goBack) {
onEpisodeSelected?.call(null);
menuManager.clearEpisodeSelection();
if (_availableGames.length > 1) {
menuManager.startTransition(WolfMenuScreen.gameSelect);
} else {
_exitTopLevelMenu();
}
return;
}
if (menuResult.selectedIndex != null) {
_currentEpisodeIndex = menuResult.selectedIndex!;
onEpisodeSelected?.call(_currentEpisodeIndex);
menuManager.startTransition(WolfMenuScreen.difficultySelect);
}
}
void _tickDifficultySelectionMenu(EngineInput input) {
final menuResult = menuManager.updateDifficultySelection(input);
if (menuResult.goBack) {
// Explicitly keep the engine in menu mode when leaving this screen.
difficulty = null;
onGameWon();
menuManager.startTransition(WolfMenuScreen.episodeSelect);
return;
}
if (menuResult.selected != null) {
difficulty = menuResult.selected;
_currentLevelIndex = 0;
_returnLevelIndex = null;
_loadLevel();
}
}
void _exitTopLevelMenu() {
if (onMenuExit != null) {
onMenuExit!.call();
return;
}
onGameWon();
}
/// Wipes the current world state and builds a new floor from map data.
void _loadLevel() {
entities.clear();