Refactor menu rendering and state management

- Introduced _AsciiMenuTypography and _AsciiMenuRowFont enums to manage typography settings for menu rendering.
- Updated AsciiRenderer to utilize new typography settings for main menu and game select screens.
- Enhanced SixelRenderer and SoftwareRenderer to support new menu rendering logic, including sidebars for options labels.
- Added disabled text color handling in WolfMenuPalette for better visual feedback on menu entries.
- Implemented a new method _drawSelectableMenuRows to streamline the drawing of menu rows based on selection state.
- Created a comprehensive test suite for level state carry-over and pause menu functionality, ensuring player state is preserved across levels and menus.
- Adjusted footer rendering to account for layout changes and improved visual consistency across different renderers.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-20 09:58:48 +01:00
parent 536a10d99e
commit 9b053e1c02
8 changed files with 1198 additions and 135 deletions

View File

@@ -0,0 +1,393 @@
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_entities.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
void main() {
group('Level state carry-over', () {
test('preserves player session state between levels but clears keys', () {
final input = _TestInput();
final engine = _buildEngine(input: input, difficulty: Difficulty.hard);
engine.init();
engine.player
..health = 47
..ammo = 33
..score = 1200
..lives = 5
..hasMachineGun = true
..hasChainGun = true
..hasGoldKey = true
..hasSilverKey = true;
engine.player.weapons[WeaponType.machineGun] = MachineGun();
engine.player.weapons[WeaponType.chainGun] = ChainGun();
engine.player.currentWeapon = engine.player.weapons[WeaponType.chainGun]!;
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
expect(engine.activeLevel.name, 'Level 2');
expect(engine.player.health, 47);
expect(engine.player.ammo, 33);
expect(engine.player.score, 1200);
expect(engine.player.lives, 5);
expect(engine.player.hasMachineGun, isTrue);
expect(engine.player.hasChainGun, isTrue);
expect(engine.player.currentWeapon.type, WeaponType.chainGun);
expect(engine.player.hasGoldKey, isFalse);
expect(engine.player.hasSilverKey, isFalse);
expect(engine.player.x, closeTo(4.5, 0.001));
expect(engine.player.y, closeTo(4.5, 0.001));
});
});
group('Pause and main menu', () {
test(
'shows game selection before the shared main menu when multiple games exist',
() {
final input = _TestInput();
final engine = _buildMultiGameEngine(input: input, difficulty: null);
engine.init();
expect(engine.isMenuOpen, isTrue);
expect(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);
expect(
engine.menuManager.mainMenuEntries
.map((entry) => entry.label)
.toList(),
[
'NEW GAME',
'SOUND',
'CONTROL',
'LOAD GAME',
'SAVE GAME',
'CHANGE VIEW',
'READ THIS!',
'VIEW SCORES',
'BACK TO DEMO',
'QUIT',
],
);
expect(
engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled)
.toList(),
[true, false, false, false, false, false, false, false, true, true],
);
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
engine.tick(const Duration(milliseconds: 300));
expect(engine.menuManager.activeMenu, WolfMenuScreen.episodeSelect);
},
);
test('single-game startup opens the shared main menu directly', () {
final input = _TestInput();
final engine = _buildEngine(input: input, difficulty: null);
engine.init();
expect(engine.isMenuOpen, isTrue);
expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu);
expect(
engine.menuManager.mainMenuEntries.map((entry) => entry.label).toList(),
[
'NEW GAME',
'SOUND',
'CONTROL',
'LOAD GAME',
'SAVE GAME',
'CHANGE VIEW',
'READ THIS!',
'VIEW SCORES',
'BACK TO DEMO',
'QUIT',
],
);
expect(
engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled)
.toList(),
[true, false, false, false, false, false, false, false, true, true],
);
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
engine.tick(const Duration(milliseconds: 300));
expect(engine.menuManager.activeMenu, WolfMenuScreen.episodeSelect);
});
test(
'escape opens pause menu and back to game resumes without resetting state',
() {
final input = _TestInput();
final engine = _buildEngine(input: input, difficulty: Difficulty.hard);
engine.init();
final double startX = engine.player.x;
final double startY = engine.player.y;
input.isBack = true;
engine.tick(const Duration(milliseconds: 16));
input.isBack = false;
expect(engine.isMenuOpen, isTrue);
expect(engine.menuManager.activeMenu, WolfMenuScreen.mainMenu);
expect(
engine.menuManager.mainMenuEntries
.map((entry) => entry.label)
.toList(),
[
'NEW GAME',
'SOUND',
'CONTROL',
'LOAD GAME',
'SAVE GAME',
'CHANGE VIEW',
'READ THIS!',
'END GAME',
'BACK TO GAME',
'QUIT',
],
);
expect(
engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled)
.toList(),
[true, false, false, false, false, false, false, true, true, true],
);
input.isMovingForward = true;
engine.tick(const Duration(milliseconds: 16));
expect(engine.player.x, closeTo(startX, 0.001));
expect(engine.player.y, closeTo(startY, 0.001));
input
..isMovingForward = false
..isBack = true;
engine.tick(const Duration(milliseconds: 16));
input.isBack = false;
expect(engine.isMenuOpen, isFalse);
input.isMovingForward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingForward = false;
expect(engine.player.x, greaterThan(startX));
},
);
test('main menu skips disabled entries during navigation', () {
final manager = MenuManager();
manager.showMainMenu(hasResumableGame: false);
expect(manager.selectedMainIndex, 0);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 8);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 9);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 0);
});
test('quit selection triggers top-level menu exit callback', () {
final input = _TestInput();
int exitCalls = 0;
final engine = _buildEngine(
input: input,
difficulty: null,
onMenuExit: () {
exitCalls++;
},
);
engine.init();
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));
expect(engine.menuManager.selectedMainIndex, 9);
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
expect(exitCalls, 1);
});
});
}
WolfEngine _buildMultiGameEngine({
required _TestInput input,
required Difficulty? difficulty,
void Function()? onMenuExit,
}) {
final WolfensteinData retail = _buildTestData(
gameVersion: GameVersion.retail,
);
final WolfensteinData shareware = _buildTestData(
gameVersion: GameVersion.shareware,
);
return WolfEngine(
availableGames: [retail, shareware],
difficulty: difficulty,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: input,
engineAudio: _SilentAudio(),
onGameWon: () {},
onMenuExit: onMenuExit,
);
}
WolfEngine _buildEngine({
required _TestInput input,
required Difficulty? difficulty,
void Function()? onMenuExit,
}) {
return WolfEngine(
data: _buildTestData(gameVersion: GameVersion.retail),
difficulty: difficulty,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: input,
engineAudio: _SilentAudio(),
onGameWon: () {},
onMenuExit: onMenuExit,
);
}
WolfensteinData _buildTestData({required GameVersion gameVersion}) {
final levelOneWalls = _buildGrid();
final levelOneObjects = _buildGrid();
final levelTwoWalls = _buildGrid();
final levelTwoObjects = _buildGrid();
_fillBoundaries(levelOneWalls, 2);
_fillBoundaries(levelTwoWalls, 2);
levelOneObjects[2][2] = MapObject.playerEast;
levelOneWalls[2][3] = MapObject.normalElevatorSwitch;
levelTwoObjects[4][4] = MapObject.playerEast;
return WolfensteinData(
version: gameVersion,
dataVersion: DataVersion.unknown,
registry: gameVersion == GameVersion.shareware
? SharewareAssetRegistry()
: RetailAssetRegistry(),
walls: [
_solidSprite(1),
_solidSprite(1),
_solidSprite(2),
_solidSprite(2),
],
sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const [],
music: const [],
vgaImages: const [],
episodes: [
Episode(
name: 'Episode 1',
levels: [
WolfLevel(
name: 'Level 1',
wallGrid: levelOneWalls,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: levelOneObjects,
musicIndex: 0,
),
WolfLevel(
name: 'Level 2',
wallGrid: levelTwoWalls,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: levelTwoObjects,
musicIndex: 1,
),
],
),
],
);
}
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(WolfLevel level) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(int sfxId) {}
@override
void stopMusic() {}
@override
void dispose() {}
}
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
void _fillBoundaries(SpriteMap 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 _solidSprite(int colorIndex) {
return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex)));
}