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:
@@ -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();
|
||||
|
||||
@@ -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_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<GameScreen> {
|
||||
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<GameScreen> {
|
||||
onQuit: () {
|
||||
SystemNavigator.pop();
|
||||
},
|
||||
saveGamePersistence: _savePersistence,
|
||||
);
|
||||
_syncRendererModeFrom(_engine.rendererSettings);
|
||||
_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: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]));
|
||||
}
|
||||
@@ -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({
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user