feat: Implement chaingun pickup face animation and update HUD rendering logic

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 12:23:10 +01:00
parent 8ed460b03e
commit 400ce4f680
9 changed files with 193 additions and 6 deletions
@@ -26,6 +26,16 @@ class Player {
double bonusFlash = 0.0; // 0.0 is none, 1.0 is maximum white double bonusFlash = 0.0; // 0.0 is none, 1.0 is maximum white
final double bonusFlashFadeSpeed = 0.05; // How fast it fades per tick final double bonusFlashFadeSpeed = 0.05; // How fast it fades per tick
// Chaingun pickup face (classic GOTGATLINGPIC)
int _chaingunPickupFaceMsRemaining = 0;
static const int _chaingunPickupFaceDurationMs = 900;
// Classic face animation (UpdateFace/FACETICS random glance frames)
math.Random _faceRng = math.Random(0);
int _faceFrame = 0;
double _faceCountTics = 0.0;
int _nextFaceChangeThreshold = 0;
// Inventory // Inventory
bool hasGoldKey = false; bool hasGoldKey = false;
bool hasSilverKey = false; bool hasSilverKey = false;
@@ -52,11 +62,22 @@ class Player {
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]!;
setHudFaceAnimationSeed(0);
} }
// Helper getter to interface with the RaycasterPainter // Helper getter to interface with the RaycasterPainter
Coordinate2D get position => Coordinate2D(x, y); Coordinate2D get position => Coordinate2D(x, y);
bool get isChaingunPickupFaceActive => _chaingunPickupFaceMsRemaining > 0;
int get hudFaceFrame => _faceFrame;
void setHudFaceAnimationSeed(int seed) {
_faceRng = math.Random(seed);
_faceFrame = 0;
_faceCountTics = 0.0;
_nextFaceChangeThreshold = _faceRng.nextInt(256);
}
// --- General Update --- // --- General Update ---
void tick(Duration elapsed) { void tick(Duration elapsed) {
@@ -70,6 +91,24 @@ class Player {
bonusFlash = math.max(0.0, bonusFlash - bonusFlashFadeSpeed); bonusFlash = math.max(0.0, bonusFlash - bonusFlashFadeSpeed);
} }
if (_chaingunPickupFaceMsRemaining > 0) {
_chaingunPickupFaceMsRemaining = math.max(
0,
_chaingunPickupFaceMsRemaining - elapsed.inMilliseconds,
);
} else {
_faceCountTics += (elapsed.inMilliseconds * 70) / 1000.0;
if (_faceCountTics > _nextFaceChangeThreshold) {
int nextFrame = _faceRng.nextInt(4);
if (nextFrame == 3) {
nextFrame = 1;
}
_faceFrame = nextFrame;
_faceCountTics = 0.0;
_nextFaceChangeThreshold = _faceRng.nextInt(256);
}
}
updateWeaponSwitch(); updateWeaponSwitch();
} }
@@ -123,6 +162,7 @@ class Player {
// Spike the damage flash based on how much damage was taken // Spike the damage flash based on how much damage was taken
// 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;
if (health <= 0) { if (health <= 0) {
log("[PLAYER] Died! Final Score: $score"); log("[PLAYER] Died! Final Score: $score");
@@ -137,12 +177,19 @@ class Player {
log("[PLAYER] Healed for $amount ($health -> $newHealth)"); log("[PLAYER] Healed for $amount ($health -> $newHealth)");
} }
health = newHealth; health = newHealth;
_chaingunPickupFaceMsRemaining = 0;
} }
void triggerBonusFlash() { void triggerBonusFlash() {
bonusFlash = 1.0; bonusFlash = 1.0;
} }
void triggerChaingunPickupFace({int? durationMs}) {
_chaingunPickupFaceMsRemaining = durationMs == null
? _chaingunPickupFaceDurationMs
: math.max(0, durationMs);
}
void addAmmo(int amount) { void addAmmo(int amount) {
final int newAmmo = math.min(99, ammo + amount); final int newAmmo = math.min(99, ammo + amount);
if (ammo < 99) { if (ammo < 99) {
@@ -214,7 +261,10 @@ class Player {
} }
if (weaponType == WeaponType.machineGun) hasMachineGun = true; if (weaponType == WeaponType.machineGun) hasMachineGun = true;
if (weaponType == WeaponType.chainGun) hasChainGun = true; if (weaponType == WeaponType.chainGun) {
hasChainGun = true;
triggerChaingunPickupFace();
}
log("[PLAYER] Collected ${weaponType.name}."); log("[PLAYER] Collected ${weaponType.name}.");
} }
@@ -822,6 +822,14 @@ class WolfEngine {
player = Player(x: 1.5, y: 1.5, angle: 0.0); player = Player(x: 1.5, y: 1.5, angle: 0.0);
} }
if (!preservePlayerState) {
final int faceSeed =
((_currentEpisodeIndex + 1) * 1000) +
((_currentLevelIndex + 1) * 10) +
(_currentGameIndex + 1);
player.setHudFaceAnimationSeed(faceSeed);
}
// Sanitize the level grid to ensure only valid walls/doors remain // Sanitize the level grid to ensure only valid walls/doors remain
for (int y = 0; y < 64; y++) { for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) { for (int x = 0; x < 64; x++) {
@@ -1095,6 +1103,11 @@ class WolfEngine {
if (player.position.distanceTo(entity.position) < 0.5) { if (player.position.distanceTo(entity.position) < 0.5) {
final pickupSoundEffect = player.tryPickup(entity); final pickupSoundEffect = player.tryPickup(entity);
if (pickupSoundEffect != null) { if (pickupSoundEffect != null) {
if (pickupSoundEffect == SoundEffect.getChainGun) {
player.triggerChaingunPickupFace(
durationMs: _soundEffectDurationMs(pickupSoundEffect),
);
}
audio.playSoundEffect(pickupSoundEffect); audio.playSoundEffect(pickupSoundEffect);
itemsToRemove.add(entity); itemsToRemove.add(entity);
} }
@@ -1154,6 +1167,23 @@ class WolfEngine {
return area >= 0 && area < _areasByPlayer.length && _areasByPlayer[area]; return area >= 0 && area < _areasByPlayer.length && _areasByPlayer[area];
} }
int _soundEffectDurationMs(SoundEffect effect) {
final int slotIndex =
data.registry.sfx.resolve(effect)?.slotIndex ??
effect.idFor(data.version);
if (slotIndex < 0 || slotIndex >= data.sounds.length) {
return 0;
}
final int sampleCount = data.sounds[slotIndex].bytes.length;
if (sampleCount <= 0) {
return 0;
}
// Digitized Wolf3D effects are played as 8-bit mono PCM at 7000Hz.
return ((sampleCount * 1000) / 7000).round();
}
void _buildFallbackAreasIfNeeded() { void _buildFallbackAreasIfNeeded() {
int maxArea = -1; int maxArea = -1;
for (int y = 0; y < 64; y++) { for (int y = 0; y < 64; y++) {
@@ -44,6 +44,7 @@ class RetailHudModule extends HudModule {
HudKey.faceDying: 121, HudKey.faceDying: 121,
HudKey.faceNearDeath: 124, HudKey.faceNearDeath: 124,
HudKey.faceDead: 127, HudKey.faceDead: 127,
HudKey.faceGotGatling: 128,
// Weapon icons. // Weapon icons.
HudKey.pistolIcon: 89, HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90, HudKey.machineGunIcon: 90,
@@ -44,6 +44,7 @@ class SharewareHudModule extends HudModule {
HudKey.faceDying: 121, HudKey.faceDying: 121,
HudKey.faceNearDeath: 124, HudKey.faceNearDeath: 124,
HudKey.faceDead: 127, HudKey.faceDead: 127,
HudKey.faceGotGatling: 128,
HudKey.pistolIcon: 89, HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90, HudKey.machineGunIcon: 90,
HudKey.chainGunIcon: 91, HudKey.chainGunIcon: 91,
@@ -37,6 +37,7 @@ class SpearDemoHudModule extends HudModule {
HudKey.faceDying: 109, HudKey.faceDying: 109,
HudKey.faceNearDeath: 112, HudKey.faceNearDeath: 112,
HudKey.faceDead: 122, // BJOUCHPIC HudKey.faceDead: 122, // BJOUCHPIC
HudKey.faceGotGatling: 116, // GOTGATLINGPIC
HudKey.pistolIcon: 77, // GUNPIC HudKey.pistolIcon: 77, // GUNPIC
HudKey.machineGunIcon: 78, // MACHINEGUNPIC HudKey.machineGunIcon: 78, // MACHINEGUNPIC
HudKey.chainGunIcon: 79, // GATLINGGUNPIC HudKey.chainGunIcon: 79, // GATLINGGUNPIC
@@ -24,6 +24,7 @@ enum HudKey {
faceDying('faceDying'), // health 5-20 faceDying('faceDying'), // health 5-20
faceNearDeath('faceNearDeath'), // health 1-4 faceNearDeath('faceNearDeath'), // health 1-4
faceDead('faceDead'), // health <= 0 faceDead('faceDead'), // health <= 0
faceGotGatling('faceGotGatling'),
// --- Weapon icons --- // --- Weapon icons ---
pistolIcon('pistolIcon'), pistolIcon('pistolIcon'),
@@ -222,10 +222,25 @@ abstract class RendererBackend<T>
} }
void _drawHudFace(WolfEngine engine, List<VgaImage> vgaImages) { void _drawHudFace(WolfEngine engine, List<VgaImage> vgaImages) {
final faceRef = engine.data.registry.hud.faceForHealth( int faceIndex = -1;
engine.player.health,
); if (engine.player.isChaingunPickupFaceActive) {
final int faceIndex = faceRef?.vgaIndex ?? -1; faceIndex =
engine.data.registry.hud.resolve(HudKey.faceGotGatling)?.vgaIndex ??
-1;
} else {
final HudKey faceKey = engine.data.registry.hud.faceKeyForHealth(
engine.player.health,
);
final int baseIndex =
engine.data.registry.hud.resolve(faceKey)?.vgaIndex ?? -1;
if (baseIndex >= 0) {
faceIndex = faceKey == HudKey.faceDead
? baseIndex
: baseIndex + engine.player.hudFaceFrame;
}
}
if (faceIndex >= 0 && faceIndex < vgaImages.length) { if (faceIndex >= 0 && faceIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[faceIndex], 136, 164); blitHudVgaImage(vgaImages[faceIndex], 136, 164);
} }
@@ -190,6 +190,10 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
? normalExitColor ? normalExitColor
: objTile == MapObject.secretExitTrigger : objTile == MapObject.secretExitTrigger
? secretExitColor ? secretExitColor
: objTile == MapObject.goldKey
? goldKeyColor
: objTile == MapObject.silverKey
? silverKeyColor
: (wallTile == 0 : (wallTile == 0
? floorColor ? floorColor
: (wallTile >= 90 ? doorColor : wallColor)); : (wallTile >= 90 ? doorColor : wallColor));
@@ -4,6 +4,7 @@ import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart'; import 'package:wolf_3d_dart/src/rendering/renderer_backend.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_input.dart'; import 'package:wolf_3d_dart/wolf_3d_input.dart';
void main() { void main() {
@@ -78,6 +79,89 @@ void main() {
expect(faceCall.imageIndex, expectedFaceIndex); expect(faceCall.imageIndex, expectedFaceIndex);
}); });
test('chaingun pickup shows GOTGATLING face then returns to health face', () {
final engine = _buildEngine();
engine.init();
engine.player.health = 100;
const int chainGunSfxId = 38;
const int chainGunDurationMs = 350;
engine.data.sounds[chainGunSfxId] = PcmSound(
Uint8List(chainGunDurationMs * 7),
);
final chainGun = WeaponCollectible(
x: engine.player.x,
y: engine.player.y,
mapId: MapObject.chainGun,
);
engine.entities.add(chainGun);
engine.tick(const Duration(milliseconds: 16));
final renderer = _HudProbeRenderer(vgaImages: engine.data.vgaImages);
renderer.drawHudForTest(engine);
final expectedGotGatlingIndex = engine.data.registry.hud
.resolve(HudKey.faceGotGatling)
?.vgaIndex;
expect(expectedGotGatlingIndex, isNotNull);
final gotGatlingFaceCall = renderer.drawCalls.firstWhere(
(call) => call.startY == 164 && call.startX == 136,
orElse: () => throw StateError('Face slot was not rendered.'),
);
expect(gotGatlingFaceCall.imageIndex, expectedGotGatlingIndex);
engine.player.tick(Duration(milliseconds: chainGunDurationMs + 50));
final rendererAfterTimeout = _HudProbeRenderer(
vgaImages: engine.data.vgaImages,
);
rendererAfterTimeout.drawHudForTest(engine);
final expectedHealthFaceIndex = engine.data.registry.hud
.faceForHealth(engine.player.health)
?.vgaIndex;
expect(expectedHealthFaceIndex, isNotNull);
final healthFaceCall = rendererAfterTimeout.drawCalls.firstWhere(
(call) => call.startY == 164 && call.startX == 136,
orElse: () =>
throw StateError('Face slot was not rendered after timeout.'),
);
expect(healthFaceCall.imageIndex, expectedHealthFaceIndex);
});
test(
'standard VGA HUD face animates between health-band frames over time',
() {
final engine = _buildEngine();
engine.init();
engine.player.health = 100;
final renderer = _HudProbeRenderer(vgaImages: engine.data.vgaImages);
renderer.drawHudForTest(engine);
final initialFaceCall = renderer.drawCalls.firstWhere(
(call) => call.startY == 164 && call.startX == 136,
orElse: () => throw StateError('Initial face slot was not rendered.'),
);
engine.player.tick(const Duration(milliseconds: 4500));
final rendererAfter = _HudProbeRenderer(vgaImages: engine.data.vgaImages);
rendererAfter.drawHudForTest(engine);
final animatedFaceCall = rendererAfter.drawCalls.firstWhere(
(call) => call.startY == 164 && call.startX == 136,
orElse: () => throw StateError('Animated face slot was not rendered.'),
);
expect(
animatedFaceCall.imageIndex,
isNot(equals(initialFaceCall.imageIndex)),
);
},
);
} }
class _HudProbeRenderer extends RendererBackend<int> { class _HudProbeRenderer extends RendererBackend<int> {
@@ -168,7 +252,7 @@ WolfEngine _buildEngine() {
_solidSprite(2), _solidSprite(2),
], ],
sprites: List.generate(436, (_) => _solidSprite(255)), sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: const [], sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const [], adLibSounds: const [],
music: const [], music: const [],
vgaImages: List.generate(220, (_) => _vgaStub()), vgaImages: List.generate(220, (_) => _vgaStub()),