feat: Add bonus flash effect for player pickups and update rendering logic

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 12:11:19 +01:00
parent 604923618a
commit 8ed460b03e
7 changed files with 165 additions and 37 deletions
@@ -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;
}
@@ -483,16 +483,16 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
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<dynamic> {
@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<dynamic> {
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);
@@ -248,7 +248,7 @@ abstract class RendererBackend<T>
_drawHudKeySlot(
engine,
vgaImages,
startX: 30,
startX: 240,
startY: 164,
hasKey: engine.player.hasGoldKey,
presentKey: HudKey.goldKeyIcon,
@@ -256,7 +256,7 @@ abstract class RendererBackend<T>
_drawHudKeySlot(
engine,
vgaImages,
startX: 30,
startX: 240,
startY: 180,
hasKey: engine.player.hasSilverKey,
presentKey: HudKey.silverKeyIcon,
@@ -407,16 +407,16 @@ class SixelRenderer extends CliRendererBackend<String> {
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<String> {
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<String> {
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;
@@ -1397,9 +1397,12 @@ class SoftwareRenderer extends RendererBackend<FrameBuffer> {
@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<FrameBuffer> {
}
}
}
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;
}
}
}
}
@@ -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));
});
});
}
@@ -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<int> {