feat: Enhance weapon switching logic and add tests for animation pacing and menu behavior

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 12:35:31 +01:00
parent 827b8c779e
commit a66ccf52c5
6 changed files with 66 additions and 15 deletions
@@ -61,8 +61,11 @@ class Player {
// 0.0 is resting, 500.0 is fully off-screen // 0.0 is resting, 500.0 is fully off-screen
double weaponAnimOffset = 0.0; double weaponAnimOffset = 0.0;
// How fast the weapon drops/raises per tick static const double _weaponSwitchTravel = 500.0;
final double switchSpeed = 30.0; static const double _weaponSwitchPhaseTics = 6.0;
static const double _ticRate = 70.0;
static const double _weaponSwitchUnitsPerSecond =
(_weaponSwitchTravel * _ticRate) / _weaponSwitchPhaseTics;
Player({required this.x, required this.y, required this.angle}) { Player({required this.x, required this.y, required this.angle}) {
currentWeapon = weapons[WeaponType.pistol]!; currentWeapon = weapons[WeaponType.pistol]!;
@@ -121,12 +124,18 @@ class Player {
} }
} }
updateWeaponSwitch(); updateWeaponSwitch(elapsed);
} }
// --- Weapon Switching & Animation Logic --- // --- Weapon Switching & Animation Logic ---
void updateWeaponSwitch() { void updateWeaponSwitch(Duration elapsed) {
final double delta =
_weaponSwitchUnitsPerSecond * (elapsed.inMicroseconds / 1000000.0);
if (delta <= 0.0) {
return;
}
if (switchState == WeaponSwitchState.lowering) { if (switchState == WeaponSwitchState.lowering) {
// If the map doesn't contain the pending weapon, stop immediately // If the map doesn't contain the pending weapon, stop immediately
if (weapons[pendingWeaponType] == null) { if (weapons[pendingWeaponType] == null) {
@@ -134,9 +143,9 @@ class Player {
return; return;
} }
weaponAnimOffset += switchSpeed; weaponAnimOffset += delta;
if (weaponAnimOffset >= 500.0) { if (weaponAnimOffset >= _weaponSwitchTravel) {
weaponAnimOffset = 500.0; weaponAnimOffset = _weaponSwitchTravel;
// We already know it's not null now, but we can keep the // We already know it's not null now, but we can keep the
// fallback to pistol just to be extra safe. // fallback to pistol just to be extra safe.
@@ -145,7 +154,7 @@ class Player {
switchState = WeaponSwitchState.raising; switchState = WeaponSwitchState.raising;
} }
} else if (switchState == WeaponSwitchState.raising) { } else if (switchState == WeaponSwitchState.raising) {
weaponAnimOffset -= switchSpeed; weaponAnimOffset -= delta;
if (weaponAnimOffset <= 0) { if (weaponAnimOffset <= 0) {
weaponAnimOffset = 0.0; weaponAnimOffset = 0.0;
switchState = WeaponSwitchState.idle; switchState = WeaponSwitchState.idle;
@@ -175,8 +184,7 @@ class Player {
// A 10 damage hit gives a 0.5 flash, a 20 damage hit maxes it out at 1.0 // A 10 damage hit gives a 0.5 flash, a 20 damage hit maxes it out at 1.0
damageFlash = math.min(1.0, damageFlash + (damage * 0.05)); damageFlash = math.min(1.0, damageFlash + (damage * 0.05));
_chaingunPickupFaceMsRemaining = 0; _chaingunPickupFaceMsRemaining = 0;
_mutantDeathFaceActive = _mutantDeathFaceActive = health <= 0 && attackerType == EnemyType.mutant;
health <= 0 && attackerType == EnemyType.mutant;
if (health <= 0) { if (health <= 0) {
log("[PLAYER] Died! Final Score: $score"); log("[PLAYER] Died! Final Score: $score");
@@ -2094,7 +2094,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
@override @override
dynamic finalizeFrame() { dynamic finalizeFrame() {
if (engine.difficulty != null) { if (engine.difficulty != null && !engine.isMenuOpen) {
if (engine.player.damageFlash > 0.0) { if (engine.player.damageFlash > 0.0) {
if (_usesTerminalLayout) { if (_usesTerminalLayout) {
_applyDamageFlashToScene(); _applyDamageFlashToScene();
@@ -1336,7 +1336,7 @@ class SixelRenderer extends CliRendererBackend<String> {
sb.write('\x1bPq'); sb.write('\x1bPq');
sb.write('"1;1;$_outputWidth;$_outputHeight'); sb.write('"1;1;$_outputWidth;$_outputHeight');
final bool gameplayActive = engine.difficulty != null; final bool gameplayActive = engine.difficulty != null && !engine.isMenuOpen;
final double damageIntensity = gameplayActive final double damageIntensity = gameplayActive
? engine.player.damageFlash ? engine.player.damageFlash
: 0.0; : 0.0;
@@ -1401,7 +1401,7 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
@override @override
FrameBuffer finalizeFrame() { FrameBuffer finalizeFrame() {
if (engine.difficulty != null) { if (engine.difficulty != null && !engine.isMenuOpen) {
if (engine.player.damageFlash > 0) { if (engine.player.damageFlash > 0) {
_applyDamageFlash(); _applyDamageFlash();
} else if (engine.player.bonusFlash > 0) { } else if (engine.player.bonusFlash > 0) {
@@ -115,5 +115,22 @@ void main() {
expect(player.bonusFlash, lessThan(1.0)); expect(player.bonusFlash, lessThan(1.0));
expect(player.bonusFlash, greaterThan(0.0)); expect(player.bonusFlash, greaterThan(0.0));
}); });
test('weapon switch animates at canonical tic pacing', () {
final player = Player(x: 1.5, y: 1.5, angle: 0);
player.weapons[WeaponType.machineGun] = MachineGun();
player.requestWeaponSwitch(WeaponType.machineGun);
expect(player.switchState, WeaponSwitchState.lowering);
player.tick(const Duration(milliseconds: 86));
expect(player.switchState, WeaponSwitchState.raising);
expect(player.weaponAnimOffset, closeTo(500.0, 0.001));
expect(player.currentWeapon.type, WeaponType.machineGun);
player.tick(const Duration(milliseconds: 86));
expect(player.switchState, WeaponSwitchState.idle);
expect(player.weaponAnimOffset, closeTo(0.0, 0.001));
});
}); });
} }
@@ -49,6 +49,27 @@ void main() {
expect(mapPixels.contains(ColorPalette.vga32Bit[2]), isTrue); expect(mapPixels.contains(ColorPalette.vga32Bit[2]), isTrue);
expect(mapPixels[hudProbeIndex], equals(normalPixels[hudProbeIndex])); expect(mapPixels[hudProbeIndex], equals(normalPixels[hudProbeIndex]));
}); });
test('software renderer does not apply bonus flash while menu is open', () {
final input = _MutableInput();
final engine = _buildEngine(input: input);
engine.init();
input.isBack = true;
engine.tick(const Duration(milliseconds: 16));
input.isBack = false;
expect(engine.isMenuOpen, isTrue);
final renderer = SoftwareRenderer();
engine.player.bonusFlash = 0.0;
final List<int> menuBaseline = List<int>.from(renderer.render(engine).pixels);
engine.player.bonusFlash = 1.0;
final List<int> menuWithFlash = List<int>.from(renderer.render(engine).pixels);
expect(menuWithFlash, equals(menuBaseline));
});
}); });
} }
@@ -69,7 +90,12 @@ class _StaticInput extends Wolf3dInput {
} }
} }
WolfEngine _buildEngine() { class _MutableInput extends Wolf3dInput {
@override
void update() {}
}
WolfEngine _buildEngine({Wolf3dInput? input}) {
final wallGrid = _buildGrid(); final wallGrid = _buildGrid();
final objectGrid = _buildGrid(); final objectGrid = _buildGrid();
@@ -115,7 +141,7 @@ WolfEngine _buildEngine() {
difficulty: Difficulty.medium, difficulty: Difficulty.medium,
startingEpisode: 0, startingEpisode: 0,
frameBuffer: FrameBuffer(96, 96), frameBuffer: FrameBuffer(96, 96),
input: _StaticInput(), input: input ?? _StaticInput(),
onGameWon: () {}, onGameWon: () {},
); );
} }