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
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
bool hasGoldKey = false;
bool hasSilverKey = false;
@@ -52,11 +62,22 @@ class Player {
Player({required this.x, required this.y, required this.angle}) {
currentWeapon = weapons[WeaponType.pistol]!;
setHudFaceAnimationSeed(0);
}
// Helper getter to interface with the RaycasterPainter
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 ---
void tick(Duration elapsed) {
@@ -70,6 +91,24 @@ class Player {
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();
}
@@ -123,6 +162,7 @@ class Player {
// 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
damageFlash = math.min(1.0, damageFlash + (damage * 0.05));
_chaingunPickupFaceMsRemaining = 0;
if (health <= 0) {
log("[PLAYER] Died! Final Score: $score");
@@ -137,12 +177,19 @@ class Player {
log("[PLAYER] Healed for $amount ($health -> $newHealth)");
}
health = newHealth;
_chaingunPickupFaceMsRemaining = 0;
}
void triggerBonusFlash() {
bonusFlash = 1.0;
}
void triggerChaingunPickupFace({int? durationMs}) {
_chaingunPickupFaceMsRemaining = durationMs == null
? _chaingunPickupFaceDurationMs
: math.max(0, durationMs);
}
void addAmmo(int amount) {
final int newAmmo = math.min(99, ammo + amount);
if (ammo < 99) {
@@ -214,7 +261,10 @@ class Player {
}
if (weaponType == WeaponType.machineGun) hasMachineGun = true;
if (weaponType == WeaponType.chainGun) hasChainGun = true;
if (weaponType == WeaponType.chainGun) {
hasChainGun = true;
triggerChaingunPickupFace();
}
log("[PLAYER] Collected ${weaponType.name}.");
}
@@ -822,6 +822,14 @@ class WolfEngine {
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
for (int y = 0; y < 64; y++) {
for (int x = 0; x < 64; x++) {
@@ -1095,6 +1103,11 @@ class WolfEngine {
if (player.position.distanceTo(entity.position) < 0.5) {
final pickupSoundEffect = player.tryPickup(entity);
if (pickupSoundEffect != null) {
if (pickupSoundEffect == SoundEffect.getChainGun) {
player.triggerChaingunPickupFace(
durationMs: _soundEffectDurationMs(pickupSoundEffect),
);
}
audio.playSoundEffect(pickupSoundEffect);
itemsToRemove.add(entity);
}
@@ -1154,6 +1167,23 @@ class WolfEngine {
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() {
int maxArea = -1;
for (int y = 0; y < 64; y++) {
@@ -44,6 +44,7 @@ class RetailHudModule extends HudModule {
HudKey.faceDying: 121,
HudKey.faceNearDeath: 124,
HudKey.faceDead: 127,
HudKey.faceGotGatling: 128,
// Weapon icons.
HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90,
@@ -44,6 +44,7 @@ class SharewareHudModule extends HudModule {
HudKey.faceDying: 121,
HudKey.faceNearDeath: 124,
HudKey.faceDead: 127,
HudKey.faceGotGatling: 128,
HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90,
HudKey.chainGunIcon: 91,
@@ -37,6 +37,7 @@ class SpearDemoHudModule extends HudModule {
HudKey.faceDying: 109,
HudKey.faceNearDeath: 112,
HudKey.faceDead: 122, // BJOUCHPIC
HudKey.faceGotGatling: 116, // GOTGATLINGPIC
HudKey.pistolIcon: 77, // GUNPIC
HudKey.machineGunIcon: 78, // MACHINEGUNPIC
HudKey.chainGunIcon: 79, // GATLINGGUNPIC
@@ -24,6 +24,7 @@ enum HudKey {
faceDying('faceDying'), // health 5-20
faceNearDeath('faceNearDeath'), // health 1-4
faceDead('faceDead'), // health <= 0
faceGotGatling('faceGotGatling'),
// --- Weapon icons ---
pistolIcon('pistolIcon'),
@@ -222,10 +222,25 @@ abstract class RendererBackend<T>
}
void _drawHudFace(WolfEngine engine, List<VgaImage> vgaImages) {
final faceRef = engine.data.registry.hud.faceForHealth(
engine.player.health,
);
final int faceIndex = faceRef?.vgaIndex ?? -1;
int faceIndex = -1;
if (engine.player.isChaingunPickupFaceActive) {
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) {
blitHudVgaImage(vgaImages[faceIndex], 136, 164);
}
@@ -190,6 +190,10 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
? normalExitColor
: objTile == MapObject.secretExitTrigger
? secretExitColor
: objTile == MapObject.goldKey
? goldKeyColor
: objTile == MapObject.silverKey
? silverKeyColor
: (wallTile == 0
? floorColor
: (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/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() {
@@ -78,6 +79,89 @@ void main() {
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> {
@@ -168,7 +252,7 @@ WolfEngine _buildEngine() {
_solidSprite(2),
],
sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: const [],
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const [],
music: const [],
vgaImages: List.generate(220, (_) => _vgaStub()),