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

@@ -37,6 +37,14 @@ void main() async {
recursive: true,
);
if (availableGames.isEmpty) {
stderr.writeln('\nNo Wolf3D game files were found at: $targetPath');
stderr.writeln(
'Please provide valid game data files before starting the CLI host.',
);
exitCleanly(1);
}
CliGameLoop? gameLoop;
void stopAndExit(int code) {
@@ -45,7 +53,7 @@ void main() async {
}
final engine = WolfEngine(
data: availableGames.values.first,
availableGames: availableGames.values.toList(growable: false),
startingEpisode: 0,
frameBuffer: FrameBuffer(
stdout.terminalColumns,

View File

@@ -6,7 +6,7 @@ library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/game_select_screen.dart';
import 'package:wolf_3d_gui/screens/game_screen.dart';
/// Creates the application shell after loading available Wolf3D data sets.
void main() async {
@@ -16,7 +16,65 @@ void main() async {
runApp(
MaterialApp(
home: GameSelectScreen(wolf3d: wolf3d),
home: wolf3d.availableGames.isEmpty
? const _NoGameDataScreen()
: GameScreen(wolf3d: wolf3d),
),
);
}
class _NoGameDataScreen extends StatelessWidget {
const _NoGameDataScreen();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF140000),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF590002),
border: Border.all(color: const Color(0xFFB00000), width: 2),
),
child: const Padding(
padding: EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'WOLF3D DATA NOT FOUND',
style: TextStyle(
color: Color(0xFFFFF700),
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 16),
Text(
'No game files were discovered.\n\n'
'Add Wolfenstein 3D data files to one of these locations:\n'
'- packages/wolf_3d_assets/assets/retail\n'
'- packages/wolf_3d_assets/assets/shareware\n'
'- or a discoverable local game-data folder.\n\n'
'Restart the app after adding the files.',
style: TextStyle(
color: Colors.white,
fontSize: 15,
height: 1.4,
),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -51,28 +51,9 @@ class _GameScreenState extends State<GameScreen> {
child: Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final viewportRect = _menuViewportRect(
Size(constraints.maxWidth, constraints.maxHeight),
);
return Listener(
onPointerDown: (event) {
widget.wolf3d.input.onPointerDown(event);
if (_engine.difficulty == null &&
viewportRect.width > 0 &&
viewportRect.height > 0 &&
viewportRect.contains(event.localPosition)) {
final normalizedX =
(event.localPosition.dx - viewportRect.left) /
viewportRect.width;
final normalizedY =
(event.localPosition.dy - viewportRect.top) /
viewportRect.height;
widget.wolf3d.input.queueMenuTap(
x: normalizedX,
y: normalizedY,
);
}
},
onPointerUp: widget.wolf3d.input.onPointerUp,
onPointerMove: widget.wolf3d.input.onPointerMove,
@@ -148,32 +129,4 @@ class _GameScreenState extends State<GameScreen> {
),
);
}
Rect _menuViewportRect(Size availableSize) {
if (availableSize.width <= 0 || availableSize.height <= 0) {
return Rect.zero;
}
const double aspect = 4 / 3;
final double outerPadding = _useAsciiMode ? 0.0 : 16.0;
final double maxWidth = (availableSize.width - (outerPadding * 2)).clamp(
1.0,
double.infinity,
);
final double maxHeight = (availableSize.height - (outerPadding * 2)).clamp(
1.0,
double.infinity,
);
double viewportWidth = maxWidth;
double viewportHeight = viewportWidth / aspect;
if (viewportHeight > maxHeight) {
viewportHeight = maxHeight;
viewportWidth = viewportHeight * aspect;
}
final double left = (availableSize.width - viewportWidth) / 2;
final double top = (availableSize.height - viewportHeight) / 2;
return Rect.fromLTWH(left, top, viewportWidth, viewportHeight);
}
}

View File

@@ -1,43 +0,0 @@
/// Game-selection screen shown after the GUI host discovers available assets.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_gui/screens/episode_screen.dart';
/// Lists every discovered data set and lets the user choose the active one.
class GameSelectScreen extends StatelessWidget {
/// Shared application facade that owns discovered games, audio, and input.
final Wolf3d wolf3d;
/// Creates the game-selection screen for the supplied [wolf3d] session.
const GameSelectScreen({super.key, required this.wolf3d});
@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView.builder(
itemCount: wolf3d.availableGames.length,
itemBuilder: (context, i) {
final WolfensteinData data = wolf3d.availableGames[i];
final GameVersion version = data.version;
return Card(
child: ListTile(
title: Text(version.name),
onTap: () {
wolf3d.setActiveGame(data);
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => EpisodeScreen(wolf3d: wolf3d),
),
);
},
),
);
},
),
);
}
}

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

