feat: Implement save game functionality with encoding/decoding

- Added SaveGameCodec for encoding and decoding save game files.
- Introduced SaveGamePersistence interface for slot-based save game persistence.
- Implemented FlutterSaveGamePersistence for file-based save management on Flutter.
- Enhanced WolfEngine to support saving and loading game states.
- Updated menu manager to include save/load game options.
- Created tests for SaveGameCodec to ensure proper functionality.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 14:50:53 +01:00
parent 1a93b7d4a2
commit db06f5f5cb
12 changed files with 1205 additions and 9 deletions
@@ -154,7 +154,7 @@ void main() {
engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled)
.toList(),
[true, false, false, false, false, true, false, false, true, true],
[true, false, false, true, false, true, false, false, true, true],
);
input.isInteracting = true;
@@ -194,7 +194,7 @@ void main() {
engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled)
.toList(),
[true, false, false, false, false, true, false, false, true, true],
[true, false, false, true, false, true, false, false, true, true],
);
input.isInteracting = true;
@@ -242,7 +242,7 @@ void main() {
engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled)
.toList(),
[true, false, false, false, false, true, false, true, true, true],
[true, false, false, true, true, true, false, true, true, true],
);
input.isMovingForward = true;
@@ -273,6 +273,10 @@ void main() {
expect(manager.selectedMainIndex, 0);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 3);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 5);
@@ -363,6 +367,11 @@ void main() {
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;
@@ -0,0 +1,192 @@
import 'dart:typed_data';
import 'package:test/test.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() {
test('SaveGameCodec round-trips a captured engine session snapshot', () {
final WolfEngine engine = _buildEngine();
engine.init();
engine.player
..health = 80
..ammo = 40
..score = 900
..lives = 6
..hasMachineGun = true
..weapons[WeaponType.machineGun] = MachineGun()
..currentWeapon = MachineGun();
final Guard guard =
EntityRegistry.spawn(
MapObject.guardStart,
8.5,
7.5,
Difficulty.medium,
engine.data.sprites.length,
registry: engine.data.registry,
)!
as Guard
..health = 11
..state = EntityState.attacking;
engine.entities = <Entity>[guard, SmallAmmoCollectible(x: 7.5, y: 6.5)];
final GameSessionSnapshot snapshot = engine.captureSaveState();
final SaveGameCodec codec = SaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Unit Test Save',
createdAtMs: 123456789,
snapshot: snapshot,
checksum: 0,
);
final Uint8List encoded = codec.encode(file);
final SaveGameFile decoded = codec.decode(encoded);
expect(decoded.slot, 0);
expect(decoded.gameVersion, engine.data.version);
expect(decoded.description, 'Unit Test Save');
expect(decoded.snapshot.player.health, 80);
expect(decoded.snapshot.player.currentWeaponType, WeaponType.machineGun);
expect(decoded.snapshot.entities, hasLength(2));
expect(decoded.snapshot.entities.first.kind, 'Guard');
expect(decoded.snapshot.entities.first.state, EntityState.attacking);
});
test('SaveGameCodec rejects payloads with invalid checksum', () {
final WolfEngine engine = _buildEngine();
engine.init();
final SaveGameCodec codec = SaveGameCodec();
final Uint8List encoded = codec.encode(
SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Checksum Test',
createdAtMs: 42,
snapshot: engine.captureSaveState(),
checksum: 0,
),
);
encoded[encoded.length - 1] ^= 0xFF;
expect(
() => codec.decode(encoded),
throwsA(isA<FormatException>()),
);
});
}
class _TestInput extends Wolf3dInput {
@override
void update() {}
}
class _SilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
void dispose() {}
@override
Future<void> init() async {}
@override
void playLevelMusic(Music music) {}
@override
void playMenuMusic() {}
@override
void playSoundEffect(SoundEffect effect) {}
@override
void playSoundEffectId(int sfxId) {}
@override
Future<void> stopAllAudio() async {}
@override
void stopMusic() {}
}
WolfEngine _buildEngine() {
final SpriteMap wallGrid = _buildGrid();
final SpriteMap objectGrid = _buildGrid();
_fillBoundaries(wallGrid, 2);
objectGrid[2][2] = MapObject.playerEast;
wallGrid[2][3] = 90;
return WolfEngine(
data: WolfensteinData(
version: GameVersion.retail,
dataVersion: DataVersion.unknown,
registry: RetailAssetRegistry(),
walls: <Sprite>[
_solidSprite(1),
_solidSprite(1),
_solidSprite(2),
_solidSprite(2),
],
sprites: List<Sprite>.generate(436, (_) => _solidSprite(255)),
sounds: List<PcmSound>.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const <PcmSound>[],
music: const <ImfMusic>[],
vgaImages: const <VgaImage>[],
episodes: <Episode>[
Episode(
name: 'Episode 1',
levels: <WolfLevel>[
WolfLevel(
name: 'Level 1',
wallGrid: wallGrid,
areaGrid: List<List<int>>.generate(
64,
(_) => List<int>.filled(64, -1),
growable: false,
),
objectGrid: objectGrid,
music: Music.level01,
),
],
),
],
),
difficulty: Difficulty.medium,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: _TestInput(),
onGameWon: () {},
engineAudio: _SilentAudio(),
);
}
SpriteMap _buildGrid() =>
List<List<int>>.generate(64, (_) => List<int>.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 paletteIndex) {
return Sprite(Uint8List.fromList(<int>[paletteIndex]));
}