From db06f5f5cb03532e0c3a94b1017f97259091ff45 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 14:50:53 +0100 Subject: [PATCH] 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 --- apps/wolf_3d_cli/bin/main.dart | 2 + .../lib/cli_save_game_persistence.dart | 55 ++ apps/wolf_3d_gui/lib/screens/game_screen.dart | 4 + .../lib/src/engine/save/save_game_codec.dart | 658 ++++++++++++++++++ .../engine/save/save_game_persistence.dart | 18 + .../lib/src/engine/wolf_3d_engine_base.dart | 177 ++++- .../lib/src/menu/menu_manager.dart | 11 +- packages/wolf_3d_dart/lib/wolf_3d_engine.dart | 2 + .../level_state_and_pause_menu_test.dart | 15 +- .../test/engine/save_game_codec_test.dart | 192 +++++ .../lib/save_game_persistence_flutter.dart | 78 +++ .../wolf_3d_flutter/lib/wolf_3d_flutter.dart | 2 + 12 files changed, 1205 insertions(+), 9 deletions(-) create mode 100644 apps/wolf_3d_cli/lib/cli_save_game_persistence.dart create mode 100644 packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart create mode 100644 packages/wolf_3d_dart/lib/src/engine/save/save_game_persistence.dart create mode 100644 packages/wolf_3d_dart/test/engine/save_game_codec_test.dart create mode 100644 packages/wolf_3d_flutter/lib/save_game_persistence_flutter.dart diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index 0f500aa..49804a3 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -8,6 +8,7 @@ import 'dart:io'; 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_save_game_persistence.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_engine.dart'; @@ -63,6 +64,7 @@ void main() async { input: CliInput(), onGameWon: () => stopAndExit(0), onQuit: () => stopAndExit(0), + saveGamePersistence: CliSaveGamePersistence(), ); engine.init(); diff --git a/apps/wolf_3d_cli/lib/cli_save_game_persistence.dart b/apps/wolf_3d_cli/lib/cli_save_game_persistence.dart new file mode 100644 index 0000000..21066a2 --- /dev/null +++ b/apps/wolf_3d_cli/lib/cli_save_game_persistence.dart @@ -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 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 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}'; + } +} diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index a62c1fe..29cb163 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -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_renderer.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_input_flutter.dart'; import 'package:wolf_3d_gui/screens/debug_tools_screen.dart'; @@ -142,6 +143,8 @@ class _GameScreenState extends State { late final WolfEngine _engine; final FlutterRendererSettingsPersistence _persistence = FlutterRendererSettingsPersistence(); + final FlutterSaveGamePersistence _savePersistence = + FlutterSaveGamePersistence(); /// Mirrors [WolfRendererSettings.mode] into the Flutter renderer enum. RendererMode _rendererMode = RendererMode.hardware; @@ -181,6 +184,7 @@ class _GameScreenState extends State { onQuit: () { SystemNavigator.pop(); }, + saveGamePersistence: _savePersistence, ); _syncRendererModeFrom(_engine.rendererSettings); _loadPersistedSettings(); diff --git a/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart b/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart new file mode 100644 index 0000000..e67e11c --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart @@ -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 _magic = [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> currentLevel = _readIntGrid(reader); + final List> areaGrid = _readIntGrid(reader); + + final int areasByPlayerLength = reader.readUint32(); + final List areasByPlayer = List.generate( + areasByPlayerLength, + (_) => reader.readBool(), + growable: false, + ); + + final int entityCount = reader.readUint32(); + final List entities = List.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 extraData = decoded is Map + ? decoded + : {}; + + 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 doors = List.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 pushwalls = List.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 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 weaponStates = + {}; + 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> grid) { + writer.writeUint32(grid.length); + final int width = grid.isEmpty ? 0 : grid.first.length; + writer.writeUint32(width); + for (final List 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> _readIntGrid(_BinaryReader reader) { + final int height = reader.readUint32(); + final int width = reader.readUint32(); + + return List>.generate( + height, + (_) => + List.generate(width, (_) => reader.readInt32(), growable: false), + growable: false, + ); + } + + bool _listEquals(List a, List 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 bytes) { + _builder.add(bytes); + } + + void writeBool(bool value) { + writeUint8(value ? 1 : 0); + } + + void writeUint8(int value) { + _builder.add([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.', + ); + } + } +} diff --git a/packages/wolf_3d_dart/lib/src/engine/save/save_game_persistence.dart b/packages/wolf_3d_dart/lib/src/engine/save/save_game_persistence.dart new file mode 100644 index 0000000..4241dcd --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/engine/save/save_game_persistence.dart @@ -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 load({required int slot, required GameVersion version}); + + /// Persists [bytes] for [slot] and [version]. + Future save({ + required int slot, + required GameVersion version, + required Uint8List bytes, + }); +} diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 32c46a8..82da94d 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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 ?? [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 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 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 _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 _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; diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart index 0835c61..b6f2196 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -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, ); } diff --git a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart index 2a932c2..c9c7b30 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart @@ -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'; diff --git a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart index 03a038e..303a182 100644 --- a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart +++ b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.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; diff --git a/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart b/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart new file mode 100644 index 0000000..c275db4 --- /dev/null +++ b/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart @@ -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 = [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()), + ); + }); +} + +class _TestInput extends Wolf3dInput { + @override + void update() {} +} + +class _SilentAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + @override + Future debugSoundTest() async {} + + @override + void dispose() {} + + @override + Future init() async {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + Future 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: [ + _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: wallGrid, + areaGrid: List>.generate( + 64, + (_) => List.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>.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 paletteIndex) { + return Sprite(Uint8List.fromList([paletteIndex])); +} diff --git a/packages/wolf_3d_flutter/lib/save_game_persistence_flutter.dart b/packages/wolf_3d_flutter/lib/save_game_persistence_flutter.dart new file mode 100644 index 0000000..525581a --- /dev/null +++ b/packages/wolf_3d_flutter/lib/save_game_persistence_flutter.dart @@ -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 _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 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 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}'; + } +} diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index 7bafee0..06f9840 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -85,6 +85,7 @@ class Wolf3d { WolfEngine launchEngine({ required void Function() onGameWon, void Function()? onQuit, + SaveGamePersistence? saveGamePersistence, WolfRendererCapabilities? rendererCapabilities, WolfRendererSettings? rendererSettings, void Function(WolfRendererSettings settings)? onRendererSettingsChanged, @@ -109,6 +110,7 @@ class Wolf3d { // so backing out of the top-level menu should not pop the route. onMenuExit: () {}, onQuit: onQuit, + saveGamePersistence: saveGamePersistence, rendererCapabilities: rendererCapabilities, rendererSettings: rendererSettings, onRendererSettingsChanged: onRendererSettingsChanged,