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,
|
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;
|
CliGameLoop? gameLoop;
|
||||||
|
|
||||||
void stopAndExit(int code) {
|
void stopAndExit(int code) {
|
||||||
@@ -45,7 +53,7 @@ void main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final engine = WolfEngine(
|
final engine = WolfEngine(
|
||||||
data: availableGames.values.first,
|
availableGames: availableGames.values.toList(growable: false),
|
||||||
startingEpisode: 0,
|
startingEpisode: 0,
|
||||||
frameBuffer: FrameBuffer(
|
frameBuffer: FrameBuffer(
|
||||||
stdout.terminalColumns,
|
stdout.terminalColumns,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wolf_3d_flutter/wolf_3d_flutter.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.
|
/// Creates the application shell after loading available Wolf3D data sets.
|
||||||
void main() async {
|
void main() async {
|
||||||
@@ -16,7 +16,65 @@ void main() async {
|
|||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MaterialApp(
|
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(
|
child: Scaffold(
|
||||||
body: LayoutBuilder(
|
body: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final viewportRect = _menuViewportRect(
|
|
||||||
Size(constraints.maxWidth, constraints.maxHeight),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Listener(
|
return Listener(
|
||||||
onPointerDown: (event) {
|
onPointerDown: (event) {
|
||||||
widget.wolf3d.input.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,
|
onPointerUp: widget.wolf3d.input.onPointerUp,
|
||||||
onPointerMove: widget.wolf3d.input.onPointerMove,
|
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.
|
/// input systems, and the world state.
|
||||||
class WolfEngine {
|
class WolfEngine {
|
||||||
WolfEngine({
|
WolfEngine({
|
||||||
required this.data,
|
WolfensteinData? data,
|
||||||
required this.startingEpisode,
|
List<WolfensteinData>? availableGames,
|
||||||
|
this.startingEpisode,
|
||||||
required this.onGameWon,
|
required this.onGameWon,
|
||||||
required this.input,
|
required this.input,
|
||||||
required this.frameBuffer,
|
required this.frameBuffer,
|
||||||
this.difficulty,
|
this.difficulty,
|
||||||
this.menuBackgroundRgb = 0x890000,
|
this.menuBackgroundRgb = 0x890000,
|
||||||
this.menuPanelRgb = 0x590002,
|
this.menuPanelRgb = 0x590002,
|
||||||
EngineAudio? audio,
|
this.onMenuExit,
|
||||||
}) : audio = audio ?? CliSilentAudio(),
|
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(
|
doorManager = DoorManager(
|
||||||
onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId),
|
onPlaySound: (sfxId) => engineAudio?.playSoundEffect(sfxId),
|
||||||
),
|
),
|
||||||
pushwallManager = PushwallManager(
|
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.
|
/// Total milliseconds elapsed since the engine was initialized.
|
||||||
int _timeAliveMs = 0;
|
int _timeAliveMs = 0;
|
||||||
|
|
||||||
/// The static game data (textures, sounds, maps) parsed from original files.
|
/// The discovered game data sets available for selection.
|
||||||
final WolfensteinData data;
|
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.
|
/// Desired menu background color in 24-bit RGB.
|
||||||
final int menuBackgroundRgb;
|
final int menuBackgroundRgb;
|
||||||
@@ -52,7 +74,7 @@ class WolfEngine {
|
|||||||
int get timeAliveMs => _timeAliveMs;
|
int get timeAliveMs => _timeAliveMs;
|
||||||
|
|
||||||
/// The episode index where the game session begins.
|
/// The episode index where the game session begins.
|
||||||
final int startingEpisode;
|
final int? startingEpisode;
|
||||||
|
|
||||||
/// Handles music and sound effect playback.
|
/// Handles music and sound effect playback.
|
||||||
late final EngineAudio audio;
|
late final EngineAudio audio;
|
||||||
@@ -60,6 +82,15 @@ class WolfEngine {
|
|||||||
/// Callback triggered when the final level of an episode is completed.
|
/// Callback triggered when the final level of an episode is completed.
|
||||||
final void Function() onGameWon;
|
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 ---
|
// --- State Managers ---
|
||||||
|
|
||||||
/// Manages the state and animation of doors throughout the level.
|
/// 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.
|
/// Initializes the engine, sets the starting episode, and loads the first level.
|
||||||
void init() {
|
void init() {
|
||||||
|
_currentGameIndex = 0;
|
||||||
audio.activeGame = data;
|
audio.activeGame = data;
|
||||||
_currentEpisodeIndex = startingEpisode;
|
onGameSelected?.call(data);
|
||||||
|
|
||||||
|
_currentEpisodeIndex = startingEpisode ?? 0;
|
||||||
_currentLevelIndex = 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) {
|
if (difficulty != null) {
|
||||||
_loadLevel();
|
_loadLevel();
|
||||||
@@ -146,7 +190,8 @@ class WolfEngine {
|
|||||||
final currentInput = input.currentInput;
|
final currentInput = input.currentInput;
|
||||||
|
|
||||||
if (difficulty == null) {
|
if (difficulty == null) {
|
||||||
_tickDifficultyMenu(currentInput);
|
menuManager.tickTransition(delta.inMilliseconds);
|
||||||
|
_tickMenu(currentInput);
|
||||||
return;
|
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);
|
final menuResult = menuManager.updateDifficultySelection(input);
|
||||||
if (menuResult.goBack) {
|
if (menuResult.goBack) {
|
||||||
// Explicitly keep the engine in menu mode when leaving this screen.
|
menuManager.startTransition(WolfMenuScreen.episodeSelect);
|
||||||
difficulty = null;
|
|
||||||
onGameWon();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (menuResult.selected != null) {
|
if (menuResult.selected != null) {
|
||||||
difficulty = menuResult.selected;
|
difficulty = menuResult.selected;
|
||||||
|
_currentLevelIndex = 0;
|
||||||
|
_returnLevelIndex = null;
|
||||||
_loadLevel();
|
_loadLevel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _exitTopLevelMenu() {
|
||||||
|
if (onMenuExit != null) {
|
||||||
|
onMenuExit!.call();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onGameWon();
|
||||||
|
}
|
||||||
|
|
||||||
/// Wipes the current world state and builds a new floor from map data.
|
/// Wipes the current world state and builds a new floor from map data.
|
||||||
void _loadLevel() {
|
void _loadLevel() {
|
||||||
entities.clear();
|
entities.clear();
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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_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.
|
/// Handles menu-only input state such as selection movement and edge triggers.
|
||||||
class MenuManager {
|
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;
|
int _selectedDifficultyIndex = 0;
|
||||||
|
|
||||||
bool _prevUp = false;
|
bool _prevUp = false;
|
||||||
@@ -10,30 +24,122 @@ class MenuManager {
|
|||||||
bool _prevConfirm = false;
|
bool _prevConfirm = false;
|
||||||
bool _prevBack = 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.
|
/// Current selected difficulty row index.
|
||||||
int get selectedDifficultyIndex => _selectedDifficultyIndex;
|
int get selectedDifficultyIndex => _selectedDifficultyIndex;
|
||||||
|
|
||||||
/// Resets menu navigation state for a new difficulty selection flow.
|
/// Resets menu state for startup, optionally skipping game selection.
|
||||||
void beginDifficultySelection({Difficulty? initialDifficulty}) {
|
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
|
_selectedDifficultyIndex = initialDifficulty == null
|
||||||
? 0
|
? 0
|
||||||
: Difficulty.values
|
: Difficulty.values
|
||||||
.indexOf(initialDifficulty)
|
.indexOf(initialDifficulty)
|
||||||
.clamp(
|
.clamp(0, Difficulty.values.length - 1);
|
||||||
0,
|
_activeMenu = gameCount > 1
|
||||||
Difficulty.values.length - 1,
|
? WolfMenuScreen.gameSelect
|
||||||
);
|
: WolfMenuScreen.episodeSelect;
|
||||||
|
_transitionTarget = null;
|
||||||
|
_transitionElapsedMs = 0;
|
||||||
|
_transitionSwappedMenu = false;
|
||||||
|
_resetEdgeState();
|
||||||
|
}
|
||||||
|
|
||||||
_prevUp = false;
|
/// Resets menu navigation state for a new difficulty selection flow.
|
||||||
_prevDown = false;
|
void beginDifficultySelection({Difficulty? initialDifficulty}) {
|
||||||
_prevConfirm = false;
|
beginSelectionFlow(
|
||||||
_prevBack = false;
|
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.
|
/// Returns a menu action snapshot for this frame.
|
||||||
({Difficulty? selected, bool goBack}) updateDifficultySelection(
|
({Difficulty? selected, bool goBack}) updateDifficultySelection(
|
||||||
EngineInput input,
|
EngineInput input,
|
||||||
) {
|
) {
|
||||||
|
if (isTransitioning) {
|
||||||
|
_consumeEdgeState(input);
|
||||||
|
return (selected: null, goBack: false);
|
||||||
|
}
|
||||||
|
|
||||||
final upNow = input.isMovingForward;
|
final upNow = input.isMovingForward;
|
||||||
final downNow = input.isMovingBackward;
|
final downNow = input.isMovingBackward;
|
||||||
final confirmNow = input.isInteracting || input.isFiring;
|
final confirmNow = input.isInteracting || input.isFiring;
|
||||||
@@ -50,30 +156,6 @@ class MenuManager {
|
|||||||
(_selectedDifficultyIndex + 1) % Difficulty.values.length;
|
(_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;
|
Difficulty? selected;
|
||||||
if (confirmNow && !_prevConfirm) {
|
if (confirmNow && !_prevConfirm) {
|
||||||
selected = Difficulty.values[_selectedDifficultyIndex];
|
selected = Difficulty.values[_selectedDifficultyIndex];
|
||||||
@@ -89,6 +171,112 @@ class MenuManager {
|
|||||||
return (selected: selected, goBack: goBack);
|
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].
|
/// Whether to show the alternate cursor frame at [elapsedMs].
|
||||||
bool isCursorAltFrame(int elapsedMs) => ((elapsedMs ~/ 220) % 2) == 1;
|
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 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:arcane_helper_utils/arcane_helper_utils.dart';
|
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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
@@ -95,14 +96,16 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
AsciiRasterizer({
|
AsciiRasterizer({
|
||||||
this.activeTheme = AsciiThemes.blocks,
|
this.activeTheme = AsciiThemes.blocks,
|
||||||
this.mode = AsciiRasterizerMode.terminalGrid,
|
this.mode = AsciiRasterizerMode.terminalGrid,
|
||||||
|
this.useTerminalLayout = true,
|
||||||
this.aspectMultiplier = 1.0,
|
this.aspectMultiplier = 1.0,
|
||||||
this.verticalStretch = 1.0,
|
this.verticalStretch = 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
AsciiTheme activeTheme = AsciiThemes.blocks;
|
AsciiTheme activeTheme = AsciiThemes.blocks;
|
||||||
final AsciiRasterizerMode mode;
|
final AsciiRasterizerMode mode;
|
||||||
|
final bool useTerminalLayout;
|
||||||
|
|
||||||
bool get _usesTerminalLayout => true;
|
bool get _usesTerminalLayout => useTerminalLayout;
|
||||||
|
|
||||||
bool get _emitAnsi => mode == AsciiRasterizerMode.terminalAnsi;
|
bool get _emitAnsi => mode == AsciiRasterizerMode.terminalAnsi;
|
||||||
|
|
||||||
@@ -132,7 +135,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool isTerminalSizeSupported(int columns, int rows) {
|
bool isTerminalSizeSupported(int columns, int rows) {
|
||||||
if (!_usesTerminalLayout) {
|
if (!_emitAnsi) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows;
|
return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows;
|
||||||
@@ -350,8 +353,6 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void drawMenu(WolfEngine engine) {
|
void drawMenu(WolfEngine engine) {
|
||||||
final int selectedDifficultyIndex =
|
|
||||||
engine.menuManager.selectedDifficultyIndex;
|
|
||||||
final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb);
|
final int bgColor = _rgbToPaletteColor(engine.menuBackgroundRgb);
|
||||||
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
|
final int panelColor = _rgbToPaletteColor(engine.menuPanelRgb);
|
||||||
final int headingColor = WolfMenuPalette.headerTextColor;
|
final int headingColor = WolfMenuPalette.headerTextColor;
|
||||||
@@ -368,6 +369,164 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
|
|
||||||
final art = WolfClassicMenuArt(engine.data);
|
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(
|
final face = art.difficultyOption(
|
||||||
Difficulty.values[selectedDifficultyIndex],
|
Difficulty.values[selectedDifficultyIndex],
|
||||||
);
|
);
|
||||||
@@ -429,6 +588,36 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_drawCenteredMenuFooter();
|
_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(
|
void _drawMenuText(
|
||||||
@@ -516,9 +705,32 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
int unselectedTextColor,
|
int unselectedTextColor,
|
||||||
int panelColor,
|
int panelColor,
|
||||||
) {
|
) {
|
||||||
const int panelX320 = 28;
|
_drawMinimalMenuRows(
|
||||||
const int panelW320 = 264;
|
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 =
|
final int panelX =
|
||||||
projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt();
|
projectionOffsetX + ((panelX320 / 320.0) * projectionWidth).toInt();
|
||||||
final int panelW = math.max(
|
final int panelW = math.max(
|
||||||
@@ -526,19 +738,15 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
((panelW320 / 320.0) * projectionWidth).toInt(),
|
((panelW320 / 320.0) * projectionWidth).toInt(),
|
||||||
);
|
);
|
||||||
final int panelRight = panelX + panelW - 1;
|
final int panelRight = panelX + panelW - 1;
|
||||||
final int textLeft = _menuX320ToColumn(70);
|
final int textLeft = _menuX320ToColumn(textX320);
|
||||||
final int textWidth = math.max(1, panelRight - textLeft);
|
final int textWidth = math.max(1, panelRight - textLeft);
|
||||||
|
|
||||||
const int rowYStart = 86;
|
for (int i = 0; i < rows.length; i++) {
|
||||||
const int rowStep = 15;
|
final bool isSelected = i == selectedIndex;
|
||||||
for (int i = 0; i < Difficulty.values.length; i++) {
|
final int rowY = _menuY200ToRow(rowYStart200 + (i * rowStep200));
|
||||||
final bool isSelected = i == selectedDifficultyIndex;
|
|
||||||
final int rowY = _menuY200ToRow(rowYStart + (i * rowStep));
|
|
||||||
|
|
||||||
final String rowText = Difficulty.values[i].title;
|
|
||||||
_writeLeftClipped(
|
_writeLeftClipped(
|
||||||
rowY,
|
rowY,
|
||||||
rowText,
|
rows[i],
|
||||||
isSelected ? selectedTextColor : unselectedTextColor,
|
isSelected ? selectedTextColor : unselectedTextColor,
|
||||||
panelColor,
|
panelColor,
|
||||||
textWidth,
|
textWidth,
|
||||||
@@ -955,9 +1163,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
}
|
}
|
||||||
if (_usesTerminalLayout) {
|
if (_usesTerminalLayout) {
|
||||||
_composeTerminalScene();
|
_composeTerminalScene();
|
||||||
return _emitAnsi ? toAnsiString() : _screen;
|
|
||||||
}
|
}
|
||||||
return _screen;
|
return _emitAnsi ? toAnsiString() : _screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PRIVATE HUD DRAWING HELPERS ---
|
// --- PRIVATE HUD DRAWING HELPERS ---
|
||||||
@@ -974,8 +1181,8 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
(_usesTerminalLayout ? projectionOffsetX : 0) +
|
(_usesTerminalLayout ? projectionOffsetX : 0) +
|
||||||
(startX_320 * scaleX).toInt();
|
(startX_320 * scaleX).toInt();
|
||||||
int destStartY = (startY_200 * scaleY).toInt();
|
int destStartY = (startY_200 * scaleY).toInt();
|
||||||
int destWidth = (image.width * scaleX).toInt();
|
int destWidth = math.max(1, (image.width * scaleX).toInt());
|
||||||
int destHeight = (image.height * scaleY).toInt();
|
int destHeight = math.max(1, (image.height * scaleY).toInt());
|
||||||
|
|
||||||
for (int dy = 0; dy < destHeight; dy++) {
|
for (int dy = 0; dy < destHeight; dy++) {
|
||||||
for (int dx = 0; dx < destWidth; dx++) {
|
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
|
/// Returns the texture Y coordinate for the given screen row inside a wall
|
||||||
/// column. Works for both pixel and terminal renderers.
|
/// column. Works for both pixel and terminal renderers.
|
||||||
int wallTexY(int y, int columnHeight) {
|
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 =
|
final double relativeY =
|
||||||
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
|
(y - (-columnHeight ~/ 2 + projectionCenterY)) / columnHeight;
|
||||||
return (relativeY * 64).toInt().clamp(0, 63);
|
return (relativeY * 64).toInt().clamp(0, 63);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'dart:io';
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
@@ -321,8 +322,6 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void drawMenu(WolfEngine engine) {
|
void drawMenu(WolfEngine engine) {
|
||||||
final int selectedDifficultyIndex =
|
|
||||||
engine.menuManager.selectedDifficultyIndex;
|
|
||||||
final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb);
|
final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb);
|
||||||
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
|
final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb);
|
||||||
final int headingIndex = WolfMenuPalette.headerTextIndex;
|
final int headingIndex = WolfMenuPalette.headerTextIndex;
|
||||||
@@ -336,8 +335,82 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
_fillRect320(28, 70, 264, 82, panelColor);
|
_fillRect320(28, 70, 264, 82, panelColor);
|
||||||
|
|
||||||
final art = WolfClassicMenuArt(engine.data);
|
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) {
|
if (_useCompactMenuLayout) {
|
||||||
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
|
_drawCompactMenu(selectedDifficultyIndex, headingIndex, panelColor);
|
||||||
|
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +455,36 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
scale: _menuOptionScale,
|
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 =>
|
bool get _useCompactMenuLayout =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:math' as math;
|
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/menu_font.dart';
|
||||||
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.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_data_types.dart';
|
||||||
@@ -125,8 +126,6 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void drawMenu(WolfEngine engine) {
|
void drawMenu(WolfEngine engine) {
|
||||||
final int selectedDifficultyIndex =
|
|
||||||
engine.menuManager.selectedDifficultyIndex;
|
|
||||||
final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb);
|
final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb);
|
||||||
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
|
final int panelColor = _rgbToFrameColor(engine.menuPanelRgb);
|
||||||
final int headingColor = WolfMenuPalette.headerTextColor;
|
final int headingColor = WolfMenuPalette.headerTextColor;
|
||||||
@@ -137,22 +136,157 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
|||||||
_buffer.pixels[i] = bgColor;
|
_buffer.pixels[i] = bgColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelX = 28;
|
final art = WolfClassicMenuArt(engine.data);
|
||||||
const panelY = 70;
|
switch (engine.menuManager.activeMenu) {
|
||||||
const panelW = 264;
|
case WolfMenuScreen.gameSelect:
|
||||||
const panelH = 82;
|
_drawGameSelectMenu(
|
||||||
|
engine,
|
||||||
for (int y = panelY; y < panelY + panelH; y++) {
|
art,
|
||||||
if (y < 0 || y >= height) continue;
|
panelColor,
|
||||||
final rowStart = y * width;
|
headingColor,
|
||||||
for (int x = panelX; x < panelX + panelW; x++) {
|
selectedTextColor,
|
||||||
if (x >= 0 && x < width) {
|
unselectedTextColor,
|
||||||
_buffer.pixels[rowStart + x] = panelColor;
|
);
|
||||||
}
|
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);
|
_drawMenuTextCentered(Difficulty.menuText, 48, headingColor, scale: 2);
|
||||||
|
|
||||||
final bottom = art.pic(15);
|
final bottom = art.pic(15);
|
||||||
@@ -172,9 +306,9 @@ class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
|||||||
final cursor = art.pic(
|
final cursor = art.pic(
|
||||||
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
|
||||||
);
|
);
|
||||||
const rowYStart = panelY + 16;
|
const int rowYStart = panelY + 16;
|
||||||
const rowStep = 15;
|
const int rowStep = 15;
|
||||||
const textX = panelX + 42;
|
const int textX = panelX + 42;
|
||||||
for (int i = 0; i < Difficulty.values.length; i++) {
|
for (int i = 0; i < Difficulty.values.length; i++) {
|
||||||
final y = rowYStart + (i * rowStep);
|
final y = rowYStart + (i * rowStep);
|
||||||
final isSelected = i == selectedDifficultyIndex;
|
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
|
/// Converts an `RRGGBB` menu color into the framebuffer's packed channel
|
||||||
/// order (`0xAABBGGRR`) used throughout this renderer.
|
/// order (`0xAABBGGRR`) used throughout this renderer.
|
||||||
int _rgbToFrameColor(int rgb) {
|
int _rgbToFrameColor(int rgb) {
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ WolfEngine _buildEngine({
|
|||||||
startingEpisode: 0,
|
startingEpisode: 0,
|
||||||
frameBuffer: FrameBuffer(64, 64),
|
frameBuffer: FrameBuffer(64, 64),
|
||||||
input: input,
|
input: input,
|
||||||
audio: audio,
|
engineAudio: audio,
|
||||||
onGameWon: () {},
|
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
|
@override
|
||||||
WolfensteinData? get activeGame => wolf3d.activeGame;
|
WolfensteinData? get activeGame => wolf3d.maybeActiveGame;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set activeGame(WolfensteinData? value) {
|
set activeGame(WolfensteinData? value) {
|
||||||
|
|||||||
@@ -40,12 +40,15 @@ class Wolf3d {
|
|||||||
return _activeGame!;
|
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
|
// Episode selection lives on the facade so menus can configure gameplay
|
||||||
// before constructing a new engine instance.
|
// before constructing a new engine instance.
|
||||||
int _activeEpisode = 0;
|
int? _activeEpisode;
|
||||||
|
|
||||||
/// Index of the episode currently selected in the UI flow.
|
/// Index of the episode currently selected in the UI flow.
|
||||||
int get activeEpisode => _activeEpisode;
|
int? get activeEpisode => _activeEpisode;
|
||||||
|
|
||||||
Difficulty? _activeDifficulty;
|
Difficulty? _activeDifficulty;
|
||||||
|
|
||||||
@@ -80,16 +83,30 @@ class Wolf3d {
|
|||||||
/// engine so it can be retrieved via [engine]. [onGameWon] is invoked when
|
/// engine so it can be retrieved via [engine]. [onGameWon] is invoked when
|
||||||
/// the player completes the final level of the episode.
|
/// the player completes the final level of the episode.
|
||||||
WolfEngine launchEngine({required void Function() onGameWon}) {
|
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(
|
_engine = WolfEngine(
|
||||||
data: activeGame,
|
availableGames: availableGames,
|
||||||
difficulty: _activeDifficulty,
|
difficulty: _activeDifficulty,
|
||||||
startingEpisode: _activeEpisode,
|
startingEpisode: _activeEpisode,
|
||||||
frameBuffer: FrameBuffer(320, 200),
|
frameBuffer: FrameBuffer(320, 200),
|
||||||
menuBackgroundRgb: menuBackgroundRgb,
|
menuBackgroundRgb: menuBackgroundRgb,
|
||||||
menuPanelRgb: menuPanelRgb,
|
menuPanelRgb: menuPanelRgb,
|
||||||
audio: audio,
|
engineAudio: audio,
|
||||||
input: input,
|
input: input,
|
||||||
onGameWon: onGameWon,
|
onGameWon: onGameWon,
|
||||||
|
onMenuExit: onGameWon,
|
||||||
|
onGameSelected: (game) {
|
||||||
|
_activeGame = game;
|
||||||
|
audio.activeGame = game;
|
||||||
|
},
|
||||||
|
onEpisodeSelected: (episodeIndex) {
|
||||||
|
_activeEpisode = episodeIndex;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
_engine!.init();
|
_engine!.init();
|
||||||
return _engine!;
|
return _engine!;
|
||||||
@@ -107,8 +124,18 @@ class Wolf3d {
|
|||||||
_activeEpisode = episodeIndex;
|
_activeEpisode = episodeIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears any selected episode so menu flow starts fresh.
|
||||||
|
void clearActiveEpisode() {
|
||||||
|
_activeEpisode = null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Convenience access to the active episode's level list.
|
/// 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.
|
/// Convenience access to the active game's wall textures.
|
||||||
List<Sprite> get walls => activeGame.walls;
|
List<Sprite> get walls => activeGame.walls;
|
||||||
@@ -141,6 +168,7 @@ class Wolf3d {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_activeGame = game;
|
_activeGame = game;
|
||||||
|
_activeEpisode = null;
|
||||||
audio.activeGame = game;
|
audio.activeGame = game;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
double _mouseDeltaY = 0.0;
|
double _mouseDeltaY = 0.0;
|
||||||
bool _previousMouseRightDown = false;
|
bool _previousMouseRightDown = false;
|
||||||
bool _queuedBack = false;
|
bool _queuedBack = false;
|
||||||
double? _queuedMenuTapX;
|
|
||||||
double? _queuedMenuTapY;
|
|
||||||
|
|
||||||
// Mouse-look is optional so touch or keyboard-only hosts can keep the same
|
// Mouse-look is optional so touch or keyboard-only hosts can keep the same
|
||||||
// adapter without incurring accidental pointer-driven movement.
|
// adapter without incurring accidental pointer-driven movement.
|
||||||
@@ -118,12 +116,6 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
_queuedBack = true;
|
_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.
|
/// Returns whether any bound key for [action] is currently pressed.
|
||||||
bool _isActive(WolfInputAction action, Set<LogicalKeyboardKey> pressedKeys) {
|
bool _isActive(WolfInputAction action, Set<LogicalKeyboardKey> pressedKeys) {
|
||||||
return bindings[action]!.any((key) => pressedKeys.contains(key));
|
return bindings[action]!.any((key) => pressedKeys.contains(key));
|
||||||
@@ -167,8 +159,8 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
|
|
||||||
isBack =
|
isBack =
|
||||||
_isNewlyPressed(WolfInputAction.back, newlyPressedKeys) || _queuedBack;
|
_isNewlyPressed(WolfInputAction.back, newlyPressedKeys) || _queuedBack;
|
||||||
menuTapX = _queuedMenuTapX;
|
menuTapX = null;
|
||||||
menuTapY = _queuedMenuTapY;
|
menuTapY = null;
|
||||||
|
|
||||||
// Left click or Ctrl to fire
|
// Left click or Ctrl to fire
|
||||||
isFiring =
|
isFiring =
|
||||||
@@ -194,7 +186,5 @@ class Wolf3dFlutterInput extends Wolf3dInput {
|
|||||||
_previousKeys = Set.from(pressedKeys);
|
_previousKeys = Set.from(pressedKeys);
|
||||||
_previousMouseRightDown = isMouseRightDown;
|
_previousMouseRightDown = isMouseRightDown;
|
||||||
_queuedBack = false;
|
_queuedBack = false;
|
||||||
_queuedMenuTapX = null;
|
|
||||||
_queuedMenuTapY = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user