Improved hud in cli

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 23:56:21 +01:00
parent 458c0a5d14
commit 0647f779cd
2 changed files with 190 additions and 63 deletions

View File

@@ -60,6 +60,11 @@ class ColoredChar {
}
class AsciiRasterizer extends Rasterizer {
static const double _targetAspectRatio = 4 / 3;
static const int _terminalBackdropArgb = 0xFF009688;
static const int _simpleHudMinWidth = 84;
static const int _simpleHudMinRows = 7;
AsciiRasterizer({
this.activeTheme = AsciiThemes.blocks,
this.isTerminal = false,
@@ -79,11 +84,26 @@ class AsciiRasterizer extends Rasterizer {
@override
final double verticalStretch;
@override
int get projectionWidth => isTerminal
? math.max(
1,
math.min(width, (_terminalPixelHeight * _targetAspectRatio).floor()),
)
: width;
@override
int get projectionOffsetX => isTerminal ? (width - projectionWidth) ~/ 2 : 0;
@override
int get projectionViewHeight => isTerminal ? viewHeight * 2 : viewHeight;
int get _terminalPixelHeight => isTerminal ? height * 2 : height;
int get _viewportRightX => projectionOffsetX + projectionWidth;
int get _terminalBackdropColor => _argbToRawColor(_terminalBackdropArgb);
// Intercept the base render call to initialize our text grid
@override
dynamic render(WolfEngine engine, FrameBuffer buffer) {
@@ -101,18 +121,20 @@ class AsciiRasterizer extends Rasterizer {
// Just grab the raw ints!
final int ceilingColor = ColorPalette.vga32Bit[25];
final int floorColor = ColorPalette.vga32Bit[29];
final int black = ColorPalette.vga32Bit[0];
final int backdropColor = isTerminal
? _terminalBackdropColor
: ColorPalette.vga32Bit[0];
_scenePixels = List.generate(
_terminalPixelHeight,
(_) => List.filled(width, black),
(_) => List.filled(width, backdropColor),
);
for (int y = 0; y < projectionViewHeight; y++) {
final int color = y < projectionViewHeight / 2
? ceilingColor
: floorColor;
for (int x = 0; x < width; x++) {
for (int x = projectionOffsetX; x < _viewportRightX; x++) {
_scenePixels[y][x] = color;
}
}
@@ -217,10 +239,11 @@ class AsciiRasterizer extends Rasterizer {
);
Sprite weaponSprite = engine.data.sprites[spriteIndex];
int weaponWidth = (width * 0.5).toInt();
int weaponWidth = (projectionWidth * 0.5).toInt();
int weaponHeight = ((projectionViewHeight * 0.8)).toInt();
int startX = (width ~/ 2) - (weaponWidth ~/ 2);
int startX =
projectionOffsetX + (projectionWidth ~/ 2) - (weaponWidth ~/ 2);
int startY =
projectionViewHeight -
weaponHeight +
@@ -235,7 +258,9 @@ class AsciiRasterizer extends Rasterizer {
if (colorByte != 255) {
int sceneX = startX + dx;
int drawY = startY + dy;
if (sceneX >= 0 && sceneX < width && drawY >= 0) {
if (sceneX >= projectionOffsetX &&
sceneX < _viewportRightX &&
drawY >= 0) {
if (isTerminal && drawY < projectionViewHeight) {
_scenePixels[drawY][sceneX] = ColorPalette.vga32Bit[colorByte];
} else if (!isTerminal && drawY < viewHeight) {
@@ -272,7 +297,8 @@ class AsciiRasterizer extends Rasterizer {
void drawHud(WolfEngine engine) {
// If the terminal is at least 160 columns wide and 50 rows tall,
// there are enough "pixels" to downscale the VGA image clearly.
if (width >= 160 && height >= 50) {
int hudWidth = isTerminal ? projectionWidth : width;
if (hudWidth >= 160 && height >= 50) {
_drawFullVgaHud(engine);
} else {
_drawSimpleHud(engine);
@@ -280,6 +306,13 @@ class AsciiRasterizer extends Rasterizer {
}
void _drawSimpleHud(WolfEngine engine) {
final int hudWidth = isTerminal ? projectionWidth : width;
final int hudRows = height - viewHeight;
if (hudWidth < _simpleHudMinWidth || hudRows < _simpleHudMinRows) {
_drawMinimalHud(engine);
return;
}
// 1. Pull Retro Colors
final int vgaStatusBarBlue = ColorPalette.vga32Bit[153];
final int vgaPanelDark = ColorPalette.vga32Bit[0];
@@ -287,21 +320,46 @@ class AsciiRasterizer extends Rasterizer {
final int yellow = ColorPalette.vga32Bit[11];
final int red = ColorPalette.vga32Bit[4];
// 2. Setup Centered Layout
// The total width of our standard HUD elements is roughly 120 chars
const int hudContentWidth = 120;
final int offsetX = ((width - hudContentWidth) ~/ 2).clamp(0, width);
// Compact full simple HUD layout.
const int floorW = 10;
const int scoreW = 14;
const int livesW = 9;
const int faceW = 10;
const int healthW = 12;
const int ammoW = 10;
const int weaponW = 13;
const int gap = 1;
const int hudContentWidth =
floorW +
scoreW +
livesW +
faceW +
healthW +
ammoW +
weaponW +
(gap * 6);
final int offsetX =
projectionOffsetX +
((projectionWidth - hudContentWidth) ~/ 2).clamp(0, projectionWidth);
final int baseY = viewHeight + 1;
// 3. Clear HUD Base
if (isTerminal) {
_fillTerminalRect(
0,
projectionOffsetX,
viewHeight * 2,
width,
(height - viewHeight) * 2,
projectionWidth,
hudRows * 2,
vgaStatusBarBlue,
);
_fillTerminalRect(0, viewHeight * 2, width, 1, white);
_fillTerminalRect(
projectionOffsetX,
viewHeight * 2,
projectionWidth,
1,
white,
);
} else {
_fillRect(
0,
@@ -335,36 +393,35 @@ class AsciiRasterizer extends Rasterizer {
}
}
// 5. Draw the Panels
// FLOOR
drawBorderedPanel(offsetX + 4, viewHeight + 2, 12, 5);
_writeString(offsetX + 7, viewHeight + 3, "FLOOR", white, vgaPanelDark);
_writeString(
offsetX + 9,
viewHeight + 5,
engine.activeLevel.name.split(' ').last,
white,
vgaPanelDark,
);
// 5. Draw compact panels.
int cursorX = offsetX;
// SCORE
drawBorderedPanel(offsetX + 18, viewHeight + 2, 24, 5);
_writeString(offsetX + 27, viewHeight + 3, "SCORE", white, vgaPanelDark);
drawBorderedPanel(cursorX, baseY + 1, floorW, 4);
_writeString(cursorX + 2, baseY + 2, "FLR", white, vgaPanelDark);
String floorLabel = engine.activeLevel.name.split(' ').last;
if (floorLabel.length > 4) {
floorLabel = floorLabel.substring(floorLabel.length - 4);
}
_writeString(cursorX + 2, baseY + 3, floorLabel, white, vgaPanelDark);
cursorX += floorW + gap;
drawBorderedPanel(cursorX, baseY + 1, scoreW, 4);
_writeString(cursorX + 4, baseY + 2, "SCORE", white, vgaPanelDark);
_writeString(
offsetX + 27,
viewHeight + 5,
cursorX + 4,
baseY + 3,
engine.player.score.toString().padLeft(6, '0'),
white,
vgaPanelDark,
);
cursorX += scoreW + gap;
// LIVES
drawBorderedPanel(offsetX + 44, viewHeight + 2, 12, 5);
_writeString(offsetX + 47, viewHeight + 3, "LIVES", white, vgaPanelDark);
_writeString(offsetX + 49, viewHeight + 5, "3", white, vgaPanelDark);
drawBorderedPanel(cursorX, baseY + 1, livesW, 4);
_writeString(cursorX + 2, baseY + 2, "LIV", white, vgaPanelDark);
_writeString(cursorX + 3, baseY + 3, "3", white, vgaPanelDark);
cursorX += livesW + gap;
// FACE (With Reactive BJ Logic)
drawBorderedPanel(offsetX + 58, viewHeight + 1, 14, 7);
drawBorderedPanel(cursorX, baseY, faceW, 5);
String face = "ಠ⌣ಠ";
if (engine.player.health <= 0) {
face = "x⸑x";
@@ -375,37 +432,89 @@ class AsciiRasterizer extends Rasterizer {
} else if (engine.player.health <= 60) {
face = "ಠ~ಠ";
}
_writeString(offsetX + 63, viewHeight + 4, face, yellow, vgaPanelDark);
_writeString(cursorX + 3, baseY + 2, face, yellow, vgaPanelDark);
cursorX += faceW + gap;
// HEALTH
int healthColor = engine.player.health > 25 ? white : red;
drawBorderedPanel(offsetX + 74, viewHeight + 2, 16, 5);
_writeString(offsetX + 78, viewHeight + 3, "HEALTH", white, vgaPanelDark);
drawBorderedPanel(cursorX, baseY + 1, healthW, 4);
_writeString(cursorX + 2, baseY + 2, "HEALTH", white, vgaPanelDark);
_writeString(
offsetX + 79,
viewHeight + 5,
cursorX + 3,
baseY + 3,
"${engine.player.health}%",
healthColor,
vgaPanelDark,
);
cursorX += healthW + gap;
// AMMO
drawBorderedPanel(offsetX + 92, viewHeight + 2, 12, 5);
_writeString(offsetX + 95, viewHeight + 3, "AMMO", white, vgaPanelDark);
drawBorderedPanel(cursorX, baseY + 1, ammoW, 4);
_writeString(cursorX + 2, baseY + 2, "AMMO", white, vgaPanelDark);
_writeString(
offsetX + 97,
viewHeight + 5,
cursorX + 2,
baseY + 3,
"${engine.player.ammo}",
white,
vgaPanelDark,
);
cursorX += ammoW + gap;
// WEAPON
drawBorderedPanel(offsetX + 106, viewHeight + 2, 14, 5);
drawBorderedPanel(cursorX, baseY + 1, weaponW, 4);
String weapon = engine.player.currentWeapon.type.name.spacePascalCase!
.toUpperCase();
if (weapon.length > 12) weapon = weapon.substring(0, 12);
_writeString(offsetX + 107, viewHeight + 4, weapon, white, vgaPanelDark);
if (weapon.length > weaponW - 2) {
weapon = weapon.substring(0, weaponW - 2);
}
_writeString(cursorX + 1, baseY + 3, weapon, white, vgaPanelDark);
}
void _drawMinimalHud(WolfEngine engine) {
final int vgaStatusBarBlue = ColorPalette.vga32Bit[153];
final int white = ColorPalette.vga32Bit[15];
final int red = ColorPalette.vga32Bit[4];
final int hudRows = height - viewHeight;
if (isTerminal) {
_fillTerminalRect(
projectionOffsetX,
viewHeight * 2,
projectionWidth,
hudRows * 2,
vgaStatusBarBlue,
);
_fillTerminalRect(
projectionOffsetX,
viewHeight * 2,
projectionWidth,
1,
white,
);
} else {
_fillRect(0, viewHeight, width, hudRows, ' ', vgaStatusBarBlue);
_writeString(0, viewHeight, "" * width, white);
}
final int healthColor = engine.player.health > 25 ? white : red;
String weapon = engine.player.currentWeapon.type.name.spacePascalCase!
.toUpperCase();
if (weapon.length > 8) {
weapon = weapon.substring(0, 8);
}
final String hudText =
'H:${engine.player.health}% A:${engine.player.ammo} S:${engine.player.score} W:$weapon';
final int lineY = viewHeight + 1;
if (lineY >= height) return;
final int drawStartX = isTerminal ? projectionOffsetX : 0;
final int drawWidth = isTerminal ? projectionWidth : width;
final int maxTextLen = math.max(0, drawWidth - 2);
String clipped = hudText;
if (clipped.length > maxTextLen) {
clipped = clipped.substring(0, maxTextLen);
}
final int startX = drawStartX + ((drawWidth - clipped.length) ~/ 2);
_writeString(startX, lineY, clipped, healthColor, vgaStatusBarBlue);
}
void _drawFullVgaHud(WolfEngine engine) {
@@ -526,11 +635,13 @@ class AsciiRasterizer extends Rasterizer {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
int maxDrawHeight = isTerminal ? _terminalPixelHeight : height;
int maxDrawWidth = isTerminal ? _viewportRightX : width;
double scaleX = width / 320.0;
double scaleX = (isTerminal ? projectionWidth : width) / 320.0;
double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
int destStartX = (startX_320 * scaleX).toInt();
int destStartX =
(isTerminal ? projectionOffsetX : 0) + (startX_320 * scaleX).toInt();
int destStartY = (startY_200 * scaleY).toInt();
int destWidth = (image.width * scaleX).toInt();
int destHeight = (image.height * scaleY).toInt();
@@ -541,7 +652,7 @@ class AsciiRasterizer extends Rasterizer {
int drawY = destStartY + dy;
if (drawX >= 0 &&
drawX < width &&
drawX < maxDrawWidth &&
drawY >= 0 &&
drawY < maxDrawHeight) {
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
@@ -597,12 +708,19 @@ class AsciiRasterizer extends Rasterizer {
void _applyDamageFlashToScene() {
for (int y = 0; y < _terminalPixelHeight; y++) {
for (int x = 0; x < width; x++) {
for (int x = projectionOffsetX; x < _viewportRightX; x++) {
_scenePixels[y][x] = _applyDamageFlashToColor(_scenePixels[y][x]);
}
}
}
int _argbToRawColor(int argb) {
int r = (argb >> 16) & 0xFF;
int g = (argb >> 8) & 0xFF;
int b = argb & 0xFF;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
int _applyDamageFlashToColor(int color) {
double intensity = _engine.player.damageFlash;
int redBoost = (150 * intensity).toInt();

View File

@@ -19,6 +19,13 @@ abstract class Rasterizer {
/// Defaults to 1.0 (no squish) for standard pixel rendering.
double get verticalStretch => 1.0;
/// The logical width of the projection area used for raycasting and sprites.
/// Most renderers use the full buffer width.
int get projectionWidth => width;
/// Horizontal offset of the projection area within the output buffer.
int get projectionOffsetX => 0;
/// The logical height of the 3D projection before a renderer maps rows to output pixels.
/// Most renderers use the visible view height. Terminal ASCII can override this to render
/// more vertical detail and collapse it into half-block glyphs.
@@ -31,7 +38,7 @@ abstract class Rasterizer {
height = buffer.height;
// The 3D view typically takes up the top 80% of the screen
viewHeight = (height * 0.8).toInt();
zBuffer = List.filled(width, 0.0);
zBuffer = List.filled(projectionWidth, 0.0);
// 1. Setup the frame (clear screen, draw floor/ceiling)
prepareFrame(engine);
@@ -108,6 +115,7 @@ abstract class Rasterizer {
final Player player = engine.player;
final SpriteMap map = engine.currentLevel;
final List<Sprite> wallTextures = engine.data.walls;
final int sceneWidth = projectionWidth;
final int sceneHeight = projectionViewHeight;
final Map<String, double> doorOffsets = engine.doorManager
@@ -121,8 +129,8 @@ abstract class Rasterizer {
);
Coordinate2D plane = Coordinate2D(-dir.y, dir.x) * math.tan(fov / 2);
for (int x = 0; x < width; x++) {
double cameraX = 2 * x / width - 1.0;
for (int x = 0; x < sceneWidth; x++) {
double cameraX = 2 * x / sceneWidth - 1.0;
Coordinate2D rayDir = dir + (plane * cameraX);
int mapX = player.x.toInt();
@@ -302,7 +310,7 @@ abstract class Rasterizer {
// Tell the implementation to draw this column
drawWallColumn(
x,
projectionOffsetX + x,
drawStart,
drawEnd,
columnHeight,
@@ -317,6 +325,7 @@ abstract class Rasterizer {
void _castSprites(WolfEngine engine) {
final Player player = engine.player;
final List<Entity> activeSprites = List.from(engine.entities);
final int sceneWidth = projectionWidth;
final int sceneHeight = projectionViewHeight;
// Sort from furthest to closest (Painter's Algorithm)
@@ -343,7 +352,7 @@ abstract class Rasterizer {
// Only process if the sprite is in front of the camera
if (transformY > 0) {
int spriteScreenX = ((width / 2) * (1 + transformX / transformY))
int spriteScreenX = ((sceneWidth / 2) * (1 + transformX / transformY))
.toInt();
int spriteHeight = ((sceneHeight / transformY).abs() * verticalStretch)
.toInt();
@@ -361,7 +370,7 @@ abstract class Rasterizer {
int drawEndX = spriteWidth ~/ 2 + spriteScreenX;
int clipStartX = math.max(0, drawStartX);
int clipEndX = math.min(width, drawEndX);
int clipEndX = math.min(sceneWidth, drawEndX);
int safeIndex = entity.spriteIndex.clamp(
0,
@@ -377,7 +386,7 @@ abstract class Rasterizer {
// Tell the implementation to draw this stripe
drawSpriteStripe(
stripe,
projectionOffsetX + stripe,
drawStartY,
drawEndY,
spriteHeight,