feat: Implement save and restore functionality for game session state, including player and entity states

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 14:37:58 +01:00
parent 7cb3f25c74
commit 1a93b7d4a2
8 changed files with 813 additions and 0 deletions
@@ -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<WeaponType, WeaponSaveState> weaponStates =
<WeaponType, WeaponSaveState>{};
for (final MapEntry<WeaponType, Weapon?> 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<WeaponType, WeaponSaveState> 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) {
@@ -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<WeaponType, WeaponSaveState> 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 <String, Object?>{},
});
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<String, Object?> 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<List<int>> currentLevel;
final List<List<int>> areaGrid;
final List<bool> areasByPlayer;
final List<EntitySaveState> entities;
final List<DoorSaveState> doors;
final List<PushwallSaveState> pushwalls;
}
@@ -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<WolfensteinData> 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<bool>.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<bool>.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<Entity>()
.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<List<int>>.generate(
grid.length,
(int y) => List<int>.from(grid[y]),
growable: false,
);
}
EntitySaveState _captureEntityState(Entity entity) {
if (entity is Enemy) {
return entity.toSaveState();
}
final Map<String, Object?> extraData = <String, Object?>{};
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
@@ -34,6 +34,25 @@ class Dog extends Enemy {
health = type.hitPointsFor(difficulty);
}
@override
Map<String, Object?> exportExtraSaveState() {
return <String, Object?>{
'dodgeAngleOffset': _dodgeAngleOffset,
'dodgeTicTimer': _dodgeTicTimer,
'stuckFrames': _stuckFrames,
'wasMoving': _wasMoving,
};
}
@override
void importExtraSaveState(Map<String, Object?> 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) {
@@ -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: <String, Object?>{
'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<String, Object?> exportExtraSaveState() => const <String, Object?>{};
void importExtraSaveState(Map<String, Object?> 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
@@ -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) {
@@ -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';