feat: Update HUD rendering to display current player lives dynamically

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 11:36:07 +01:00
parent 85583214ba
commit b0f6e865b4
4 changed files with 287 additions and 2 deletions
@@ -1951,7 +1951,13 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
drawBorderedPanel(cursorX, baseY + 1, livesW, 4);
_writeString(cursorX + 2, baseY + 2, "LIV", white, vgaPanelDark);
_writeString(cursorX + 3, baseY + 3, "3", white, vgaPanelDark);
_writeString(
cursorX + 3,
baseY + 3,
engine.player.lives.toString(),
white,
vgaPanelDark,
);
cursorX += livesW + gap;
drawBorderedPanel(cursorX, baseY, faceW, 5);
@@ -179,7 +179,7 @@ abstract class RendererBackend<T>
_drawHudNumber(engine, vgaImages, 1, 32, 176);
_drawHudNumber(engine, vgaImages, engine.player.score, 96, 176);
_drawHudNumber(engine, vgaImages, 3, 120, 176);
_drawHudNumber(engine, vgaImages, engine.player.lives, 120, 176);
_drawHudNumber(engine, vgaImages, engine.player.health, 192, 176);
_drawHudNumber(engine, vgaImages, engine.player.ammo, 232, 176);
_drawHudFace(engine, vgaImages);
@@ -38,12 +38,75 @@ void main() {
expect(engine.player.lives, 5);
expect(engine.player.hasMachineGun, isTrue);
expect(engine.player.hasChainGun, isTrue);
expect(engine.player.weapons[WeaponType.machineGun], isNotNull);
expect(engine.player.weapons[WeaponType.chainGun], isNotNull);
expect(engine.player.currentWeapon.type, WeaponType.chainGun);
expect(engine.player.hasGoldKey, isFalse);
expect(engine.player.hasSilverKey, isFalse);
expect(engine.player.x, closeTo(4.5, 0.001));
expect(engine.player.y, closeTo(4.5, 0.001));
});
test('secret-exit transitions clear keys but preserve other player state', () {
final input = _TestInput();
final engine = WolfEngine(
data: _buildSecretExitTestData(),
difficulty: Difficulty.hard,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: input,
engineAudio: _SilentAudio(),
onGameWon: () {},
);
engine.init();
engine.player
..health = 61
..ammo = 42
..score = 777
..lives = 6
..hasMachineGun = true
..hasChainGun = true
..hasGoldKey = true
..hasSilverKey = true;
engine.player.weapons[WeaponType.machineGun] = MachineGun();
engine.player.weapons[WeaponType.chainGun] = ChainGun();
engine.player.currentWeapon = engine.player.weapons[WeaponType.chainGun]!;
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
expect(engine.activeLevel.name, 'Level 10');
expect(engine.player.health, 61);
expect(engine.player.ammo, 42);
expect(engine.player.score, 777);
expect(engine.player.lives, 6);
expect(engine.player.hasMachineGun, isTrue);
expect(engine.player.hasChainGun, isTrue);
expect(engine.player.currentWeapon.type, WeaponType.chainGun);
expect(engine.player.hasGoldKey, isFalse);
expect(engine.player.hasSilverKey, isFalse);
engine.player
..hasGoldKey = true
..hasSilverKey = true;
input.isInteracting = true;
engine.tick(const Duration(milliseconds: 16));
input.isInteracting = false;
expect(engine.activeLevel.name, 'Level 2');
expect(engine.player.health, 61);
expect(engine.player.ammo, 42);
expect(engine.player.score, 777);
expect(engine.player.lives, 6);
expect(engine.player.hasMachineGun, isTrue);
expect(engine.player.hasChainGun, isTrue);
expect(engine.player.currentWeapon.type, WeaponType.chainGun);
expect(engine.player.hasGoldKey, isFalse);
expect(engine.player.hasSilverKey, isFalse);
});
});
group('Pause and main menu', () {
@@ -434,6 +497,49 @@ WolfensteinData _buildTestData({required GameVersion gameVersion}) {
);
}
WolfensteinData _buildSecretExitTestData() {
final List<WolfLevel> levels = [];
for (int i = 0; i < 10; i++) {
final walls = _buildGrid();
final objects = _buildGrid();
_fillBoundaries(walls, 2);
objects[2][2] = MapObject.playerEast;
if (i == 0) {
walls[2][3] = MapObject.secretElevatorSwitch;
} else if (i == 9) {
walls[2][3] = MapObject.normalElevatorSwitch;
}
levels.add(
WolfLevel(
name: 'Level ${i + 1}',
wallGrid: walls,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objects,
music: Music.level01,
),
);
}
return WolfensteinData(
version: GameVersion.retail,
dataVersion: DataVersion.unknown,
registry: RetailAssetRegistry(),
walls: [_solidSprite(1), _solidSprite(1), _solidSprite(2), _solidSprite(2)],
sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: List.generate(200, (_) => PcmSound(Uint8List(1))),
adLibSounds: const [],
music: const [],
vgaImages: const [],
episodes: [
Episode(name: 'Episode 1', levels: levels),
],
);
}
class _TestInput extends Wolf3dInput {
@override
void update() {}
@@ -0,0 +1,173 @@
import 'dart:typed_data';
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_input.dart';
void main() {
test('standard VGA HUD renders current player lives value', () {
final engine = _buildEngine();
engine.init();
engine.player.lives = 7;
final renderer = _HudProbeRenderer(vgaImages: engine.data.vgaImages);
renderer.drawHudForTest(engine);
final expectedDigitIndex = engine.data.registry.hud
.resolve(HudKey.digit7)
?.vgaIndex;
expect(expectedDigitIndex, isNotNull);
final livesDigitCall = renderer.drawCalls.firstWhere(
(call) => call.startY == 176 && call.startX == 112,
orElse: () => throw StateError('Lives digit was not rendered.'),
);
expect(livesDigitCall.imageIndex, expectedDigitIndex);
});
}
class _HudProbeRenderer extends RendererBackend<int> {
final List<VgaImage> vgaImages;
final List<_HudDrawCall> drawCalls = <_HudDrawCall>[];
_HudProbeRenderer({required this.vgaImages});
void drawHudForTest(WolfEngine engine) {
drawStandardVgaHud(engine);
}
@override
void blitHudVgaImage(VgaImage image, int startX320, int startY200) {
drawCalls.add(
_HudDrawCall(
imageIndex: vgaImages.indexOf(image),
startX: startX320,
startY: startY200,
),
);
}
@override
int finalizeFrame() => 0;
@override
void drawHud(WolfEngine engine) {}
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
) {}
@override
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
) {}
@override
void drawWeapon(WolfEngine engine) {}
@override
void prepareFrame(WolfEngine engine) {}
}
class _HudDrawCall {
final int imageIndex;
final int startX;
final int startY;
const _HudDrawCall({
required this.imageIndex,
required this.startX,
required this.startY,
});
}
WolfEngine _buildEngine() {
final wallGrid = _buildGrid();
final objectGrid = _buildGrid();
_fillBoundaries(wallGrid, 2);
objectGrid[2][2] = MapObject.playerEast;
return WolfEngine(
data: WolfensteinData(
version: GameVersion.retail,
dataVersion: DataVersion.unknown,
registry: RetailAssetRegistry(),
walls: [
_solidSprite(1),
_solidSprite(1),
_solidSprite(2),
_solidSprite(2),
],
sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: const [],
adLibSounds: const [],
music: const [],
vgaImages: List.generate(220, (_) => _vgaStub()),
episodes: [
Episode(
name: 'Episode 1',
levels: [
WolfLevel(
name: 'Level 1',
wallGrid: wallGrid,
areaGrid: List.generate(64, (_) => List.filled(64, -1)),
objectGrid: objectGrid,
music: Music.level01,
),
],
),
],
),
difficulty: Difficulty.medium,
startingEpisode: 0,
frameBuffer: FrameBuffer(96, 96),
input: _StaticInput(),
onGameWon: () {},
);
}
class _StaticInput extends Wolf3dInput {
@override
void update() {}
}
VgaImage _vgaStub() {
return VgaImage(
width: 4,
height: 1,
pixels: Uint8List.fromList([0, 0, 0, 0]),
);
}
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
void _fillBoundaries(SpriteMap grid, int wallId) {
for (int i = 0; i < 64; i++) {
grid[0][i] = wallId;
grid[63][i] = wallId;
grid[i][0] = wallId;
grid[i][63] = wallId;
}
}
Sprite _solidSprite(int colorIndex) {
return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex)));
}