Double "resolution" in the CLI

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 23:41:05 +01:00
parent 72ed1ce968
commit 458c0a5d14
3 changed files with 260 additions and 81 deletions

View File

@@ -46,8 +46,9 @@ abstract class AsciiThemes {
class ColoredChar {
final String char;
final int rawColor; // Stores the AABBGGRR integer from the palette
final int? rawBackgroundColor;
ColoredChar(this.char, this.rawColor);
ColoredChar(this.char, this.rawColor, [this.rawBackgroundColor]);
// Safely extract the exact RGB channels regardless of framework
int get r => rawColor & 0xFF;
@@ -61,13 +62,16 @@ class ColoredChar {
class AsciiRasterizer extends Rasterizer {
AsciiRasterizer({
this.activeTheme = AsciiThemes.blocks,
this.isTerminal = false,
this.aspectMultiplier = 1.0,
this.verticalStretch = 1.0,
});
AsciiTheme activeTheme = AsciiThemes.blocks;
final bool isTerminal;
late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels;
late WolfEngine _engine;
@override
@@ -75,6 +79,11 @@ class AsciiRasterizer extends Rasterizer {
@override
final double verticalStretch;
@override
int get projectionViewHeight => isTerminal ? viewHeight * 2 : viewHeight;
int get _terminalPixelHeight => isTerminal ? height * 2 : height;
// Intercept the base render call to initialize our text grid
@override
dynamic render(WolfEngine engine, FrameBuffer buffer) {
@@ -92,13 +101,30 @@ 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];
for (int y = 0; y < height; y++) {
_scenePixels = List.generate(
_terminalPixelHeight,
(_) => List.filled(width, black),
);
for (int y = 0; y < projectionViewHeight; y++) {
final int color = y < projectionViewHeight / 2
? ceilingColor
: floorColor;
for (int x = 0; x < width; x++) {
if (y < viewHeight / 2) {
_screen[y][x] = ColoredChar(activeTheme.solid, ceilingColor);
} else if (y < viewHeight) {
_screen[y][x] = ColoredChar(activeTheme.solid, floorColor);
_scenePixels[y][x] = color;
}
}
if (!isTerminal) {
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (y < viewHeight / 2) {
_screen[y][x] = ColoredChar(activeTheme.solid, ceilingColor);
} else if (y < viewHeight) {
_screen[y][x] = ColoredChar(activeTheme.solid, floorColor);
}
}
}
}
@@ -116,11 +142,10 @@ class AsciiRasterizer extends Rasterizer {
int side,
) {
double brightness = calculateDepthBrightness(perpWallDist);
String wallChar = activeTheme.getByBrightness(brightness);
for (int y = drawStart; y < drawEnd; y++) {
double relativeY =
(y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight;
(y - (-columnHeight ~/ 2 + projectionViewHeight ~/ 2)) / columnHeight;
int texY = (relativeY * 64).toInt().clamp(0, 63);
int colorByte = texture.pixels[texX * 64 + texY];
@@ -131,7 +156,12 @@ class AsciiRasterizer extends Rasterizer {
pixelColor = shadeColor(pixelColor);
}
_screen[y][x] = ColoredChar(wallChar, pixelColor);
if (isTerminal) {
_scenePixels[y][x] = _scaleColor(pixelColor, brightness);
} else {
String wallChar = activeTheme.getByBrightness(brightness);
_screen[y][x] = ColoredChar(wallChar, pixelColor);
}
}
}
@@ -149,7 +179,7 @@ class AsciiRasterizer extends Rasterizer {
for (
int y = math.max(0, drawStartY);
y < math.min(viewHeight, drawEndY);
y < math.min(projectionViewHeight, drawEndY);
y++
) {
double relativeY = (y - drawStartY) / spriteHeight;
@@ -170,8 +200,12 @@ class AsciiRasterizer extends Rasterizer {
int shadedColor = (0xFF000000) | (b << 16) | (g << 8) | r;
// Force sprites to be SOLID so they don't vanish into the terminal background
_screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor);
if (isTerminal) {
_scenePixels[y][stripeX] = shadedColor;
} else {
// Force sprites to be SOLID so they don't vanish into the terminal background
_screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor);
}
}
}
}
@@ -184,11 +218,13 @@ class AsciiRasterizer extends Rasterizer {
Sprite weaponSprite = engine.data.sprites[spriteIndex];
int weaponWidth = (width * 0.5).toInt();
int weaponHeight = (viewHeight * 0.8).toInt();
int weaponHeight = ((projectionViewHeight * 0.8)).toInt();
int startX = (width ~/ 2) - (weaponWidth ~/ 2);
int startY =
viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4);
projectionViewHeight -
weaponHeight +
(engine.player.weaponAnimOffset * (isTerminal ? 2 : 1) ~/ 4);
for (int dy = 0; dy < weaponHeight; dy++) {
for (int dx = 0; dx < weaponWidth; dx++) {
@@ -197,13 +233,17 @@ class AsciiRasterizer extends Rasterizer {
int colorByte = weaponSprite.pixels[texX * 64 + texY];
if (colorByte != 255) {
int drawX = startX + dx;
int sceneX = startX + dx;
int drawY = startY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) {
_screen[drawY][drawX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
if (sceneX >= 0 && sceneX < width && drawY >= 0) {
if (isTerminal && drawY < projectionViewHeight) {
_scenePixels[drawY][sceneX] = ColorPalette.vga32Bit[colorByte];
} else if (!isTerminal && drawY < viewHeight) {
_screen[drawY][sceneX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
}
}
}
}
@@ -213,11 +253,17 @@ class AsciiRasterizer extends Rasterizer {
// --- PRIVATE HUD DRAWING HELPER ---
/// Injects a pure text string directly into the rasterizer grid
void _writeString(int startX, int y, String text, int color) {
void _writeString(
int startX,
int y,
String text,
int color, [
int? backgroundColor,
]) {
for (int i = 0; i < text.length; i++) {
int x = startX + i;
if (x >= 0 && x < width && y >= 0 && y < height) {
_screen[y][x] = ColoredChar(text[i], color);
_screen[y][x] = ColoredChar(text[i], color, backgroundColor);
}
}
}
@@ -247,47 +293,75 @@ class AsciiRasterizer extends Rasterizer {
final int offsetX = ((width - hudContentWidth) ~/ 2).clamp(0, width);
// 3. Clear HUD Base
_fillRect(0, viewHeight, width, height - viewHeight, ' ', vgaStatusBarBlue);
_writeString(0, viewHeight, "" * width, white);
if (isTerminal) {
_fillTerminalRect(
0,
viewHeight * 2,
width,
(height - viewHeight) * 2,
vgaStatusBarBlue,
);
_fillTerminalRect(0, viewHeight * 2, width, 1, white);
} else {
_fillRect(
0,
viewHeight,
width,
height - viewHeight,
' ',
vgaStatusBarBlue,
);
_writeString(0, viewHeight, "" * width, white);
}
// 4. Panel Drawing Helper
void drawBorderedPanel(int startX, int startY, int w, int h) {
_fillRect(startX, startY, w, h, ' ', vgaPanelDark);
// Horizontal lines
_writeString(startX, startY, "${"" * (w - 2)}", white);
_writeString(startX, startY + h - 1, "${"" * (w - 2)}", white);
// Vertical sides
for (int i = 1; i < h - 1; i++) {
_writeString(startX, startY + i, "", white);
_writeString(startX + w - 1, startY + i, "", white);
if (isTerminal) {
_fillTerminalRect(startX, startY * 2, w, h * 2, vgaPanelDark);
_fillTerminalRect(startX, startY * 2, w, 1, white);
_fillTerminalRect(startX, (startY + h) * 2 - 1, w, 1, white);
_fillTerminalRect(startX, startY * 2, 1, h * 2, white);
_fillTerminalRect(startX + w - 1, startY * 2, 1, h * 2, white);
} else {
_fillRect(startX, startY, w, h, ' ', vgaPanelDark);
// Horizontal lines
_writeString(startX, startY, "${"" * (w - 2)}", white);
_writeString(startX, startY + h - 1, "${"" * (w - 2)}", white);
// Vertical sides
for (int i = 1; i < h - 1; i++) {
_writeString(startX, startY + i, "", white);
_writeString(startX + w - 1, startY + i, "", white);
}
}
}
// 5. Draw the Panels
// FLOOR
drawBorderedPanel(offsetX + 4, viewHeight + 2, 12, 5);
_writeString(offsetX + 7, viewHeight + 3, "FLOOR", white);
_writeString(offsetX + 7, viewHeight + 3, "FLOOR", white, vgaPanelDark);
_writeString(
offsetX + 9,
viewHeight + 5,
engine.activeLevel.name.split(' ').last,
white,
vgaPanelDark,
);
// SCORE
drawBorderedPanel(offsetX + 18, viewHeight + 2, 24, 5);
_writeString(offsetX + 27, viewHeight + 3, "SCORE", white);
_writeString(offsetX + 27, viewHeight + 3, "SCORE", white, vgaPanelDark);
_writeString(
offsetX + 27,
viewHeight + 5,
engine.player.score.toString().padLeft(6, '0'),
white,
vgaPanelDark,
);
// LIVES
drawBorderedPanel(offsetX + 44, viewHeight + 2, 12, 5);
_writeString(offsetX + 47, viewHeight + 3, "LIVES", white);
_writeString(offsetX + 49, viewHeight + 5, "3", white);
_writeString(offsetX + 47, viewHeight + 3, "LIVES", white, vgaPanelDark);
_writeString(offsetX + 49, viewHeight + 5, "3", white, vgaPanelDark);
// FACE (With Reactive BJ Logic)
drawBorderedPanel(offsetX + 58, viewHeight + 1, 14, 7);
@@ -301,30 +375,37 @@ class AsciiRasterizer extends Rasterizer {
} else if (engine.player.health <= 60) {
face = "ಠ~ಠ";
}
_writeString(offsetX + 63, viewHeight + 4, face, yellow);
_writeString(offsetX + 63, viewHeight + 4, face, yellow, vgaPanelDark);
// HEALTH
int healthColor = engine.player.health > 25 ? white : red;
drawBorderedPanel(offsetX + 74, viewHeight + 2, 16, 5);
_writeString(offsetX + 78, viewHeight + 3, "HEALTH", white);
_writeString(offsetX + 78, viewHeight + 3, "HEALTH", white, vgaPanelDark);
_writeString(
offsetX + 79,
viewHeight + 5,
"${engine.player.health}%",
healthColor,
vgaPanelDark,
);
// AMMO
drawBorderedPanel(offsetX + 92, viewHeight + 2, 12, 5);
_writeString(offsetX + 95, viewHeight + 3, "AMMO", white);
_writeString(offsetX + 97, viewHeight + 5, "${engine.player.ammo}", white);
_writeString(offsetX + 95, viewHeight + 3, "AMMO", white, vgaPanelDark);
_writeString(
offsetX + 97,
viewHeight + 5,
"${engine.player.ammo}",
white,
vgaPanelDark,
);
// WEAPON
drawBorderedPanel(offsetX + 106, viewHeight + 2, 14, 5);
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);
_writeString(offsetX + 107, viewHeight + 4, weapon, white, vgaPanelDark);
}
void _drawFullVgaHud(WolfEngine engine) {
@@ -427,7 +508,14 @@ class AsciiRasterizer extends Rasterizer {
@override
dynamic finalizeFrame() {
if (_engine.player.damageFlash > 0.0) {
_applyDamageFlash();
if (isTerminal) {
_applyDamageFlashToScene();
} else {
_applyDamageFlash();
}
}
if (isTerminal) {
_composeTerminalScene();
}
return _screen;
}
@@ -437,9 +525,10 @@ class AsciiRasterizer extends Rasterizer {
void _blitVgaImageAscii(VgaImage image, int startX_320, int startY_200) {
int planeWidth = image.width ~/ 4;
int planeSize = planeWidth * image.height;
int maxDrawHeight = isTerminal ? _terminalPixelHeight : height;
double scaleX = width / 320.0;
double scaleY = height / 200.0;
double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0;
int destStartX = (startX_320 * scaleX).toInt();
int destStartY = (startY_200 * scaleY).toInt();
@@ -451,7 +540,10 @@ class AsciiRasterizer extends Rasterizer {
int drawX = destStartX + dx;
int drawY = destStartY + dy;
if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) {
if (drawX >= 0 &&
drawX < width &&
drawY >= 0 &&
drawY < maxDrawHeight) {
int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1);
int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1);
@@ -461,39 +553,102 @@ class AsciiRasterizer extends Rasterizer {
int colorByte = image.pixels[index];
if (colorByte != 255) {
_screen[drawY][drawX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
if (isTerminal) {
_scenePixels[drawY][drawX] = ColorPalette.vga32Bit[colorByte];
} else {
_screen[drawY][drawX] = ColoredChar(
activeTheme.solid,
ColorPalette.vga32Bit[colorByte],
);
}
}
}
}
}
}
void _fillTerminalRect(int startX, int startY, int w, int h, int color) {
for (int dy = 0; dy < h; dy++) {
for (int dx = 0; dx < w; dx++) {
int x = startX + dx;
int y = startY + dy;
if (x >= 0 && x < width && y >= 0 && y < _terminalPixelHeight) {
_scenePixels[y][x] = color;
}
}
}
}
// --- DAMAGE FLASH ---
void _applyDamageFlash() {
for (int y = 0; y < viewHeight; y++) {
for (int x = 0; x < width; x++) {
ColoredChar cell = _screen[y][x];
_screen[y][x] = ColoredChar(
cell.char,
_applyDamageFlashToColor(cell.rawColor),
cell.rawBackgroundColor == null
? null
: _applyDamageFlashToColor(cell.rawBackgroundColor!),
);
}
}
}
void _applyDamageFlashToScene() {
for (int y = 0; y < _terminalPixelHeight; y++) {
for (int x = 0; x < width; x++) {
_scenePixels[y][x] = _applyDamageFlashToColor(_scenePixels[y][x]);
}
}
}
int _applyDamageFlashToColor(int color) {
double intensity = _engine.player.damageFlash;
int redBoost = (150 * intensity).toInt();
double colorDrop = 1.0 - (0.5 * intensity);
for (int y = 0; y < viewHeight; y++) {
int r = color & 0xFF;
int g = (color >> 8) & 0xFF;
int b = (color >> 16) & 0xFF;
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt().clamp(0, 255);
b = (b * colorDrop).toInt().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);
int b = (((color >> 16) & 0xFF) * brightness).toInt().clamp(0, 255);
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
void _composeTerminalScene() {
for (int y = 0; y < height; y++) {
int topY = y * 2;
int bottomY = math.min(topY + 1, _terminalPixelHeight - 1);
for (int x = 0; x < width; x++) {
ColoredChar cell = _screen[y][x];
int topColor = _scenePixels[topY][x];
int bottomColor = _scenePixels[bottomY][x];
// Use our safe getters!
int r = cell.r;
int g = cell.g;
int b = cell.b;
ColoredChar overlay = _screen[y][x];
if (overlay.char != ' ') {
if (overlay.rawBackgroundColor == null) {
_screen[y][x] = ColoredChar(
overlay.char,
overlay.rawColor,
bottomColor,
);
}
continue;
}
r = (r + redBoost).clamp(0, 255);
g = (g * colorDrop).toInt().clamp(0, 255);
b = (b * colorDrop).toInt().clamp(0, 255);
// Pack back into the native AABBGGRR format that ColoredChar expects
int newRawColor = (0xFF000000) | (b << 16) | (g << 8) | r;
_screen[y][x] = ColoredChar(cell.char, newRawColor);
_screen[y][x] = topColor == bottomColor
? ColoredChar('', topColor)
: ColoredChar('', topColor, bottomColor);
}
}
}
@@ -502,18 +657,30 @@ class AsciiRasterizer extends Rasterizer {
String toAnsiString() {
StringBuffer buffer = StringBuffer();
int lastR = -1;
int lastG = -1;
int lastB = -1;
int? lastForeground;
int? lastBackground;
for (int y = 0; y < _screen.length; y++) {
List<ColoredChar> row = _screen[y];
for (ColoredChar cell in row) {
if (cell.r != lastR || cell.g != lastG || cell.b != lastB) {
if (cell.rawColor != lastForeground) {
buffer.write('\x1b[38;2;${cell.r};${cell.g};${cell.b}m');
lastR = cell.r;
lastG = cell.g;
lastB = cell.b;
lastForeground = cell.rawColor;
}
if (cell.rawBackgroundColor != lastBackground) {
if (cell.rawBackgroundColor == null) {
buffer.write('\x1b[49m');
} else {
int background = cell.rawBackgroundColor!;
int bgR = background & 0xFF;
int bgG = (background >> 8) & 0xFF;
int bgB = (background >> 16) & 0xFF;
buffer.write(
'\x1b[48;2;$bgR;$bgG;$bgB'
'm',
);
}
lastBackground = cell.rawBackgroundColor;
}
buffer.write(cell.char);
}

View File

@@ -19,6 +19,11 @@ abstract class Rasterizer {
/// Defaults to 1.0 (no squish) for standard pixel rendering.
double get verticalStretch => 1.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.
int get projectionViewHeight => viewHeight;
/// The main entry point called by the game loop.
/// Orchestrates the mathematical rendering pipeline.
dynamic render(WolfEngine engine, FrameBuffer buffer) {
@@ -103,6 +108,7 @@ abstract class Rasterizer {
final Player player = engine.player;
final SpriteMap map = engine.currentLevel;
final List<Sprite> wallTextures = engine.data.walls;
final int sceneHeight = projectionViewHeight;
final Map<String, double> doorOffsets = engine.doorManager
.getOffsetsForRenderer();
@@ -283,13 +289,16 @@ abstract class Rasterizer {
if (side == 1 && math.sin(player.angle) < 0) texX = 63 - texX;
// Calculate drawing dimensions
int columnHeight = ((viewHeight / perpWallDist) * verticalStretch)
int columnHeight = ((sceneHeight / perpWallDist) * verticalStretch)
.toInt();
int drawStart = (-columnHeight ~/ 2 + viewHeight ~/ 2).clamp(
int drawStart = (-columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
0,
viewHeight,
sceneHeight,
);
int drawEnd = (columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
0,
sceneHeight,
);
int drawEnd = (columnHeight ~/ 2 + viewHeight ~/ 2).clamp(0, viewHeight);
// Tell the implementation to draw this column
drawWallColumn(
@@ -308,6 +317,7 @@ abstract class Rasterizer {
void _castSprites(WolfEngine engine) {
final Player player = engine.player;
final List<Entity> activeSprites = List.from(engine.entities);
final int sceneHeight = projectionViewHeight;
// Sort from furthest to closest (Painter's Algorithm)
activeSprites.sort((a, b) {
@@ -335,20 +345,23 @@ abstract class Rasterizer {
if (transformY > 0) {
int spriteScreenX = ((width / 2) * (1 + transformX / transformY))
.toInt();
int spriteHeight = ((viewHeight / transformY).abs() * verticalStretch)
int spriteHeight = ((sceneHeight / transformY).abs() * verticalStretch)
.toInt();
int displayedSpriteHeight =
((viewHeight / transformY).abs() * verticalStretch).toInt();
// Scale width based on the aspectMultiplier (useful for ASCII)
int spriteWidth = (spriteHeight * aspectMultiplier / verticalStretch)
.toInt();
int spriteWidth =
(displayedSpriteHeight * aspectMultiplier / verticalStretch)
.toInt();
int drawStartY = -spriteHeight ~/ 2 + viewHeight ~/ 2;
int drawEndY = spriteHeight ~/ 2 + viewHeight ~/ 2;
int drawStartY = -spriteHeight ~/ 2 + sceneHeight ~/ 2;
int drawEndY = spriteHeight ~/ 2 + sceneHeight ~/ 2;
int drawStartX = -spriteWidth ~/ 2 + spriteScreenX;
int drawEndX = spriteWidth ~/ 2 + spriteScreenX;
int clipStartX = math.max(0, drawStartX);
int clipEndX = math.min(width - 1, drawEndX);
int clipEndX = math.min(width, drawEndX);
int safeIndex = entity.spriteIndex.clamp(
0,