From 8ed460b03e14827d70aa3b69bf25507b53429a6d Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 12:11:19 +0100 Subject: [PATCH] feat: Add bonus flash effect for player pickups and update rendering logic Signed-off-by: Hans Kokx --- .../lib/src/engine/player/player.dart | 14 ++++ .../lib/src/rendering/ascii_renderer.dart | 76 +++++++++++++++---- .../lib/src/rendering/renderer_backend.dart | 4 +- .../lib/src/rendering/sixel_renderer.dart | 39 ++++++---- .../lib/src/rendering/software_renderer.dart | 31 +++++++- .../canonical_numeric_parity_test.dart | 13 ++++ .../rendering/hud_lives_rendering_test.dart | 25 +++++- 7 files changed, 165 insertions(+), 37 deletions(-) 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 43a2570..c64ff4a 100644 --- a/packages/wolf_3d_dart/lib/src/engine/player/player.dart +++ b/packages/wolf_3d_dart/lib/src/engine/player/player.dart @@ -22,6 +22,10 @@ class Player { double damageFlash = 0.0; // 0.0 is none, 1.0 is maximum red final double damageFlashFadeSpeed = 0.05; // How fast it fades per tick + // Bonus flash + double bonusFlash = 0.0; // 0.0 is none, 1.0 is maximum white + final double bonusFlashFadeSpeed = 0.05; // How fast it fades per tick + // Inventory bool hasGoldKey = false; bool hasSilverKey = false; @@ -62,6 +66,10 @@ class Player { damageFlash = math.max(0.0, damageFlash - damageFlashFadeSpeed); } + if (bonusFlash > 0.0) { + bonusFlash = math.max(0.0, bonusFlash - bonusFlashFadeSpeed); + } + updateWeaponSwitch(); } @@ -131,6 +139,10 @@ class Player { health = newHealth; } + void triggerBonusFlash() { + bonusFlash = 1.0; + } + void addAmmo(int amount) { final int newAmmo = math.min(99, ammo + amount); if (ammo < 99) { @@ -211,6 +223,8 @@ class Player { requestWeaponSwitch(weaponType); } + triggerBonusFlash(); + return effect.pickupSoundEffect; } diff --git a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart index 111d8ba..483e1d4 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -483,16 +483,16 @@ class AsciiRenderer extends CliRendererBackend { final int color = isPushwall ? pushwallColor : objTile == MapObject.normalExitTrigger - ? normalExitColor - : objTile == MapObject.secretExitTrigger - ? secretExitColor - : objTile == MapObject.goldKey - ? goldKeyColor - : objTile == MapObject.silverKey - ? silverKeyColor - : (wallTile == 0 - ? floorColor - : (wallTile >= 90 ? doorColor : wallColor)); + ? normalExitColor + : objTile == MapObject.secretExitTrigger + ? secretExitColor + : objTile == MapObject.goldKey + ? goldKeyColor + : objTile == MapObject.silverKey + ? silverKeyColor + : (wallTile == 0 + ? floorColor + : (wallTile >= 90 ? doorColor : wallColor)); _fillMapRect( mapStartX + (x * tileSize), mapStartY + (y * tileSize), @@ -2094,11 +2094,19 @@ class AsciiRenderer extends CliRendererBackend { @override dynamic finalizeFrame() { - if (engine.difficulty != null && engine.player.damageFlash > 0.0) { - if (_usesTerminalLayout) { - _applyDamageFlashToScene(); - } else { - _applyDamageFlash(); + if (engine.difficulty != null) { + if (engine.player.damageFlash > 0.0) { + if (_usesTerminalLayout) { + _applyDamageFlashToScene(); + } else { + _applyDamageFlash(); + } + } else if (engine.player.bonusFlash > 0.0) { + if (_usesTerminalLayout) { + _applyBonusFlashToScene(); + } else { + _applyBonusFlash(); + } } } if (_usesTerminalLayout) { @@ -2275,6 +2283,44 @@ class AsciiRenderer extends CliRendererBackend { return (0xFF000000) | (b << 16) | (g << 8) | r; } + void _applyBonusFlash() { + for (int y = 0; y < viewHeight; y++) { + for (int x = 0; x < width; x++) { + final ColoredChar cell = _screen[y][x]; + _screen[y][x] = ColoredChar( + cell.char, + _applyBonusFlashToColor(cell.rawColor), + cell.rawBackgroundColor == null + ? null + : _applyBonusFlashToColor(cell.rawBackgroundColor!), + ); + } + } + } + + void _applyBonusFlashToScene() { + for (int y = 0; y < _terminalPixelHeight; y++) { + for (int x = projectionOffsetX; x < _viewportRightX; x++) { + _scenePixels[y][x] = _applyBonusFlashToColor(_scenePixels[y][x]); + } + } + } + + int _applyBonusFlashToColor(int color) { + final double intensity = engine.player.bonusFlash; + final double whiteMix = 0.65 * intensity; + + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + r = (r + ((255 - r) * whiteMix)).round().clamp(0, 255); + g = (g + ((255 - g) * whiteMix)).round().clamp(0, 255); + b = (b + ((255 - b) * whiteMix)).round().clamp(0, 255); + + return (0xFF000000) | (b << 16) | (g << 8) | r; + } + int _scaleColor(int color, double brightness) { int r = ((color & 0xFF) * brightness).toInt().clamp(0, 255); int g = (((color >> 8) & 0xFF) * brightness).toInt().clamp(0, 255); 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 4725a3a..43c2a63 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart @@ -248,7 +248,7 @@ abstract class RendererBackend _drawHudKeySlot( engine, vgaImages, - startX: 30, + startX: 240, startY: 164, hasKey: engine.player.hasGoldKey, presentKey: HudKey.goldKeyIcon, @@ -256,7 +256,7 @@ abstract class RendererBackend _drawHudKeySlot( engine, vgaImages, - startX: 30, + startX: 240, startY: 180, hasKey: engine.player.hasSilverKey, presentKey: HudKey.silverKeyIcon, diff --git a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart index ced1a28..64bf4c2 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/sixel_renderer.dart @@ -407,16 +407,16 @@ class SixelRenderer extends CliRendererBackend { final int color = isPushwall ? pushwallColor : objTile == MapObject.normalExitTrigger - ? normalExitColor - : objTile == MapObject.secretExitTrigger - ? secretExitColor - : objTile == MapObject.goldKey - ? goldKeyColor - : objTile == MapObject.silverKey - ? silverKeyColor - : (wallTile == 0 - ? floorColor - : (wallTile >= 90 ? doorColor : wallColor)); + ? normalExitColor + : objTile == MapObject.secretExitTrigger + ? secretExitColor + : objTile == MapObject.goldKey + ? goldKeyColor + : objTile == MapObject.silverKey + ? silverKeyColor + : (wallTile == 0 + ? floorColor + : (wallTile >= 90 ? doorColor : wallColor)); _fillMapRect( mapStartX + (x * tileSize), mapStartY + (y * tileSize), @@ -1336,11 +1336,16 @@ class SixelRenderer extends CliRendererBackend { sb.write('\x1bPq'); sb.write('"1;1;$_outputWidth;$_outputHeight'); - double damageIntensity = engine.difficulty == null - ? 0.0 - : engine.player.damageFlash; - int redBoost = (150 * damageIntensity).toInt(); - double colorDrop = 1.0 - (0.5 * damageIntensity); + final bool gameplayActive = engine.difficulty != null; + final double damageIntensity = gameplayActive + ? engine.player.damageFlash + : 0.0; + final double bonusIntensity = gameplayActive && damageIntensity <= 0.0 + ? engine.player.bonusFlash + : 0.0; + final int redBoost = (150 * damageIntensity).toInt(); + final double colorDrop = 1.0 - (0.5 * damageIntensity); + final double whiteMix = 0.65 * bonusIntensity; for (int i = 0; i < 256; i++) { int color = ColorPalette.vga32Bit[i]; @@ -1352,6 +1357,10 @@ class SixelRenderer extends CliRendererBackend { r = (r + redBoost).clamp(0, 255); g = (g * colorDrop).toInt().clamp(0, 255); b = (b * colorDrop).toInt().clamp(0, 255); + } else if (bonusIntensity > 0) { + r = (r + ((255 - r) * whiteMix)).round().clamp(0, 255); + g = (g + ((255 - g) * whiteMix)).round().clamp(0, 255); + b = (b + ((255 - b) * whiteMix)).round().clamp(0, 255); } int sixelR = (r * 100) ~/ 255; 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 cfd00ac..1bfff3e 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/software_renderer.dart @@ -1397,9 +1397,12 @@ class SoftwareRenderer extends RendererBackend { @override FrameBuffer finalizeFrame() { - // If the player took damage, overlay a red tint across the 3D view - if (engine.difficulty != null && engine.player.damageFlash > 0) { - _applyDamageFlash(); + if (engine.difficulty != null) { + if (engine.player.damageFlash > 0) { + _applyDamageFlash(); + } else if (engine.player.bonusFlash > 0) { + _applyBonusFlash(); + } } return _buffer; // Return the fully painted pixel array } @@ -1467,4 +1470,26 @@ class SoftwareRenderer extends RendererBackend { } } } + + void _applyBonusFlash() { + final double intensity = engine.player.bonusFlash; + final double whiteMix = 0.65 * intensity; + + for (int y = 0; y < viewHeight; y++) { + for (int x = 0; x < width; x++) { + final int index = y * width + x; + final int color = _buffer.pixels[index]; + + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + r = (r + ((255 - r) * whiteMix)).round().clamp(0, 255); + g = (g + ((255 - g) * whiteMix)).round().clamp(0, 255); + b = (b + ((255 - b) * whiteMix)).round().clamp(0, 255); + + _buffer.pixels[index] = (0xFF000000) | (b << 16) | (g << 8) | r; + } + } + } } diff --git a/packages/wolf_3d_dart/test/entities/canonical_numeric_parity_test.dart b/packages/wolf_3d_dart/test/entities/canonical_numeric_parity_test.dart index 3bcc8b5..6f2f06c 100644 --- a/packages/wolf_3d_dart/test/entities/canonical_numeric_parity_test.dart +++ b/packages/wolf_3d_dart/test/entities/canonical_numeric_parity_test.dart @@ -102,5 +102,18 @@ void main() { } expect(player.lives, 9); }); + + test('successful pickups trigger bonus flash and it fades over time', () { + final player = Player(x: 1.5, y: 1.5, angle: 0)..health = 50; + final food = HealthCollectible(x: 1, y: 1, mapId: MapObject.food); + + expect(player.bonusFlash, 0.0); + player.tryPickup(food); + expect(player.bonusFlash, 1.0); + + player.tick(const Duration(milliseconds: 16)); + expect(player.bonusFlash, lessThan(1.0)); + expect(player.bonusFlash, greaterThan(0.0)); + }); }); } 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 bcc1741..2c25f84 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 @@ -46,17 +46,38 @@ void main() { expect(expectedNoKeyIndex, isNotNull); final goldKeyCall = renderer.drawCalls.firstWhere( - (call) => call.startY == 164 && call.startX == 30, + (call) => call.startY == 164 && call.startX == 240, orElse: () => throw StateError('Gold key slot was not rendered.'), ); final silverKeyCall = renderer.drawCalls.firstWhere( - (call) => call.startY == 180 && call.startX == 30, + (call) => call.startY == 180 && call.startX == 240, orElse: () => throw StateError('Silver key slot was not rendered.'), ); expect(goldKeyCall.imageIndex, expectedGoldKeyIndex); expect(silverKeyCall.imageIndex, expectedNoKeyIndex); }); + + test('standard VGA HUD face follows current health band', () { + final engine = _buildEngine(); + engine.init(); + engine.player.health = 20; + + final renderer = _HudProbeRenderer(vgaImages: engine.data.vgaImages); + renderer.drawHudForTest(engine); + + final expectedFaceIndex = engine.data.registry.hud + .faceForHealth(engine.player.health) + ?.vgaIndex; + expect(expectedFaceIndex, isNotNull); + + final faceCall = renderer.drawCalls.firstWhere( + (call) => call.startY == 164 && call.startX == 136, + orElse: () => throw StateError('Face slot was not rendered.'), + ); + + expect(faceCall.imageIndex, expectedFaceIndex); + }); } class _HudProbeRenderer extends RendererBackend {