View File

@@ -43,7 +43,7 @@ class FlutterAudioAdapter implements EngineAudio {
}
@override
WolfensteinData? get activeGame => wolf3d.activeGame;
WolfensteinData? get activeGame => wolf3d.maybeActiveGame;
@override
set activeGame(WolfensteinData? value) {

View File

@@ -40,12 +40,15 @@ class Wolf3d {
return _activeGame!;
}
/// Nullable access to the selected game, useful during menu bootstrap.
WolfensteinData? get maybeActiveGame => _activeGame;
// Episode selection lives on the facade so menus can configure gameplay
// before constructing a new engine instance.
int _activeEpisode = 0;
int? _activeEpisode;
/// Index of the episode currently selected in the UI flow.
int get activeEpisode => _activeEpisode;
int? get activeEpisode => _activeEpisode;
Difficulty? _activeDifficulty;
@@ -80,16 +83,30 @@ class Wolf3d {
/// engine so it can be retrieved via [engine]. [onGameWon] is invoked when
/// the player completes the final level of the episode.
WolfEngine launchEngine({required void Function() onGameWon}) {
if (availableGames.isEmpty) {
throw StateError(
'No game data was discovered. Add game files before launching the engine.',
);
}
_engine = WolfEngine(
data: activeGame,
availableGames: availableGames,
difficulty: _activeDifficulty,
startingEpisode: _activeEpisode,
frameBuffer: FrameBuffer(320, 200),
menuBackgroundRgb: menuBackgroundRgb,
menuPanelRgb: menuPanelRgb,
audio: audio,
engineAudio: audio,
input: input,
onGameWon: onGameWon,
onMenuExit: onGameWon,
onGameSelected: (game) {
_activeGame = game;
audio.activeGame = game;
},
onEpisodeSelected: (episodeIndex) {
_activeEpisode = episodeIndex;
},
);
_engine!.init();
return _engine!;
@@ -107,8 +124,18 @@ class Wolf3d {
_activeEpisode = episodeIndex;
}
/// Clears any selected episode so menu flow starts fresh.
void clearActiveEpisode() {
_activeEpisode = null;
}
/// Convenience access to the active episode's level list.
List<WolfLevel> get levels => activeGame.episodes[activeEpisode].levels;
List<WolfLevel> get levels {
if (_activeEpisode == null) {
throw StateError('No active episode selected.');
}
return activeGame.episodes[_activeEpisode!].levels;
}
/// Convenience access to the active game's wall textures.
List<Sprite> get walls => activeGame.walls;
@@ -141,6 +168,7 @@ class Wolf3d {
}
_activeGame = game;
_activeEpisode = null;
audio.activeGame = game;
}

View File

@@ -58,8 +58,6 @@ class Wolf3dFlutterInput extends Wolf3dInput {
double _mouseDeltaY = 0.0;
bool _previousMouseRightDown = false;
bool _queuedBack = false;
double? _queuedMenuTapX;
double? _queuedMenuTapY;
// Mouse-look is optional so touch or keyboard-only hosts can keep the same
// adapter without incurring accidental pointer-driven movement.
@@ -118,12 +116,6 @@ class Wolf3dFlutterInput extends Wolf3dInput {
_queuedBack = true;
}
/// Queues a one-frame menu tap with normalized coordinates [0..1].
void queueMenuTap({required double x, required double y}) {
_queuedMenuTapX = x.clamp(0.0, 1.0);
_queuedMenuTapY = y.clamp(0.0, 1.0);
}
/// Returns whether any bound key for [action] is currently pressed.
bool _isActive(WolfInputAction action, Set<LogicalKeyboardKey> pressedKeys) {
return bindings[action]!.any((key) => pressedKeys.contains(key));
@@ -167,8 +159,8 @@ class Wolf3dFlutterInput extends Wolf3dInput {
isBack =
_isNewlyPressed(WolfInputAction.back, newlyPressedKeys) || _queuedBack;
menuTapX = _queuedMenuTapX;
menuTapY = _queuedMenuTapY;
menuTapX = null;
menuTapY = null;
// Left click or Ctrl to fire
isFiring =
@@ -194,7 +186,5 @@ class Wolf3dFlutterInput extends Wolf3dInput {
_previousKeys = Set.from(pressedKeys);
_previousMouseRightDown = isMouseRightDown;
_queuedBack = false;
_queuedMenuTapX = null;
_queuedMenuTapY = null;
}
}