diff --git a/packages/wolf_3d_dart/lib/src/engine/player/player.dart b/packages/wolf_3d_dart/lib/src/engine/player/player.dart index c64ff4a..986e628 100644 --- a/packages/wolf_3d_dart/lib/src/engine/player/player.dart +++ b/packages/wolf_3d_dart/lib/src/engine/player/player.dart @@ -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}."); } diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index aabd338..eef8aa6 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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++) { diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_hud_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/retail_hud_module.dart index a5a0521..2b33363 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/retail_hud_module.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/retail_hud_module.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_hud_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_hud_module.dart index 5451af4..14c09c3 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_hud_module.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/shareware_hud_module.dart @@ -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, diff --git a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart index 87c8679..3d4813d 100644 --- a/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart +++ b/packages/wolf_3d_dart/lib/src/registry/built_in/spear_demo_hud_module.dart @@ -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 diff --git a/packages/wolf_3d_dart/lib/src/registry/keys/hud_key.dart b/packages/wolf_3d_dart/lib/src/registry/keys/hud_key.dart index e7cf0bf..3f9b487 100644 --- a/packages/wolf_3d_dart/lib/src/registry/keys/hud_key.dart +++ b/packages/wolf_3d_dart/lib/src/registry/keys/hud_key.dart @@ -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'), diff --git a/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart b/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart index 43c2a63..4dc37dc 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart @@ -222,10 +222,25 @@ abstract class RendererBackend } void _drawHudFace(WolfEngine engine, List 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); } diff --git a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart index 1bfff3e..aee2d4e 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -190,6 +190,10 @@ class SoftwareRenderer extends RendererBackend { ? normalExitColor : objTile == MapObject.secretExitTrigger ? secretExitColor + : objTile == MapObject.goldKey + ? goldKeyColor + : objTile == MapObject.silverKey + ? silverKeyColor : (wallTile == 0 ? floorColor : (wallTile >= 90 ? doorColor : wallColor)); diff --git a/packages/wolf_3d_dart/test/rendering/hud_lives_rendering_test.dart b/packages/wolf_3d_dart/test/rendering/hud_lives_rendering_test.dart index 2c25f84..ae1f210 100644 --- a/packages/wolf_3d_dart/test/rendering/hud_lives_rendering_test.dart +++ b/packages/wolf_3d_dart/test/rendering/hud_lives_rendering_test.dart @@ -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 { @@ -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()),