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
+2
View File
@@ -8,6 +8,7 @@ import 'dart:io';
import 'package:wolf_3d_cli/cli_game_loop.dart'; import 'package:wolf_3d_cli/cli_game_loop.dart';
import 'package:wolf_3d_cli/cli_renderer_settings_persistence.dart'; import 'package:wolf_3d_cli/cli_renderer_settings_persistence.dart';
import 'package:wolf_3d_cli/cli_save_game_persistence.dart';
import 'package:wolf_3d_dart/wolf_3d_data.dart'; import 'package:wolf_3d_dart/wolf_3d_data.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.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_engine.dart';
@@ -63,6 +64,7 @@ void main() async {
input: CliInput(), input: CliInput(),
onGameWon: () => stopAndExit(0), onGameWon: () => stopAndExit(0),
onQuit: () => stopAndExit(0), onQuit: () => stopAndExit(0),
saveGamePersistence: CliSaveGamePersistence(),
); );
engine.init(); engine.init();
@@ -0,0 +1,55 @@
library;
import 'dart:io';
import 'dart:typed_data';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// CLI host adapter for slot-based game save persistence.
///
/// Files are stored under `~/.wolf3d_saves` by default and named
/// `SAVEGAM{slot}.{ext}` where `{ext}` follows the active game version.
class CliSaveGamePersistence implements SaveGamePersistence {
CliSaveGamePersistence({String? directoryPath})
: _directoryPath =
directoryPath ??
'${Platform.environment['HOME'] ?? '.'}/.wolf3d_saves';
final String _directoryPath;
@override
Future<Uint8List?> load({
required int slot,
required GameVersion version,
}) async {
try {
final File file = File(_slotPath(slot, version));
if (!file.existsSync()) {
return null;
}
return await file.readAsBytes();
} catch (_) {
return null;
}
}
@override
Future<void> save({
required int slot,
required GameVersion version,
required Uint8List bytes,
}) async {
final Directory dir = Directory(_directoryPath);
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
await File(_slotPath(slot, version)).writeAsBytes(bytes, flush: true);
}
String _slotPath(int slot, GameVersion version) {
final String normalizedSlot = slot.clamp(0, 9).toString();
return '$_directoryPath/SAVEGAM$normalizedSlot.${version.fileExtension}';
}
}
@@ -11,6 +11,7 @@ import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart'; import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart'; import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_flutter/renderer_settings_persistence_flutter.dart'; import 'package:wolf_3d_flutter/renderer_settings_persistence_flutter.dart';
import 'package:wolf_3d_flutter/save_game_persistence_flutter.dart';
import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart';
import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart'; import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart';
import 'package:wolf_3d_gui/screens/debug_tools_screen.dart'; import 'package:wolf_3d_gui/screens/debug_tools_screen.dart';
@@ -142,6 +143,8 @@ class _GameScreenState extends State<GameScreen> {
late final WolfEngine _engine; late final WolfEngine _engine;
final FlutterRendererSettingsPersistence _persistence = final FlutterRendererSettingsPersistence _persistence =
FlutterRendererSettingsPersistence(); FlutterRendererSettingsPersistence();
final FlutterSaveGamePersistence _savePersistence =
FlutterSaveGamePersistence();
/// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum. /// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum.
RendererMode _rendererMode = RendererMode.hardware; RendererMode _rendererMode = RendererMode.hardware;
@@ -181,6 +184,7 @@ class _GameScreenState extends State<GameScreen> {
onQuit: () { onQuit: () {
SystemNavigator.pop(); SystemNavigator.pop();
}, },
saveGamePersistence: _savePersistence,
); );
_syncRendererModeFrom(_engine.rendererSettings); _syncRendererModeFrom(_engine.rendererSettings);
_loadPersistedSettings(); _loadPersistedSettings();
@@ -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:developer';
import 'dart:math' as math; 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/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_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.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_entities.dart';
@@ -29,6 +30,9 @@ class WolfEngine {
this.onGameSelected, this.onGameSelected,
this.onEpisodeSelected, this.onEpisodeSelected,
this.onRendererSettingsChanged, this.onRendererSettingsChanged,
this.saveGamePersistence,
SaveGameCodec? saveGameCodec,
this.defaultSaveSlot = 0,
WolfRendererCapabilities? rendererCapabilities, WolfRendererCapabilities? rendererCapabilities,
WolfRendererSettings? rendererSettings, WolfRendererSettings? rendererSettings,
EngineAudio? engineAudio, EngineAudio? engineAudio,
@@ -46,6 +50,7 @@ class WolfEngine {
'Provide either data or a non-empty availableGames list.', 'Provide either data or a non-empty availableGames list.',
), ),
_availableGames = availableGames ?? <WolfensteinData>[data!], _availableGames = availableGames ?? <WolfensteinData>[data!],
saveGameCodec = saveGameCodec ?? SaveGameCodec(),
audio = engineAudio ?? CliSilentAudio(), audio = engineAudio ?? CliSilentAudio(),
doorManager = DoorManager( doorManager = DoorManager(
onPlaySound: (effect) => engineAudio?.playSoundEffect(effect), onPlaySound: (effect) => engineAudio?.playSoundEffect(effect),
@@ -133,6 +138,22 @@ class WolfEngine {
/// Callback triggered whenever renderer settings are updated by the engine. /// Callback triggered whenever renderer settings are updated by the engine.
final void Function(WolfRendererSettings settings)? onRendererSettingsChanged; 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. /// Host-reported mode/effect capabilities that drive menu visibility.
WolfRendererCapabilities rendererCapabilities; WolfRendererCapabilities rendererCapabilities;
@@ -240,6 +261,99 @@ class WolfEngine {
/// Whether the current gameplay session can be resumed from the main menu. /// Whether the current gameplay session can be resumed from the main menu.
bool get canResumeGame => _hasActiveSession; 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() { GameSessionSnapshot captureSaveState() {
if (!_hasActiveSession || difficulty == null) { if (!_hasActiveSession || difficulty == null) {
throw StateError('Cannot capture save state without an active session.'); 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. /// Replaces the shared framebuffer when dimensions change.
void setFrameBuffer(int width, int height) { void setFrameBuffer(int width, int height) {
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
@@ -710,10 +848,14 @@ class WolfEngine {
_syncRendererMenuModel(); _syncRendererMenuModel();
menuManager.showChangeViewMenu(); menuManager.showChangeViewMenu();
break; break;
case WolfMenuMainAction.loadGame:
unawaited(_loadGameFromMenu());
break;
case WolfMenuMainAction.saveGame:
unawaited(_saveGameFromMenu());
break;
case WolfMenuMainAction.sound: case WolfMenuMainAction.sound:
case WolfMenuMainAction.control: case WolfMenuMainAction.control:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.readThis: case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores: case WolfMenuMainAction.viewScores:
case null: case null:
@@ -890,6 +1032,35 @@ class WolfEngine {
_exitTopLevelMenu(); _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. /// Wipes the current world state and builds a new floor from map data.
void _loadLevel({required bool preservePlayerState}) { void _loadLevel({required bool preservePlayerState}) {
isMapOverlayVisible = false; isMapOverlayVisible = false;
@@ -79,6 +79,8 @@ class WolfMenuRendererOptionEntry {
bool _isWiredMainMenuAction(WolfMenuMainAction action) { bool _isWiredMainMenuAction(WolfMenuMainAction action) {
switch (action) { switch (action) {
case WolfMenuMainAction.newGame: case WolfMenuMainAction.newGame:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.endGame: case WolfMenuMainAction.endGame:
case WolfMenuMainAction.backToGame: case WolfMenuMainAction.backToGame:
case WolfMenuMainAction.backToDemo: case WolfMenuMainAction.backToDemo:
@@ -88,8 +90,6 @@ bool _isWiredMainMenuAction(WolfMenuMainAction action) {
return true; return true;
case WolfMenuMainAction.sound: case WolfMenuMainAction.sound:
case WolfMenuMainAction.control: case WolfMenuMainAction.control:
case WolfMenuMainAction.loadGame:
case WolfMenuMainAction.saveGame:
case WolfMenuMainAction.readThis: case WolfMenuMainAction.readThis:
case WolfMenuMainAction.viewScores: case WolfMenuMainAction.viewScores:
return false; return false;
@@ -985,10 +985,15 @@ class MenuManager {
required WolfMenuMainAction action, required WolfMenuMainAction action,
required String label, required String label,
}) { }) {
bool isEnabled = _isWiredMainMenuAction(action);
if (action == WolfMenuMainAction.saveGame) {
isEnabled = isEnabled && _showResumeOption;
}
return WolfMenuMainEntry( return WolfMenuMainEntry(
action: action, action: action,
label: label, 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.dart';
export 'src/engine/rendering/renderer_settings_persistence.dart'; export 'src/engine/rendering/renderer_settings_persistence.dart';
export 'src/engine/save/game_session_snapshot.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'; export 'src/engine/wolf_3d_engine_base.dart';
@@ -154,7 +154,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, false, false, true, false, false, true, true], [true, false, false, true, false, true, false, false, true, true],
); );
input.isInteracting = true; input.isInteracting = true;
@@ -194,7 +194,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, false, false, true, false, false, true, true], [true, false, false, true, false, true, false, false, true, true],
); );
input.isInteracting = true; input.isInteracting = true;
@@ -242,7 +242,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, false, false, true, false, true, true, true], [true, false, false, true, true, true, false, true, true, true],
); );
input.isMovingForward = true; input.isMovingForward = true;
@@ -273,6 +273,10 @@ void main() {
expect(manager.selectedMainIndex, 0); 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(isMovingBackward: true));
manager.updateMainMenu(const EngineInput()); manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 5); expect(manager.selectedMainIndex, 5);
@@ -363,6 +367,11 @@ void main() {
input.isMovingBackward = false; input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16)); 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); expect(engine.menuManager.selectedMainIndex, 9);
input.isInteracting = true; 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]));
}
@@ -0,0 +1,78 @@
library;
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Flutter desktop adapter for slot-based game save persistence.
class FlutterSaveGamePersistence implements SaveGamePersistence {
FlutterSaveGamePersistence({String? directoryPath})
: _directoryPath = directoryPath;
final String? _directoryPath;
String? _resolvedDirectoryPath;
Future<String> _resolveDirectoryPath() async {
if (_resolvedDirectoryPath != null) {
return _resolvedDirectoryPath!;
}
if (_directoryPath != null) {
_resolvedDirectoryPath = _directoryPath;
return _resolvedDirectoryPath!;
}
final String home =
Platform.environment['HOME'] ?? Platform.environment['APPDATA'] ?? '.';
_resolvedDirectoryPath = '$home/.wolf3d_saves';
return _resolvedDirectoryPath!;
}
@override
Future<Uint8List?> load({
required int slot,
required GameVersion version,
}) async {
if (kIsWeb) {
return null;
}
try {
final String dirPath = await _resolveDirectoryPath();
final File file = File(_slotPath(dirPath, slot, version));
if (!file.existsSync()) {
return null;
}
return await file.readAsBytes();
} catch (_) {
return null;
}
}
@override
Future<void> save({
required int slot,
required GameVersion version,
required Uint8List bytes,
}) async {
if (kIsWeb) {
return;
}
final String dirPath = await _resolveDirectoryPath();
final Directory dir = Directory(dirPath);
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
await File(
_slotPath(dirPath, slot, version),
).writeAsBytes(bytes, flush: true);
}
String _slotPath(String dirPath, int slot, GameVersion version) {
final String normalizedSlot = slot.clamp(0, 9).toString();
return '$dirPath/SAVEGAM$normalizedSlot.${version.fileExtension}';
}
}
@@ -85,6 +85,7 @@ class Wolf3d {
WolfEngine launchEngine({ WolfEngine launchEngine({
required void Function() onGameWon, required void Function() onGameWon,
void Function()? onQuit, void Function()? onQuit,
SaveGamePersistence? saveGamePersistence,
WolfRendererCapabilities? rendererCapabilities, WolfRendererCapabilities? rendererCapabilities,
WolfRendererSettings? rendererSettings, WolfRendererSettings? rendererSettings,
void Function(WolfRendererSettings settings)? onRendererSettingsChanged, void Function(WolfRendererSettings settings)? onRendererSettingsChanged,
@@ -109,6 +110,7 @@ class Wolf3d {
// so backing out of the top-level menu should not pop the route. // so backing out of the top-level menu should not pop the route.
onMenuExit: () {}, onMenuExit: () {},
onQuit: onQuit, onQuit: onQuit,
saveGamePersistence: saveGamePersistence,
rendererCapabilities: rendererCapabilities, rendererCapabilities: rendererCapabilities,
rendererSettings: rendererSettings, rendererSettings: rendererSettings,
onRendererSettingsChanged: onRendererSettingsChanged, onRendererSettingsChanged: onRendererSettingsChanged,