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:
@@ -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)));
|
||||
}
|
||||
Reference in New Issue
Block a user