Compare commits

..

2 Commits

11 changed files with 966 additions and 49 deletions
@@ -1,6 +1,7 @@
import 'dart:developer'; import 'dart:developer';
import 'dart:math' as math; 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_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart';
@@ -36,6 +37,7 @@ class Player {
// Classic face animation (UpdateFace/FACETICS random glance frames) // Classic face animation (UpdateFace/FACETICS random glance frames)
math.Random _faceRng = math.Random(0); math.Random _faceRng = math.Random(0);
int _faceSeed = 0;
int _faceFrame = 0; int _faceFrame = 0;
double _faceCountTics = 0.0; double _faceCountTics = 0.0;
int _nextFaceChangeThreshold = 0; int _nextFaceChangeThreshold = 0;
@@ -81,6 +83,7 @@ class Player {
int get hudFaceFrame => _faceFrame; int get hudFaceFrame => _faceFrame;
void setHudFaceAnimationSeed(int seed) { void setHudFaceAnimationSeed(int seed) {
_faceSeed = seed;
_faceRng = math.Random(seed); _faceRng = math.Random(seed);
_faceFrame = 0; _faceFrame = 0;
_faceCountTics = 0.0; _faceCountTics = 0.0;
@@ -93,6 +96,99 @@ class Player {
_godModeFaceEnabled = enabled; _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 --- // --- General Update ---
void tick(Duration elapsed) { 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 'dart:math' as math;
import 'package:wolf_3d_dart/src/menu/menu_manager.dart'; import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/src/engine/save/game_session_snapshot.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart'; import 'package:wolf_3d_dart/wolf_3d_entities.dart';
@@ -70,6 +71,12 @@ class WolfEngine {
List<WolfensteinData> get availableGames => List<WolfensteinData> get availableGames =>
List.unmodifiable(_availableGames); List.unmodifiable(_availableGames);
int get currentGameIndex => _currentGameIndex;
int get currentEpisodeIndex => _currentEpisodeIndex;
int get currentLevelIndex => _currentLevelIndex;
int _currentGameIndex = 0; int _currentGameIndex = 0;
/// The currently active game data set. /// The currently active game data set.
@@ -233,6 +240,125 @@ class WolfEngine {
/// Whether the current gameplay session can be resumed from the main menu. /// Whether the current gameplay session can be resumed from the main menu.
bool get canResumeGame => _hasActiveSession; bool get canResumeGame => _hasActiveSession;
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. /// Replaces the shared framebuffer when dimensions change.
void setFrameBuffer(int width, int height) { void setFrameBuffer(int width, int height) {
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
@@ -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. /// Returns true if a tile is empty or contains a door that is sufficiently open.
bool isWalkable(int x, int y) { bool isWalkable(int x, int y) {
// 1. Boundary Guard: Prevent range errors by checking if coordinates are on the map // 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); 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 @override
EnemyAnimation animationForState(EntityState state) { EnemyAnimation animationForState(EntityState state) {
return switch (state) { return switch (state) {
@@ -1,6 +1,7 @@
import 'dart:developer'; import 'dart:developer';
import 'dart:math' as math; 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/dog.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart'; import 'package:wolf_3d_dart/src/entities/entities/enemies/enemy_type.dart';
import 'package:wolf_3d_dart/src/entities/entities/enemies/guard.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 _patrolDirX = 0;
int _patrolDirY = 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. /// 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 /// Movement is applied continuously during the frame, but state changes (like deciding
@@ -1,6 +1,7 @@
import 'dart:developer'; import 'dart:developer';
import 'dart:math' as math; 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/entities/enemies/enemy.dart';
import 'package:wolf_3d_dart/src/entities/entity.dart'; import 'package:wolf_3d_dart/src/entities/entity.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
@@ -54,6 +55,23 @@ abstract class Weapon {
_triggerReleased = true; _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}) { bool fire(int currentTime, {required int currentAmmo}) {
if (state == WeaponState.idle && currentAmmo > 0) { if (state == WeaponState.idle && currentAmmo > 0) {
if (!isAutomatic && !_triggerReleased) { if (!isAutomatic && !_triggerReleased) {
@@ -14,4 +14,5 @@ export 'src/engine/player/player.dart';
export 'src/engine/player_locomotion_constants.dart'; export 'src/engine/player_locomotion_constants.dart';
export 'src/engine/rendering/renderer_settings.dart'; export 'src/engine/rendering/renderer_settings.dart';
export 'src/engine/rendering/renderer_settings_persistence.dart'; export 'src/engine/rendering/renderer_settings_persistence.dart';
export 'src/engine/save/game_session_snapshot.dart';
export 'src/engine/wolf_3d_engine_base.dart'; export 'src/engine/wolf_3d_engine_base.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 = <Entity>[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 = <Entity>[];
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<Guard>());
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<SmallAmmoCollectible>());
},
);
}
class _TestInput extends Wolf3dInput {
@override
void update() {}
}
class _SilentAudio implements EngineAudio {
@override
WolfensteinData? activeGame;
@override
Future<void> debugSoundTest() async {}
@override
Future<void> 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<void> 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(<int>[paletteIndex]));
}
@@ -1,10 +1,15 @@
/// Shared Flutter renderer shell for driving the Wolf3D engine from a widget tree. /// Shared Flutter renderer shell for driving the Wolf3D engine from a widget tree.
library; library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart';
typedef PresentFrameAction = void Function(VoidCallback onComplete);
/// Base widget for renderers that present frames from a [WolfEngine]. /// Base widget for renderers that present frames from a [WolfEngine].
abstract class BaseWolfRenderer extends StatefulWidget { abstract class BaseWolfRenderer extends StatefulWidget {
/// Engine instance that owns world state and the shared framebuffer. /// Engine instance that owns world state and the shared framebuffer.
@@ -32,6 +37,17 @@ abstract class BaseWolfRendererState<T extends BaseWolfRenderer>
final FocusNode focusNode = FocusNode(); final FocusNode focusNode = FocusNode();
Duration _lastTick = Duration.zero; Duration _lastTick = Duration.zero;
bool _isPresenting = false;
PresentFrameAction? _queuedPresentAction;
int _tickWindowCount = 0;
int _presentWindowCount = 0;
int _coalescedPresentWindowCount = 0;
int _tickWindowMicrosTotal = 0;
int _renderWindowMicrosTotal = 0;
int _presentWindowMicrosTotal = 0;
static const int _perfLogWindowFrames = 180;
@override @override
void initState() { void initState() {
@@ -48,12 +64,18 @@ abstract class BaseWolfRendererState<T extends BaseWolfRenderer>
return; return;
} }
Duration delta = elapsed - _lastTick; final Duration delta = elapsed - _lastTick;
_lastTick = elapsed; _lastTick = elapsed;
final Stopwatch tickStopwatch = Stopwatch()..start();
widget.engine.tick(delta); widget.engine.tick(delta);
tickStopwatch.stop();
_tickWindowMicrosTotal += tickStopwatch.elapsedMicroseconds;
_tickWindowCount++;
performRender(); performRender();
_maybeLogPerfWindow();
} }
@override @override
@@ -66,6 +88,80 @@ abstract class BaseWolfRendererState<T extends BaseWolfRenderer>
/// Renders the latest engine state into the concrete renderer's output type. /// Renders the latest engine state into the concrete renderer's output type.
void performRender(); void performRender();
/// Schedules presentation work while coalescing to the latest requested frame.
@protected
void scheduleLatestPresent(PresentFrameAction action) {
if (_isPresenting) {
_queuedPresentAction = action;
_coalescedPresentWindowCount++;
return;
}
_runPresentAction(action);
}
/// Records CPU-side rendering stage duration in microseconds.
@protected
void recordRenderStageMicros(int microseconds) {
_renderWindowMicrosTotal += microseconds;
}
void _runPresentAction(PresentFrameAction action) {
_isPresenting = true;
final Stopwatch presentStopwatch = Stopwatch()..start();
bool isCompleted = false;
void onComplete() {
if (isCompleted) {
return;
}
isCompleted = true;
presentStopwatch.stop();
_presentWindowMicrosTotal += presentStopwatch.elapsedMicroseconds;
_presentWindowCount++;
_isPresenting = false;
if (!mounted) {
return;
}
final PresentFrameAction? nextAction = _queuedPresentAction;
_queuedPresentAction = null;
if (nextAction != null) {
scheduleMicrotask(() => _runPresentAction(nextAction));
}
}
action(onComplete);
}
void _maybeLogPerfWindow() {
if (!kDebugMode || _tickWindowCount < _perfLogWindowFrames) {
return;
}
final double avgTickMs = _tickWindowMicrosTotal / _tickWindowCount / 1000.0;
final double avgRenderMs =
_renderWindowMicrosTotal / _tickWindowCount / 1000.0;
final double avgPresentMs = _presentWindowCount > 0
? _presentWindowMicrosTotal / _presentWindowCount / 1000.0
: 0.0;
debugPrint(
'Renderer perf window ($_tickWindowCount frames): '
'avg tick ${avgTickMs.toStringAsFixed(2)}ms, '
'avg render ${avgRenderMs.toStringAsFixed(2)}ms, '
'avg present ${avgPresentMs.toStringAsFixed(2)}ms, '
'coalesced $_coalescedPresentWindowCount',
);
_tickWindowCount = 0;
_presentWindowCount = 0;
_coalescedPresentWindowCount = 0;
_tickWindowMicrosTotal = 0;
_renderWindowMicrosTotal = 0;
_presentWindowMicrosTotal = 0;
}
/// Builds the visible viewport widget for the latest rendered frame. /// Builds the visible viewport widget for the latest rendered frame.
Widget buildViewport(BuildContext context); Widget buildViewport(BuildContext context);
@@ -29,7 +29,6 @@ class _WolfFlutterRendererState
final SoftwareRenderer _renderer = SoftwareRenderer(); final SoftwareRenderer _renderer = SoftwareRenderer();
ui.Image? _renderedFrame; ui.Image? _renderedFrame;
bool _isRendering = false;
@override @override
void initState() { void initState() {
@@ -46,12 +45,19 @@ class _WolfFlutterRendererState
Color get scaffoldColor => Colors.black; Color get scaffoldColor => Colors.black;
@override @override
void performRender() { void dispose() {
if (_isRendering) return; _renderedFrame?.dispose();
_isRendering = true; super.dispose();
}
@override
void performRender() {
scheduleLatestPresent((onComplete) {
final FrameBuffer frameBuffer = widget.engine.frameBuffer; final FrameBuffer frameBuffer = widget.engine.frameBuffer;
final Stopwatch renderStopwatch = Stopwatch()..start();
_renderer.render(widget.engine); _renderer.render(widget.engine);
renderStopwatch.stop();
recordRenderStageMicros(renderStopwatch.elapsedMicroseconds);
// Convert the engine-owned framebuffer into a GPU-friendly ui.Image on // Convert the engine-owned framebuffer into a GPU-friendly ui.Image on
// the Flutter side while preserving nearest-neighbor pixel fidelity. // the Flutter side while preserving nearest-neighbor pixel fidelity.
@@ -66,10 +72,13 @@ class _WolfFlutterRendererState
_renderedFrame?.dispose(); _renderedFrame?.dispose();
_renderedFrame = image; _renderedFrame = image;
}); });
} else {
image.dispose();
} }
_isRendering = false; onComplete();
}, },
); );
});
} }
@override @override
@@ -43,7 +43,6 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
ui.Image? _renderedFrame; ui.Image? _renderedFrame;
ui.FragmentProgram? _shaderProgram; ui.FragmentProgram? _shaderProgram;
ui.FragmentShader? _shader; ui.FragmentShader? _shader;
bool _isRendering = false;
bool _isShaderUnavailable = false; bool _isShaderUnavailable = false;
@override @override
@@ -67,13 +66,12 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
@override @override
void performRender() { void performRender() {
if (_isRendering) { scheduleLatestPresent((onComplete) {
return;
}
_isRendering = true;
final FrameBuffer frameBuffer = widget.engine.frameBuffer; final FrameBuffer frameBuffer = widget.engine.frameBuffer;
final Stopwatch renderStopwatch = Stopwatch()..start();
_renderer.render(widget.engine); _renderer.render(widget.engine);
renderStopwatch.stop();
recordRenderStageMicros(renderStopwatch.elapsedMicroseconds);
ui.decodeImageFromPixels( ui.decodeImageFromPixels(
frameBuffer.pixels.buffer.asUint8List(), frameBuffer.pixels.buffer.asUint8List(),
@@ -89,9 +87,10 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
} else { } else {
image.dispose(); image.dispose();
} }
_isRendering = false; onComplete();
}, },
); );
});
} }
@override @override