feat: Implement Change View and Renderer Options menus

- Added functionality to display and navigate the Change View menu in SixelRenderer and SoftwareRenderer.
- Introduced methods to draw the Change View and Renderer Options menus, including handling cursor and selection states.
- Updated WolfClassicMenuArt to include a customize label for the new menu.
- Enhanced WolfMenuScreen to support new menu states.
- Created tests for Change View menu interactions, ensuring proper transitions and renderer settings toggling.
- Implemented persistence for renderer settings in Flutter, allowing settings to be saved and loaded from a local file.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 20:49:37 +01:00
parent 45e5302eac
commit 3270338f44
20 changed files with 2223 additions and 140 deletions

View File

@@ -7,6 +7,7 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:wolf_3d_cli/cli_game_loop.dart'; import 'package:wolf_3d_cli/cli_game_loop.dart';
import 'package:wolf_3d_cli/cli_renderer_settings_persistence.dart';
import 'package:wolf_3d_dart/wolf_3d_data.dart'; import 'package:wolf_3d_dart/wolf_3d_data.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';
@@ -66,9 +67,14 @@ void main() async {
engine.init(); engine.init();
final persistence = CliRendererSettingsPersistence();
final WolfRendererSettings? saved = await persistence.load();
gameLoop = CliGameLoop( gameLoop = CliGameLoop(
engine: engine, engine: engine,
onExit: stopAndExit, onExit: stopAndExit,
persistence: persistence,
initialSettings: saved,
); );
await gameLoop.start(); await gameLoop.start();

View File

@@ -17,6 +17,8 @@ class CliGameLoop {
CliGameLoop({ CliGameLoop({
required this.engine, required this.engine,
required this.onExit, required this.onExit,
this.persistence,
this.initialSettings,
}) : input = engine.input is CliInput }) : input = engine.input is CliInput
? engine.input as CliInput ? engine.input as CliInput
: throw ArgumentError.value( : throw ArgumentError.value(
@@ -37,6 +39,8 @@ class CliGameLoop {
final CliRendererBackend secondaryRenderer; final CliRendererBackend secondaryRenderer;
final CliInput input; final CliInput input;
final void Function(int code) onExit; final void Function(int code) onExit;
final RendererSettingsPersistence? persistence;
final WolfRendererSettings? initialSettings;
final Stopwatch _stopwatch = Stopwatch(); final Stopwatch _stopwatch = Stopwatch();
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream(); final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
@@ -59,12 +63,32 @@ class CliGameLoop {
inputStream: _stdinStream, inputStream: _stdinStream,
); );
_isSixelAvailable = sixel.isSixelSupported; _isSixelAvailable = sixel.isSixelSupported;
_renderer = _isSixelAvailable ? primaryRenderer : secondaryRenderer;
} else { } else {
_isSixelAvailable = false; _isSixelAvailable = false;
_renderer = secondaryRenderer;
} }
final Set<WolfRendererMode> supportedModes = <WolfRendererMode>{
WolfRendererMode.ascii,
if (_isSixelAvailable) WolfRendererMode.sixel,
};
engine.setRendererCapabilities(
WolfRendererCapabilities(
supportedModes: supportedModes,
supportsAsciiThemes: true,
supportsFpsCounter: true,
),
);
if (initialSettings != null) {
engine.updateRendererSettings(initialSettings!);
} else if (_isSixelAvailable) {
engine.updateRendererSettings(
engine.rendererSettings.copyWith(mode: WolfRendererMode.sixel),
);
}
_syncRendererFromEngine();
if (stdin.hasTerminal) { if (stdin.hasTerminal) {
try { try {
stdin.echoMode = false; stdin.echoMode = false;
@@ -124,43 +148,48 @@ class CliGameLoop {
} }
if (input.matchesRendererToggleShortcut(bytes)) { if (input.matchesRendererToggleShortcut(bytes)) {
if (!_isSixelAvailable) { engine.cycleRendererMode();
return; _syncRendererFromEngine();
} unawaited(persistence?.save(engine.rendererSettings));
// Allow dynamic renderer-switch bindings configured on the CLI input.
_renderer = identical(_renderer, secondaryRenderer)
? primaryRenderer
: secondaryRenderer;
stdout.write('\x1b[2J\x1b[H'); stdout.write('\x1b[2J\x1b[H');
return; return;
} }
if (input.matchesAsciiThemeCycleShortcut(bytes)) { if (input.matchesAsciiThemeCycleShortcut(bytes)) {
_cycleAsciiTheme(); engine.cycleAsciiTheme();
_syncRendererFromEngine();
unawaited(persistence?.save(engine.rendererSettings));
return; return;
} }
input.handleKey(bytes); input.handleKey(bytes);
} }
void _cycleAsciiTheme() { void _syncRendererFromEngine() {
final List<AsciiRenderer> asciiRenderers = <AsciiRenderer>[ final CliRendererBackend previousRenderer = _renderer;
if (primaryRenderer is AsciiRenderer) primaryRenderer as AsciiRenderer, final WolfRendererMode mode = engine.rendererSettings.mode;
if (secondaryRenderer is AsciiRenderer) if (mode == WolfRendererMode.sixel && _isSixelAvailable) {
secondaryRenderer as AsciiRenderer, _renderer = primaryRenderer;
]; } else {
if (asciiRenderers.isEmpty) { _renderer = secondaryRenderer;
return;
} }
final AsciiTheme nextTheme = AsciiThemes.nextOf( final AsciiTheme theme =
asciiRenderers.first.activeTheme, engine.rendererSettings.asciiThemeId ==
); WolfRendererSettings.asciiThemeQuadrant
for (final renderer in asciiRenderers) { ? AsciiThemes.quadrant
renderer.activeTheme = nextTheme; : AsciiThemes.blocks;
if (primaryRenderer is AsciiRenderer) {
(primaryRenderer as AsciiRenderer).activeTheme = theme;
}
if (secondaryRenderer is AsciiRenderer) {
(secondaryRenderer as AsciiRenderer).activeTheme = theme;
} }
if (stdout.hasTerminal) { engine.showFpsCounter = engine.rendererSettings.fpsCounterEnabled;
if (!identical(previousRenderer, _renderer) && stdout.hasTerminal) {
// Clear stale frame content when switching backend output modes.
stdout.write('\x1b[2J\x1b[H'); stdout.write('\x1b[2J\x1b[H');
} }
} }
@@ -170,6 +199,9 @@ class CliGameLoop {
return; return;
} }
// Apply renderer changes made via in-game menus or settings updates.
_syncRendererFromEngine();
if (stdout.hasTerminal) { if (stdout.hasTerminal) {
final int cols = stdout.terminalColumns; final int cols = stdout.terminalColumns;
final int rows = stdout.terminalLines; final int rows = stdout.terminalLines;
@@ -198,51 +230,5 @@ class CliGameLoop {
engine.tick(elapsed); engine.tick(elapsed);
stdout.write(_renderer.render(engine)); stdout.write(_renderer.render(engine));
_writeShortcutHintLine();
}
void _writeShortcutHintLine() {
if (!stdout.hasTerminal) {
return;
}
final int cols = stdout.terminalColumns;
if (cols <= 0) {
return;
}
final int safeCols = cols > 1 ? cols - 1 : cols;
final String hint = _buildShortcutHintText();
if (hint.isEmpty) {
return;
}
final String visible = hint.length > safeCols
? hint.substring(0, safeCols)
: hint;
final String padded = visible.padRight(safeCols);
// Draw an overlay line without disturbing the renderer's cursor position.
stdout.write('\x1b[s\x1b[1;1H\x1b[0m\x1b[2m\x1b[2K$padded\x1b[0m\x1b[u');
}
String _buildShortcutHintText() {
if (!_isSixelAvailable) {
if (_renderer is AsciiRenderer) {
final AsciiRenderer ascii = _renderer as AsciiRenderer;
return '<${input.asciiThemeCycleKeyLabel}> ${ascii.activeTheme.name}';
}
return '';
}
final String rendererMode = _renderer is SixelRenderer ? 'sixel' : 'ascii';
final String rendererHint =
'<${input.rendererToggleKeyLabel}> $rendererMode';
if (_renderer is AsciiRenderer) {
final AsciiRenderer ascii = _renderer as AsciiRenderer;
return '$rendererHint <${input.asciiThemeCycleKeyLabel}> ${ascii.activeTheme.name}';
}
return rendererHint;
} }
} }

View File

@@ -0,0 +1,42 @@
/// CLI host adapter for persisting renderer settings to a local file.
library;
import 'dart:io';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Persists [WolfRendererSettings] as JSON to a local file.
///
/// The default path is `~/.wolf3d_cli_settings.json`.
/// An alternative [filePath] can be supplied at construction time.
class CliRendererSettingsPersistence extends RendererSettingsPersistence
with JsonRendererSettingsPersistence {
CliRendererSettingsPersistence({String? filePath})
: _filePath =
filePath ??
'${Platform.environment['HOME'] ?? '.'}/.wolf3d_cli_settings.json';
final String _filePath;
@override
Future<String?> readRaw() async {
try {
final File f = File(_filePath);
if (!f.existsSync()) {
return null;
}
return await f.readAsString();
} catch (_) {
return null;
}
}
@override
Future<void> writeRaw(String json) async {
try {
await File(_filePath).writeAsString(json, flush: true);
} catch (_) {
// Best-effort; never crash the loop on a write failure.
}
}
}

View File

@@ -10,6 +10,7 @@ import 'package:window_manager/window_manager.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_input.dart'; import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart'; import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_flutter/renderer_settings_persistence_flutter.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
import 'package:wolf_3d_gui/screens/debug_tools_screen.dart'; import 'package:wolf_3d_gui/screens/debug_tools_screen.dart';
@@ -139,20 +140,38 @@ class GameScreen extends StatefulWidget {
class _GameScreenState extends State<GameScreen> { class _GameScreenState extends State<GameScreen> {
late final WolfEngine _engine; late final WolfEngine _engine;
final FlutterRendererSettingsPersistence _persistence =
FlutterRendererSettingsPersistence();
/// Current active renderer implementation. /// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
RendererMode _rendererMode = RendererMode.hardware; RendererMode _rendererMode = RendererMode.hardware;
/// Active ASCII glyph theme used by [RendererMode.ascii].
AsciiTheme _asciiTheme = AsciiThemes.blocks;
/// Whether CRT post-processing is enabled in [RendererMode.hardware].
bool _glslEffectsEnabled = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
const Set<WolfRendererMode> supportedModes = <WolfRendererMode>{
WolfRendererMode.hardware,
WolfRendererMode.software,
WolfRendererMode.ascii,
};
_engine = widget.wolf3d.launchEngine( _engine = widget.wolf3d.launchEngine(
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: supportedModes,
supportsAsciiThemes: true,
supportsHardwareEffects: true,
supportsFpsCounter: true,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.hardware,
),
onRendererSettingsChanged: (settings) {
unawaited(_persistence.save(settings));
if (mounted) {
setState(() {
_syncRendererModeFrom(settings);
});
}
},
onGameWon: () { onGameWon: () {
_engine.difficulty = null; _engine.difficulty = null;
widget.wolf3d.clearActiveDifficulty(); widget.wolf3d.clearActiveDifficulty();
@@ -162,6 +181,30 @@ class _GameScreenState extends State<GameScreen> {
SystemNavigator.pop(); SystemNavigator.pop();
}, },
); );
_syncRendererModeFrom(_engine.rendererSettings);
_loadPersistedSettings();
}
Future<void> _loadPersistedSettings() async {
final WolfRendererSettings? saved = await _persistence.load();
if (saved != null && mounted) {
_engine.updateRendererSettings(saved);
}
}
void _syncRendererModeFrom(WolfRendererSettings settings) {
switch (settings.mode) {
case WolfRendererMode.hardware:
_rendererMode = RendererMode.hardware;
break;
case WolfRendererMode.software:
_rendererMode = RendererMode.software;
break;
case WolfRendererMode.ascii:
case WolfRendererMode.sixel:
_rendererMode = RendererMode.ascii;
break;
}
} }
@override @override
@@ -222,17 +265,6 @@ class _GameScreenState extends State<GameScreen> {
child: CircularProgressIndicator(color: Colors.teal), child: CircularProgressIndicator(color: Colors.teal),
), ),
), ),
Positioned(
top: 16,
right: 16,
child: Text(
'<${widget.wolf3d.input.rendererToggleKeyLabel}> ${_rendererMode.name}${_activeModeOverlayHint()} <${widget.wolf3d.input.fpsToggleKeyLabel}> FPS ${_engine.showFpsCounter ? 'On' : 'Off'}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
),
),
),
], ],
), ),
); );
@@ -245,6 +277,7 @@ class _GameScreenState extends State<GameScreen> {
Widget _buildRenderer() { Widget _buildRenderer() {
// Keep all renderers behind the same engine so mode switching does not // Keep all renderers behind the same engine so mode switching does not
// reset level state or audio playback. // reset level state or audio playback.
final WolfRendererSettings settings = _engine.rendererSettings;
switch (_rendererMode) { switch (_rendererMode) {
case RendererMode.software: case RendererMode.software:
return WolfFlutterRenderer( return WolfFlutterRenderer(
@@ -252,15 +285,19 @@ class _GameScreenState extends State<GameScreen> {
onKeyEvent: _handleRendererKeyEvent, onKeyEvent: _handleRendererKeyEvent,
); );
case RendererMode.ascii: case RendererMode.ascii:
final AsciiTheme theme =
settings.asciiThemeId == WolfRendererSettings.asciiThemeQuadrant
? AsciiThemes.quadrant
: AsciiThemes.blocks;
return WolfAsciiRenderer( return WolfAsciiRenderer(
engine: _engine, engine: _engine,
theme: _asciiTheme, theme: theme,
onKeyEvent: _handleRendererKeyEvent, onKeyEvent: _handleRendererKeyEvent,
); );
case RendererMode.hardware: case RendererMode.hardware:
return WolfGlslRenderer( return WolfGlslRenderer(
engine: _engine, engine: _engine,
effectsEnabled: _glslEffectsEnabled, effectsEnabled: settings.hardwareEffectsEnabled,
onKeyEvent: _handleRendererKeyEvent, onKeyEvent: _handleRendererKeyEvent,
onUnavailable: _onGlslUnavailable, onUnavailable: _onGlslUnavailable,
); );
@@ -279,67 +316,31 @@ class _GameScreenState extends State<GameScreen> {
} }
if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) { if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) {
setState(_cycleRendererMode); _engine.cycleRendererMode();
return; return;
} }
if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) { if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) {
setState(_toggleFpsCounter); setState(() => _engine.toggleFpsCounter());
return; return;
} }
if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) { if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) {
if (_rendererMode == RendererMode.ascii) { if (_rendererMode == RendererMode.ascii) {
setState(_cycleAsciiTheme); _engine.cycleAsciiTheme();
} else if (_rendererMode == RendererMode.hardware) { } else if (_rendererMode == RendererMode.hardware) {
setState(_toggleGlslEffects); _engine.toggleHardwareEffects();
} }
} }
} }
String _activeModeOverlayHint() {
if (_rendererMode == RendererMode.ascii) {
return ' <${widget.wolf3d.input.asciiThemeCycleKeyLabel}> ${_asciiTheme.name}';
}
if (_rendererMode == RendererMode.hardware) {
return ' <${widget.wolf3d.input.asciiThemeCycleKeyLabel}> Effects ${_glslEffectsEnabled ? 'on' : 'off'}';
}
return '';
}
void _cycleRendererMode() {
switch (_rendererMode) {
case RendererMode.hardware:
_rendererMode = RendererMode.software;
break;
case RendererMode.software:
_rendererMode = RendererMode.ascii;
break;
case RendererMode.ascii:
_rendererMode = RendererMode.hardware;
break;
}
}
void _onGlslUnavailable() { void _onGlslUnavailable() {
if (!mounted || _rendererMode != RendererMode.hardware) { if (!mounted || _rendererMode != RendererMode.hardware) {
return; return;
} }
setState(() { _engine.updateRendererSettings(
_rendererMode = RendererMode.software; _engine.rendererSettings.copyWith(mode: WolfRendererMode.software),
}); );
}
void _toggleFpsCounter() {
_engine.showFpsCounter = !_engine.showFpsCounter;
}
void _cycleAsciiTheme() {
_asciiTheme = AsciiThemes.nextOf(_asciiTheme);
}
void _toggleGlslEffects() {
_glslEffectsEnabled = !_glslEffectsEnabled;
} }
void _openDebugTools() { void _openDebugTools() {

View File

@@ -0,0 +1,123 @@
/// Shared renderer settings and capability metadata.
library;
/// Engine-visible renderer modes used by host adapters and menu flow.
enum WolfRendererMode {
ascii('ASCII'),
sixel('SIXEL'),
software('SOFTWARE'),
hardware('HARDWARE')
;
const WolfRendererMode(this.label);
final String label;
}
/// Renderer-specific toggles shown by the Change View customization submenu.
enum WolfRendererOptionId {
asciiTheme,
hardwareEffects,
fpsCounter,
}
/// Host-reported renderer capability set used to filter visible menu options.
class WolfRendererCapabilities {
const WolfRendererCapabilities({
required this.supportedModes,
this.supportsAsciiThemes = false,
this.supportsHardwareEffects = false,
this.supportsFpsCounter = false,
});
final Set<WolfRendererMode> supportedModes;
final bool supportsAsciiThemes;
final bool supportsHardwareEffects;
final bool supportsFpsCounter;
bool supportsMode(WolfRendererMode mode) => supportedModes.contains(mode);
}
/// Engine-owned renderer settings that both menu flow and host shortcuts mutate.
class WolfRendererSettings {
const WolfRendererSettings({
this.mode = WolfRendererMode.software,
this.asciiThemeId = asciiThemeBlocks,
this.hardwareEffectsEnabled = false,
this.fpsCounterEnabled = false,
});
static const String asciiThemeBlocks = 'blocks';
static const String asciiThemeQuadrant = 'quadrant';
static const List<String> asciiThemeIds = <String>[
asciiThemeBlocks,
asciiThemeQuadrant,
];
final WolfRendererMode mode;
final String asciiThemeId;
final bool hardwareEffectsEnabled;
final bool fpsCounterEnabled;
WolfRendererSettings copyWith({
WolfRendererMode? mode,
String? asciiThemeId,
bool? hardwareEffectsEnabled,
bool? fpsCounterEnabled,
}) {
return WolfRendererSettings(
mode: mode ?? this.mode,
asciiThemeId: asciiThemeId ?? this.asciiThemeId,
hardwareEffectsEnabled:
hardwareEffectsEnabled ?? this.hardwareEffectsEnabled,
fpsCounterEnabled: fpsCounterEnabled ?? this.fpsCounterEnabled,
);
}
WolfRendererSettings cycleAsciiTheme() {
final int current = asciiThemeIds.indexOf(asciiThemeId);
final int next = current == -1 ? 0 : (current + 1) % asciiThemeIds.length;
return copyWith(asciiThemeId: asciiThemeIds[next]);
}
static String asciiThemeLabel(String id) {
switch (id) {
case asciiThemeQuadrant:
return 'CHARSET: QUADRANT';
case asciiThemeBlocks:
default:
return 'CHARSET: BLOCKS';
}
}
Map<String, Object> toJson() {
return <String, Object>{
'mode': mode.name,
'asciiThemeId': asciiThemeId,
'hardwareEffectsEnabled': hardwareEffectsEnabled,
'fpsCounterEnabled': fpsCounterEnabled,
};
}
static WolfRendererSettings fromJson(Map<String, Object?> json) {
final Object? modeRaw = json['mode'];
final WolfRendererMode mode = WolfRendererMode.values.firstWhere(
(candidate) => candidate.name == modeRaw,
orElse: () => WolfRendererMode.software,
);
final Object? themeRaw = json['asciiThemeId'];
final String asciiThemeId =
themeRaw is String && asciiThemeIds.contains(themeRaw)
? themeRaw
: asciiThemeBlocks;
return WolfRendererSettings(
mode: mode,
asciiThemeId: asciiThemeId,
hardwareEffectsEnabled: json['hardwareEffectsEnabled'] == true,
fpsCounterEnabled: json['fpsCounterEnabled'] == true,
);
}
}

View File

@@ -0,0 +1,48 @@
/// Platform-agnostic persistence contract for renderer settings.
library;
import 'dart:convert';
import 'package:wolf_3d_dart/src/engine/rendering/renderer_settings.dart';
/// Abstract contract that host adapters must implement to persist settings.
abstract class RendererSettingsPersistence {
/// Loads previously saved settings, returning `null` on first run or error.
Future<WolfRendererSettings?> load();
/// Saves [settings] asynchronously; failures should be silently absorbed.
Future<void> save(WolfRendererSettings settings);
}
/// Mixin that handles JSON encode/decode so adapters only need to implement
/// raw string read/write.
mixin JsonRendererSettingsPersistence on RendererSettingsPersistence {
Future<String?> readRaw();
Future<void> writeRaw(String json);
@override
Future<WolfRendererSettings?> load() async {
try {
final String? raw = await readRaw();
if (raw == null || raw.isEmpty) {
return null;
}
final Object? decoded = jsonDecode(raw);
if (decoded is! Map<String, Object?>) {
return null;
}
return WolfRendererSettings.fromJson(decoded);
} catch (_) {
return null;
}
}
@override
Future<void> save(WolfRendererSettings settings) async {
try {
await writeRaw(jsonEncode(settings.toJson()));
} catch (_) {
// Best-effort — never break the game loop on persistence failure.
}
}
}

View File

@@ -27,8 +27,20 @@ class WolfEngine {
this.onQuit, this.onQuit,
this.onGameSelected, this.onGameSelected,
this.onEpisodeSelected, this.onEpisodeSelected,
this.onRendererSettingsChanged,
WolfRendererCapabilities? rendererCapabilities,
WolfRendererSettings? rendererSettings,
EngineAudio? engineAudio, EngineAudio? engineAudio,
}) : assert( }) : rendererCapabilities =
rendererCapabilities ??
const WolfRendererCapabilities(
supportedModes: <WolfRendererMode>{
WolfRendererMode.software,
},
supportsFpsCounter: true,
),
rendererSettings = rendererSettings ?? const WolfRendererSettings(),
assert(
data != null || (availableGames != null && availableGames.isNotEmpty), data != null || (availableGames != null && availableGames.isNotEmpty),
'Provide either data or a non-empty availableGames list.', 'Provide either data or a non-empty availableGames list.',
), ),
@@ -44,6 +56,8 @@ class WolfEngine {
throw StateError('WolfEngine requires at least one game data set.'); throw StateError('WolfEngine requires at least one game data set.');
} }
menuManager.menuBackgroundRgb = menuBackgroundRgb; menuManager.menuBackgroundRgb = menuBackgroundRgb;
_normalizeRendererSettings();
_syncRendererMenuModel();
} }
/// Total milliseconds elapsed since the engine was initialized. /// Total milliseconds elapsed since the engine was initialized.
@@ -106,6 +120,15 @@ class WolfEngine {
/// Callback triggered when episode selection changes; `null` means cleared. /// Callback triggered when episode selection changes; `null` means cleared.
final void Function(int? episodeIndex)? onEpisodeSelected; final void Function(int? episodeIndex)? onEpisodeSelected;
/// Callback triggered whenever renderer settings are updated by the engine.
final void Function(WolfRendererSettings settings)? onRendererSettingsChanged;
/// Host-reported mode/effect capabilities that drive menu visibility.
WolfRendererCapabilities rendererCapabilities;
/// Engine-owned renderer settings shared between menus and host shortcuts.
WolfRendererSettings rendererSettings;
// --- State Managers --- // --- State Managers ---
/// Manages the state and animation of doors throughout the level. /// Manages the state and animation of doors throughout the level.
@@ -219,6 +242,169 @@ class WolfEngine {
log("[DEBUG] FrameBuffer resized to ${width}x$height"); log("[DEBUG] FrameBuffer resized to ${width}x$height");
} }
/// Replaces host capability metadata and refreshes menu visibility.
void setRendererCapabilities(WolfRendererCapabilities capabilities) {
rendererCapabilities = capabilities;
_normalizeRendererSettings();
_syncRendererMenuModel();
}
/// Applies [settings], notifies host, and refreshes renderer menu visibility.
void updateRendererSettings(WolfRendererSettings settings) {
rendererSettings = settings;
_normalizeRendererSettings();
showFpsCounter = rendererSettings.fpsCounterEnabled;
_syncRendererMenuModel();
onRendererSettingsChanged?.call(rendererSettings);
}
/// Cycles to the next available renderer mode supported by the host.
void cycleRendererMode() {
final List<WolfRendererMode> available = rendererCapabilities.supportedModes
.toList(growable: false);
if (available.isEmpty) {
return;
}
final int current = available.indexOf(rendererSettings.mode);
final int next = current == -1 ? 0 : (current + 1) % available.length;
updateRendererSettings(rendererSettings.copyWith(mode: available[next]));
}
/// Cycles the ASCII character set when the host supports ASCII rendering.
void cycleAsciiTheme() {
if (!rendererCapabilities.supportsAsciiThemes) {
return;
}
updateRendererSettings(rendererSettings.cycleAsciiTheme());
}
/// Toggles CRT/post-processing effects for hardware renderer hosts.
void toggleHardwareEffects() {
if (!rendererCapabilities.supportsHardwareEffects) {
return;
}
updateRendererSettings(
rendererSettings.copyWith(
hardwareEffectsEnabled: !rendererSettings.hardwareEffectsEnabled,
),
);
}
/// Toggles the shared FPS counter setting.
void toggleFpsCounter() {
if (!rendererCapabilities.supportsFpsCounter) {
return;
}
updateRendererSettings(
rendererSettings.copyWith(
fpsCounterEnabled: !rendererSettings.fpsCounterEnabled,
),
);
}
void _normalizeRendererSettings() {
final Set<WolfRendererMode> supported = rendererCapabilities.supportedModes;
if (supported.isEmpty) {
rendererSettings = rendererSettings.copyWith(
mode: WolfRendererMode.software,
);
} else if (!supported.contains(rendererSettings.mode)) {
rendererSettings = rendererSettings.copyWith(mode: supported.first);
}
if (!WolfRendererSettings.asciiThemeIds.contains(
rendererSettings.asciiThemeId,
)) {
rendererSettings = rendererSettings.copyWith(
asciiThemeId: WolfRendererSettings.asciiThemeBlocks,
);
}
if (!rendererCapabilities.supportsHardwareEffects &&
rendererSettings.hardwareEffectsEnabled) {
rendererSettings = rendererSettings.copyWith(
hardwareEffectsEnabled: false,
);
}
if (!rendererCapabilities.supportsFpsCounter &&
rendererSettings.fpsCounterEnabled) {
rendererSettings = rendererSettings.copyWith(fpsCounterEnabled: false);
}
showFpsCounter = rendererSettings.fpsCounterEnabled;
}
void _syncRendererMenuModel() {
final WolfRendererMode activeMode = rendererSettings.mode;
final List<WolfMenuRendererOptionEntry> options =
<WolfMenuRendererOptionEntry>[];
if (activeMode == WolfRendererMode.ascii &&
rendererCapabilities.supportsAsciiThemes) {
options.add(
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.asciiTheme,
label: WolfRendererSettings.asciiThemeLabel(
rendererSettings.asciiThemeId,
),
isChecked: true,
),
);
}
if (activeMode == WolfRendererMode.hardware &&
rendererCapabilities.supportsHardwareEffects) {
options.add(
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.hardwareEffects,
label: 'EFFECTS',
isChecked: rendererSettings.hardwareEffectsEnabled,
),
);
}
if (rendererCapabilities.supportsFpsCounter) {
options.add(
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.fpsCounter,
label: 'FPS COUNTER',
isChecked: rendererSettings.fpsCounterEnabled,
),
);
}
final List<WolfMenuRendererEntry> rendererEntries =
<WolfMenuRendererEntry>[];
void addRenderer(WolfRendererMode mode) {
if (!rendererCapabilities.supportsMode(mode)) {
return;
}
rendererEntries.add(
WolfMenuRendererEntry(
mode: mode,
label: mode.label,
hasOptions: false,
isEnabled: true,
isChecked: rendererSettings.mode == mode,
),
);
}
addRenderer(WolfRendererMode.hardware);
addRenderer(WolfRendererMode.software);
addRenderer(WolfRendererMode.sixel);
addRenderer(WolfRendererMode.ascii);
menuManager.setChangeViewEntries(rendererEntries);
menuManager.setRendererOptionEntries(
title: 'CUSTOMIZE ${activeMode.label}',
entries: options,
);
}
/// The primary heartbeat of the engine. /// The primary heartbeat of the engine.
/// ///
/// Updates all world subsystems based on the [elapsed] time. /// Updates all world subsystems based on the [elapsed] time.
@@ -312,6 +498,12 @@ class WolfEngine {
case WolfMenuScreen.difficultySelect: case WolfMenuScreen.difficultySelect:
_tickDifficultySelectionMenu(input); _tickDifficultySelectionMenu(input);
break; break;
case WolfMenuScreen.changeView:
_tickChangeViewMenu(input);
break;
case WolfMenuScreen.rendererOptions:
_tickRendererOptionsMenu(input);
break;
} }
} }
@@ -345,11 +537,14 @@ class WolfEngine {
case WolfMenuMainAction.backToDemo: case WolfMenuMainAction.backToDemo:
_exitTopLevelMenu(); _exitTopLevelMenu();
break; break;
case WolfMenuMainAction.changeView:
_syncRendererMenuModel();
menuManager.showChangeViewMenu();
break;
case WolfMenuMainAction.sound: case WolfMenuMainAction.sound:
case WolfMenuMainAction.control: case WolfMenuMainAction.control:
case WolfMenuMainAction.loadGame: case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame: case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.changeView:
case WolfMenuMainAction.readThis: case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores: case WolfMenuMainAction.viewScores:
case null: case null:
@@ -413,6 +608,57 @@ class WolfEngine {
} }
} }
void _tickChangeViewMenu(EngineInput input) {
final menuResult = menuManager.updateChangeViewMenu(input);
if (menuResult.goBack) {
menuManager.showMainMenu(hasResumableGame: _hasActiveSession);
return;
}
if (menuResult.selectedMode != null) {
updateRendererSettings(
rendererSettings.copyWith(mode: menuResult.selectedMode),
);
}
switch (menuResult.selectedOption) {
case WolfRendererOptionId.asciiTheme:
cycleAsciiTheme();
break;
case WolfRendererOptionId.hardwareEffects:
toggleHardwareEffects();
break;
case WolfRendererOptionId.fpsCounter:
toggleFpsCounter();
break;
case null:
break;
}
}
void _tickRendererOptionsMenu(EngineInput input) {
final menuResult = menuManager.updateRendererOptionsMenu(input);
if (menuResult.goBack) {
_syncRendererMenuModel();
menuManager.showChangeViewMenu();
return;
}
switch (menuResult.selectedOption) {
case WolfRendererOptionId.asciiTheme:
cycleAsciiTheme();
break;
case WolfRendererOptionId.hardwareEffects:
toggleHardwareEffects();
break;
case WolfRendererOptionId.fpsCounter:
toggleFpsCounter();
break;
case null:
break;
}
}
void _beginNewGameMenuFlow() { void _beginNewGameMenuFlow() {
onEpisodeSelected?.call(null); onEpisodeSelected?.call(null);
menuManager.clearEpisodeSelection(); menuManager.clearEpisodeSelection();

View File

@@ -7,6 +7,8 @@ enum WolfMenuScreen {
gameSelect, gameSelect,
episodeSelect, episodeSelect,
difficultySelect, difficultySelect,
changeView,
rendererOptions,
} }
enum WolfIntroSlide { retailWarning, pg13, title } enum WolfIntroSlide { retailWarning, pg13, title }
@@ -40,6 +42,36 @@ class WolfMenuMainEntry {
final bool isEnabled; final bool isEnabled;
} }
class WolfMenuRendererEntry {
const WolfMenuRendererEntry({
required this.mode,
required this.label,
required this.hasOptions,
this.isEnabled = true,
this.isChecked = false,
});
final WolfRendererMode mode;
final String label;
final bool hasOptions;
final bool isEnabled;
final bool isChecked;
}
class WolfMenuRendererOptionEntry {
const WolfMenuRendererOptionEntry({
required this.id,
required this.label,
this.isEnabled = true,
this.isChecked = false,
});
final WolfRendererOptionId id;
final String label;
final bool isEnabled;
final bool isChecked;
}
bool _isWiredMainMenuAction(WolfMenuMainAction action) { bool _isWiredMainMenuAction(WolfMenuMainAction action) {
switch (action) { switch (action) {
case WolfMenuMainAction.newGame: case WolfMenuMainAction.newGame:
@@ -48,11 +80,12 @@ bool _isWiredMainMenuAction(WolfMenuMainAction action) {
case WolfMenuMainAction.backToDemo: case WolfMenuMainAction.backToDemo:
case WolfMenuMainAction.quit: case WolfMenuMainAction.quit:
return true; return true;
case WolfMenuMainAction.changeView:
return true;
case WolfMenuMainAction.sound: case WolfMenuMainAction.sound:
case WolfMenuMainAction.control: case WolfMenuMainAction.control:
case WolfMenuMainAction.loadGame: case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame: case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.changeView:
case WolfMenuMainAction.readThis: case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores: case WolfMenuMainAction.viewScores:
return false; return false;
@@ -85,6 +118,13 @@ class MenuManager {
int _selectedGameIndex = 0; int _selectedGameIndex = 0;
int _selectedEpisodeIndex = 0; int _selectedEpisodeIndex = 0;
int _selectedDifficultyIndex = 0; int _selectedDifficultyIndex = 0;
int _selectedChangeViewIndex = 0;
int _selectedRendererOptionIndex = 0;
String _rendererOptionsTitle = 'CUSTOMIZE';
List<WolfMenuRendererEntry> _changeViewEntries =
const <WolfMenuRendererEntry>[];
List<WolfMenuRendererOptionEntry> _rendererOptionEntries =
const <WolfMenuRendererOptionEntry>[];
bool _showResumeOption = false; bool _showResumeOption = false;
int _gameCount = 1; int _gameCount = 1;
@@ -161,6 +201,18 @@ class MenuManager {
int get selectedEpisodeIndex => _selectedEpisodeIndex; int get selectedEpisodeIndex => _selectedEpisodeIndex;
int get selectedChangeViewIndex => _selectedChangeViewIndex;
int get selectedRendererOptionIndex => _selectedRendererOptionIndex;
String get rendererOptionsTitle => _rendererOptionsTitle;
List<WolfMenuRendererEntry> get changeViewEntries =>
List<WolfMenuRendererEntry>.unmodifiable(_changeViewEntries);
List<WolfMenuRendererOptionEntry> get rendererOptionEntries =>
List<WolfMenuRendererOptionEntry>.unmodifiable(_rendererOptionEntries);
List<WolfMenuMainEntry> get mainMenuEntries { List<WolfMenuMainEntry> get mainMenuEntries {
return List<WolfMenuMainEntry>.unmodifiable( return List<WolfMenuMainEntry>.unmodifiable(
<WolfMenuMainEntry>[ <WolfMenuMainEntry>[
@@ -290,6 +342,133 @@ class MenuManager {
_resetEdgeState(); _resetEdgeState();
} }
int get _changeViewItemCount =>
_changeViewEntries.length + _rendererOptionEntries.length;
void setChangeViewEntries(List<WolfMenuRendererEntry> entries) {
final WolfRendererMode? previouslySelectedMode =
(_selectedChangeViewIndex >= 0 &&
_selectedChangeViewIndex < _changeViewEntries.length)
? _changeViewEntries[_selectedChangeViewIndex].mode
: null;
_changeViewEntries = List<WolfMenuRendererEntry>.unmodifiable(entries);
final int itemCount = _changeViewItemCount;
if (itemCount == 0) {
_selectedChangeViewIndex = 0;
return;
}
if (previouslySelectedMode != null) {
final int modeIndex = _changeViewEntries.indexWhere(
(entry) => entry.mode == previouslySelectedMode,
);
if (modeIndex >= 0 && _isSelectableChangeViewIndex(modeIndex)) {
_selectedChangeViewIndex = modeIndex;
return;
}
}
_selectedChangeViewIndex = _findSelectableIndex(
_clampIndex(_selectedChangeViewIndex, itemCount),
itemCount,
_isSelectableChangeViewIndex,
);
}
void setRendererOptionEntries({
required String title,
required List<WolfMenuRendererOptionEntry> entries,
}) {
final bool wasSelectingOption =
_selectedChangeViewIndex >= _changeViewEntries.length;
final WolfRendererOptionId? previousOption =
(_selectedRendererOptionIndex >= 0 &&
_selectedRendererOptionIndex < _rendererOptionEntries.length)
? _rendererOptionEntries[_selectedRendererOptionIndex].id
: null;
_rendererOptionsTitle = title;
_rendererOptionEntries = List<WolfMenuRendererOptionEntry>.unmodifiable(
entries,
);
final int totalCount = _changeViewItemCount;
if (_rendererOptionEntries.isEmpty || totalCount == 0) {
_selectedRendererOptionIndex = 0;
if (_changeViewEntries.isNotEmpty) {
_selectedChangeViewIndex = _findSelectableIndex(
0,
_changeViewEntries.length,
_isSelectableChangeViewIndex,
);
}
return;
}
if (previousOption != null) {
final int previousIndex = _rendererOptionEntries.indexWhere(
(entry) => entry.id == previousOption,
);
if (previousIndex >= 0 &&
_isSelectableRendererOptionIndex(previousIndex)) {
_selectedRendererOptionIndex = previousIndex;
if (wasSelectingOption) {
_selectedChangeViewIndex = _changeViewEntries.length + previousIndex;
}
return;
}
}
_selectedRendererOptionIndex = _findSelectableIndex(
_clampIndex(_selectedRendererOptionIndex, _rendererOptionEntries.length),
_rendererOptionEntries.length,
_isSelectableRendererOptionIndex,
);
if (wasSelectingOption) {
_selectedChangeViewIndex =
_changeViewEntries.length + _selectedRendererOptionIndex;
} else {
_selectedChangeViewIndex = _findSelectableIndex(
_clampIndex(_selectedChangeViewIndex, totalCount),
totalCount,
_isSelectableChangeViewIndex,
);
}
}
void showChangeViewMenu() {
_activeMenu = WolfMenuScreen.changeView;
_selectedChangeViewIndex = _changeViewItemCount == 0
? 0
: _findSelectableIndex(
0,
_changeViewItemCount,
_isSelectableChangeViewIndex,
);
_transitionTarget = null;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
_resetEdgeState();
}
void showRendererOptionsMenu() {
_activeMenu = WolfMenuScreen.rendererOptions;
_selectedRendererOptionIndex = _rendererOptionEntries.isEmpty
? 0
: _findSelectableIndex(
0,
_rendererOptionEntries.length,
_isSelectableRendererOptionIndex,
);
_transitionTarget = null;
_transitionElapsedMs = 0;
_transitionSwappedMenu = false;
_resetEdgeState();
}
/// Starts a menu transition. Input is locked until it completes. /// Starts a menu transition. Input is locked until it completes.
/// ///
/// Hosts can reuse this fade timing for future pre-menu splash/image /// Hosts can reuse this fade timing for future pre-menu splash/image
@@ -453,6 +632,88 @@ class MenuManager {
); );
} }
({
WolfRendererMode? selectedMode,
WolfRendererOptionId? selectedOption,
bool goBack,
})
updateChangeViewMenu(EngineInput input) {
if (isTransitioning) {
_consumeEdgeState(input);
return (
selectedMode: null,
selectedOption: null,
goBack: false,
);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: _selectedChangeViewIndex,
itemCount: _changeViewItemCount,
isSelectableIndex: _isSelectableChangeViewIndex,
);
_selectedChangeViewIndex = action.index;
if (!action.confirmed) {
return (
selectedMode: null,
selectedOption: null,
goBack: action.goBack,
);
}
if (_selectedChangeViewIndex < _changeViewEntries.length) {
final WolfMenuRendererEntry entry =
_changeViewEntries[_selectedChangeViewIndex];
return (
selectedMode: entry.mode,
selectedOption: null,
goBack: action.goBack,
);
}
final int optionIndex =
_selectedChangeViewIndex - _changeViewEntries.length;
if (optionIndex < 0 || optionIndex >= _rendererOptionEntries.length) {
return (
selectedMode: null,
selectedOption: null,
goBack: action.goBack,
);
}
_selectedRendererOptionIndex = optionIndex;
return (
selectedMode: null,
selectedOption: _rendererOptionEntries[optionIndex].id,
goBack: action.goBack,
);
}
({WolfRendererOptionId? selectedOption, bool goBack})
updateRendererOptionsMenu(EngineInput input) {
if (isTransitioning) {
_consumeEdgeState(input);
return (selectedOption: null, goBack: false);
}
final _MenuAction action = _updateLinearSelection(
input,
currentIndex: _selectedRendererOptionIndex,
itemCount: _rendererOptionEntries.length,
isSelectableIndex: _isSelectableRendererOptionIndex,
);
_selectedRendererOptionIndex = action.index;
return (
selectedOption: action.confirmed && _rendererOptionEntries.isNotEmpty
? _rendererOptionEntries[_selectedRendererOptionIndex].id
: null,
goBack: action.goBack,
);
}
/// 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,
@@ -612,6 +873,27 @@ class MenuManager {
return mainMenuEntries[index].isEnabled; return mainMenuEntries[index].isEnabled;
} }
bool _isSelectableChangeViewIndex(int index) {
if (index < 0) {
return false;
}
if (index < _changeViewEntries.length) {
return _changeViewEntries[index].isEnabled;
}
final int optionIndex = index - _changeViewEntries.length;
if (optionIndex >= 0 && optionIndex < _rendererOptionEntries.length) {
return _rendererOptionEntries[optionIndex].isEnabled;
}
return false;
}
bool _isSelectableRendererOptionIndex(int index) {
if (index < 0 || index >= _rendererOptionEntries.length) {
return false;
}
return _rendererOptionEntries[index].isEnabled;
}
WolfMenuMainEntry _mainMenuEntry({ WolfMenuMainEntry _mainMenuEntry({
required WolfMenuMainAction action, required WolfMenuMainAction action,
required String label, required String label,

View File

@@ -31,6 +31,7 @@ class RetailMenuPicModule extends MenuPicModule {
MenuPicKey.footer: 15, // C_MOUSELBACKPIC (footer art) MenuPicKey.footer: 15, // C_MOUSELBACKPIC (footer art)
MenuPicKey.heading: 3, // H_TOPWINDOWPIC MenuPicKey.heading: 3, // H_TOPWINDOWPIC
MenuPicKey.optionsLabel: 7, // C_OPTIONSPIC MenuPicKey.optionsLabel: 7, // C_OPTIONSPIC
MenuPicKey.customizeLabel: 24, // C_CUSTOMIZEPIC
// --- Cursor / markers --- // --- Cursor / markers ---
MenuPicKey.cursorActive: 8, // C_CURSOR1PIC MenuPicKey.cursorActive: 8, // C_CURSOR1PIC
MenuPicKey.cursorInactive: 9, // C_CURSOR2PIC MenuPicKey.cursorInactive: 9, // C_CURSOR2PIC

View File

@@ -31,6 +31,7 @@ class SharewareMenuPicModule extends MenuPicModule {
MenuPicKey.footer: 15, MenuPicKey.footer: 15,
MenuPicKey.heading: 3, MenuPicKey.heading: 3,
MenuPicKey.optionsLabel: 7, MenuPicKey.optionsLabel: 7,
MenuPicKey.customizeLabel: 36,
MenuPicKey.cursorActive: 8, MenuPicKey.cursorActive: 8,
MenuPicKey.cursorInactive: 9, MenuPicKey.cursorInactive: 9,
MenuPicKey.markerSelected: 11, MenuPicKey.markerSelected: 11,
@@ -57,6 +58,7 @@ class SharewareMenuPicModule extends MenuPicModule {
MenuPicKey.footer: 27, MenuPicKey.footer: 27,
MenuPicKey.heading: 14, MenuPicKey.heading: 14,
MenuPicKey.optionsLabel: 19, MenuPicKey.optionsLabel: 19,
MenuPicKey.customizeLabel: 36,
MenuPicKey.cursorActive: 20, MenuPicKey.cursorActive: 20,
MenuPicKey.cursorInactive: 21, MenuPicKey.cursorInactive: 21,
MenuPicKey.markerSelected: 23, MenuPicKey.markerSelected: 23,

View File

@@ -10,6 +10,7 @@ enum MenuPicKey {
footer('footer'), footer('footer'),
heading('heading'), heading('heading'),
optionsLabel('optionsLabel'), optionsLabel('optionsLabel'),
customizeLabel('customizeLabel'),
// --- Cursor / selection markers --- // --- Cursor / selection markers ---
cursorActive('cursorActive'), cursorActive('cursorActive'),

View File

@@ -642,6 +642,163 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
return; return;
} }
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
_drawCustomizeMenuHeader(art, headingColor, bgColor);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 66;
const int rowStep = 18;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.changeViewEntries;
final optionEntries = engine.menuManager.rendererOptionEntries;
final int modeCount = entries.length;
final int optionCount = optionEntries.length;
const int modesPanelY = 52;
final int modesContentHeight = modeCount <= 0
? 0
: ((modeCount - 1) * rowStep) + 12;
final int modesPanelHeight = math.max(56, modesContentHeight + 14);
final int sectionHeaderY = modesPanelY + modesPanelHeight + 6;
final int optionsPanelY = sectionHeaderY + 14;
final int optionsContentHeight = optionCount <= 0
? 0
: ((optionCount - 1) * 15) + 12;
final int optionsPanelHeight = math.max(30, optionsContentHeight + 10);
_fillRect320(46, modesPanelY, 228, modesPanelHeight, panelColor);
_fillRect320(46, optionsPanelY, 228, optionsPanelHeight, panelColor);
for (int i = 0; i < entries.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == engine.menuManager.selectedChangeViewIndex;
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, cursorX, y - 2);
}
final entry = entries[i];
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImageAscii(marker, markerX, y);
}
final int textColor = entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor;
_drawMenuLabelAdaptive(
typography: menuTypography,
text: entry.label,
textX320: textX,
y200: y + 1,
color: textColor,
panelX320: 46,
panelW320: 228,
panelColor: panelColor,
);
}
_drawMenuSectionHeader(
text: engine.menuManager.rendererOptionsTitle,
y200: sectionHeaderY,
);
const int optionsRowStep = 15;
final int optionsRowsHeight = optionCount <= 0
? 0
: ((optionCount - 1) * optionsRowStep) + 10;
final int optionsRowStart =
optionsPanelY +
((optionsPanelHeight - optionsRowsHeight) ~/ 2).clamp(0, 200);
for (int i = 0; i < optionEntries.length; i++) {
final int optionIndex = modeCount + i;
final bool isSelected =
optionIndex == engine.menuManager.selectedChangeViewIndex;
final int y = optionsRowStart + (i * optionsRowStep);
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, cursorX, y - 2);
}
final entry = optionEntries[i];
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImageAscii(marker, markerX, y);
}
final int textColor = entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor;
_drawMenuLabelAdaptive(
typography: menuTypography,
text: entry.label,
textX320: textX,
y200: y + 1,
color: textColor,
panelX320: 46,
panelW320: 228,
panelColor: panelColor,
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
_drawCustomizeMenuHeader(art, headingColor, bgColor);
_fillRect320(56, 52, 208, 120, panelColor);
_drawMenuTextCentered(
engine.menuManager.rendererOptionsTitle,
46,
headingColor,
scale: 1,
);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 68;
const int rowStep = 20;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.rendererOptionEntries;
for (int i = 0; i < entries.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected =
i == engine.menuManager.selectedRendererOptionIndex;
final entry = entries[i];
if (isSelected && cursor != null) {
_blitVgaImageAscii(cursor, cursorX, y - 2);
}
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImageAscii(marker, markerX, y);
}
final int textColor = entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor;
_drawMenuLabelAdaptive(
typography: menuTypography,
text: entry.label,
textX320: textX,
y200: y + 1,
color: textColor,
panelX320: 56,
panelW320: 208,
panelColor: panelColor,
);
}
_drawCenteredMenuFooter();
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int selectedDifficultyIndex = final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex; engine.menuManager.selectedDifficultyIndex;
@@ -997,7 +1154,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
int scale = 1, int scale = 1,
}) { }) {
if (y200 == _headerHeadingY) { if (y200 == _headerHeadingY) {
y200 = _centerHeaderTitleInBlackBand(defaultY: y200, scale: scale); y200 = _centerHeaderTitleInBlackBand(
defaultY: y200,
scale: scale,
);
} }
final int textWidth = WolfMenuFont.measureTextWidth(text, scale); final int textWidth = WolfMenuFont.measureTextWidth(text, scale);
final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319); final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319);
@@ -1156,6 +1316,103 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
} }
} }
void _drawCustomizeMenuHeader(
WolfClassicMenuArt art,
int headingColor,
int backgroundColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: ColorPalette.vga32Bit[0],
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_blitVgaImageAscii(heading, headingX, 0);
return;
}
_drawMenuTextCentered('CUSTOMIZE', _headerHeadingY, headingColor, scale: 2);
}
void _drawMenuSectionHeader({
required String text,
required int y200,
}) {
final int textW = WolfMenuFont.measureTextWidth(text, 1);
final int boxW = (textW + 28).clamp(110, 250);
final int x = ((320 - boxW) ~/ 2).clamp(0, 319);
final int outer = _rgbToPaletteColor(0x727272);
final int inner = _rgbToPaletteColor(0x8E8E8E);
final int fill = _rgbToPaletteColor(0xC9C9C9);
final int textColor = _rgbToPaletteColor(0x5A5A5A);
_fillRect320(x, y200, boxW, 12, outer);
_fillRect320(x + 1, y200 + 1, boxW - 2, 10, inner);
_fillRect320(x + 2, y200 + 2, boxW - 4, 8, fill);
_drawMenuLabelAdaptive(
typography: _resolveMenuTypography(),
text: text,
textX320: x + 8,
y200: y200 + 2,
color: textColor,
panelX320: x + 2,
panelW320: boxW - 4,
panelColor: fill,
centerWhenCompact: true,
);
}
void _drawMenuLabelAdaptive({
required _AsciiMenuTypography typography,
required String text,
required int textX320,
required int y200,
required int color,
required int panelX320,
required int panelW320,
required int panelColor,
bool centerWhenCompact = false,
}) {
if (!typography.usesCompactRows) {
_drawMenuText(text, textX320, y200, color);
return;
}
if (centerWhenCompact) {
final int panelX = _menuX320ToColumn(panelX320);
final int panelW = math.max(
1,
((panelW320 / 320.0) * projectionWidth).toInt(),
);
final int textW = text.length;
final int centeredLeft = panelX + math.max(0, ((panelW - textW) ~/ 2));
_writeLeftClipped(
_menuY200ToRow(y200),
text,
color,
panelColor,
math.max(1, panelW - 2),
centeredLeft,
);
return;
}
_drawMinimalMenuRows(
rows: <String>[text],
selectedIndex: -1,
rowYStart200: y200,
rowStep200: 1,
textX320: textX320,
panelX320: panelX320,
panelW320: panelW320,
panelColor: panelColor,
colorForRow: (_, _) => color,
);
}
void _drawCenteredMenuFooter() { void _drawCenteredMenuFooter() {
if (_usesTerminalLayout && !_emitAnsi) { if (_usesTerminalLayout && !_emitAnsi) {
_drawFlutterGridMenuFooter(); _drawFlutterGridMenuFooter();

View File

@@ -526,6 +526,146 @@ class SixelRenderer extends CliRendererBackend<String> {
return; return;
} }
if (engine.menuManager.activeMenu == WolfMenuScreen.changeView) {
_drawCustomizeMenuHeader(art, headingIndex, bgColor);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 66;
const int rowStep = 18;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.changeViewEntries;
final optionEntries = engine.menuManager.rendererOptionEntries;
final int modeCount = entries.length;
final int optionCount = optionEntries.length;
const int modesPanelY = 52;
final int modesContentHeight = modeCount <= 0
? 0
: ((modeCount - 1) * rowStep) + 12;
final int modesPanelHeight = math.max(56, modesContentHeight + 14);
final int sectionHeaderY = modesPanelY + modesPanelHeight + 6;
final int optionsPanelY = sectionHeaderY + 14;
final int optionsContentHeight = optionCount <= 0
? 0
: ((optionCount - 1) * 15) + 12;
final int optionsPanelHeight = math.max(30, optionsContentHeight + 10);
_fillRect320(46, modesPanelY, 228, modesPanelHeight, panelColor);
_fillRect320(46, optionsPanelY, 228, optionsPanelHeight, panelColor);
for (int i = 0; i < entries.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected = i == engine.menuManager.selectedChangeViewIndex;
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final entry = entries[i];
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextIndex : unselectedTextIndex)
: disabledTextIndex,
scale: 1,
);
}
_drawMenuSectionHeader(
text: engine.menuManager.rendererOptionsTitle,
y200: sectionHeaderY,
);
const int optionsRowStep = 15;
final int optionsRowsHeight = optionCount <= 0
? 0
: ((optionCount - 1) * optionsRowStep) + 10;
final int optionsRowStart =
optionsPanelY +
((optionsPanelHeight - optionsRowsHeight) ~/ 2).clamp(0, 200);
for (int i = 0; i < optionEntries.length; i++) {
final int optionIndex = modeCount + i;
final bool isSelected =
optionIndex == engine.menuManager.selectedChangeViewIndex;
final int y = optionsRowStart + (i * optionsRowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final entry = optionEntries[i];
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextIndex : unselectedTextIndex)
: disabledTextIndex,
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
if (engine.menuManager.activeMenu == WolfMenuScreen.rendererOptions) {
_drawCustomizeMenuHeader(art, headingIndex, bgColor);
_fillRect320(56, 52, 208, 120, panelColor);
_drawMenuTextCentered(
engine.menuManager.rendererOptionsTitle,
46,
headingIndex,
scale: 1,
);
final cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
final selectedMarker = art.selectedMarker;
final unselectedMarker = art.unselectedMarker;
const int rowYStart = 68;
const int rowStep = 20;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.rendererOptionEntries;
for (int i = 0; i < entries.length; i++) {
final int y = rowYStart + (i * rowStep);
final bool isSelected =
i == engine.menuManager.selectedRendererOptionIndex;
final entry = entries[i];
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final marker = entry.isChecked ? selectedMarker : unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextIndex : unselectedTextIndex)
: disabledTextIndex,
scale: 1,
);
}
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
return;
}
final int selectedDifficultyIndex = final int selectedDifficultyIndex =
engine.menuManager.selectedDifficultyIndex; engine.menuManager.selectedDifficultyIndex;
_drawHeaderBarStack( _drawHeaderBarStack(
@@ -580,6 +720,45 @@ class SixelRenderer extends CliRendererBackend<String> {
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
} }
void _drawCustomizeMenuHeader(
WolfClassicMenuArt art,
int headingIndex,
int backgroundColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: backgroundColor,
barColor: 0,
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_blitVgaImage(heading, headingX, 0);
return;
}
_drawMenuTextCentered('CUSTOMIZE', _headerHeadingY, headingIndex, scale: 2);
}
void _drawMenuSectionHeader({
required String text,
required int y200,
}) {
final int textW = WolfMenuFont.measureTextWidth(text, 1);
final int boxW = (textW + 28).clamp(110, 250);
final int x = ((320 - boxW) ~/ 2).clamp(0, 319);
final int outer = _rgbToPaletteIndex(0x727272);
final int inner = _rgbToPaletteIndex(0x8E8E8E);
final int fill = _rgbToPaletteIndex(0xC9C9C9);
final int textColor = _rgbToPaletteIndex(0x5A5A5A);
_fillRect320(x, y200, boxW, 12, outer);
_fillRect320(x + 1, y200 + 1, boxW - 2, 10, inner);
_fillRect320(x + 2, y200 + 2, boxW - 4, 8, fill);
_drawMenuTextCentered(text, y200 + 2, textColor, scale: 1);
}
void _drawMenuFooterArt(WolfClassicMenuArt art) { void _drawMenuFooterArt(WolfClassicMenuArt art) {
final bottom = art.mappedPic(15); final bottom = art.mappedPic(15);
if (bottom == null) { if (bottom == null) {

View File

@@ -209,6 +209,28 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
unselectedTextColor, unselectedTextColor,
); );
break; break;
case WolfMenuScreen.changeView:
_drawChangeViewMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
disabledTextColor,
);
break;
case WolfMenuScreen.rendererOptions:
_drawRendererOptionsMenu(
engine,
art,
panelColor,
headingColor,
selectedTextColor,
unselectedTextColor,
disabledTextColor,
);
break;
} }
_applyMenuFade(engine.menuManager.transitionAlpha, bgColor); _applyMenuFade(engine.menuManager.transitionAlpha, bgColor);
@@ -410,6 +432,216 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
} }
} }
void _drawChangeViewMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
int disabledTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
const int modesPanelX = 46;
const int modesPanelY = 52;
const int modesPanelW = 228;
const int modesPanelH = 74;
_fillCanonicalRect(
modesPanelX,
modesPanelY,
modesPanelW,
modesPanelH,
panelColor,
);
const int optionsPanelX = 46;
const int optionsPanelY = 146;
const int optionsPanelW = 228;
const int optionsPanelH = 42;
_fillCanonicalRect(
optionsPanelX,
optionsPanelY,
optionsPanelW,
optionsPanelH,
panelColor,
);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_blitVgaImage(heading, headingX, 0);
} else {
_drawCanonicalMenuTextCentered(
'CUSTOMIZE',
_headerHeadingY,
headingColor,
scale: 2,
);
}
final VgaImage? selectedMarker = art.selectedMarker;
final VgaImage? unselectedMarker = art.unselectedMarker;
final VgaImage? cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
const int rowYStart = 66;
const int rowStep = 18;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.changeViewEntries;
final optionEntries = engine.menuManager.rendererOptionEntries;
final int modeCount = entries.length;
final int selectedIndex = engine.menuManager.selectedChangeViewIndex;
for (int i = 0; i < entries.length; i++) {
final bool isSelected = i == selectedIndex;
final int y = rowYStart + (i * rowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final entry = entries[i];
final VgaImage? marker = entry.isChecked
? selectedMarker
: unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawCanonicalMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor,
);
}
_drawMenuSectionHeader(
text: engine.menuManager.rendererOptionsTitle,
y200: 132,
textColor: ColorPalette.vga32Bit[8],
);
const int optionsRowStart = 159;
const int optionsRowStep = 15;
for (int i = 0; i < optionEntries.length; i++) {
final int optionIndex = modeCount + i;
final bool isSelected = optionIndex == selectedIndex;
final int y = optionsRowStart + (i * optionsRowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final entry = optionEntries[i];
final VgaImage? marker = entry.isChecked
? selectedMarker
: unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawCanonicalMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor,
);
}
}
void _drawRendererOptionsMenu(
WolfEngine engine,
WolfClassicMenuArt art,
int panelColor,
int headingColor,
int selectedTextColor,
int unselectedTextColor,
int disabledTextColor,
) {
_drawHeaderBarStack(
headingY200: _headerHeadingY,
backgroundColor: _rgbToFrameColor(engine.menuManager.menuBackgroundRgb),
barColor: ColorPalette.vga32Bit[0],
);
const int panelX = 56;
const int panelY = 52;
const int panelW = 208;
const int panelH = 120;
_fillCanonicalRect(panelX, panelY, panelW, panelH, panelColor);
final VgaImage? heading = art.customizeLabel ?? art.optionsLabel;
if (heading != null) {
final int headingX = ((320 - heading.width) ~/ 2).clamp(0, 319);
_blitVgaImage(heading, headingX, 0);
} else {
_drawCanonicalMenuTextCentered(
'CUSTOMIZE',
_headerHeadingY,
headingColor,
scale: 2,
);
}
final VgaImage? selectedMarker = art.selectedMarker;
final VgaImage? unselectedMarker = art.unselectedMarker;
final VgaImage? cursor = art.mappedPic(
engine.menuManager.isCursorAltFrame(engine.timeAliveMs) ? 9 : 8,
);
_drawCanonicalMenuTextCentered(
engine.menuManager.rendererOptionsTitle,
46,
headingColor,
scale: 1,
);
const int rowYStart = 68;
const int rowStep = 20;
const int cursorX = 62;
const int markerX = 92;
const int textX = 122;
final entries = engine.menuManager.rendererOptionEntries;
final int selectedIndex = engine.menuManager.selectedRendererOptionIndex;
for (int i = 0; i < entries.length; i++) {
final entry = entries[i];
final bool isSelected = i == selectedIndex;
final int y = rowYStart + (i * rowStep);
if (isSelected && cursor != null) {
_blitVgaImage(cursor, cursorX, y - 2);
}
final VgaImage? marker = entry.isChecked
? selectedMarker
: unselectedMarker;
if (marker != null) {
_blitVgaImage(marker, markerX, y);
}
_drawCanonicalMenuText(
entry.label,
textX,
y + 1,
entry.isEnabled
? (isSelected ? selectedTextColor : unselectedTextColor)
: disabledTextColor,
);
}
}
void _drawGameSelectMenu( void _drawGameSelectMenu(
WolfEngine engine, WolfEngine engine,
WolfClassicMenuArt art, WolfClassicMenuArt art,
@@ -461,6 +693,26 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
} }
} }
void _drawMenuSectionHeader({
required String text,
required int y200,
required int textColor,
}) {
final int textW = WolfMenuFont.measureTextWidth(text, 1);
final int boxW = (textW + 28).clamp(110, 250);
final int x = ((320 - boxW) ~/ 2).clamp(0, 319);
final int y = y200.clamp(0, 199);
final int outer = _rgbToFrameColor(0x727272);
final int inner = _rgbToFrameColor(0x8E8E8E);
final int fill = _rgbToFrameColor(0xC9C9C9);
final int menuTextColor = _rgbToFrameColor(0x5A5A5A);
_fillCanonicalRect(x, y, boxW, 12, outer);
_fillCanonicalRect(x + 1, y + 1, boxW - 2, 10, inner);
_fillCanonicalRect(x + 2, y + 2, boxW - 4, 8, fill);
_drawCanonicalMenuTextCentered(text, y + 2, menuTextColor, scale: 1);
}
void _drawEpisodeSelectMenu( void _drawEpisodeSelectMenu(
WolfEngine engine, WolfEngine engine,
WolfClassicMenuArt art, WolfClassicMenuArt art,

View File

@@ -11,4 +11,6 @@ export 'src/engine/input/engine_input.dart';
export 'src/engine/managers/door_manager.dart'; export 'src/engine/managers/door_manager.dart';
export 'src/engine/managers/pushwall_manager.dart'; export 'src/engine/managers/pushwall_manager.dart';
export 'src/engine/player/player.dart'; export 'src/engine/player/player.dart';
export 'src/engine/rendering/renderer_settings.dart';
export 'src/engine/rendering/renderer_settings_persistence.dart';
export 'src/engine/wolf_3d_engine_base.dart'; export 'src/engine/wolf_3d_engine_base.dart';

View File

@@ -21,6 +21,7 @@ abstract class WolfMenuPic {
static const int cNormal = 18; // C_NORMALPIC static const int cNormal = 18; // C_NORMALPIC
static const int cHard = 19; // C_HARDPIC static const int cHard = 19; // C_HARDPIC
static const int cControl = 23; // C_CONTROLPIC static const int cControl = 23; // C_CONTROLPIC
static const int cCustomize = 24; // C_CUSTOMIZEPIC
static const int cEpisode1 = 27; // C_EPISODE1PIC static const int cEpisode1 = 27; // C_EPISODE1PIC
static const int cEpisode2 = 28; // C_EPISODE2PIC static const int cEpisode2 = 28; // C_EPISODE2PIC
static const int cEpisode3 = 29; // C_EPISODE3PIC static const int cEpisode3 = 29; // C_EPISODE3PIC
@@ -115,6 +116,8 @@ class WolfClassicMenuArt {
VgaImage? get optionsLabel => _imageForKey(MenuPicKey.optionsLabel); VgaImage? get optionsLabel => _imageForKey(MenuPicKey.optionsLabel);
VgaImage? get customizeLabel => _imageForKey(MenuPicKey.customizeLabel);
VgaImage? get credits => _imageForKey(MenuPicKey.credits); VgaImage? get credits => _imageForKey(MenuPicKey.credits);
VgaImage? episodeOption(int episodeIndex) { VgaImage? episodeOption(int episodeIndex) {
@@ -189,6 +192,8 @@ class WolfClassicMenuArt {
return MenuPicKey.difficultyHard; return MenuPicKey.difficultyHard;
case WolfMenuPic.cControl: case WolfMenuPic.cControl:
return MenuPicKey.controlBackground; return MenuPicKey.controlBackground;
case WolfMenuPic.cCustomize:
return MenuPicKey.customizeLabel;
case WolfMenuPic.cEpisode1: case WolfMenuPic.cEpisode1:
return MenuPicKey.episode1; return MenuPicKey.episode1;
case WolfMenuPic.cEpisode2: case WolfMenuPic.cEpisode2:

View File

@@ -54,6 +54,7 @@ void main() {
final engine = _buildMultiGameEngine(input: input, difficulty: null); final engine = _buildMultiGameEngine(input: input, difficulty: null);
engine.init(); engine.init();
_dismissIntroSplash(engine, input);
expect(engine.isMenuOpen, isTrue); expect(engine.isMenuOpen, isTrue);
expect(engine.menuManager.activeMenu, WolfMenuScreen.gameSelect); expect(engine.menuManager.activeMenu, WolfMenuScreen.gameSelect);
@@ -62,6 +63,7 @@ void main() {
engine.tick(const Duration(milliseconds: 16)); engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false; input.isInteracting = false;
engine.tick(const Duration(milliseconds: 300)); engine.tick(const Duration(milliseconds: 300));
_dismissIntroSplash(engine, input);
expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu); expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu);
expect( expect(
@@ -85,7 +87,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, false, false, false, false, false, true, true], [true, false, false, false, false, true, false, false, true, true],
); );
input.isInteracting = true; input.isInteracting = true;
@@ -102,6 +104,7 @@ void main() {
final engine = _buildEngine(input: input, difficulty: null); final engine = _buildEngine(input: input, difficulty: null);
engine.init(); engine.init();
_advanceToMainMenu(engine, input);
expect(engine.isMenuOpen, isTrue); expect(engine.isMenuOpen, isTrue);
expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu); expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu);
@@ -124,7 +127,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, false, false, false, false, false, true, true], [true, false, false, false, false, true, false, false, true, true],
); );
input.isInteracting = true; input.isInteracting = true;
@@ -172,7 +175,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, false, false, false, false, true, true, true], [true, false, false, false, false, true, false, true, true, true],
); );
input.isMovingForward = true; input.isMovingForward = true;
@@ -203,6 +206,10 @@ void main() {
expect(manager.selectedMainIndex, 0); expect(manager.selectedMainIndex, 0);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 5);
manager.updateMainMenu(const EngineInput(isMovingBackward: true)); manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput()); manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 8); expect(manager.selectedMainIndex, 8);
@@ -232,6 +239,12 @@ void main() {
); );
engine.init(); engine.init();
_advanceToMainMenu(engine, input);
input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = true; input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16)); engine.tick(const Duration(milliseconds: 16));
@@ -269,6 +282,7 @@ void main() {
); );
engine.init(); engine.init();
_advanceToMainMenu(engine, input);
input.isBack = true; input.isBack = true;
engine.tick(const Duration(milliseconds: 16)); engine.tick(const Duration(milliseconds: 16));
@@ -417,6 +431,28 @@ class _SilentAudio implements EngineAudio {
void dispose() {} void dispose() {}
} }
void _dismissIntroSplash(WolfEngine engine, _TestInput input) {
int safety = 0;
while (engine.menuManager.activeMenu == WolfMenuScreen.introSplash &&
safety < 160) {
input.isInteracting = safety.isEven;
engine.tick(const Duration(milliseconds: 16));
safety++;
}
input.isInteracting = false;
}
void _advanceToMainMenu(WolfEngine engine, _TestInput input) {
_dismissIntroSplash(engine, input);
if (engine.menuManager.activeMenu == WolfMenuScreen.gameSelect) {
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
engine.tick(const Duration(milliseconds: 300));
}
expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu);
}
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0)); SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
void _fillBoundaries(SpriteMap grid, int wallId) { void _fillBoundaries(SpriteMap grid, int wallId) {

View File

@@ -0,0 +1,546 @@
import 'dart:typed_data';
import 'package:test/test.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_input.dart';
void main() {
group('Change View menu wiring', () {
test('CHANGE VIEW is wired and enabled in main menu', () {
final engine = _buildEngine(difficulty: null);
engine.init();
final entries = engine.menuManager.mainMenuEntries;
final changeViewEntry = entries.firstWhere(
(e) => e.action == WolfMenuMainAction.changeView,
);
expect(changeViewEntry.isEnabled, isTrue);
});
test('selecting CHANGE VIEW transitions to changeView screen', () {
final input = _TestInput();
final engine = _buildEngine(difficulty: null, input: input);
engine.init();
// Navigate to CHANGE VIEW (index 5 in the default menu)
engine.menuManager.showMainMenu(hasResumableGame: false);
final int cvIndex = engine.menuManager.mainMenuEntries.indexWhere(
(e) => e.action == WolfMenuMainAction.changeView,
);
// Move cursor down to CHANGE VIEW
for (int i = 0; i < cvIndex; i++) {
input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16));
}
expect(engine.menuManager.selectedMainIndex, cvIndex);
// Confirm
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
expect(
engine.menuManager.activeMenu,
WolfMenuScreen.changeView,
);
});
test('back from changeView returns to main menu', () {
final input = _TestInput();
final engine = _buildEngine(difficulty: null, input: input);
engine.init();
engine.menuManager.showChangeViewMenu();
input.isBack = true;
engine.tick(const Duration(milliseconds: 16));
input.isBack = false;
expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu);
});
test('renderer selection toggles mode and stays on changeView', () {
final input = _TestInput();
final engine = _buildEngine(
difficulty: null,
input: input,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.software, WolfRendererMode.ascii},
supportsAsciiThemes: true,
supportsFpsCounter: false,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.software,
),
);
engine.init();
engine.menuManager.showChangeViewMenu();
// Move from SOFTWARE to ASCII.
input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
engine.tick(const Duration(milliseconds: 16));
expect(engine.rendererSettings.mode, WolfRendererMode.ascii);
expect(engine.menuManager.activeMenu, WolfMenuScreen.changeView);
});
test('renderer option toggles are available inline in changeView', () {
final input = _TestInput();
final engine = _buildEngine(
difficulty: null,
input: input,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.software, WolfRendererMode.ascii},
supportsAsciiThemes: true,
supportsFpsCounter: false,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.software,
),
);
engine.init();
engine.menuManager.showChangeViewMenu();
// Select ASCII so mode-specific settings become available.
input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
engine.tick(const Duration(milliseconds: 16));
// Move to first inline option row and toggle it.
input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
engine.tick(const Duration(milliseconds: 16));
expect(engine.menuManager.activeMenu, WolfMenuScreen.changeView);
expect(
engine.rendererSettings.asciiThemeId,
WolfRendererSettings.asciiThemeQuadrant,
);
});
test('toggling renderer option keeps cursor on selected option', () {
final input = _TestInput();
final engine = _buildEngine(
difficulty: null,
input: input,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.ascii},
supportsAsciiThemes: true,
supportsFpsCounter: true,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.ascii,
),
);
engine.init();
engine.menuManager.showChangeViewMenu();
// Move from renderer row to first option row, then to second option row.
input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16));
final int modeCount = engine.menuManager.changeViewEntries.length;
expect(engine.menuManager.selectedChangeViewIndex, modeCount + 1);
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
engine.tick(const Duration(milliseconds: 16));
expect(engine.menuManager.selectedChangeViewIndex, modeCount + 1);
expect(engine.rendererSettings.fpsCounterEnabled, isTrue);
});
});
group('Renderer settings model', () {
test('default capabilities include software mode', () {
final engine = _buildEngine(difficulty: null);
engine.init();
expect(
engine.rendererCapabilities.supportsMode(WolfRendererMode.software),
isTrue,
);
});
test('updateRendererSettings mutates mode and notifies callback', () {
WolfRendererSettings? notified;
final engine = _buildEngine(
difficulty: null,
onRendererSettingsChanged: (s) => notified = s,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.software, WolfRendererMode.ascii},
supportsAsciiThemes: true,
supportsFpsCounter: true,
),
);
engine.init();
engine.updateRendererSettings(
const WolfRendererSettings(mode: WolfRendererMode.ascii),
);
expect(engine.rendererSettings.mode, WolfRendererMode.ascii);
expect(notified?.mode, WolfRendererMode.ascii);
});
test('cycleRendererMode cycles through supported modes', () {
final engine = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.software, WolfRendererMode.ascii},
supportsAsciiThemes: true,
supportsFpsCounter: true,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.software,
),
);
engine.init();
engine.cycleRendererMode();
expect(engine.rendererSettings.mode, WolfRendererMode.ascii);
engine.cycleRendererMode();
expect(engine.rendererSettings.mode, WolfRendererMode.software);
});
test('cycleAsciiTheme cycles to next theme', () {
final engine = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.ascii},
supportsAsciiThemes: true,
supportsFpsCounter: false,
),
rendererSettings: const WolfRendererSettings(
asciiThemeId: WolfRendererSettings.asciiThemeBlocks,
),
);
engine.init();
engine.cycleAsciiTheme();
expect(
engine.rendererSettings.asciiThemeId,
WolfRendererSettings.asciiThemeQuadrant,
);
engine.cycleAsciiTheme();
expect(
engine.rendererSettings.asciiThemeId,
WolfRendererSettings.asciiThemeBlocks,
);
});
test('toggleFpsCounter toggles and syncs showFpsCounter', () {
final engine = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.software},
supportsFpsCounter: true,
),
);
engine.init();
expect(engine.showFpsCounter, isFalse);
engine.toggleFpsCounter();
expect(engine.rendererSettings.fpsCounterEnabled, isTrue);
expect(engine.showFpsCounter, isTrue);
engine.toggleFpsCounter();
expect(engine.rendererSettings.fpsCounterEnabled, isFalse);
expect(engine.showFpsCounter, isFalse);
});
test('toggleHardwareEffects no-ops when capability is absent', () {
final engine = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.software},
supportsHardwareEffects: false,
),
rendererSettings: const WolfRendererSettings(
hardwareEffectsEnabled: false,
),
);
engine.init();
engine.toggleHardwareEffects();
expect(engine.rendererSettings.hardwareEffectsEnabled, isFalse);
});
});
group('Renderer menu visibility', () {
test('changeViewEntries only contain supported modes', () {
final engine = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.ascii, WolfRendererMode.software},
supportsAsciiThemes: true,
supportsFpsCounter: false,
),
);
engine.init();
final modes = engine.menuManager.changeViewEntries
.map((e) => e.mode)
.toSet();
expect(
modes,
equals({WolfRendererMode.ascii, WolfRendererMode.software}),
);
});
test('checked renderer entry matches active mode', () {
final engine = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.ascii, WolfRendererMode.software},
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.ascii,
),
);
engine.init();
final checked = engine.menuManager.changeViewEntries
.where((e) => e.isChecked)
.toList();
expect(checked.length, 1);
expect(checked.first.mode, WolfRendererMode.ascii);
});
test('ASCII theme option is shown when mode is ascii', () {
final engine = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.ascii},
supportsAsciiThemes: true,
supportsFpsCounter: false,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.ascii,
),
);
engine.init();
final optionIds = engine.menuManager.rendererOptionEntries
.map((e) => e.id)
.toSet();
expect(optionIds.contains(WolfRendererOptionId.asciiTheme), isTrue);
});
test('ASCII theme option is hidden when mode is not ascii', () {
final engine = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.software},
supportsAsciiThemes: false,
supportsFpsCounter: false,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.software,
),
);
engine.init();
final optionIds = engine.menuManager.rendererOptionEntries
.map((e) => e.id)
.toSet();
expect(optionIds.contains(WolfRendererOptionId.asciiTheme), isFalse);
});
test('hardware effects option only shown when hardware mode is active', () {
final engineHardware = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.hardware},
supportsHardwareEffects: true,
supportsFpsCounter: false,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.hardware,
),
);
engineHardware.init();
final hardwareOptIds = engineHardware.menuManager.rendererOptionEntries
.map((e) => e.id)
.toSet();
expect(
hardwareOptIds.contains(WolfRendererOptionId.hardwareEffects),
isTrue,
);
final engineSoftware = _buildEngine(
difficulty: null,
rendererCapabilities: const WolfRendererCapabilities(
supportedModes: {WolfRendererMode.software},
supportsHardwareEffects: false,
supportsFpsCounter: false,
),
rendererSettings: const WolfRendererSettings(
mode: WolfRendererMode.software,
),
);
engineSoftware.init();
final softwareOptIds = engineSoftware.menuManager.rendererOptionEntries
.map((e) => e.id)
.toSet();
expect(
softwareOptIds.contains(WolfRendererOptionId.hardwareEffects),
isFalse,
);
});
});
group('WolfRendererSettings serialization', () {
test('round-trip through JSON is lossless', () {
const WolfRendererSettings original = WolfRendererSettings(
mode: WolfRendererMode.ascii,
asciiThemeId: WolfRendererSettings.asciiThemeQuadrant,
hardwareEffectsEnabled: true,
fpsCounterEnabled: true,
);
final json = original.toJson();
final restored = WolfRendererSettings.fromJson(json);
expect(restored.mode, original.mode);
expect(restored.asciiThemeId, original.asciiThemeId);
expect(restored.hardwareEffectsEnabled, original.hardwareEffectsEnabled);
expect(restored.fpsCounterEnabled, original.fpsCounterEnabled);
});
test('fromJson tolerates unknown mode with safe default', () {
final settings = WolfRendererSettings.fromJson(<String, Object?>{
'mode': 'unknown_mode_xyz',
});
expect(settings.mode, WolfRendererMode.software);
});
test('fromJson tolerates missing fields', () {
final settings = WolfRendererSettings.fromJson(<String, Object?>{});
expect(settings.mode, WolfRendererMode.software);
expect(settings.asciiThemeId, WolfRendererSettings.asciiThemeBlocks);
expect(settings.hardwareEffectsEnabled, isFalse);
expect(settings.fpsCounterEnabled, isFalse);
});
});
}
WolfEngine _buildEngine({
required Difficulty? difficulty,
_TestInput? input,
WolfRendererCapabilities? rendererCapabilities,
WolfRendererSettings? rendererSettings,
void Function(WolfRendererSettings)? onRendererSettingsChanged,
}) {
return WolfEngine(
data: _buildTestData(),
difficulty: difficulty,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: input ?? _TestInput(),
engineAudio: _SilentAudio(),
onGameWon: () {},
rendererCapabilities: rendererCapabilities,
rendererSettings: rendererSettings,
onRendererSettingsChanged: onRendererSettingsChanged,
);
}
WolfensteinData _buildTestData() {
final wallGrid = List.generate(64, (_) => List.filled(64, 0));
final objGrid = List.generate(64, (_) => List.filled(64, 0));
_fillBoundaries(wallGrid, 2);
objGrid[2][2] = MapObject.playerEast;
return WolfensteinData(
version: GameVersion.retail,
dataVersion: DataVersion.unknown,
registry: RetailAssetRegistry(),
walls: [_sprite(1), _sprite(1), _sprite(2), _sprite(2)],
sprites: List.generate(436, (_) => _sprite(255)),
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const [],
music: const [],
vgaImages: const [],
episodes: [
Episode(
name: 'Test Episode',
levels: [
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objGrid,
music: Music.level01,
),
],
),
],
);
}
void _fillBoundaries(List<List<int>> grid, int wallId) {
for (int i = 0; i < 64; i++) {
grid[0][i] = wallId;
grid[63][i] = wallId;
grid[i][0] = wallId;
grid[i][63] = wallId;
}
}
Sprite _sprite(int c) => Sprite(Uint8List.fromList(List.filled(64 * 64, c)));
class _TestInput extends Wolf3dInput {
@override
void update() {}
}
class _SilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
void stopMusic() {}
@override
Future<void> stopAllAudio() async {}
@override
void dispose() {}
}

