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();

View File

@@ -1,8 +1,22 @@
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 }
/// Handles menu-only input state such as selection movement and edge triggers.
class MenuManager {
static const int transitionDurationMs = 280;
WolfMenuScreen _activeMenu = WolfMenuScreen.difficultySelect;
WolfMenuScreen? _transitionTarget;
int _transitionElapsedMs = 0;
bool _transitionSwappedMenu = false;
int _selectedGameIndex = 0;
int _selectedEpisodeIndex = 0;
int _selectedDifficultyIndex = 0;
bool _prevUp = false;
@@ -10,30 +24,122 @@ class MenuManager {
bool _prevConfirm = false;
bool _prevBack = false;
WolfMenuScreen get activeMenu => _activeMenu;
bool get isTransitioning => _transitionTarget != null;
/// Returns the fade alpha during transitions (0.0..1.0).
double get transitionAlpha {
if (!isTransitioning) {
return 0.0;
}
final int half = transitionDurationMs ~/ 2;
if (_transitionElapsedMs <= half) {
return (_transitionElapsedMs / half).clamp(0.0, 1.0);
}
final int fadeInElapsed = _transitionElapsedMs - half;
return (1.0 - (fadeInElapsed / half)).clamp(0.0, 1.0);
}
int get selectedGameIndex => _selectedGameIndex;
int get selectedEpisodeIndex => _selectedEpisodeIndex;
/// Current selected difficulty row index.
int get selectedDifficultyIndex => _selectedDifficultyIndex;
/// Resets menu navigation state for a new difficulty selection flow.
void beginDifficultySelection({Difficulty? initialDifficulty}) {
/// Resets menu state for startup, optionally skipping game selection.
void beginSelectionFlow({
required int gameCount,
int initialGameIndex = 0,
int initialEpisodeIndex = 0,
Difficulty? initialDifficulty,
}) {
_selectedGameIndex = _clampIndex(initialGameIndex, gameCount);
_selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex;
_selectedDifficultyIndex = initialDifficulty == null
? 0
: Difficulty.values
.indexOf(initialDifficulty)
.clamp(
0,
Difficulty.values.length - 1,
);
.clamp(0, Difficulty.values.length - 1);
_activeMenu = gameCount > 1
? WolfMenuScreen.gameSelect
: WolfMenuScreen.episodeSelect;
_transitionTarget = null;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
_resetEdgeState();
}
_prevUp = false;
_prevDown = false;
_prevConfirm = false;
_prevBack = false;
/// Resets menu navigation state for a new difficulty selection flow.
void beginDifficultySelection({Difficulty? initialDifficulty}) {
beginSelectionFlow(
gameCount: 1,
initialGameIndex: 0,
initialEpisodeIndex: 0,
initialDifficulty: initialDifficulty,
);
_activeMenu = WolfMenuScreen.difficultySelect;
}
/// Starts a menu transition. Input is locked until it completes.
///
/// Hosts can reuse this fade timing for future pre-menu splash/image
/// sequences so transitions feel consistent across the whole app.
void startTransition(WolfMenuScreen target) {
if (_activeMenu == target) {
return;
}
_transitionTarget = target;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
_resetEdgeState();
}
/// Advances transition timers and swaps menu at midpoint.
void tickTransition(int deltaMs) {
if (!isTransitioning) {
return;
}
_transitionElapsedMs += deltaMs;
final int half = transitionDurationMs ~/ 2;
if (!_transitionSwappedMenu && _transitionElapsedMs >= half) {
_activeMenu = _transitionTarget!;
_transitionSwappedMenu = true;
}
if (_transitionElapsedMs >= transitionDurationMs) {
_transitionTarget = null;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
}
}
void clearEpisodeSelection() {
_selectedEpisodeIndex = 0;
}
/// Consumes current input as the new edge baseline.
void absorbInputState(EngineInput input) {
_consumeEdgeState(input);
}
void setSelectedEpisodeIndex(int index, int episodeCount) {
_selectedEpisodeIndex = _clampIndex(index, episodeCount);
}
void setSelectedGameIndex(int index, int gameCount) {
_selectedGameIndex = _clampIndex(index, gameCount);
}
/// Returns a menu action snapshot for this frame.
({Difficulty? selected, bool goBack}) updateDifficultySelection(
EngineInput input,
) {
if (isTransitioning) {
_consumeEdgeState(input);
return (selected: null, goBack: false);
}
final upNow = input.isMovingForward;
final downNow = input.isMovingBackward;
final confirmNow = input.isInteracting || input.isFiring;
@@ -50,30 +156,6 @@ class MenuManager {
(_selectedDifficultyIndex + 1) % Difficulty.values.length;
}
// Pointer/touch selection for hosts that provide menu tap coordinates.
if (input.menuTapX != null && input.menuTapY != null) {
final x320 = (input.menuTapX!.clamp(0.0, 1.0) * 320).toDouble();
final y200 = (input.menuTapY!.clamp(0.0, 1.0) * 200).toDouble();
const panelX = 28.0;
const panelY = 70.0;
const panelW = 264.0;
const panelH = 82.0;
const rowYStart = 86.0;
const rowStep = 15.0;
if (x320 >= panelX &&
x320 <= panelX + panelW &&
y200 >= panelY &&
y200 <= panelY + panelH) {
final index = ((y200 - rowYStart + (rowStep / 2)) / rowStep).floor();
if (index >= 0 && index < Difficulty.values.length) {
_selectedDifficultyIndex = index;
return (selected: Difficulty.values[index], goBack: false);
}
}
}
Difficulty? selected;
if (confirmNow && !_prevConfirm) {
selected = Difficulty.values[_selectedDifficultyIndex];
@@ -89,6 +171,112 @@ class MenuManager {
return (selected: selected, goBack: goBack);
}
({int? selectedIndex, bool goBack}) updateGameSelection(
EngineInput input, {
required int gameCount,
}) {
if (isTransitioning) {
_consumeEdgeState(input);
return (selectedIndex: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: _selectedGameIndex,
itemCount: gameCount,
);
_selectedGameIndex = action.index;
return (
selectedIndex: action.confirmed ? _selectedGameIndex : null,
goBack: action.goBack,
);
}
({int? selectedIndex, bool goBack}) updateEpisodeSelection(
EngineInput input, {
required int episodeCount,
}) {
if (isTransitioning) {
_consumeEdgeState(input);
return (selectedIndex: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: _selectedEpisodeIndex,
itemCount: episodeCount,
);
_selectedEpisodeIndex = action.index;
return (
selectedIndex: action.confirmed ? _selectedEpisodeIndex : null,
goBack: action.goBack,
);
}
_MenuAction _updateLinearSelection(
EngineInput input, {
required int currentIndex,
required int itemCount,
}) {
final upNow = input.isMovingForward;
final downNow = input.isMovingBackward;
final confirmNow = input.isInteracting || input.isFiring;
final backNow = input.isBack;
int nextIndex = _clampIndex(currentIndex, itemCount);
if (itemCount > 0) {
if (upNow && !_prevUp) {
nextIndex = (nextIndex - 1 + itemCount) % itemCount;
}
if (downNow && !_prevDown) {
nextIndex = (nextIndex + 1) % itemCount;
}
}
final bool confirmed = confirmNow && !_prevConfirm;
final bool goBack = backNow && !_prevBack;
_prevUp = upNow;
_prevDown = downNow;
_prevConfirm = confirmNow;
_prevBack = backNow;
return _MenuAction(index: nextIndex, confirmed: confirmed, goBack: goBack);
}
void _consumeEdgeState(EngineInput input) {
_prevUp = input.isMovingForward;
_prevDown = input.isMovingBackward;
_prevConfirm = input.isInteracting || input.isFiring;
_prevBack = input.isBack;
}
void _resetEdgeState() {
_prevUp = false;
_prevDown = false;
_prevConfirm = false;
_prevBack = false;
}
int _clampIndex(int index, int itemCount) {
if (itemCount <= 0) {
return 0;
}
return index.clamp(0, itemCount - 1);
}
/// Whether to show the alternate cursor frame at [elapsedMs].
bool isCursorAltFrame(int elapsedMs) => ((elapsedMs ~/ 220) % 2) == 1;
}
class _MenuAction {
const _MenuAction({
required this.index,
required this.confirmed,
required this.goBack,
});
final int index;
final bool confirmed;
final bool goBack;
}

View File

@@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'package:arcane_helper_utils/arcane_helper_utils.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_menu.dart';
@@ -95,14 +96,16 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
AsciiRasterizer({
this.activeTheme = AsciiThemes.blocks,
this.mode = AsciiRasterizerMode.terminalGrid,
this.useTerminalLayout = true,
this.aspectMultiplier = 1.0,
this.verticalStretch = 1.0,
});
AsciiTheme activeTheme = AsciiThemes.blocks;
final AsciiRasterizerMode mode;
final bool useTerminalLayout;
bool get _usesTerminalLayout => true;
bool get _usesTerminalLayout => useTerminalLayout;
bool get _emitAnsi => mode == AsciiRasterizerMode.terminalAnsi;
@@ -132,7 +135,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
@override
bool isTerminalSizeSupported(int columns, int rows) {
if (!_usesTerminalLayout) {
if (!_emitAnsi) {
return true;
}
return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows;
@@ -350,8 +353,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
@override
void drawMenu(WolfEngine engine) {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb);
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
@@ -368,6 +369,164 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
final art = WolfClassicMenuArt(engine.data);
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 84;
const int rowStep = 18;
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,
);
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,
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
_fillRect320(12, 18, 296, 168, panelColor);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 24;
const int rowStep = 26;
final List<String> rows = engine.data.episodes
.map((episode) => episode.name.replaceAll('\n', ' '))
.toList(growable: false);
if (_useMinimalMenuText) {
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
8,
headingColor,
scale: 2,
);
_drawMinimalMenuRows(
rows: rows,
selectedIndex: engine.menuManager.selectedEpisodeIndex,
rowYStart200: rowYStart,
rowStep200: rowStep,
textX320: 98,
panelX320: 12,
panelW320: 296,
selectedTextColor: selectedTextColor,
unselectedTextColor: unselectedTextColor,
panelColor: panelColor,
);
// Keep episode icons visible in compact ASCII layouts so this screen
// still communicates the same visual affordances as full-size menus.
for (int i = 0; i < engine.data.episodes.length; i++) {
final image = art.episodeOption(i);
if (image != null) {
_blitVgaImageAscii(image, 40, rowYStart + (i * rowStep));
}
}
if (cursor != null) {
_blitVgaImageAscii(
cursor,
16,
rowYStart + (engine.menuManager.selectedEpisodeIndex * rowStep) + 2,
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
8,
headingColor,
scale: _fullMenuHeadingScale,
);
for (int i = 0; i < engine.data.episodes.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == engine.menuManager.selectedEpisodeIndex;
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, 16, y + 2);
}
final image = art.episodeOption(i);
if (image != null) {
_blitVgaImageAscii(image, 40, y);
}
final parts = engine.data.episodes[i].name.split('\n');
if (parts.isNotEmpty) {
_drawMenuText(
parts.first,
98,
y + 1,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
if (parts.length > 1) {
_drawMenuText(
parts.sublist(1).join(' '),
98,
y + 13,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
final face = art.difficultyOption(
Difficulty.values[selectedDifficultyIndex],
);
@@ -429,6 +588,36 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
String _gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
void _applyMenuFade(double alpha, int fadeColor) {
if (alpha <= 0.0) {
return;
}
final int threshold = (alpha * 3).round().clamp(1, 3);
for (int y = 0; y < _screen.length; y++) {
final row = _screen[y];
for (int x = 0; x < row.length; x++) {
if (((x + y) % 3) < threshold) {
row[x] = ColoredChar(' ', fadeColor, fadeColor);
}
}
}
}
void _drawMenuText(
@@ -516,9 +705,32 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
int unselectedTextColor,
int panelColor,
) {
const int panelX320 = 28;
const int panelW320 = 264;
_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 _drawMinimalMenuRows({
required List<String> rows,
required int selectedIndex,
required int rowYStart200,
required int rowStep200,
required int textX320,
required int panelX320,
required int panelW320,
required int selectedTextColor,
required int unselectedTextColor,
required int panelColor,
}) {
final int panelX =
projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt();
final int panelW = math.max(
@@ -526,19 +738,15 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
((panelW320 / 320.0) * projectionWidth).toInt(),
);
final int panelRight = panelX + panelW - 1;
final int textLeft = _menuX320ToColumn(70);
final int textLeft = _menuX320ToColumn(textX320);
final int textWidth = math.max(1, panelRight - textLeft);
const int rowYStart = 86;
const int rowStep = 15;
for (int i = 0; i < Difficulty.values.length; i++) {
final bool isSelected = i == selectedDifficultyIndex;
final int rowY = _menuY200ToRow(rowYStart + (i * rowStep));
final String rowText = Difficulty.values[i].title;
for (int i = 0; i < rows.length; i++) {
final bool isSelected = i == selectedIndex;
final int rowY = _menuY200ToRow(rowYStart200 + (i * rowStep200));
_writeLeftClipped(
rowY,
rowText,
rows[i],
isSelected ? selectedTextColor : unselectedTextColor,
panelColor,
textWidth,
@@ -955,9 +1163,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
}
if (_usesTerminalLayout) {
_composeTerminalScene();
return _emitAnsi ? toAnsiString() : _screen;
}
return _screen;
return _emitAnsi ? toAnsiString() : _screen;
}
// --- PRIVATE HUD DRAWING HELPERS ---
@@ -974,8 +1181,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
(_usesTerminalLayout ? projectionOffsetX : 0) +
(startX_320 * scaleX).toInt();
int destStartY = (startY_200 * scaleY).toInt();
int destWidth = (image.width * scaleX).toInt();
int destHeight = (image.height * scaleY).toInt();
int destWidth = math.max(1, (image.width * scaleX).toInt());
int destHeight = math.max(1, (image.height * scaleY).toInt());
for (int dy = 0; dy < destHeight; dy++) {
for (int dx = 0; dx < destWidth; dx++) {

View File

@@ -202,8 +202,12 @@ abstract class Rasterizer<T> {
/// Returns the texture Y coordinate for the given screen row inside a wall
/// column. Works for both pixel and terminal renderers.
int wallTexY(int y, int columnHeight) {
// Anchor sampling to the same projection center used when computing
// drawStart/drawEnd. This keeps wall textures stable for renderers that
// use a taller logical projection (for example terminal ASCII mode).
final int projectionCenterY = projectionViewHeight ~/ 2;
final double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
(y - (-columnHeight ~/ 2 + projectionCenterY)) / columnHeight;
return (relativeY * 64).toInt().clamp(0, 63);
}

View File

@@ -6,6 +6,7 @@ import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
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_menu.dart';
@@ -321,8 +322,6 @@ class SixelRasterizer extends CliRasterizer<String> {
@override
void drawMenu(WolfEngine engine) {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb);
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
final int headingIndex = WolfMenuPalette.headerTextIndex;
@@ -336,8 +335,82 @@ class SixelRasterizer extends CliRasterizer<String> {
_fillRect320(28, 70, 264, 82, panelColor);
final art = WolfClassicMenuArt(engine.data);
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
_drawMenuTextCentered('SELECT GAME', 48, headingIndex, scale: 2);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 84;
const int rowStep = 18;
for (int i = 0; i < engine.availableGames.length; i++) {
final bool isSelected = i == engine.menuManager.selectedGameIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 38, (rowYStart + (i * rowStep)) - 2);
}
_drawMenuText(
_gameTitle(engine.availableGames[i].version),
70,
rowYStart + (i * rowStep),
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.episodeSelect) {
_fillRect320(12, 18, 296, 168, panelColor);
_drawMenuTextCentered(
'WHICH EPISODE TO PLAY?',
8,
headingIndex,
scale: 2,
);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 24;
const int rowStep = 26;
for (int i = 0; i < engine.data.episodes.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == engine.menuManager.selectedEpisodeIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 16, y + 2);
}
final image = art.episodeOption(i);
if (image != null) {
_blitVgaImage(image, 40, y);
}
final parts = engine.data.episodes[i].name.split('\n');
if (parts.isNotEmpty) {
_drawMenuText(
parts.first,
98,
y + 1,
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: 1,
);
}
if (parts.length > 1) {
_drawMenuText(
parts.sublist(1).join(' '),
98,
y + 13,
isSelected ? selectedTextIndex : unselectedTextIndex,
scale: 1,
);
}
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
if (_useCompactMenuLayout) {
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
@@ -382,6 +455,36 @@ class SixelRasterizer extends CliRasterizer<String> {
scale: _menuOptionScale,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
String _gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
void _applyMenuFade(double alpha, int bgColor) {
if (alpha <= 0.0) {
return;
}
final int threshold = (alpha * 3).round().clamp(1, 3);
for (int y = 0; y < height; y++) {
final int rowOffset = y * width;
for (int x = 0; x < width; x++) {
if (((x + y) % 3) < threshold) {
_screen[rowOffset + x] = bgColor;
}
}
}
}
bool get _useCompactMenuLayout =>

View File

@@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/src/rasterizer/menu_font.dart';
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
@@ -125,8 +126,6 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
@override
void drawMenu(WolfEngine engine) {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb);
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
final int headingColor = WolfMenuPalette.headerTextColor;
@@ -137,22 +136,157 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
_buffer.pixels[i] = bgColor;
}
const panelX = 28;
const panelY = 70;
const panelW = 264;
const panelH = 82;
for (int y = panelY; y < panelY + panelH; y++) {
if (y < 0 || y >= height) continue;
final rowStart = y * width;
for (int x = panelX; x < panelX + panelW; x++) {
if (x >= 0 && x < width) {
_buffer.pixels[rowStart + x] = panelColor;
}
}
final art = WolfClassicMenuArt(engine.data);
switch (engine.menuManager.activeMenu) {
case WolfMenuScreen.gameSelect:
_drawGameSelectMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
);
break;
case WolfMenuScreen.episodeSelect:
_drawEpisodeSelectMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
);
break;
case WolfMenuScreen.difficultySelect:
_drawDifficultyMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
);
break;
}
final art = WolfClassicMenuArt(engine.data);
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
}
void _drawGameSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
) {
const int panelX = 28;
const int panelY = 58;
const int panelW = 264;
const int panelH = 104;
_fillMenuPanel(panelX, panelY, panelW, panelH, panelColor);
_drawMenuTextCentered('SELECT GAME', 38, headingColor, scale: 2);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 78;
const int rowStep = 20;
const int textX = 70;
final int selectedIndex = engine.menuManager.selectedGameIndex;
for (int i = 0; i < engine.availableGames.length; i++) {
final bool isSelected = i == selectedIndex;
final int y = rowYStart + (i * rowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, panelX + 10, y - 2);
}
_drawMenuText(
_gameTitle(engine.availableGames[i].version),
textX,
y,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
}
void _drawEpisodeSelectMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
) {
const int panelX = 12;
const int panelY = 18;
const int panelW = 296;
const int panelH = 168;
_fillMenuPanel(panelX, panelY, panelW, panelH, panelColor);
_drawMenuTextCentered('WHICH EPISODE TO PLAY?', 2, headingColor, scale: 2);
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 24;
const int rowStep = 26;
const int imageX = 40;
const int textX = 98;
final int selectedIndex = engine.menuManager.selectedEpisodeIndex;
for (int i = 0; i < engine.data.episodes.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == selectedIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, 16, y + 2);
}
final image = art.episodeOption(i);
if (image != null) {
_blitVgaImage(image, imageX, y);
}
final parts = engine.data.episodes[i].name.split('\n');
if (parts.isNotEmpty) {
_drawMenuText(
parts.first,
textX,
y + 1,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
if (parts.length > 1) {
_drawMenuText(
parts.sublist(1).join(' '),
textX,
y + 13,
isSelected ? selectedTextColor : unselectedTextColor,
);
}
}
}
void _drawDifficultyMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
) {
final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex;
const int panelX = 28;
const int panelY = 70;
const int panelW = 264;
const int panelH = 82;
_fillMenuPanel(panelX, panelY, panelW, panelH, panelColor);
_drawMenuTextCentered(Difficulty.menuText, 48, headingColor, scale: 2);
final bottom = art.pic(15);
@@ -172,9 +306,9 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
final cursor = art.pic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const rowYStart = panelY + 16;
const rowStep = 15;
const textX = panelX + 42;
const int rowYStart = panelY + 16;
const int rowStep = 15;
const int textX = panelX + 42;
for (int i = 0; i < Difficulty.values.length; i++) {
final y = rowYStart + (i * rowStep);
final isSelected = i == selectedDifficultyIndex;
@@ -192,6 +326,59 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
}
}
void _fillMenuPanel(
int panelX,
int panelY,
int panelW,
int panelH,
int color,
) {
for (int y = panelY; y < panelY + panelH; y++) {
if (y < 0 || y >= height) continue;
final rowStart = y * width;
for (int x = panelX; x < panelX + panelW; x++) {
if (x >= 0 && x < width) {
_buffer.pixels[rowStart + x] = color;
}
}
}
}
String _gameTitle(GameVersion version) {
switch (version) {
case GameVersion.shareware:
return 'SHAREWARE';
case GameVersion.retail:
return 'RETAIL';
case GameVersion.spearOfDestiny:
return 'SPEAR OF DESTINY';
case GameVersion.spearOfDestinyDemo:
return 'SOD DEMO';
}
}
void _applyMenuFade(double alpha, int fadeColor) {
if (alpha <= 0.0) {
return;
}
final int fadeR = fadeColor & 0xFF;
final int fadeG = (fadeColor >> 8) & 0xFF;
final int fadeB = (fadeColor >> 16) & 0xFF;
for (int i = 0; i < _buffer.pixels.length; i++) {
final int c = _buffer.pixels[i];
final int r = c & 0xFF;
final int g = (c >> 8) & 0xFF;
final int b = (c >> 16) & 0xFF;
final int outR = (r + ((fadeR - r) * alpha)).round().clamp(0, 255);
final int outG = (g + ((fadeG - g) * alpha)).round().clamp(0, 255);
final int outB = (b + ((fadeB - b) * alpha)).round().clamp(0, 255);
_buffer.pixels[i] = (0xFF << 24) | (outB << 16) | (outG << 8) | outR;
}
}
/// Converts an `RRGGBB` menu color into the framebuffer's packed channel
/// order (`0xAABBGGRR`) used throughout this renderer.
int _rgbToFrameColor(int rgb) {

View File

@@ -147,7 +147,7 @@ WolfEngine _buildEngine({
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: input,
audio: audio,
engineAudio: audio,
onGameWon: () {},
);
}

View File

@@ -0,0 +1,85 @@
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
void main() {
group('Rasterizer wall texture sampling', () {
test('anchors wall texel sampling to projection height center', () {
final rasterizer = _TestRasterizer(customProjectionViewHeight: 40);
rasterizer.configureViewGeometry(width: 64, height: 64, viewHeight: 20);
// With sceneHeight=40 and columnHeight=20, projected wall spans y=10..30.
// Top pixel should sample from top texel row.
expect(rasterizer.wallTexY(10, 20), 0);
// Bottom visible pixel should sample close to bottom texel row.
expect(rasterizer.wallTexY(29, 20), inInclusiveRange(60, 63));
});
test('keeps legacy behavior when projection height equals view height', () {
final rasterizer = _TestRasterizer(customProjectionViewHeight: 20);
rasterizer.configureViewGeometry(width: 64, height: 64, viewHeight: 20);
// With sceneHeight=viewHeight=20 and columnHeight=20, top starts at y=0.
expect(rasterizer.wallTexY(0, 20), 0);
expect(rasterizer.wallTexY(19, 20), inInclusiveRange(60, 63));
});
});
}
class _TestRasterizer extends Rasterizer<FrameBuffer> {
_TestRasterizer({required this.customProjectionViewHeight});
final int customProjectionViewHeight;
@override
int get projectionViewHeight => customProjectionViewHeight;
void configureViewGeometry({
required int width,
required int height,
required int viewHeight,
}) {
this.width = width;
this.height = height;
this.viewHeight = viewHeight;
}
@override
void prepareFrame(WolfEngine engine) {}
@override
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
) {}
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
) {}
@override
void drawWeapon(WolfEngine engine) {}
@override
void drawHud(WolfEngine engine) {}
@override
FrameBuffer finalizeFrame() {
return FrameBuffer(1, 1);
}
}