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
@@ -0,0 +1,658 @@
library;
import 'dart:convert';
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/engine/save/game_session_snapshot.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
class SaveGameFile {
const SaveGameFile({
required this.slot,
required this.gameVersion,
required this.dataVersionName,
required this.description,
required this.createdAtMs,
required this.snapshot,
required this.checksum,
});
final int slot;
final GameVersion gameVersion;
final String dataVersionName;
final String description;
final int createdAtMs;
final GameSessionSnapshot snapshot;
final int checksum;
}
class SaveGameCodec {
static const int _formatVersion = 1;
static const List<int> _magic = <int>[0x57, 0x33, 0x44, 0x53]; // W3DS
static const int _descriptionBytes = 32;
Uint8List encode(SaveGameFile file) {
final _BinaryWriter writer = _BinaryWriter();
final Uint8List payload = _encodeSnapshot(file.snapshot);
final int checksum = doChecksum(payload);
writer.writeBytes(_magic);
writer.writeUint16(_formatVersion);
writer.writeUint8(file.gameVersion.index);
writer.writeUint8(file.slot & 0xFF);
writer.writeInt64(file.createdAtMs);
writer.writeFixedUtf8(file.description, _descriptionBytes);
writer.writeUtf8(file.dataVersionName);
writer.writeUint32(payload.length);
writer.writeBytes(payload);
writer.writeUint32(checksum);
return writer.toBytes();
}
SaveGameFile decode(Uint8List bytes) {
final _BinaryReader reader = _BinaryReader(bytes);
final Uint8List magic = reader.readBytes(_magic.length);
if (!_listEquals(magic, _magic)) {
throw const FormatException('Invalid save magic header.');
}
final int version = reader.readUint16();
if (version != _formatVersion) {
throw FormatException('Unsupported save format version: $version');
}
final int gameVersionIndex = reader.readUint8();
if (gameVersionIndex < 0 || gameVersionIndex >= GameVersion.values.length) {
throw const FormatException('Invalid game version in save header.');
}
final GameVersion gameVersion = GameVersion.values[gameVersionIndex];
final int slot = reader.readUint8();
final int createdAtMs = reader.readInt64();
final String description = reader.readFixedUtf8(_descriptionBytes);
final String dataVersionName = reader.readUtf8();
final int payloadLength = reader.readUint32();
final Uint8List payload = reader.readBytes(payloadLength);
final int checksum = reader.readUint32();
final int computed = doChecksum(payload);
if (computed != checksum) {
throw FormatException(
'Save checksum mismatch. expected=$checksum actual=$computed',
);
}
final GameSessionSnapshot snapshot = _decodeSnapshot(payload);
return SaveGameFile(
slot: slot,
gameVersion: gameVersion,
dataVersionName: dataVersionName,
description: description,
createdAtMs: createdAtMs,
snapshot: snapshot,
checksum: checksum,
);
}
int doChecksum(Uint8List source, {int checksum = 0}) {
if (source.length <= 1) {
return checksum & 0xFFFFFFFF;
}
int running = checksum;
for (int i = 0; i < source.length - 1; i++) {
running = (running + (source[i] ^ source[i + 1])) & 0xFFFFFFFF;
}
return running;
}
Uint8List _encodeSnapshot(GameSessionSnapshot snapshot) {
final _BinaryWriter writer = _BinaryWriter();
writer.writeInt32(snapshot.currentGameIndex);
writer.writeInt32(snapshot.currentEpisodeIndex);
writer.writeInt32(snapshot.currentLevelIndex);
writer.writeBool(snapshot.returnLevelIndex != null);
writer.writeInt32(snapshot.returnLevelIndex ?? -1);
writer.writeInt32(snapshot.difficulty.index);
writer.writeInt64(snapshot.timeAliveMs);
writer.writeInt64(snapshot.lastAcousticAlertTime);
writer.writeBool(snapshot.isMapOverlayVisible);
writer.writeBool(snapshot.isMenuOverlayVisible);
_writePlayerState(writer, snapshot.player);
_writeIntGrid(writer, snapshot.currentLevel);
_writeIntGrid(writer, snapshot.areaGrid);
writer.writeUint32(snapshot.areasByPlayer.length);
for (final bool value in snapshot.areasByPlayer) {
writer.writeBool(value);
}
writer.writeUint32(snapshot.entities.length);
for (final EntitySaveState entity in snapshot.entities) {
writer.writeUtf8(entity.kind);
writer.writeFloat64(entity.x);
writer.writeFloat64(entity.y);
writer.writeInt32(entity.spriteIndex);
writer.writeFloat64(entity.angle);
writer.writeInt32(entity.state.index);
writer.writeInt32(entity.mapId);
writer.writeInt32(entity.lastActionTime);
writer.writeUtf8(jsonEncode(entity.extraData));
}
writer.writeUint32(snapshot.doors.length);
for (final DoorSaveState door in snapshot.doors) {
writer.writeInt32(door.x);
writer.writeInt32(door.y);
writer.writeInt32(door.mapId);
writer.writeInt32(door.state.index);
writer.writeFloat64(door.offset);
writer.writeInt32(door.openTime);
}
writer.writeUint32(snapshot.pushwalls.length);
for (final PushwallSaveState pushwall in snapshot.pushwalls) {
writer.writeInt32(pushwall.x);
writer.writeInt32(pushwall.y);
writer.writeInt32(pushwall.mapId);
writer.writeInt32(pushwall.dirX);
writer.writeInt32(pushwall.dirY);
writer.writeFloat64(pushwall.offset);
writer.writeInt32(pushwall.tilesMoved);
writer.writeBool(pushwall.isActive);
}
return writer.toBytes();
}
GameSessionSnapshot _decodeSnapshot(Uint8List payload) {
final _BinaryReader reader = _BinaryReader(payload);
final int currentGameIndex = reader.readInt32();
final int currentEpisodeIndex = reader.readInt32();
final int currentLevelIndex = reader.readInt32();
final bool hasReturnLevel = reader.readBool();
final int returnLevelRaw = reader.readInt32();
final int difficultyIndex = reader.readInt32();
if (difficultyIndex < 0 || difficultyIndex >= Difficulty.values.length) {
throw const FormatException('Invalid difficulty value in save payload.');
}
final Difficulty difficulty = Difficulty.values[difficultyIndex];
final int timeAliveMs = reader.readInt64();
final int lastAcousticAlertTime = reader.readInt64();
final bool isMapOverlayVisible = reader.readBool();
final bool isMenuOverlayVisible = reader.readBool();
final PlayerSaveState player = _readPlayerState(reader);
final List<List<int>> currentLevel = _readIntGrid(reader);
final List<List<int>> areaGrid = _readIntGrid(reader);
final int areasByPlayerLength = reader.readUint32();
final List<bool> areasByPlayer = List<bool>.generate(
areasByPlayerLength,
(_) => reader.readBool(),
growable: false,
);
final int entityCount = reader.readUint32();
final List<EntitySaveState> entities = List<EntitySaveState>.generate(
entityCount,
(_) {
final String kind = reader.readUtf8();
final double x = reader.readFloat64();
final double y = reader.readFloat64();
final int spriteIndex = reader.readInt32();
final double angle = reader.readFloat64();
final int stateIndex = reader.readInt32();
final int mapId = reader.readInt32();
final int lastActionTime = reader.readInt32();
final String extraDataRaw = reader.readUtf8();
final Object? decoded = jsonDecode(extraDataRaw);
final Map<String, Object?> extraData = decoded is Map<String, Object?>
? decoded
: <String, Object?>{};
if (stateIndex < 0 || stateIndex >= EntityState.values.length) {
throw const FormatException('Invalid entity state index in save.');
}
return EntitySaveState(
kind: kind,
x: x,
y: y,
spriteIndex: spriteIndex,
angle: angle,
state: EntityState.values[stateIndex],
mapId: mapId,
lastActionTime: lastActionTime,
extraData: extraData,
);
},
growable: false,
);
final int doorCount = reader.readUint32();
final List<DoorSaveState> doors = List<DoorSaveState>.generate(
doorCount,
(_) {
final int x = reader.readInt32();
final int y = reader.readInt32();
final int mapId = reader.readInt32();
final int stateIndex = reader.readInt32();
final double offset = reader.readFloat64();
final int openTime = reader.readInt32();
if (stateIndex < 0 || stateIndex >= DoorState.values.length) {
throw const FormatException('Invalid door state index in save.');
}
return DoorSaveState(
x: x,
y: y,
mapId: mapId,
state: DoorState.values[stateIndex],
offset: offset,
openTime: openTime,
);
},
growable: false,
);
final int pushwallCount = reader.readUint32();
final List<PushwallSaveState> pushwalls = List<PushwallSaveState>.generate(
pushwallCount,
(_) => PushwallSaveState(
x: reader.readInt32(),
y: reader.readInt32(),
mapId: reader.readInt32(),
dirX: reader.readInt32(),
dirY: reader.readInt32(),
offset: reader.readFloat64(),
tilesMoved: reader.readInt32(),
isActive: reader.readBool(),
),
growable: false,
);
return GameSessionSnapshot(
currentGameIndex: currentGameIndex,
currentEpisodeIndex: currentEpisodeIndex,
currentLevelIndex: currentLevelIndex,
returnLevelIndex: hasReturnLevel ? returnLevelRaw : null,
difficulty: difficulty,
timeAliveMs: timeAliveMs,
lastAcousticAlertTime: lastAcousticAlertTime,
isMapOverlayVisible: isMapOverlayVisible,
isMenuOverlayVisible: isMenuOverlayVisible,
player: player,
currentLevel: currentLevel,
areaGrid: areaGrid,
areasByPlayer: areasByPlayer,
entities: entities,
doors: doors,
pushwalls: pushwalls,
);
}
void _writePlayerState(_BinaryWriter writer, PlayerSaveState player) {
writer.writeFloat64(player.x);
writer.writeFloat64(player.y);
writer.writeFloat64(player.angle);
writer.writeInt32(player.health);
writer.writeInt32(player.ammo);
writer.writeInt32(player.score);
writer.writeInt32(player.lives);
writer.writeFloat64(player.damageFlash);
writer.writeFloat64(player.bonusFlash);
writer.writeInt32(player.chaingunPickupFaceMsRemaining);
writer.writeBool(player.mutantDeathFaceActive);
writer.writeBool(player.godModeFaceEnabled);
writer.writeInt32(player.faceSeed);
writer.writeInt32(player.faceFrame);
writer.writeFloat64(player.faceCountTics);
writer.writeInt32(player.nextFaceChangeThreshold);
writer.writeBool(player.hasGoldKey);
writer.writeBool(player.hasSilverKey);
writer.writeBool(player.hasMachineGun);
writer.writeBool(player.hasChainGun);
writer.writeInt32(player.currentWeaponType.index);
writer.writeInt32(player.switchStateIndex);
writer.writeBool(player.pendingWeaponType != null);
writer.writeInt32(player.pendingWeaponType?.index ?? -1);
writer.writeFloat64(player.weaponAnimOffset);
writer.writeUint32(player.weaponStates.length);
for (final MapEntry<WeaponType, WeaponSaveState> entry
in player.weaponStates.entries) {
final WeaponSaveState state = entry.value;
writer.writeInt32(entry.key.index);
writer.writeInt32(state.type.index);
writer.writeInt32(state.state.index);
writer.writeInt32(state.frameIndex);
writer.writeInt32(state.lastFrameTime);
writer.writeBool(state.triggerReleased);
}
}
PlayerSaveState _readPlayerState(_BinaryReader reader) {
final double x = reader.readFloat64();
final double y = reader.readFloat64();
final double angle = reader.readFloat64();
final int health = reader.readInt32();
final int ammo = reader.readInt32();
final int score = reader.readInt32();
final int lives = reader.readInt32();
final double damageFlash = reader.readFloat64();
final double bonusFlash = reader.readFloat64();
final int chaingunPickupFaceMsRemaining = reader.readInt32();
final bool mutantDeathFaceActive = reader.readBool();
final bool godModeFaceEnabled = reader.readBool();
final int faceSeed = reader.readInt32();
final int faceFrame = reader.readInt32();
final double faceCountTics = reader.readFloat64();
final int nextFaceChangeThreshold = reader.readInt32();
final bool hasGoldKey = reader.readBool();
final bool hasSilverKey = reader.readBool();
final bool hasMachineGun = reader.readBool();
final bool hasChainGun = reader.readBool();
final int currentWeaponIndex = reader.readInt32();
final int switchStateIndex = reader.readInt32();
final bool hasPendingWeapon = reader.readBool();
final int pendingWeaponIndex = reader.readInt32();
final double weaponAnimOffset = reader.readFloat64();
if (currentWeaponIndex < 0 ||
currentWeaponIndex >= WeaponType.values.length) {
throw const FormatException('Invalid current weapon index in save.');
}
final int weaponStateCount = reader.readUint32();
final Map<WeaponType, WeaponSaveState> weaponStates =
<WeaponType, WeaponSaveState>{};
for (int i = 0; i < weaponStateCount; i++) {
final int keyIndex = reader.readInt32();
final int typeIndex = reader.readInt32();
final int stateIndex = reader.readInt32();
final int frameIndex = reader.readInt32();
final int lastFrameTime = reader.readInt32();
final bool triggerReleased = reader.readBool();
if (keyIndex < 0 || keyIndex >= WeaponType.values.length) {
throw const FormatException('Invalid weapon key index in save.');
}
if (typeIndex < 0 || typeIndex >= WeaponType.values.length) {
throw const FormatException('Invalid weapon type index in save.');
}
if (stateIndex < 0 || stateIndex >= WeaponState.values.length) {
throw const FormatException('Invalid weapon state index in save.');
}
weaponStates[WeaponType.values[keyIndex]] = WeaponSaveState(
type: WeaponType.values[typeIndex],
state: WeaponState.values[stateIndex],
frameIndex: frameIndex,
lastFrameTime: lastFrameTime,
triggerReleased: triggerReleased,
);
}
return PlayerSaveState(
x: x,
y: y,
angle: angle,
health: health,
ammo: ammo,
score: score,
lives: lives,
damageFlash: damageFlash,
bonusFlash: bonusFlash,
chaingunPickupFaceMsRemaining: chaingunPickupFaceMsRemaining,
mutantDeathFaceActive: mutantDeathFaceActive,
godModeFaceEnabled: godModeFaceEnabled,
faceSeed: faceSeed,
faceFrame: faceFrame,
faceCountTics: faceCountTics,
nextFaceChangeThreshold: nextFaceChangeThreshold,
hasGoldKey: hasGoldKey,
hasSilverKey: hasSilverKey,
hasMachineGun: hasMachineGun,
hasChainGun: hasChainGun,
currentWeaponType: WeaponType.values[currentWeaponIndex],
weaponStates: weaponStates,
switchStateIndex: switchStateIndex,
pendingWeaponType:
hasPendingWeapon &&
pendingWeaponIndex >= 0 &&
pendingWeaponIndex < WeaponType.values.length
? WeaponType.values[pendingWeaponIndex]
: null,
weaponAnimOffset: weaponAnimOffset,
);
}
void _writeIntGrid(_BinaryWriter writer, List<List<int>> grid) {
writer.writeUint32(grid.length);
final int width = grid.isEmpty ? 0 : grid.first.length;
writer.writeUint32(width);
for (final List<int> row in grid) {
if (row.length != width) {
throw const FormatException('Grid rows must all have equal width.');
}
for (final int value in row) {
writer.writeInt32(value);
}
}
}
List<List<int>> _readIntGrid(_BinaryReader reader) {
final int height = reader.readUint32();
final int width = reader.readUint32();
return List<List<int>>.generate(
height,
(_) =>
List<int>.generate(width, (_) => reader.readInt32(), growable: false),
growable: false,
);
}
bool _listEquals(List<int> a, List<int> b) {
if (a.length != b.length) {
return false;
}
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
}
}
class _BinaryWriter {
final BytesBuilder _builder = BytesBuilder(copy: false);
void writeBytes(List<int> bytes) {
_builder.add(bytes);
}
void writeBool(bool value) {
writeUint8(value ? 1 : 0);
}
void writeUint8(int value) {
_builder.add(<int>[value & 0xFF]);
}
void writeUint16(int value) {
final ByteData data = ByteData(2);
data.setUint16(0, value, Endian.little);
_builder.add(data.buffer.asUint8List());
}
void writeUint32(int value) {
final ByteData data = ByteData(4);
data.setUint32(0, value, Endian.little);
_builder.add(data.buffer.asUint8List());
}
void writeInt32(int value) {
final ByteData data = ByteData(4);
data.setInt32(0, value, Endian.little);
_builder.add(data.buffer.asUint8List());
}
void writeInt64(int value) {
final ByteData data = ByteData(8);
data.setInt64(0, value, Endian.little);
_builder.add(data.buffer.asUint8List());
}
void writeFloat64(double value) {
final ByteData data = ByteData(8);
data.setFloat64(0, value, Endian.little);
_builder.add(data.buffer.asUint8List());
}
void writeUtf8(String value) {
final Uint8List raw = Uint8List.fromList(utf8.encode(value));
writeUint32(raw.length);
writeBytes(raw);
}
void writeFixedUtf8(String value, int width) {
final Uint8List raw = Uint8List.fromList(utf8.encode(value));
final Uint8List fixed = Uint8List(width);
final int copyLength = raw.length < width ? raw.length : width;
fixed.setRange(0, copyLength, raw);
writeBytes(fixed);
}
Uint8List toBytes() => _builder.toBytes();
}
class _BinaryReader {
_BinaryReader(this._data);
final Uint8List _data;
int _offset = 0;
int get remaining => _data.length - _offset;
Uint8List readBytes(int length) {
_ensure(length);
final Uint8List out = Uint8List.sublistView(
_data,
_offset,
_offset + length,
);
_offset += length;
return out;
}
bool readBool() => readUint8() != 0;
int readUint8() {
_ensure(1);
final int value = _data[_offset];
_offset += 1;
return value;
}
int readUint16() {
_ensure(2);
final int value = ByteData.sublistView(
_data,
_offset,
_offset + 2,
).getUint16(0, Endian.little);
_offset += 2;
return value;
}
int readUint32() {
_ensure(4);
final int value = ByteData.sublistView(
_data,
_offset,
_offset + 4,
).getUint32(0, Endian.little);
_offset += 4;
return value;
}
int readInt32() {
_ensure(4);
final int value = ByteData.sublistView(
_data,
_offset,
_offset + 4,
).getInt32(0, Endian.little);
_offset += 4;
return value;
}
int readInt64() {
_ensure(8);
final int value = ByteData.sublistView(
_data,
_offset,
_offset + 8,
).getInt64(0, Endian.little);
_offset += 8;
return value;
}
double readFloat64() {
_ensure(8);
final double value = ByteData.sublistView(
_data,
_offset,
_offset + 8,
).getFloat64(0, Endian.little);
_offset += 8;
return value;
}
String readUtf8() {
final int length = readUint32();
if (length == 0) {
return '';
}
final Uint8List raw = readBytes(length);
return utf8.decode(raw, allowMalformed: true);
}
String readFixedUtf8(int width) {
final Uint8List raw = readBytes(width);
int end = raw.length;
for (int i = 0; i < raw.length; i++) {
if (raw[i] == 0) {
end = i;
break;
}
}
if (end <= 0) {
return '';
}
return utf8.decode(raw.sublist(0, end), allowMalformed: true);
}
void _ensure(int needed) {
if (remaining < needed) {
throw FormatException(
'Unexpected EOF while reading save payload. Needed $needed bytes, '
'remaining $remaining.',
);
}
}
}
@@ -0,0 +1,18 @@
library;
import 'dart:typed_data';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
/// Host adapter contract for slot-based save-game persistence.
abstract class SaveGamePersistence {
/// Loads raw bytes for [slot] and [version], or `null` when no save exists.
Future<Uint8List?> load({required int slot, required GameVersion version});
/// Persists [bytes] for [slot] and [version].
Future<void> save({
required int slot,
required GameVersion version,
required Uint8List bytes,
});
}
@@ -1,8 +1,9 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/src/engine/save/game_session_snapshot.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';
@@ -29,6 +30,9 @@ class WolfEngine {
this.onGameSelected,
this.onEpisodeSelected,
this.onRendererSettingsChanged,
this.saveGamePersistence,
SaveGameCodec? saveGameCodec,
this.defaultSaveSlot = 0,
WolfRendererCapabilities? rendererCapabilities,
WolfRendererSettings? rendererSettings,
EngineAudio? engineAudio,
@@ -46,6 +50,7 @@ class WolfEngine {
'Provide either data or a non-empty availableGames list.',
),
_availableGames = availableGames ?? <WolfensteinData>[data!],
saveGameCodec = saveGameCodec ?? SaveGameCodec(),
audio = engineAudio ?? CliSilentAudio(),
doorManager = DoorManager(
onPlaySound: (effect) => engineAudio?.playSoundEffect(effect),
@@ -133,6 +138,22 @@ class WolfEngine {
/// Callback triggered whenever renderer settings are updated by the engine.
final void Function(WolfRendererSettings settings)? onRendererSettingsChanged;
/// Optional host adapter that persists save-game bytes by slot.
final SaveGamePersistence? saveGamePersistence;
/// Binary codec used to encode/decode persisted save files.
final SaveGameCodec saveGameCodec;
/// Menu quick slot used for LOAD/SAVE GAME until slot UI is implemented.
final int defaultSaveSlot;
bool _isSaveLoadBusy = false;
String? _lastSaveLoadError;
bool get isSaveLoadBusy => _isSaveLoadBusy;
String? get lastSaveLoadError => _lastSaveLoadError;
/// Host-reported mode/effect capabilities that drive menu visibility.
WolfRendererCapabilities rendererCapabilities;
@@ -240,6 +261,99 @@ class WolfEngine {
/// Whether the current gameplay session can be resumed from the main menu.
bool get canResumeGame => _hasActiveSession;
Future<bool> saveToSlot(int slot, {String description = ''}) async {
if (_isSaveLoadBusy || saveGamePersistence == null) {
return false;
}
if (!_hasActiveSession || difficulty == null) {
_lastSaveLoadError = 'No active session to save.';
return false;
}
_isSaveLoadBusy = true;
_lastSaveLoadError = null;
try {
final snapshot = captureSaveState();
final file = SaveGameFile(
slot: slot,
gameVersion: data.version,
dataVersionName: data.dataVersion.name,
description: description,
createdAtMs: DateTime.now().millisecondsSinceEpoch,
snapshot: snapshot,
checksum: 0,
);
final bytes = saveGameCodec.encode(file);
await saveGamePersistence!.save(
slot: slot,
version: data.version,
bytes: bytes,
);
return true;
} catch (e) {
_lastSaveLoadError = e.toString();
return false;
} finally {
_isSaveLoadBusy = false;
}
}
Future<bool> loadFromSlot(int slot) async {
if (_isSaveLoadBusy || saveGamePersistence == null) {
return false;
}
_isSaveLoadBusy = true;
_lastSaveLoadError = null;
try {
final Uint8List? bytes = await saveGamePersistence!.load(
slot: slot,
version: data.version,
);
if (bytes == null || bytes.isEmpty) {
_lastSaveLoadError = 'No save found in slot $slot.';
return false;
}
final SaveGameFile file = saveGameCodec.decode(bytes);
GameSessionSnapshot snapshot = file.snapshot;
int gameIndex = snapshot.currentGameIndex;
if (gameIndex < 0 || gameIndex >= _availableGames.length) {
gameIndex = _availableGames.indexWhere(
(game) =>
game.version == file.gameVersion &&
game.dataVersion.name == file.dataVersionName,
);
if (gameIndex < 0) {
gameIndex = _availableGames.indexWhere(
(game) => game.version == file.gameVersion,
);
}
}
if (gameIndex < 0 || gameIndex >= _availableGames.length) {
_lastSaveLoadError =
'Save targets an unavailable game data set (${file.gameVersion.name}).';
return false;
}
if (snapshot.currentGameIndex != gameIndex) {
snapshot = _snapshotWithGameIndex(snapshot, gameIndex);
}
restoreSaveState(snapshot);
return true;
} catch (e) {
_lastSaveLoadError = e.toString();
return false;
} finally {
_isSaveLoadBusy = false;
}
}
GameSessionSnapshot captureSaveState() {
if (!_hasActiveSession || difficulty == null) {
throw StateError('Cannot capture save state without an active session.');
@@ -359,6 +473,30 @@ class WolfEngine {
}
}
GameSessionSnapshot _snapshotWithGameIndex(
GameSessionSnapshot snapshot,
int gameIndex,
) {
return GameSessionSnapshot(
currentGameIndex: gameIndex,
currentEpisodeIndex: snapshot.currentEpisodeIndex,
currentLevelIndex: snapshot.currentLevelIndex,
returnLevelIndex: snapshot.returnLevelIndex,
difficulty: snapshot.difficulty,
timeAliveMs: snapshot.timeAliveMs,
lastAcousticAlertTime: snapshot.lastAcousticAlertTime,
isMapOverlayVisible: snapshot.isMapOverlayVisible,
isMenuOverlayVisible: snapshot.isMenuOverlayVisible,
player: snapshot.player,
currentLevel: snapshot.currentLevel,
areaGrid: snapshot.areaGrid,
areasByPlayer: snapshot.areasByPlayer,
entities: snapshot.entities,
doors: snapshot.doors,
pushwalls: snapshot.pushwalls,
);
}
/// Replaces the shared framebuffer when dimensions change.
void setFrameBuffer(int width, int height) {
if (width <= 0 || height <= 0) {
@@ -710,10 +848,14 @@ class WolfEngine {
_syncRendererMenuModel();
menuManager.showChangeViewMenu();
break;
case WolfMenuMainAction.loadGame:
unawaited(_loadGameFromMenu());
break;
case WolfMenuMainAction.saveGame:
unawaited(_saveGameFromMenu());
break;
case WolfMenuMainAction.sound:
case WolfMenuMainAction.control:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores:
case null:
@@ -890,6 +1032,35 @@ class WolfEngine {
_exitTopLevelMenu();
}
Future<void> _saveGameFromMenu() async {
if (!_hasActiveSession || difficulty == null) {
_lastSaveLoadError = 'No active session to save.';
return;
}
if (saveGamePersistence == null || _isSaveLoadBusy) {
return;
}
await saveToSlot(
defaultSaveSlot,
description:
'E${_currentEpisodeIndex + 1}L${_currentLevelIndex + 1} ${difficulty!.name.toUpperCase()}',
);
}
Future<void> _loadGameFromMenu() async {
if (saveGamePersistence == null || _isSaveLoadBusy) {
return;
}
final bool loaded = await loadFromSlot(defaultSaveSlot);
if (!loaded) {
return;
}
_resumeGame();
}
/// Wipes the current world state and builds a new floor from map data.
void _loadLevel({required bool preservePlayerState}) {
isMapOverlayVisible = false;
@@ -79,6 +79,8 @@ class WolfMenuRendererOptionEntry {
bool _isWiredMainMenuAction(WolfMenuMainAction action) {
switch (action) {
case WolfMenuMainAction.newGame:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.endGame:
case WolfMenuMainAction.backToGame:
case WolfMenuMainAction.backToDemo:
@@ -88,8 +90,6 @@ bool _isWiredMainMenuAction(WolfMenuMainAction action) {
return true;
case WolfMenuMainAction.sound:
case WolfMenuMainAction.control:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores:
return false;
@@ -985,10 +985,15 @@ class MenuManager {
required WolfMenuMainAction action,
required String label,
}) {
bool isEnabled = _isWiredMainMenuAction(action);
if (action == WolfMenuMainAction.saveGame) {
isEnabled = isEnabled && _showResumeOption;
}
return WolfMenuMainEntry(
action: action,
label: label,
isEnabled: _isWiredMainMenuAction(action),
isEnabled: isEnabled,
);
}
@@ -15,4 +15,6 @@ export 'src/engine/player_locomotion_constants.dart';
export 'src/engine/rendering/renderer_settings.dart';
export 'src/engine/rendering/renderer_settings_persistence.dart';
export 'src/engine/save/game_session_snapshot.dart';
export 'src/engine/save/save_game_codec.dart';
export 'src/engine/save/save_game_persistence.dart';
export 'src/engine/wolf_3d_engine_base.dart';
@@ -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]));
}