View File

@@ -0,0 +1,62 @@
/// Flutter host adapter for persisting renderer settings to a local file.
///
/// Uses `dart:io` for desktop targets where a writable path is available.
/// On web and other platforms that lack dart:io, persistence is silently
/// skipped so the rest of the app continues to work normally.
library;
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Persists [WolfRendererSettings] as JSON to the app's support directory.
///
/// This implementation relies on `dart:io` and is therefore only active on
/// non-web platforms. Pass an explicit [filePath] during testing.
class FlutterRendererSettingsPersistence extends RendererSettingsPersistence
with JsonRendererSettingsPersistence {
FlutterRendererSettingsPersistence({String? filePath}) : _filePath = filePath;
final String? _filePath;
String? _resolvedPath;
Future<String> _getFilePath() async {
if (_resolvedPath != null) {
return _resolvedPath!;
}
if (_filePath != null) {
_resolvedPath = _filePath;
return _resolvedPath!;
}
// Resolve platform app-support directory.
final String home =
Platform.environment['HOME'] ?? Platform.environment['APPDATA'] ?? '.';
_resolvedPath = '$home/.wolf3d_settings.json';
return _resolvedPath!;
}
@override
Future<String?> readRaw() async {
if (kIsWeb) return null;
try {
final String path = await _getFilePath();
final File f = File(path);
if (!f.existsSync()) return null;
return await f.readAsString();
} catch (_) {
return null;
}
}
@override
Future<void> writeRaw(String json) async {
if (kIsWeb) return;
try {
final String path = await _getFilePath();
await File(path).writeAsString(json, flush: true);
} catch (_) {
// Best-effort.
}
}
}

View File

@@ -85,6 +85,9 @@ class Wolf3d {
WolfEngine launchEngine({ WolfEngine launchEngine({
required void Function() onGameWon, required void Function() onGameWon,
void Function()? onQuit, void Function()? onQuit,
WolfRendererCapabilities? rendererCapabilities,
WolfRendererSettings? rendererSettings,
void Function(WolfRendererSettings settings)? onRendererSettingsChanged,
}) { }) {
if (availableGames.isEmpty) { if (availableGames.isEmpty) {
throw StateError( throw StateError(
@@ -106,6 +109,9 @@ class Wolf3d {
// so backing out of the top-level menu should not pop the route. // so backing out of the top-level menu should not pop the route.
onMenuExit: () {}, onMenuExit: () {},
onQuit: onQuit, onQuit: onQuit,
rendererCapabilities: rendererCapabilities,
rendererSettings: rendererSettings,
onRendererSettingsChanged: onRendererSettingsChanged,
onGameSelected: (game) { onGameSelected: (game) {
_activeGame = game; _activeGame = game;
audio.activeGame = game; audio.activeGame = game;