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
|
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;
|
||||||
|
|
||||||
|
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,
|
engine.player.health,
|
||||||
);
|
);
|
||||||
final int faceIndex = faceRef?.vgaIndex ?? -1;
|
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()),
|
||||||
|
|||||||
Reference in New Issue
Block a user