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

@@ -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) {

View File

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