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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -147,7 +147,7 @@ WolfEngine _buildEngine({
|
||||
startingEpisode: 0,
|
||||
frameBuffer: FrameBuffer(64, 64),
|
||||
input: input,
|
||||
audio: audio,
|
||||
engineAudio: audio,
|
||||
onGameWon: () {},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ class FlutterAudioAdapter implements EngineAudio {
|
||||
}
|
||||
|
||||
@override
|
||||
WolfensteinData? get activeGame => wolf3d.activeGame;
|
||||
WolfensteinData? get activeGame => wolf3d.maybeActiveGame;
|
||||
|
||||
@override
|
||||
set activeGame(WolfensteinData? value) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user