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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user