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 '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();

View File

@@ -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<List<int>> _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<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) {
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<AsciiRenderer> asciiRenderers = <AsciiRenderer>[
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;
}
}

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.
}
}
}