Refactor menu rendering and improve projection sampling

- Updated AsciiRasterizer to support game and episode selection menus with improved layout and cursor handling.
- Enhanced SixelRasterizer and SoftwareRasterizer to modularize menu drawing logic for game and episode selection.
- Introduced new methods for drawing menus and applying fade effects across rasterizers.
- Adjusted wall texture sampling in Rasterizer to anchor to projection height center for consistent rendering.
- Added tests for wall texture sampling behavior to ensure legacy compatibility and new functionality.
- Modified Flutter audio adapter to use nullable access for active game and adjusted game selection logic in the main class.
- Cleaned up input handling in Wolf3dFlutterInput by removing unused menu tap variables.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 20:06:18 +01:00
parent d93f467163
commit 0e143892f0
15 changed files with 1090 additions and 204 deletions
@@ -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;
}