diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index 67d8f34..0f500aa 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -7,6 +7,7 @@ library; import 'dart:io'; 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_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; @@ -66,9 +67,14 @@ void main() async { engine.init(); + final persistence = CliRendererSettingsPersistence(); + final WolfRendererSettings? saved = await persistence.load(); + gameLoop = CliGameLoop( engine: engine, onExit: stopAndExit, + persistence: persistence, + initialSettings: saved, ); await gameLoop.start(); diff --git a/apps/wolf_3d_cli/lib/cli_game_loop.dart b/apps/wolf_3d_cli/lib/cli_game_loop.dart index cc09591..0b0e8a6 100644 --- a/apps/wolf_3d_cli/lib/cli_game_loop.dart +++ b/apps/wolf_3d_cli/lib/cli_game_loop.dart @@ -17,6 +17,8 @@ class CliGameLoop { CliGameLoop({ required this.engine, required this.onExit, + this.persistence, + this.initialSettings, }) : input = engine.input is CliInput ? engine.input as CliInput : throw ArgumentError.value( @@ -37,6 +39,8 @@ class CliGameLoop { final CliRendererBackend secondaryRenderer; final CliInput input; final void Function(int code) onExit; + final RendererSettingsPersistence? persistence; + final WolfRendererSettings? initialSettings; final Stopwatch _stopwatch = Stopwatch(); final Stream> _stdinStream = stdin.asBroadcastStream(); @@ -59,12 +63,32 @@ class CliGameLoop { inputStream: _stdinStream, ); _isSixelAvailable = sixel.isSixelSupported; - _renderer = _isSixelAvailable ? primaryRenderer : secondaryRenderer; } else { _isSixelAvailable = false; - _renderer = secondaryRenderer; } + final Set supportedModes = { + 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) { try { stdin.echoMode = false; @@ -124,43 +148,48 @@ class CliGameLoop { } if (input.matchesRendererToggleShortcut(bytes)) { - if (!_isSixelAvailable) { - return; - } - // Allow dynamic renderer-switch bindings configured on the CLI input. - _renderer = identical(_renderer, secondaryRenderer) - ? primaryRenderer - : secondaryRenderer; + engine.cycleRendererMode(); + _syncRendererFromEngine(); + unawaited(persistence?.save(engine.rendererSettings)); stdout.write('\x1b[2J\x1b[H'); return; } if (input.matchesAsciiThemeCycleShortcut(bytes)) { - _cycleAsciiTheme(); + engine.cycleAsciiTheme(); + _syncRendererFromEngine(); + unawaited(persistence?.save(engine.rendererSettings)); return; } input.handleKey(bytes); } - void _cycleAsciiTheme() { - final List asciiRenderers = [ - if (primaryRenderer is AsciiRenderer) primaryRenderer as AsciiRenderer, - if (secondaryRenderer is AsciiRenderer) - secondaryRenderer as AsciiRenderer, - ]; - if (asciiRenderers.isEmpty) { - return; + void _syncRendererFromEngine() { + final CliRendererBackend previousRenderer = _renderer; + final WolfRendererMode mode = engine.rendererSettings.mode; + if (mode == WolfRendererMode.sixel && _isSixelAvailable) { + _renderer = primaryRenderer; + } else { + _renderer = secondaryRenderer; } - final AsciiTheme nextTheme = AsciiThemes.nextOf( - asciiRenderers.first.activeTheme, - ); - for (final renderer in asciiRenderers) { - renderer.activeTheme = nextTheme; + final AsciiTheme theme = + engine.rendererSettings.asciiThemeId == + WolfRendererSettings.asciiThemeQuadrant + ? AsciiThemes.quadrant + : 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'); } } @@ -170,6 +199,9 @@ class CliGameLoop { return; } + // Apply renderer changes made via in-game menus or settings updates. + _syncRendererFromEngine(); + if (stdout.hasTerminal) { final int cols = stdout.terminalColumns; final int rows = stdout.terminalLines; @@ -198,51 +230,5 @@ class CliGameLoop { engine.tick(elapsed); 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; } } diff --git a/apps/wolf_3d_cli/lib/cli_renderer_settings_persistence.dart b/apps/wolf_3d_cli/lib/cli_renderer_settings_persistence.dart new file mode 100644 index 0000000..745e746 --- /dev/null +++ b/apps/wolf_3d_cli/lib/cli_renderer_settings_persistence.dart @@ -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 readRaw() async { + try { + final File f = File(_filePath); + if (!f.existsSync()) { + return null; + } + return await f.readAsString(); + } catch (_) { + return null; + } + } + + @override + Future writeRaw(String json) async { + try { + await File(_filePath).writeAsString(json, flush: true); + } catch (_) { + // Best-effort; never crash the loop on a write failure. + } + } +} diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index d0f4338..ffd32ea 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -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_input.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_input_flutter.dart'; import 'package:wolf_3d_gui/screens/debug_tools_screen.dart'; @@ -139,20 +140,38 @@ class GameScreen extends StatefulWidget { class _GameScreenState extends State { late final WolfEngine _engine; + final FlutterRendererSettingsPersistence _persistence = + FlutterRendererSettingsPersistence(); - /// Current active renderer implementation. + /// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum. 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 void initState() { super.initState(); + const Set supportedModes = { + WolfRendererMode.hardware, + WolfRendererMode.software, + WolfRendererMode.ascii, + }; _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: () { _engine.difficulty = null; widget.wolf3d.clearActiveDifficulty(); @@ -162,6 +181,30 @@ class _GameScreenState extends State { SystemNavigator.pop(); }, ); + _syncRendererModeFrom(_engine.rendererSettings); + _loadPersistedSettings(); + } + + Future _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 @@ -222,17 +265,6 @@ class _GameScreenState extends State { 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 { Widget _buildRenderer() { // Keep all renderers behind the same engine so mode switching does not // reset level state or audio playback. + final WolfRendererSettings settings = _engine.rendererSettings; switch (_rendererMode) { case RendererMode.software: return WolfFlutterRenderer( @@ -252,15 +285,19 @@ class _GameScreenState extends State { onKeyEvent: _handleRendererKeyEvent, ); case RendererMode.ascii: + final AsciiTheme theme = + settings.asciiThemeId == WolfRendererSettings.asciiThemeQuadrant + ? AsciiThemes.quadrant + : AsciiThemes.blocks; return WolfAsciiRenderer( engine: _engine, - theme: _asciiTheme, + theme: theme, onKeyEvent: _handleRendererKeyEvent, ); case RendererMode.hardware: return WolfGlslRenderer( engine: _engine, - effectsEnabled: _glslEffectsEnabled, + effectsEnabled: settings.hardwareEffectsEnabled, onKeyEvent: _handleRendererKeyEvent, onUnavailable: _onGlslUnavailable, ); @@ -279,67 +316,31 @@ class _GameScreenState extends State { } if (event.logicalKey == widget.wolf3d.input.rendererToggleKey) { - setState(_cycleRendererMode); + _engine.cycleRendererMode(); return; } if (event.logicalKey == widget.wolf3d.input.fpsToggleKey) { - setState(_toggleFpsCounter); + setState(() => _engine.toggleFpsCounter()); return; } if (event.logicalKey == widget.wolf3d.input.asciiThemeCycleKey) { if (_rendererMode == RendererMode.ascii) { - setState(_cycleAsciiTheme); + _engine.cycleAsciiTheme(); } 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() { if (!mounted || _rendererMode != RendererMode.hardware) { return; } - setState(() { - _rendererMode = RendererMode.software; - }); - } - - void _toggleFpsCounter() { - _engine.showFpsCounter = !_engine.showFpsCounter; - } - - void _cycleAsciiTheme() { - _asciiTheme = AsciiThemes.nextOf(_asciiTheme); - } - - void _toggleGlslEffects() { - _glslEffectsEnabled = !_glslEffectsEnabled; + _engine.updateRendererSettings( + _engine.rendererSettings.copyWith(mode: WolfRendererMode.software), + ); } void _openDebugTools() { diff --git a/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings.dart b/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings.dart new file mode 100644 index 0000000..7195b22 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings.dart @@ -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 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 asciiThemeIds = [ + 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 toJson() { + return { + 'mode': mode.name, + 'asciiThemeId': asciiThemeId, + 'hardwareEffectsEnabled': hardwareEffectsEnabled, + 'fpsCounterEnabled': fpsCounterEnabled, + }; + } + + static WolfRendererSettings fromJson(Map 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, + ); + } +} diff --git a/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings_persistence.dart b/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings_persistence.dart new file mode 100644 index 0000000..30b585a --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/engine/rendering/renderer_settings_persistence.dart @@ -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 load(); + + /// Saves [settings] asynchronously; failures should be silently absorbed. + Future save(WolfRendererSettings settings); +} + +/// Mixin that handles JSON encode/decode so adapters only need to implement +/// raw string read/write. +mixin JsonRendererSettingsPersistence on RendererSettingsPersistence { + Future readRaw(); + Future writeRaw(String json); + + @override + Future load() async { + try { + final String? raw = await readRaw(); + if (raw == null || raw.isEmpty) { + return null; + } + final Object? decoded = jsonDecode(raw); + if (decoded is! Map) { + return null; + } + return WolfRendererSettings.fromJson(decoded); + } catch (_) { + return null; + } + } + + @override + Future save(WolfRendererSettings settings) async { + try { + await writeRaw(jsonEncode(settings.toJson())); + } catch (_) { + // Best-effort — never break the game loop on persistence failure. + } + } +} diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 63edd0b..7652777 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -27,8 +27,20 @@ class WolfEngine { this.onQuit, this.onGameSelected, this.onEpisodeSelected, + this.onRendererSettingsChanged, + WolfRendererCapabilities? rendererCapabilities, + WolfRendererSettings? rendererSettings, EngineAudio? engineAudio, - }) : assert( + }) : rendererCapabilities = + rendererCapabilities ?? + const WolfRendererCapabilities( + supportedModes: { + WolfRendererMode.software, + }, + supportsFpsCounter: true, + ), + rendererSettings = rendererSettings ?? const WolfRendererSettings(), + assert( data != null || (availableGames != null && availableGames.isNotEmpty), '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.'); } menuManager.menuBackgroundRgb = menuBackgroundRgb; + _normalizeRendererSettings(); + _syncRendererMenuModel(); } /// Total milliseconds elapsed since the engine was initialized. @@ -106,6 +120,15 @@ class WolfEngine { /// Callback triggered when episode selection changes; `null` means cleared. 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 --- /// Manages the state and animation of doors throughout the level. @@ -219,6 +242,169 @@ class WolfEngine { 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 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 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 options = + []; + + 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 rendererEntries = + []; + + 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. /// /// Updates all world subsystems based on the [elapsed] time. @@ -312,6 +498,12 @@ class WolfEngine { case WolfMenuScreen.difficultySelect: _tickDifficultySelectionMenu(input); break; + case WolfMenuScreen.changeView: + _tickChangeViewMenu(input); + break; + case WolfMenuScreen.rendererOptions: + _tickRendererOptionsMenu(input); + break; } } @@ -345,11 +537,14 @@ class WolfEngine { case WolfMenuMainAction.backToDemo: _exitTopLevelMenu(); break; + case WolfMenuMainAction.changeView: + _syncRendererMenuModel(); + menuManager.showChangeViewMenu(); + break; case WolfMenuMainAction.sound: case WolfMenuMainAction.control: case WolfMenuMainAction.loadGame: case WolfMenuMainAction.saveGame: - case WolfMenuMainAction.changeView: case WolfMenuMainAction.readThis: case WolfMenuMainAction.viewScores: 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() { onEpisodeSelected?.call(null); menuManager.clearEpisodeSelection(); diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart index e69fcea..a414aab 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -7,6 +7,8 @@ enum WolfMenuScreen { gameSelect, episodeSelect, difficultySelect, + changeView, + rendererOptions, } enum WolfIntroSlide { retailWarning, pg13, title } @@ -40,6 +42,36 @@ class WolfMenuMainEntry { 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) { switch (action) { case WolfMenuMainAction.newGame: @@ -48,11 +80,12 @@ bool _isWiredMainMenuAction(WolfMenuMainAction action) { case WolfMenuMainAction.backToDemo: case WolfMenuMainAction.quit: return true; + case WolfMenuMainAction.changeView: + return true; case WolfMenuMainAction.sound: case WolfMenuMainAction.control: case WolfMenuMainAction.loadGame: case WolfMenuMainAction.saveGame: - case WolfMenuMainAction.changeView: case WolfMenuMainAction.readThis: case WolfMenuMainAction.viewScores: return false; @@ -85,6 +118,13 @@ class MenuManager { int _selectedGameIndex = 0; int _selectedEpisodeIndex = 0; int _selectedDifficultyIndex = 0; + int _selectedChangeViewIndex = 0; + int _selectedRendererOptionIndex = 0; + String _rendererOptionsTitle = 'CUSTOMIZE'; + List _changeViewEntries = + const []; + List _rendererOptionEntries = + const []; bool _showResumeOption = false; int _gameCount = 1; @@ -161,6 +201,18 @@ class MenuManager { int get selectedEpisodeIndex => _selectedEpisodeIndex; + int get selectedChangeViewIndex => _selectedChangeViewIndex; + + int get selectedRendererOptionIndex => _selectedRendererOptionIndex; + + String get rendererOptionsTitle => _rendererOptionsTitle; + + List get changeViewEntries => + List.unmodifiable(_changeViewEntries); + + List get rendererOptionEntries => + List.unmodifiable(_rendererOptionEntries); + List get mainMenuEntries { return List.unmodifiable( [ @@ -290,6 +342,133 @@ class MenuManager { _resetEdgeState(); } + int get _changeViewItemCount => + _changeViewEntries.length + _rendererOptionEntries.length; + + void setChangeViewEntries(List entries) { + final WolfRendererMode? previouslySelectedMode = + (_selectedChangeViewIndex >= 0 && + _selectedChangeViewIndex < _changeViewEntries.length) + ? _changeViewEntries[_selectedChangeViewIndex].mode + : null; + + _changeViewEntries = List.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 entries, + }) { + final bool wasSelectingOption = + _selectedChangeViewIndex >= _changeViewEntries.length; + final WolfRendererOptionId? previousOption = + (_selectedRendererOptionIndex >= 0 && + _selectedRendererOptionIndex < _rendererOptionEntries.length) + ? _rendererOptionEntries[_selectedRendererOptionIndex].id + : null; + + _rendererOptionsTitle = title; + _rendererOptionEntries = List.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. /// /// 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. ({Difficulty? selected, bool goBack}) updateDifficultySelection( EngineInput input, @@ -612,6 +873,27 @@ class MenuManager { 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({ required WolfMenuMainAction action, required String label, diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_menu_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/retail_menu_module.dart index d802a65..305cf1c 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_menu_module.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/retail_menu_module.dart @@ -31,6 +31,7 @@ class RetailMenuPicModule extends MenuPicModule { MenuPicKey.footer: 15, // C_MOUSELBACKPIC (footer art) MenuPicKey.heading: 3, // H_TOPWINDOWPIC MenuPicKey.optionsLabel: 7, // C_OPTIONSPIC + MenuPicKey.customizeLabel: 24, // C_CUSTOMIZEPIC // --- Cursor / markers --- MenuPicKey.cursorActive: 8, // C_CURSOR1PIC MenuPicKey.cursorInactive: 9, // C_CURSOR2PIC diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart index 81b0cf9..f6dbe8e 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_menu_module.dart @@ -31,6 +31,7 @@ class SharewareMenuPicModule extends MenuPicModule { MenuPicKey.footer: 15, MenuPicKey.heading: 3, MenuPicKey.optionsLabel: 7, + MenuPicKey.customizeLabel: 36, MenuPicKey.cursorActive: 8, MenuPicKey.cursorInactive: 9, MenuPicKey.markerSelected: 11, @@ -57,6 +58,7 @@ class SharewareMenuPicModule extends MenuPicModule { MenuPicKey.footer: 27, MenuPicKey.heading: 14, MenuPicKey.optionsLabel: 19, + MenuPicKey.customizeLabel: 36, MenuPicKey.cursorActive: 20, MenuPicKey.cursorInactive: 21, MenuPicKey.markerSelected: 23, diff --git a/packages/wolf_3d_dart/lib/src/registry/keys/menu_pic_key.dart b/packages/wolf_3d_dart/lib/src/registry/keys/menu_pic_key.dart index dc688ed..14de42c 100644 --- a/packages/wolf_3d_dart/lib/src/registry/keys/menu_pic_key.dart +++ b/packages/wolf_3d_dart/lib/src/registry/keys/menu_pic_key.dart @@ -10,6 +10,7 @@ enum MenuPicKey { footer('footer'), heading('heading'), optionsLabel('optionsLabel'), + customizeLabel('customizeLabel'), // --- Cursor / selection markers --- cursorActive('cursorActive'), diff --git a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart index 7ffc4bf..9c0ae91 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -642,6 +642,163 @@ class AsciiRenderer extends CliRendererBackend { 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 = engine.menuManager.selectedDifficultyIndex; @@ -997,7 +1154,10 @@ class AsciiRenderer extends CliRendererBackend { int scale = 1, }) { if (y200 == _headerHeadingY) { - y200 = _centerHeaderTitleInBlackBand(defaultY: y200, scale: scale); + y200 = _centerHeaderTitleInBlackBand( + defaultY: y200, + scale: scale, + ); } final int textWidth = WolfMenuFont.measureTextWidth(text, scale); final int x320 = ((320 - textWidth) ~/ 2).clamp(0, 319); @@ -1156,6 +1316,103 @@ class AsciiRenderer extends CliRendererBackend { } } + 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: [text], + selectedIndex: -1, + rowYStart200: y200, + rowStep200: 1, + textX320: textX320, + panelX320: panelX320, + panelW320: panelW320, + panelColor: panelColor, + colorForRow: (_, _) => color, + ); + } + void _drawCenteredMenuFooter() { if (_usesTerminalLayout && !_emitAnsi) { _drawFlutterGridMenuFooter(); diff --git a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart index 1f028f0..c900235 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -526,6 +526,146 @@ class SixelRenderer extends CliRendererBackend { 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 = engine.menuManager.selectedDifficultyIndex; _drawHeaderBarStack( @@ -580,6 +720,45 @@ class SixelRenderer extends CliRendererBackend { _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) { final bottom = art.mappedPic(15); if (bottom == null) { diff --git a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart index f5a3fdc..b4de566 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -209,6 +209,28 @@ class SoftwareRenderer extends RendererBackend { unselectedTextColor, ); 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); @@ -410,6 +432,216 @@ class SoftwareRenderer extends RendererBackend { } } + 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( WolfEngine engine, WolfClassicMenuArt art, @@ -461,6 +693,26 @@ class SoftwareRenderer extends RendererBackend { } } + 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( WolfEngine engine, WolfClassicMenuArt art, diff --git a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart index d4da979..97a0b0e 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart @@ -11,4 +11,6 @@ export 'src/engine/input/engine_input.dart'; export 'src/engine/managers/door_manager.dart'; export 'src/engine/managers/pushwall_manager.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'; diff --git a/packages/wolf_3d_dart/lib/wolf_3d_menu.dart b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart index c0d7ca4..1a782df 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_menu.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart @@ -21,6 +21,7 @@ abstract class WolfMenuPic { static const int cNormal = 18; // C_NORMALPIC static const int cHard = 19; // C_HARDPIC static const int cControl = 23; // C_CONTROLPIC + static const int cCustomize = 24; // C_CUSTOMIZEPIC static const int cEpisode1 = 27; // C_EPISODE1PIC static const int cEpisode2 = 28; // C_EPISODE2PIC static const int cEpisode3 = 29; // C_EPISODE3PIC @@ -115,6 +116,8 @@ class WolfClassicMenuArt { VgaImage? get optionsLabel => _imageForKey(MenuPicKey.optionsLabel); + VgaImage? get customizeLabel => _imageForKey(MenuPicKey.customizeLabel); + VgaImage? get credits => _imageForKey(MenuPicKey.credits); VgaImage? episodeOption(int episodeIndex) { @@ -189,6 +192,8 @@ class WolfClassicMenuArt { return MenuPicKey.difficultyHard; case WolfMenuPic.cControl: return MenuPicKey.controlBackground; + case WolfMenuPic.cCustomize: + return MenuPicKey.customizeLabel; case WolfMenuPic.cEpisode1: return MenuPicKey.episode1; case WolfMenuPic.cEpisode2: diff --git a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart index 2ee618f..7fcf395 100644 --- a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart +++ b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart @@ -54,6 +54,7 @@ void main() { final engine = _buildMultiGameEngine(input: input, difficulty: null); engine.init(); + _dismissIntroSplash(engine, input); expect(engine.isMenuOpen, isTrue); expect(engine.menuManager.activeMenu, WolfMenuScreen.gameSelect); @@ -62,6 +63,7 @@ void main() { engine.tick(const Duration(milliseconds: 16)); input.isInteracting = false; engine.tick(const Duration(milliseconds: 300)); + _dismissIntroSplash(engine, input); expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu); expect( @@ -85,7 +87,7 @@ void main() { engine.menuManager.mainMenuEntries .map((entry) => entry.isEnabled) .toList(), - [true, false, false, false, false, false, false, false, true, true], + [true, false, false, false, false, true, false, false, true, true], ); input.isInteracting = true; @@ -102,6 +104,7 @@ void main() { final engine = _buildEngine(input: input, difficulty: null); engine.init(); + _advanceToMainMenu(engine, input); expect(engine.isMenuOpen, isTrue); expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu); @@ -124,7 +127,7 @@ void main() { engine.menuManager.mainMenuEntries .map((entry) => entry.isEnabled) .toList(), - [true, false, false, false, false, false, false, false, true, true], + [true, false, false, false, false, true, false, false, true, true], ); input.isInteracting = true; @@ -172,7 +175,7 @@ void main() { engine.menuManager.mainMenuEntries .map((entry) => entry.isEnabled) .toList(), - [true, false, false, false, false, false, false, true, true, true], + [true, false, false, false, false, true, false, true, true, true], ); input.isMovingForward = true; @@ -203,6 +206,10 @@ void main() { 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()); expect(manager.selectedMainIndex, 8); @@ -232,6 +239,12 @@ void main() { ); 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; engine.tick(const Duration(milliseconds: 16)); @@ -269,6 +282,7 @@ void main() { ); engine.init(); + _advanceToMainMenu(engine, input); input.isBack = true; engine.tick(const Duration(milliseconds: 16)); @@ -417,6 +431,28 @@ class _SilentAudio implements EngineAudio { 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)); void _fillBoundaries(SpriteMap grid, int wallId) { diff --git a/packages/wolf_3d_dart/test/engine/renderer_settings_menu_test.dart b/packages/wolf_3d_dart/test/engine/renderer_settings_menu_test.dart new file mode 100644 index 0000000..0ea59bf --- /dev/null +++ b/packages/wolf_3d_dart/test/engine/renderer_settings_menu_test.dart @@ -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({ + 'mode': 'unknown_mode_xyz', + }); + expect(settings.mode, WolfRendererMode.software); + }); + + test('fromJson tolerates missing fields', () { + final settings = WolfRendererSettings.fromJson({}); + 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> 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 debugSoundTest() async {} + @override + Future 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 stopAllAudio() async {} + @override + void dispose() {} +} diff --git a/packages/wolf_3d_flutter/lib/renderer_settings_persistence_flutter.dart b/packages/wolf_3d_flutter/lib/renderer_settings_persistence_flutter.dart new file mode 100644 index 0000000..67b3089 --- /dev/null +++ b/packages/wolf_3d_flutter/lib/renderer_settings_persistence_flutter.dart @@ -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 _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 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 writeRaw(String json) async { + if (kIsWeb) return; + try { + final String path = await _getFilePath(); + await File(path).writeAsString(json, flush: true); + } catch (_) { + // Best-effort. + } + } +} diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index 8c0519d..7bafee0 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -85,6 +85,9 @@ class Wolf3d { WolfEngine launchEngine({ required void Function() onGameWon, void Function()? onQuit, + WolfRendererCapabilities? rendererCapabilities, + WolfRendererSettings? rendererSettings, + void Function(WolfRendererSettings settings)? onRendererSettingsChanged, }) { if (availableGames.isEmpty) { throw StateError( @@ -106,6 +109,9 @@ class Wolf3d { // so backing out of the top-level menu should not pop the route. onMenuExit: () {}, onQuit: onQuit, + rendererCapabilities: rendererCapabilities, + rendererSettings: rendererSettings, + onRendererSettingsChanged: onRendererSettingsChanged, onGameSelected: (game) { _activeGame = game; audio.activeGame = game;