From 1a93b7d4a2db1aab4e5bd2b4c937c2637cb5b52c Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 14:37:58 +0100 Subject: [PATCH] feat: Implement save and restore functionality for game session state, including player and entity states Signed-off-by: Hans Kokx --- .../lib/src/engine/player/player.dart | 96 ++++++++ .../engine/save/game_session_snapshot.dart | 180 ++++++++++++++ .../lib/src/engine/wolf_3d_engine_base.dart | 199 +++++++++++++++ .../src/entities/entities/enemies/dog.dart | 19 ++ .../src/entities/entities/enemies/enemy.dart | 73 ++++++ .../src/entities/entities/weapon/weapon.dart | 18 ++ packages/wolf_3d_dart/lib/wolf_3d_engine.dart | 1 + .../engine/game_session_snapshot_test.dart | 227 ++++++++++++++++++ 8 files changed, 813 insertions(+) create mode 100644 packages/wolf_3d_dart/lib/src/engine/save/game_session_snapshot.dart create mode 100644 packages/wolf_3d_dart/test/engine/game_session_snapshot_test.dart diff --git a/packages/wolf_3d_dart/lib/src/engine/player/player.dart b/packages/wolf_3d_dart/lib/src/engine/player/player.dart index d99b23e..04a0209 100644 --- a/packages/wolf_3d_dart/lib/src/engine/player/player.dart +++ b/packages/wolf_3d_dart/lib/src/engine/player/player.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'dart:math' as math; +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'; @@ -36,6 +37,7 @@ class Player { // Classic face animation (UpdateFace/FACETICS random glance frames) math.Random _faceRng = math.Random(0); + int _faceSeed = 0; int _faceFrame = 0; double _faceCountTics = 0.0; int _nextFaceChangeThreshold = 0; @@ -81,6 +83,7 @@ class Player { int get hudFaceFrame => _faceFrame; void setHudFaceAnimationSeed(int seed) { + _faceSeed = seed; _faceRng = math.Random(seed); _faceFrame = 0; _faceCountTics = 0.0; @@ -93,6 +96,99 @@ class Player { _godModeFaceEnabled = enabled; } + PlayerSaveState toSaveState() { + final Map weaponStates = + {}; + for (final MapEntry entry in weapons.entries) { + final Weapon? weapon = entry.value; + if (weapon == null) { + continue; + } + weaponStates[entry.key] = weapon.toSaveState(); + } + + 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: currentWeapon.type, + weaponStates: weaponStates, + switchStateIndex: switchState.index, + pendingWeaponType: pendingWeaponType, + weaponAnimOffset: weaponAnimOffset, + ); + } + + void restoreFromSaveState(PlayerSaveState saveState) { + x = saveState.x; + y = saveState.y; + angle = saveState.angle; + health = saveState.health; + ammo = saveState.ammo; + score = saveState.score; + lives = saveState.lives; + damageFlash = saveState.damageFlash; + bonusFlash = saveState.bonusFlash; + _chaingunPickupFaceMsRemaining = saveState.chaingunPickupFaceMsRemaining; + _mutantDeathFaceActive = saveState.mutantDeathFaceActive; + _godModeFaceEnabled = saveState.godModeFaceEnabled; + _faceSeed = saveState.faceSeed; + _faceRng = math.Random(_faceSeed); + _faceFrame = saveState.faceFrame; + _faceCountTics = saveState.faceCountTics; + _nextFaceChangeThreshold = saveState.nextFaceChangeThreshold; + hasGoldKey = saveState.hasGoldKey; + hasSilverKey = saveState.hasSilverKey; + hasMachineGun = saveState.hasMachineGun; + hasChainGun = saveState.hasChainGun; + switchState = WeaponSwitchState.values[saveState.switchStateIndex]; + pendingWeaponType = saveState.pendingWeaponType; + weaponAnimOffset = saveState.weaponAnimOffset; + + weapons.updateAll((_, _) => null); + for (final MapEntry entry + in saveState.weaponStates.entries) { + final weapon = _createWeapon(entry.key); + weapon.restoreFromSaveState(entry.value); + weapons[entry.key] = weapon; + } + + currentWeapon = + weapons[saveState.currentWeaponType] ?? + _createWeapon(saveState.currentWeaponType); + final WeaponSaveState? currentWeaponState = + saveState.weaponStates[saveState.currentWeaponType]; + if (currentWeaponState != null) { + currentWeapon.restoreFromSaveState(currentWeaponState); + } + } + + Weapon _createWeapon(WeaponType weaponType) { + return switch (weaponType) { + WeaponType.knife => Knife(), + WeaponType.pistol => Pistol(), + WeaponType.machineGun => MachineGun(), + WeaponType.chainGun => ChainGun(), + }; + } + // --- General Update --- void tick(Duration elapsed) { diff --git a/packages/wolf_3d_dart/lib/src/engine/save/game_session_snapshot.dart b/packages/wolf_3d_dart/lib/src/engine/save/game_session_snapshot.dart new file mode 100644 index 0000000..3877d74 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/engine/save/game_session_snapshot.dart @@ -0,0 +1,180 @@ +library; + +import 'package:wolf_3d_dart/src/entities/entities/door.dart'; +import 'package:wolf_3d_dart/src/entities/entities/weapon/weapon.dart'; +import 'package:wolf_3d_dart/src/entities/entity.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +class WeaponSaveState { + const WeaponSaveState({ + required this.type, + required this.state, + required this.frameIndex, + required this.lastFrameTime, + required this.triggerReleased, + }); + + final WeaponType type; + final WeaponState state; + final int frameIndex; + final int lastFrameTime; + final bool triggerReleased; +} + +class PlayerSaveState { + const PlayerSaveState({ + required this.x, + required this.y, + required this.angle, + required this.health, + required this.ammo, + required this.score, + required this.lives, + required this.damageFlash, + required this.bonusFlash, + required this.chaingunPickupFaceMsRemaining, + required this.mutantDeathFaceActive, + required this.godModeFaceEnabled, + required this.faceSeed, + required this.faceFrame, + required this.faceCountTics, + required this.nextFaceChangeThreshold, + required this.hasGoldKey, + required this.hasSilverKey, + required this.hasMachineGun, + required this.hasChainGun, + required this.currentWeaponType, + required this.weaponStates, + required this.switchStateIndex, + required this.pendingWeaponType, + required this.weaponAnimOffset, + }); + + final double x; + final double y; + final double angle; + final int health; + final int ammo; + final int score; + final int lives; + final double damageFlash; + final double bonusFlash; + final int chaingunPickupFaceMsRemaining; + final bool mutantDeathFaceActive; + final bool godModeFaceEnabled; + final int faceSeed; + final int faceFrame; + final double faceCountTics; + final int nextFaceChangeThreshold; + final bool hasGoldKey; + final bool hasSilverKey; + final bool hasMachineGun; + final bool hasChainGun; + final WeaponType currentWeaponType; + final Map weaponStates; + final int switchStateIndex; + final WeaponType? pendingWeaponType; + final double weaponAnimOffset; +} + +class EntitySaveState { + const EntitySaveState({ + required this.kind, + required this.x, + required this.y, + required this.spriteIndex, + required this.angle, + required this.state, + required this.mapId, + required this.lastActionTime, + this.extraData = const {}, + }); + + final String kind; + final double x; + final double y; + final int spriteIndex; + final double angle; + final EntityState state; + final int mapId; + final int lastActionTime; + final Map extraData; +} + +class DoorSaveState { + const DoorSaveState({ + required this.x, + required this.y, + required this.mapId, + required this.state, + required this.offset, + required this.openTime, + }); + + final int x; + final int y; + final int mapId; + final DoorState state; + final double offset; + final int openTime; +} + +class PushwallSaveState { + const PushwallSaveState({ + required this.x, + required this.y, + required this.mapId, + required this.dirX, + required this.dirY, + required this.offset, + required this.tilesMoved, + required this.isActive, + }); + + final int x; + final int y; + final int mapId; + final int dirX; + final int dirY; + final double offset; + final int tilesMoved; + final bool isActive; +} + +class GameSessionSnapshot { + const GameSessionSnapshot({ + required this.currentGameIndex, + required this.currentEpisodeIndex, + required this.currentLevelIndex, + required this.returnLevelIndex, + required this.difficulty, + required this.timeAliveMs, + required this.lastAcousticAlertTime, + required this.isMapOverlayVisible, + required this.isMenuOverlayVisible, + required this.player, + required this.currentLevel, + required this.areaGrid, + required this.areasByPlayer, + required this.entities, + required this.doors, + required this.pushwalls, + }); + + final int currentGameIndex; + final int currentEpisodeIndex; + final int currentLevelIndex; + final int? returnLevelIndex; + final Difficulty difficulty; + final int timeAliveMs; + final int lastAcousticAlertTime; + final bool isMapOverlayVisible; + final bool isMenuOverlayVisible; + final PlayerSaveState player; + final List> currentLevel; + final List> areaGrid; + final List areasByPlayer; + final List entities; + final List doors; + final List pushwalls; +} 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 b511e50..32c46a8 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 @@ -2,6 +2,7 @@ import 'dart:developer'; import 'dart:math' as math; 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'; @@ -70,6 +71,12 @@ class WolfEngine { List get availableGames => List.unmodifiable(_availableGames); + int get currentGameIndex => _currentGameIndex; + + int get currentEpisodeIndex => _currentEpisodeIndex; + + int get currentLevelIndex => _currentLevelIndex; + int _currentGameIndex = 0; /// The currently active game data set. @@ -233,6 +240,125 @@ class WolfEngine { /// Whether the current gameplay session can be resumed from the main menu. bool get canResumeGame => _hasActiveSession; + GameSessionSnapshot captureSaveState() { + if (!_hasActiveSession || difficulty == null) { + throw StateError('Cannot capture save state without an active session.'); + } + + return GameSessionSnapshot( + currentGameIndex: _currentGameIndex, + currentEpisodeIndex: _currentEpisodeIndex, + currentLevelIndex: _currentLevelIndex, + returnLevelIndex: _returnLevelIndex, + difficulty: difficulty!, + timeAliveMs: _timeAliveMs, + lastAcousticAlertTime: _lastAcousticAlertTime, + isMapOverlayVisible: isMapOverlayVisible, + isMenuOverlayVisible: _isMenuOverlayVisible, + player: player.toSaveState(), + currentLevel: _cloneGrid(currentLevel), + areaGrid: _cloneGrid(_areaGrid), + areasByPlayer: List.from(_areasByPlayer), + entities: entities.map(_captureEntityState).toList(growable: false), + doors: doorManager.doors.values + .map( + (door) => DoorSaveState( + x: door.x, + y: door.y, + mapId: door.mapId, + state: door.state, + offset: door.offset, + openTime: door.openTime, + ), + ) + .toList(growable: false), + pushwalls: pushwallManager.pushwalls.values + .map( + (pushwall) => PushwallSaveState( + x: pushwall.x, + y: pushwall.y, + mapId: pushwall.mapId, + dirX: pushwall.dirX, + dirY: pushwall.dirY, + offset: pushwall.offset, + tilesMoved: pushwall.tilesMoved, + isActive: identical(pushwallManager.activePushwall, pushwall), + ), + ) + .toList(growable: false), + ); + } + + void restoreSaveState(GameSessionSnapshot snapshot) { + if (snapshot.currentGameIndex < 0 || + snapshot.currentGameIndex >= _availableGames.length) { + throw RangeError( + 'Snapshot game index ${snapshot.currentGameIndex} is out of range.', + ); + } + + _currentGameIndex = snapshot.currentGameIndex; + audio.activeGame = data; + onGameSelected?.call(data); + + _currentEpisodeIndex = snapshot.currentEpisodeIndex; + _currentLevelIndex = snapshot.currentLevelIndex; + _returnLevelIndex = snapshot.returnLevelIndex; + difficulty = snapshot.difficulty; + _timeAliveMs = snapshot.timeAliveMs; + _lastAcousticAlertTime = snapshot.lastAcousticAlertTime; + isMapOverlayVisible = snapshot.isMapOverlayVisible; + _isMenuOverlayVisible = snapshot.isMenuOverlayVisible; + _hasActiveSession = true; + + _loadLevel(preservePlayerState: false); + + currentLevel = _cloneGrid(snapshot.currentLevel); + _areaGrid = _cloneGrid(snapshot.areaGrid); + _areaCount = snapshot.areasByPlayer.length; + _areasByPlayer = List.from(snapshot.areasByPlayer); + _lastPatrolTileByEnemy.clear(); + + player.restoreFromSaveState(snapshot.player); + + doorManager.doors.clear(); + for (final doorState in snapshot.doors) { + final door = Door(x: doorState.x, y: doorState.y, mapId: doorState.mapId) + ..state = doorState.state + ..offset = doorState.offset + ..openTime = doorState.openTime; + doorManager.doors[((door.y & 0xFFFF) << 16) | (door.x & 0xFFFF)] = door; + } + + pushwallManager.pushwalls.clear(); + pushwallManager.activePushwall = null; + for (final pushwallState in snapshot.pushwalls) { + final pushwall = + Pushwall( + pushwallState.x, + pushwallState.y, + pushwallState.mapId, + ) + ..dirX = pushwallState.dirX + ..dirY = pushwallState.dirY + ..offset = pushwallState.offset + ..tilesMoved = pushwallState.tilesMoved; + pushwallManager.pushwalls['${pushwall.x},${pushwall.y}'] = pushwall; + if (pushwallState.isActive) { + pushwallManager.activePushwall = pushwall; + } + } + + entities = snapshot.entities + .map(_restoreEntityState) + .whereType() + .toList(growable: true); + + if (_isMenuOverlayVisible) { + menuManager.showMainMenu(hasResumableGame: true); + } + } + /// Replaces the shared framebuffer when dimensions change. void setFrameBuffer(int width, int height) { if (width <= 0 || height <= 0) { @@ -1332,6 +1458,79 @@ class WolfEngine { } } + static SpriteMap _cloneGrid(SpriteMap grid) { + return List>.generate( + grid.length, + (int y) => List.from(grid[y]), + growable: false, + ); + } + + EntitySaveState _captureEntityState(Entity entity) { + if (entity is Enemy) { + return entity.toSaveState(); + } + + final Map extraData = {}; + if (entity is AmmoCollectible) { + extraData['ammoAmount'] = entity.ammoAmount; + } + + return EntitySaveState( + kind: entity.runtimeType.toString(), + x: entity.x, + y: entity.y, + spriteIndex: entity.spriteIndex, + angle: entity.angle, + state: entity.state, + mapId: entity.mapId, + lastActionTime: entity.lastActionTime, + extraData: extraData, + ); + } + + Entity? _restoreEntityState(EntitySaveState entityState) { + final Entity? entity = switch (entityState.kind) { + 'SmallAmmoCollectible' => SmallAmmoCollectible( + x: entityState.x, + y: entityState.y, + ), + 'AmmoCollectible' => AmmoCollectible( + x: entityState.x, + y: entityState.y, + ammoAmount: (entityState.extraData['ammoAmount'] as num?)?.toInt() ?? 8, + ), + _ => EntityRegistry.spawn( + entityState.mapId, + entityState.x, + entityState.y, + difficulty!, + data.sprites.length, + isSharewareMode: data.version == GameVersion.shareware, + registry: data.registry, + ), + }; + + if (entity == null) { + return null; + } + + if (entity is Enemy) { + entity.restoreFromSaveState(entityState); + return entity; + } + + entity + ..x = entityState.x + ..y = entityState.y + ..spriteIndex = entityState.spriteIndex + ..angle = entityState.angle + ..state = entityState.state + ..mapId = entityState.mapId + ..lastActionTime = entityState.lastActionTime; + return entity; + } + /// Returns true if a tile is empty or contains a door that is sufficiently open. bool isWalkable(int x, int y) { // 1. Boundary Guard: Prevent range errors by checking if coordinates are on the map diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart index 368adfd..d491725 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/dog.dart @@ -34,6 +34,25 @@ class Dog extends Enemy { health = type.hitPointsFor(difficulty); } + @override + Map exportExtraSaveState() { + return { + 'dodgeAngleOffset': _dodgeAngleOffset, + 'dodgeTicTimer': _dodgeTicTimer, + 'stuckFrames': _stuckFrames, + 'wasMoving': _wasMoving, + }; + } + + @override + void importExtraSaveState(Map saveState) { + _dodgeAngleOffset = + (saveState['dodgeAngleOffset'] as num?)?.toDouble() ?? 0.0; + _dodgeTicTimer = (saveState['dodgeTicTimer'] as num?)?.toInt() ?? 0; + _stuckFrames = (saveState['stuckFrames'] as num?)?.toInt() ?? 0; + _wasMoving = saveState['wasMoving'] as bool? ?? false; + } + @override EnemyAnimation animationForState(EntityState state) { return switch (state) { diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart index 3e02ec6..974c804 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/enemies/enemy.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'dart:math' as math; +import 'package:wolf_3d_dart/src/engine/save/game_session_snapshot.dart'; import 'package:wolf_3d_dart/src/entities/entities/enemies/dog.dart'; import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart'; import 'package:wolf_3d_dart/src/entities/entities/enemies/guard.dart'; @@ -146,6 +147,78 @@ abstract class Enemy extends Entity { int _patrolDirX = 0; int _patrolDirY = 0; + EntitySaveState toSaveState() { + return EntitySaveState( + kind: runtimeType.toString(), + x: x, + y: y, + spriteIndex: spriteIndex, + angle: angle, + state: state, + mapId: mapId, + lastActionTime: lastActionTime, + extraData: { + 'ticCount': _ticCount, + 'ticAccumulator': _ticAccumulator, + 'currentFrame': currentFrame, + 'health': health, + 'damage': damage, + 'isDying': isDying, + 'hasDroppedItem': hasDroppedItem, + 'hasPlayedDeathSound': hasPlayedDeathSound, + 'isAlerted': isAlerted, + 'reactionTimeMs': reactionTimeMs, + 'patrolTargetTileX': _patrolTargetTile?.x, + 'patrolTargetTileY': _patrolTargetTile?.y, + 'patrolDirX': _patrolDirX, + 'patrolDirY': _patrolDirY, + ...exportExtraSaveState(), + }, + ); + } + + void restoreFromSaveState(EntitySaveState saveState) { + x = saveState.x; + y = saveState.y; + spriteIndex = saveState.spriteIndex; + angle = saveState.angle; + state = saveState.state; + mapId = saveState.mapId; + lastActionTime = saveState.lastActionTime; + + _ticCount = (saveState.extraData['ticCount'] as num?)?.toInt() ?? 0; + _ticAccumulator = + (saveState.extraData['ticAccumulator'] as num?)?.toDouble() ?? 0.0; + currentFrame = (saveState.extraData['currentFrame'] as num?)?.toInt() ?? 0; + health = (saveState.extraData['health'] as num?)?.toInt() ?? health; + damage = (saveState.extraData['damage'] as num?)?.toInt() ?? damage; + isDying = saveState.extraData['isDying'] as bool? ?? false; + hasDroppedItem = saveState.extraData['hasDroppedItem'] as bool? ?? false; + hasPlayedDeathSound = + saveState.extraData['hasPlayedDeathSound'] as bool? ?? false; + isAlerted = saveState.extraData['isAlerted'] as bool? ?? false; + reactionTimeMs = + (saveState.extraData['reactionTimeMs'] as num?)?.toInt() ?? 0; + + final int? patrolTargetTileX = + (saveState.extraData['patrolTargetTileX'] as num?)?.toInt(); + final int? patrolTargetTileY = + (saveState.extraData['patrolTargetTileY'] as num?)?.toInt(); + if (patrolTargetTileX != null && patrolTargetTileY != null) { + _patrolTargetTile = (x: patrolTargetTileX, y: patrolTargetTileY); + } else { + _patrolTargetTile = null; + } + _patrolDirX = (saveState.extraData['patrolDirX'] as num?)?.toInt() ?? 0; + _patrolDirY = (saveState.extraData['patrolDirY'] as num?)?.toInt() ?? 0; + + importExtraSaveState(saveState.extraData); + } + + Map exportExtraSaveState() => const {}; + + void importExtraSaveState(Map saveState) {} + /// Processes elapsed time and returns true if the enemy's animation frame has completed. /// /// Movement is applied continuously during the frame, but state changes (like deciding diff --git a/packages/wolf_3d_dart/lib/src/entities/entities/weapon/weapon.dart b/packages/wolf_3d_dart/lib/src/entities/entities/weapon/weapon.dart index 0e56592..2afb83b 100644 --- a/packages/wolf_3d_dart/lib/src/entities/entities/weapon/weapon.dart +++ b/packages/wolf_3d_dart/lib/src/entities/entities/weapon/weapon.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'dart:math' as math; +import 'package:wolf_3d_dart/src/engine/save/game_session_snapshot.dart'; import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; @@ -54,6 +55,23 @@ abstract class Weapon { _triggerReleased = true; } + WeaponSaveState toSaveState() { + return WeaponSaveState( + type: type, + state: state, + frameIndex: frameIndex, + lastFrameTime: lastFrameTime, + triggerReleased: _triggerReleased, + ); + } + + void restoreFromSaveState(WeaponSaveState saveState) { + state = saveState.state; + frameIndex = saveState.frameIndex; + lastFrameTime = saveState.lastFrameTime; + _triggerReleased = saveState.triggerReleased; + } + bool fire(int currentTime, {required int currentAmmo}) { if (state == WeaponState.idle && currentAmmo > 0) { if (!isAutomatic && !_triggerReleased) { diff --git a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart index df4ab66..2a932c2 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart @@ -14,4 +14,5 @@ export 'src/engine/player/player.dart'; 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/wolf_3d_engine_base.dart'; diff --git a/packages/wolf_3d_dart/test/engine/game_session_snapshot_test.dart b/packages/wolf_3d_dart/test/engine/game_session_snapshot_test.dart new file mode 100644 index 0000000..ec67758 --- /dev/null +++ b/packages/wolf_3d_dart/test/engine/game_session_snapshot_test.dart @@ -0,0 +1,227 @@ +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( + 'captureSaveState and restoreSaveState round-trip live session state', + () { + final engine = _buildEngine(); + engine.init(); + + engine.player + ..health = 47 + ..ammo = 33 + ..score = 1200 + ..lives = 5 + ..x = 10.5 + ..y = 11.5 + ..angle = 1.25 + ..hasGoldKey = true + ..hasMachineGun = true + ..weapons[WeaponType.machineGun] = MachineGun() + ..currentWeapon = MachineGun(); + + engine.currentLevel[8][8] = 0; + engine.currentLevel[5][6] = 98; + + final door = engine.doorManager.doors.values.first + ..state = DoorState.opening + ..offset = 0.42 + ..openTime = 1337; + + final pushwall = engine.pushwallManager.pushwalls.values.first + ..dirX = 1 + ..dirY = 0 + ..offset = 0.5 + ..tilesMoved = 1; + engine.pushwallManager.activePushwall = pushwall; + + final guard = + EntityRegistry.spawn( + MapObject.guardStart, + 12.5, + 13.5, + Difficulty.medium, + engine.data.sprites.length, + registry: engine.data.registry, + )! + as Guard + ..health = 17 + ..isAlerted = true + ..state = EntityState.attacking + ..currentFrame = 2 + ..lastActionTime = 222; + + final droppedAmmo = SmallAmmoCollectible(x: 7.5, y: 9.5) + ..spriteIndex = 999 % engine.data.sprites.length; + + engine.entities = [guard, droppedAmmo]; + + final snapshot = engine.captureSaveState(); + + engine.player + ..health = 1 + ..ammo = 0 + ..score = 0 + ..x = 2.5 + ..y = 2.5 + ..hasGoldKey = false; + engine.currentLevel[8][8] = 55; + door + ..state = DoorState.closed + ..offset = 0.0 + ..openTime = 0; + engine.pushwallManager.activePushwall = null; + engine.entities = []; + + engine.restoreSaveState(snapshot); + + expect(engine.currentGameIndex, 0); + expect(engine.currentEpisodeIndex, 0); + expect(engine.currentLevelIndex, 0); + + expect(engine.player.health, 47); + expect(engine.player.ammo, 33); + expect(engine.player.score, 1200); + expect(engine.player.lives, 5); + expect(engine.player.x, closeTo(10.5, 0.001)); + expect(engine.player.y, closeTo(11.5, 0.001)); + expect(engine.player.angle, closeTo(1.25, 0.001)); + expect(engine.player.hasGoldKey, isTrue); + expect(engine.player.hasMachineGun, isTrue); + expect(engine.player.currentWeapon.type, WeaponType.machineGun); + + expect(engine.currentLevel[8][8], 0); + expect(engine.currentLevel[5][6], 98); + + final restoredDoor = engine.doorManager.doors.values.first; + expect(restoredDoor.state, DoorState.opening); + expect(restoredDoor.offset, closeTo(0.42, 0.001)); + expect(restoredDoor.openTime, 1337); + + expect(engine.pushwallManager.activePushwall, isNotNull); + expect( + engine.pushwallManager.activePushwall!.offset, + closeTo(0.5, 0.001), + ); + expect(engine.pushwallManager.activePushwall!.tilesMoved, 1); + expect(engine.pushwallManager.activePushwall!.dirX, 1); + + expect(engine.entities, hasLength(2)); + expect(engine.entities.first, isA()); + final restoredGuard = engine.entities.first as Guard; + expect(restoredGuard.health, 17); + expect(restoredGuard.isAlerted, isTrue); + expect(restoredGuard.state, EntityState.attacking); + expect(restoredGuard.currentFrame, 2); + expect(engine.entities.last, isA()); + }, + ); +} + +class _TestInput extends Wolf3dInput { + @override + void update() {} +} + +class _SilentAudio implements EngineAudio { + @override + WolfensteinData? activeGame; + + @override + Future debugSoundTest() async {} + + @override + Future init() async {} + + @override + void dispose() {} + + @override + void playLevelMusic(Music music) {} + + @override + void playMenuMusic() {} + + @override + void playSoundEffect(SoundEffect effect) {} + + @override + void playSoundEffectId(int sfxId) {} + + @override + void stopMusic() {} + + @override + Future stopAllAudio() async {} +} + +WolfEngine _buildEngine() { + final wallGrid = _buildGrid(); + final objectGrid = _buildGrid(); + _fillBoundaries(wallGrid, 2); + + objectGrid[2][2] = MapObject.playerEast; + objectGrid[4][4] = MapObject.pushwallTrigger; + wallGrid[2][3] = 90; + wallGrid[4][4] = 5; + + 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)), + 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])); +}