From 458c0a5d14cbba72ce966e3cf8fd975de7357270 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Tue, 17 Mar 2026 23:41:05 +0100 Subject: [PATCH] Double "resolution" in the CLI Signed-off-by: Hans Kokx --- apps/wolf_3d_cli/bin/main.dart | 3 +- .../engine/rasterizer/ascii_rasterizer.dart | 305 ++++++++++++++---- .../lib/src/engine/rasterizer/rasterizer.dart | 33 +- 3 files changed, 260 insertions(+), 81 deletions(-) diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index 5bbd6c5..3308b1e 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -41,7 +41,7 @@ void main() async { final input = CliInput(); final cliAudio = CliSilentAudio(); - final rasterizer = AsciiRasterizer(); + final rasterizer = AsciiRasterizer(isTerminal: true); FrameBuffer buffer = FrameBuffer( stdout.terminalColumns, @@ -69,7 +69,6 @@ void main() async { if (bytes.contains(9)) { rasterizer.activeTheme = AsciiThemes.nextOf(rasterizer.activeTheme); - print("Switched to ${rasterizer.activeTheme.name} theme!"); } input.handleKey(bytes); diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart index 86cb869..7ad613c 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart @@ -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> _screen; + late List> _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 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); } diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart index f46587c..ce2b94e 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart @@ -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 wallTextures = engine.data.walls; + final int sceneHeight = projectionViewHeight; final Map 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 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,