Refactor menu structure and add Flutter-specific input and persistence layers

- Moved menu-related classes to a new structure under `src/menu/`.
- Introduced `WolfMenuPresentation` to handle menu art and mappings.
- Added `MenuManager` tests to ensure menu state reflects game status.
- Implemented `FlutterRendererSettingsPersistence` and `FlutterSaveGamePersistence` for managing settings and save files on desktop platforms.
- Created `Wolf3dFlutterInput` to handle keyboard and mouse input in a Flutter environment.
- Updated README to reflect new package structure and usage instructions.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-24 18:45:34 +01:00
parent 9f3651b122
commit 5c309c2240
37 changed files with 2356 additions and 1565 deletions
@@ -0,0 +1,167 @@
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
void main() {
group('MenuManager', () {
test('main menu row enablement reflects resumable and loadable state', () {
final manager = MenuManager();
manager.showMainMenu(hasResumableGame: false, hasLoadableSave: false);
expect(
_entryFor(manager, WolfMenuMainAction.loadGame).isEnabled,
isFalse,
);
expect(
_entryFor(manager, WolfMenuMainAction.saveGame).isEnabled,
isFalse,
);
expect(
_entryFor(manager, WolfMenuMainAction.changeView).isEnabled,
isTrue,
);
manager.setLoadGameAvailable(true);
expect(_entryFor(manager, WolfMenuMainAction.loadGame).isEnabled, isTrue);
expect(
_entryFor(manager, WolfMenuMainAction.saveGame).isEnabled,
isFalse,
);
manager.showMainMenu(hasResumableGame: true, hasLoadableSave: true);
expect(_entryFor(manager, WolfMenuMainAction.saveGame).isEnabled, isTrue);
expect(_entryFor(manager, WolfMenuMainAction.endGame).isEnabled, isTrue);
expect(
manager.mainMenuEntries.any(
(entry) => entry.action == WolfMenuMainAction.backToGame,
),
isTrue,
);
});
test('change-view navigation skips disabled renderer rows and options', () {
final manager = MenuManager();
manager.setChangeViewEntries(const <WolfMenuRendererEntry>[
WolfMenuRendererEntry(
mode: WolfRendererMode.software,
label: 'SOFTWARE',
hasOptions: false,
isEnabled: false,
),
WolfMenuRendererEntry(
mode: WolfRendererMode.ascii,
label: 'ASCII',
hasOptions: true,
),
]);
manager.setRendererOptionEntries(
title: 'ASCII OPTIONS',
entries: const <WolfMenuRendererOptionEntry>[
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.asciiTheme,
label: 'THEME',
isEnabled: false,
),
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.fpsCounter,
label: 'FPS',
),
],
);
manager.showChangeViewMenu();
expect(manager.selectedChangeViewIndex, 1);
manager.updateChangeViewMenu(const EngineInput(isMovingBackward: true));
manager.updateChangeViewMenu(const EngineInput());
expect(manager.selectedChangeViewIndex, 3);
});
test(
'renderer option selection is preserved by option id when entries refresh',
() {
final manager = MenuManager();
manager.setChangeViewEntries(const <WolfMenuRendererEntry>[
WolfMenuRendererEntry(
mode: WolfRendererMode.ascii,
label: 'ASCII',
hasOptions: true,
),
]);
manager.setRendererOptionEntries(
title: 'ASCII OPTIONS',
entries: const <WolfMenuRendererOptionEntry>[
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.asciiTheme,
label: 'THEME',
),
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.fpsCounter,
label: 'FPS',
),
],
);
manager.showRendererOptionsMenu();
manager.updateRendererOptionsMenu(
const EngineInput(isMovingBackward: true),
);
manager.updateRendererOptionsMenu(const EngineInput());
expect(manager.selectedRendererOptionIndex, 1);
manager.setRendererOptionEntries(
title: 'ASCII OPTIONS',
entries: const <WolfMenuRendererOptionEntry>[
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.fpsCounter,
label: 'FPS',
),
WolfMenuRendererOptionEntry(
id: WolfRendererOptionId.asciiTheme,
label: 'THEME',
),
],
);
expect(manager.selectedRendererOptionIndex, 0);
expect(
manager.rendererOptionEntries[manager.selectedRendererOptionIndex].id,
WolfRendererOptionId.fpsCounter,
);
},
);
test('transition locks navigation and reports none when idle', () {
final manager = MenuManager();
manager.showMainMenu(hasResumableGame: false);
manager.startTransition(WolfMenuScreen.difficultySelect);
final result = manager.updateMainMenu(
const EngineInput(isMovingBackward: true, isInteracting: true),
);
expect(result.selected, isNull);
expect(result.goBack, isFalse);
expect(manager.transitionEffect, WolfTransitionEffect.normalFade);
manager.tickTransition(MenuManager.transitionDurationMs);
expect(manager.isTransitioning, isFalse);
expect(manager.transitionEffect, WolfTransitionEffect.none);
expect(manager.activeMenu, WolfMenuScreen.difficultySelect);
});
});
}
WolfMenuMainEntry _entryFor(MenuManager manager, WolfMenuMainAction action) {
return manager.mainMenuEntries.firstWhere((entry) => entry.action == action);
}
@@ -1,4 +1,5 @@
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/registry/built_in/spear_menu_presentation_module.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
@@ -17,6 +18,7 @@ void main() {
expect(registry.hud.resolve(HudKey.statusBar)?.vgaIndex, 73);
expect(registry.hud.resolve(HudKey.digit0)?.vgaIndex, 84);
expect(registry.hud.resolve(HudKey.pistolIcon)?.vgaIndex, 77);
expect(registry.menuPresentation, isA<SpearMenuPresentationModule>());
});
test('resolves SDM enemy sprite ranges with +4 SPEAR shift', () {
@@ -21,14 +21,14 @@ void main() {
vgaImages: List.generate(120, (i) => _img(height: i + 1)),
);
final art = WolfClassicMenuArt(data);
final menu = WolfMenuPresentation(data);
// Retail cursor constants 8/9 must resolve to shareware cursor IDs 20/21.
expect(art.mappedPic(8)?.height, 21);
expect(art.mappedPic(9)?.height, 22);
expect(menu.mappedPic(8)?.height, 21);
expect(menu.mappedPic(9)?.height, 22);
// Retail footer constant 15 must resolve to shareware footer ID 27.
expect(art.mappedPic(15)?.height, 28);
expect(menu.mappedPic(15)?.height, 28);
},
);
}