Compare commits
2 Commits
a66ccf52c5
...
1a93b7d4a2
| Author | SHA1 | Date | |
|---|---|---|---|
|
1a93b7d4a2
|
|||
|
7cb3f25c74
|
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.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].
|
||||
abstract class BaseWolfRenderer extends StatefulWidget {
|
||||
/// Engine instance that owns world state and the shared framebuffer.
|
||||
@@ -32,6 +37,17 @@ abstract class BaseWolfRendererState<T extends BaseWolfRenderer>
|
||||
final FocusNode focusNode = FocusNode();
|
||||
|
||||
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
|
||||
void initState() {
|
||||
@@ -48,12 +64,18 @@ abstract class BaseWolfRendererState<T extends BaseWolfRenderer>
|
||||
return;
|
||||
}
|
||||
|
||||
Duration delta = elapsed - _lastTick;
|
||||
final Duration delta = elapsed - _lastTick;
|
||||
_lastTick = elapsed;
|
||||
|
||||
final Stopwatch tickStopwatch = Stopwatch()..start();
|
||||
widget.engine.tick(delta);
|
||||
tickStopwatch.stop();
|
||||
_tickWindowMicrosTotal += tickStopwatch.elapsedMicroseconds;
|
||||
_tickWindowCount++;
|
||||
|
||||
performRender();
|
||||
|
||||
_maybeLogPerfWindow();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -66,6 +88,80 @@ abstract class BaseWolfRendererState<T extends BaseWolfRenderer>
|
||||
/// Renders the latest engine state into the concrete renderer's output type.
|
||||
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.
|
||||
Widget buildViewport(BuildContext context);
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ class _WolfFlutterRendererState
|
||||
final SoftwareRenderer _renderer = SoftwareRenderer();
|
||||
|
||||
ui.Image? _renderedFrame;
|
||||
bool _isRendering = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -46,12 +45,19 @@ class _WolfFlutterRendererState
|
||||
Color get scaffoldColor => Colors.black;
|
||||
|
||||
@override
|
||||
void performRender() {
|
||||
if (_isRendering) return;
|
||||
_isRendering = true;
|
||||
void dispose() {
|
||||
_renderedFrame?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void performRender() {
|
||||
scheduleLatestPresent((onComplete) {
|
||||
final FrameBuffer frameBuffer = widget.engine.frameBuffer;
|
||||
final Stopwatch renderStopwatch = Stopwatch()..start();
|
||||
_renderer.render(widget.engine);
|
||||
renderStopwatch.stop();
|
||||
recordRenderStageMicros(renderStopwatch.elapsedMicroseconds);
|
||||
|
||||
// Convert the engine-owned framebuffer into a GPU-friendly ui.Image on
|
||||
// the Flutter side while preserving nearest-neighbor pixel fidelity.
|
||||
@@ -66,10 +72,13 @@ class _WolfFlutterRendererState
|
||||
_renderedFrame?.dispose();
|
||||
_renderedFrame = image;
|
||||
});
|
||||
} else {
|
||||
image.dispose();
|
||||
}
|
||||
_isRendering = false;
|
||||
onComplete();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -43,7 +43,6 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
|
||||
ui.Image? _renderedFrame;
|
||||
ui.FragmentProgram? _shaderProgram;
|
||||
ui.FragmentShader? _shader;
|
||||
bool _isRendering = false;
|
||||
bool _isShaderUnavailable = false;
|
||||
|
||||
@override
|
||||
@@ -67,13 +66,12 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
|
||||
|
||||
@override
|
||||
void performRender() {
|
||||
if (_isRendering) {
|
||||
return;
|
||||
}
|
||||
_isRendering = true;
|
||||
|
||||
scheduleLatestPresent((onComplete) {
|
||||
final FrameBuffer frameBuffer = widget.engine.frameBuffer;
|
||||
final Stopwatch renderStopwatch = Stopwatch()..start();
|
||||
_renderer.render(widget.engine);
|
||||
renderStopwatch.stop();
|
||||
recordRenderStageMicros(renderStopwatch.elapsedMicroseconds);
|
||||
|
||||
ui.decodeImageFromPixels(
|
||||
frameBuffer.pixels.buffer.asUint8List(),
|
||||
@@ -89,9 +87,10 @@ class _WolfGlslRendererState extends BaseWolfRendererState<WolfGlslRenderer> {
|
||||
} else {
|
||||
image.dispose();
|
||||
}
|
||||
_isRendering = false;
|
||||
onComplete();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
Reference in New Issue
Block a user