From 0e143892f06bd757f070e5cb987c70f5435120ef Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Wed, 18 Mar 2026 20:06:18 +0100 Subject: [PATCH] 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 --- apps/wolf_3d_cli/bin/main.dart | 10 +- apps/wolf_3d_gui/lib/main.dart | 62 ++++- apps/wolf_3d_gui/lib/screens/game_screen.dart | 47 ---- .../lib/screens/game_select_screen.dart | 43 --- .../lib/src/engine/wolf_3d_engine_base.dart | 152 +++++++++-- .../lib/src/menu/menu_manager.dart | 256 +++++++++++++++--- .../lib/src/rasterizer/ascii_rasterizer.dart | 245 +++++++++++++++-- .../lib/src/rasterizer/rasterizer.dart | 6 +- .../lib/src/rasterizer/sixel_rasterizer.dart | 107 +++++++- .../src/rasterizer/software_rasterizer.dart | 225 +++++++++++++-- .../test/engine/audio_events_test.dart | 2 +- .../rasterizer/projection_sampling_test.dart | 85 ++++++ .../lib/audio/audio_adaptor.dart | 2 +- .../wolf_3d_flutter/lib/wolf_3d_flutter.dart | 38 ++- .../lib/wolf_3d_input_flutter.dart | 14 +- 15 files changed, 1090 insertions(+), 204 deletions(-) delete mode 100644 apps/wolf_3d_gui/lib/screens/game_select_screen.dart create mode 100644 packages/wolf_3d_dart/test/rasterizer/projection_sampling_test.dart diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index d5f9a90..0c4276f 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -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, diff --git a/apps/wolf_3d_gui/lib/main.dart b/apps/wolf_3d_gui/lib/main.dart index 8ebe414..2b39f3d 100644 --- a/apps/wolf_3d_gui/lib/main.dart +++ b/apps/wolf_3d_gui/lib/main.dart @@ -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, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index f6d5998..bdddef7 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -51,28 +51,9 @@ class _GameScreenState extends State { 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 { ), ); } - - 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); - } } diff --git a/apps/wolf_3d_gui/lib/screens/game_select_screen.dart b/apps/wolf_3d_gui/lib/screens/game_select_screen.dart deleted file mode 100644 index 8bfa0f9..0000000 --- a/apps/wolf_3d_gui/lib/screens/game_select_screen.dart +++ /dev/null @@ -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), - ), - ); - }, - ), - ); - }, - ), - ); - } -} diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 7556ecb..1de0f42 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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? 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 ?? [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 _availableGames; + + /// Available game data sets for menu rendering and selection. + List 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(); diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart index 602f04b..25bd1fb 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -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; +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart index c97787d..0e11c32 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart @@ -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 { 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 { @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 { @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 { 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 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 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 { } _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 { 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 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 { ((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 { } 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 { (_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++) { diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart index d24a306..0f8bf40 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart @@ -202,8 +202,12 @@ abstract class Rasterizer { /// 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); } diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart index 402fa7f..c31190d 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart @@ -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 { @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 { _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 { 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 => diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart index 559ae7b..2f8bc3c 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart @@ -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 { @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 { _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 { 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 { } } + 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) { diff --git a/packages/wolf_3d_dart/test/engine/audio_events_test.dart b/packages/wolf_3d_dart/test/engine/audio_events_test.dart index 0964a3b..2527e30 100644 --- a/packages/wolf_3d_dart/test/engine/audio_events_test.dart +++ b/packages/wolf_3d_dart/test/engine/audio_events_test.dart @@ -147,7 +147,7 @@ WolfEngine _buildEngine({ startingEpisode: 0, frameBuffer: FrameBuffer(64, 64), input: input, - audio: audio, + engineAudio: audio, onGameWon: () {}, ); } diff --git a/packages/wolf_3d_dart/test/rasterizer/projection_sampling_test.dart b/packages/wolf_3d_dart/test/rasterizer/projection_sampling_test.dart new file mode 100644 index 0000000..a017213 --- /dev/null +++ b/packages/wolf_3d_dart/test/rasterizer/projection_sampling_test.dart @@ -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 { + _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); + } +} diff --git a/packages/wolf_3d_flutter/lib/audio/audio_adaptor.dart b/packages/wolf_3d_flutter/lib/audio/audio_adaptor.dart index 24fd2a6..3e5a2b0 100644 --- a/packages/wolf_3d_flutter/lib/audio/audio_adaptor.dart +++ b/packages/wolf_3d_flutter/lib/audio/audio_adaptor.dart @@ -43,7 +43,7 @@ class FlutterAudioAdapter implements EngineAudio { } @override - WolfensteinData? get activeGame => wolf3d.activeGame; + WolfensteinData? get activeGame => wolf3d.maybeActiveGame; @override set activeGame(WolfensteinData? value) { diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index cb8f506..b076300 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -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 get levels => activeGame.episodes[activeEpisode].levels; + List 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 get walls => activeGame.walls; @@ -141,6 +168,7 @@ class Wolf3d { } _activeGame = game; + _activeEpisode = null; audio.activeGame = game; } diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart index 1c5874e..fbdda9f 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart @@ -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 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; } }