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:
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user