feat: Add mutant death and god mode face animations, update HUD rendering and player damage handling

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 12:29:56 +01:00
parent 400ce4f680
commit 827b8c779e
8 changed files with 124 additions and 5 deletions
@@ -30,6 +30,10 @@ class Player {
int _chaingunPickupFaceMsRemaining = 0; int _chaingunPickupFaceMsRemaining = 0;
static const int _chaingunPickupFaceDurationMs = 900; static const int _chaingunPickupFaceDurationMs = 900;
// Additional classic face states.
bool _mutantDeathFaceActive = false;
bool _godModeFaceEnabled = false;
// Classic face animation (UpdateFace/FACETICS random glance frames) // Classic face animation (UpdateFace/FACETICS random glance frames)
math.Random _faceRng = math.Random(0); math.Random _faceRng = math.Random(0);
int _faceFrame = 0; int _faceFrame = 0;
@@ -69,6 +73,8 @@ class Player {
Coordinate2D get position => Coordinate2D(x, y); Coordinate2D get position => Coordinate2D(x, y);
bool get isChaingunPickupFaceActive => _chaingunPickupFaceMsRemaining > 0; bool get isChaingunPickupFaceActive => _chaingunPickupFaceMsRemaining > 0;
bool get isMutantDeathFaceActive => _mutantDeathFaceActive;
bool get isGodModeFaceEnabled => _godModeFaceEnabled;
int get hudFaceFrame => _faceFrame; int get hudFaceFrame => _faceFrame;
void setHudFaceAnimationSeed(int seed) { void setHudFaceAnimationSeed(int seed) {
@@ -76,6 +82,12 @@ class Player {
_faceFrame = 0; _faceFrame = 0;
_faceCountTics = 0.0; _faceCountTics = 0.0;
_nextFaceChangeThreshold = _faceRng.nextInt(256); _nextFaceChangeThreshold = _faceRng.nextInt(256);
_mutantDeathFaceActive = false;
_godModeFaceEnabled = false;
}
void setGodModeFaceEnabled(bool enabled) {
_godModeFaceEnabled = enabled;
} }
// --- General Update --- // --- General Update ---
@@ -156,13 +168,15 @@ class Player {
// --- Health & Damage --- // --- Health & Damage ---
void takeDamage(int damage) { void takeDamage(int damage, {EnemyType? attackerType}) {
health = math.max(0, health - damage); health = math.max(0, health - damage);
// 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; _chaingunPickupFaceMsRemaining = 0;
_mutantDeathFaceActive =
health <= 0 && attackerType == EnemyType.mutant;
if (health <= 0) { if (health <= 0) {
log("[PLAYER] Died! Final Score: $score"); log("[PLAYER] Died! Final Score: $score");
@@ -178,6 +192,7 @@ class Player {
} }
health = newHealth; health = newHealth;
_chaingunPickupFaceMsRemaining = 0; _chaingunPickupFaceMsRemaining = 0;
_mutantDeathFaceActive = false;
} }
void triggerBonusFlash() { void triggerBonusFlash() {
@@ -1032,7 +1032,10 @@ class WolfEngine {
tryOpenDoor: doorManager.tryOpenDoor, tryOpenDoor: doorManager.tryOpenDoor,
onDamagePlayer: (int damage) { onDamagePlayer: (int damage) {
final difficultyMode = difficulty ?? Difficulty.medium; final difficultyMode = difficulty ?? Difficulty.medium;
player.takeDamage(difficultyMode.scaleIncomingEnemyDamage(damage)); player.takeDamage(
difficultyMode.scaleIncomingEnemyDamage(damage),
attackerType: entity.type,
);
}, },
onPlaySound: audio.playSoundEffect, onPlaySound: audio.playSoundEffect,
); );
@@ -45,6 +45,7 @@ class RetailHudModule extends HudModule {
HudKey.faceNearDeath: 124, HudKey.faceNearDeath: 124,
HudKey.faceDead: 127, HudKey.faceDead: 127,
HudKey.faceGotGatling: 128, HudKey.faceGotGatling: 128,
HudKey.faceMutantDeath: 129,
// Weapon icons. // Weapon icons.
HudKey.pistolIcon: 89, HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90, HudKey.machineGunIcon: 90,
@@ -45,6 +45,7 @@ class SharewareHudModule extends HudModule {
HudKey.faceNearDeath: 124, HudKey.faceNearDeath: 124,
HudKey.faceDead: 127, HudKey.faceDead: 127,
HudKey.faceGotGatling: 128, HudKey.faceGotGatling: 128,
HudKey.faceMutantDeath: 129,
HudKey.pistolIcon: 89, HudKey.pistolIcon: 89,
HudKey.machineGunIcon: 90, HudKey.machineGunIcon: 90,
HudKey.chainGunIcon: 91, HudKey.chainGunIcon: 91,
@@ -38,6 +38,7 @@ class SpearDemoHudModule extends HudModule {
HudKey.faceNearDeath: 112, HudKey.faceNearDeath: 112,
HudKey.faceDead: 122, // BJOUCHPIC HudKey.faceDead: 122, // BJOUCHPIC
HudKey.faceGotGatling: 116, // GOTGATLINGPIC HudKey.faceGotGatling: 116, // GOTGATLINGPIC
HudKey.faceGodMode: 117, // GODMODEFACE1PIC
HudKey.pistolIcon: 77, // GUNPIC HudKey.pistolIcon: 77, // GUNPIC
HudKey.machineGunIcon: 78, // MACHINEGUNPIC HudKey.machineGunIcon: 78, // MACHINEGUNPIC
HudKey.chainGunIcon: 79, // GATLINGGUNPIC HudKey.chainGunIcon: 79, // GATLINGGUNPIC
@@ -25,6 +25,8 @@ enum HudKey {
faceNearDeath('faceNearDeath'), // health 1-4 faceNearDeath('faceNearDeath'), // health 1-4
faceDead('faceDead'), // health <= 0 faceDead('faceDead'), // health <= 0
faceGotGatling('faceGotGatling'), faceGotGatling('faceGotGatling'),
faceMutantDeath('faceMutantDeath'),
faceGodMode('faceGodMode'),
// --- Weapon icons --- // --- Weapon icons ---
pistolIcon('pistolIcon'), pistolIcon('pistolIcon'),
@@ -228,6 +228,20 @@ abstract class RendererBackend<T>
faceIndex = faceIndex =
engine.data.registry.hud.resolve(HudKey.faceGotGatling)?.vgaIndex ?? engine.data.registry.hud.resolve(HudKey.faceGotGatling)?.vgaIndex ??
-1; -1;
} else if (engine.player.isMutantDeathFaceActive) {
faceIndex =
engine.data.registry.hud.resolve(HudKey.faceMutantDeath)?.vgaIndex ??
-1;
if (faceIndex < 0) {
faceIndex =
engine.data.registry.hud.resolve(HudKey.faceDead)?.vgaIndex ?? -1;
}
} else if (engine.player.isGodModeFaceEnabled) {
final int baseGodFace =
engine.data.registry.hud.resolve(HudKey.faceGodMode)?.vgaIndex ?? -1;
if (baseGodFace >= 0) {
faceIndex = baseGodFace + engine.player.hudFaceFrame;
}
} else { } else {
final HudKey faceKey = engine.data.registry.hud.faceKeyForHealth( final HudKey faceKey = engine.data.registry.hud.faceKeyForHealth(
engine.player.health, engine.player.health,
@@ -241,6 +255,19 @@ abstract class RendererBackend<T>
} }
} }
if (faceIndex < 0) {
final HudKey fallbackKey = engine.data.registry.hud.faceKeyForHealth(
engine.player.health,
);
final int fallbackBase =
engine.data.registry.hud.resolve(fallbackKey)?.vgaIndex ?? -1;
if (fallbackBase >= 0) {
faceIndex = fallbackKey == HudKey.faceDead
? fallbackBase
: fallbackBase + 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);
} }
@@ -162,6 +162,72 @@ void main() {
); );
}, },
); );
test('fatal mutant hit shows mutant death face', () {
final engine = _buildEngine();
engine.init();
engine.player.health = 10;
engine.player.takeDamage(20, attackerType: EnemyType.mutant);
final renderer = _HudProbeRenderer(vgaImages: engine.data.vgaImages);
renderer.drawHudForTest(engine);
final expectedMutantDeathFaceIndex = engine.data.registry.hud
.resolve(HudKey.faceMutantDeath)
?.vgaIndex;
expect(expectedMutantDeathFaceIndex, isNotNull);
final faceCall = renderer.drawCalls.firstWhere(
(call) => call.startY == 164 && call.startX == 136,
orElse: () => throw StateError('Face slot was not rendered.'),
);
expect(faceCall.imageIndex, expectedMutantDeathFaceIndex);
});
test('god mode face renders in spear demo HUD mapping', () {
final engine = _buildEngine(
version: GameVersion.spearOfDestinyDemo,
registry: SpearDemoAssetRegistry(),
);
engine.init();
engine.player.health = 90;
engine.player.setGodModeFaceEnabled(true);
final renderer = _HudProbeRenderer(vgaImages: engine.data.vgaImages);
renderer.drawHudForTest(engine);
final expectedGodModeFaceIndex = engine.data.registry.hud
.resolve(HudKey.faceGodMode)
?.vgaIndex;
expect(expectedGodModeFaceIndex, isNotNull);
final faceCall = renderer.drawCalls.firstWhere(
(call) => call.startY == 164 && call.startX == 136,
orElse: () => throw StateError('Face slot was not rendered.'),
);
expect(faceCall.imageIndex, expectedGodModeFaceIndex);
});
test('god mode face falls back to health face when unmapped', () {
final engine = _buildEngine();
engine.init();
engine.player.health = 90;
engine.player.setGodModeFaceEnabled(true);
final renderer = _HudProbeRenderer(vgaImages: engine.data.vgaImages);
renderer.drawHudForTest(engine);
final expectedHealthFaceIndex = engine.data.registry.hud
.faceForHealth(engine.player.health)
?.vgaIndex;
expect(expectedHealthFaceIndex, isNotNull);
final faceCall = renderer.drawCalls.firstWhere(
(call) => call.startY == 164 && call.startX == 136,
orElse: () => throw StateError('Face slot was not rendered.'),
);
expect(faceCall.imageIndex, expectedHealthFaceIndex);
});
} }
class _HudProbeRenderer extends RendererBackend<int> { class _HudProbeRenderer extends RendererBackend<int> {
@@ -233,7 +299,10 @@ class _HudDrawCall {
}); });
} }
WolfEngine _buildEngine() { WolfEngine _buildEngine({
GameVersion version = GameVersion.retail,
AssetRegistry? registry,
}) {
final wallGrid = _buildGrid(); final wallGrid = _buildGrid();
final objectGrid = _buildGrid(); final objectGrid = _buildGrid();
@@ -242,9 +311,9 @@ WolfEngine _buildEngine() {
return WolfEngine( return WolfEngine(
data: WolfensteinData( data: WolfensteinData(
version: GameVersion.retail, version: version,
dataVersion: DataVersion.unknown, dataVersion: DataVersion.unknown,
registry: RetailAssetRegistry(), registry: registry ?? RetailAssetRegistry(),
walls: [ walls: [
_solidSprite(1), _solidSprite(1),
_solidSprite(1), _solidSprite(1),