import 'dart:math' as math; import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; import 'package:wolf_3d_engine/wolf_3d_engine.dart'; class AsciiTheme { /// The character ramp, ordered from most dense (index 0) to least dense (last index). final String ramp; const AsciiTheme(this.ramp); /// Always returns the densest character (e.g., for walls, UI, floors) String get solid => ramp[0]; /// Always returns the completely empty character (e.g., for pitch black darkness) String get empty => ramp[ramp.length - 1]; /// Returns a character based on a 0.0 to 1.0 brightness scale. /// 1.0 returns the [solid] character, 0.0 returns the [empty] character. String getByBrightness(double brightness) { double b = brightness.clamp(0.0, 1.0); int index = ((1.0 - b) * (ramp.length - 1)).round(); return ramp[index]; } } /// A collection of pre-defined character sets abstract class AsciiThemes { static const AsciiTheme blocks = AsciiTheme("█▓▒░ "); static const AsciiTheme classic = AsciiTheme("@%#*+=-:. "); static const AsciiTheme dense = AsciiTheme("█▇▆▅▄▃▂ "); } class ColoredChar { final String char; final int rawColor; // Stores the AABBGGRR integer from the palette ColoredChar(this.char, this.rawColor); // Safely extract the exact RGB channels regardless of framework int get r => rawColor & 0xFF; int get g => (rawColor >> 8) & 0xFF; int get b => (rawColor >> 16) & 0xFF; // Outputs standard AARRGGBB for Flutter's Color(int) constructor int get argb => (0xFF000000) | (r << 16) | (g << 8) | b; } class AsciiRasterizer extends Rasterizer { final AsciiTheme activeTheme = AsciiThemes.blocks; late List> _screen; late WolfEngine _engine; // Terminal characters are usually twice as tall as they are wide. // We override the base multiplier to squish sprites horizontally. @override double get aspectMultiplier => 2.0; // Squish the entire 3D projection vertically by 50% to counteract tall terminal fonts @override double get verticalStretch => 1.8; // Intercept the base render call to initialize our text grid @override dynamic render(WolfEngine engine, FrameBuffer buffer) { _engine = engine; _screen = List.generate( buffer.height, (_) => List.filled(buffer.width, ColoredChar(' ', ColorPalette.vga32Bit[0])), ); return super.render(engine, buffer); } @override void prepareFrame(WolfEngine engine) { // Just grab the raw ints! final int ceilingColor = ColorPalette.vga32Bit[25]; final int floorColor = ColorPalette.vga32Bit[29]; 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); } } } } @override void drawWallColumn( int x, int drawStart, int drawEnd, int columnHeight, Sprite texture, int texX, double perpWallDist, 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; int texY = (relativeY * 64).toInt().clamp(0, 63); int colorByte = texture.pixels[texX * 64 + texY]; int pixelColor = ColorPalette.vga32Bit[colorByte]; // Raw int // Faux directional lighting using your new base class method if (side == 1) { pixelColor = shadeColor(pixelColor); } _screen[y][x] = ColoredChar(wallChar, pixelColor); } } @override void drawSpriteStripe( int stripeX, int drawStartY, int drawEndY, int spriteHeight, Sprite texture, int texX, double transformY, ) { double brightness = calculateDepthBrightness(transformY); for ( int y = math.max(0, drawStartY); y < math.min(viewHeight, drawEndY); y++ ) { double relativeY = (y - drawStartY) / spriteHeight; int texY = (relativeY * 64).toInt().clamp(0, 63); int colorByte = texture.pixels[texX * 64 + texY]; if (colorByte != 255) { int rawColor = ColorPalette.vga32Bit[colorByte]; // Shade the sprite's actual RGB color based on distance int r = (rawColor & 0xFF); int g = ((rawColor >> 8) & 0xFF); int b = ((rawColor >> 16) & 0xFF); r = (r * brightness).toInt(); g = (g * brightness).toInt(); b = (b * brightness).toInt(); 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); } } } @override void drawWeapon(WolfEngine engine) { int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( engine.data.sprites.length, ); Sprite weaponSprite = engine.data.sprites[spriteIndex]; int weaponWidth = (width * 0.5).toInt(); int weaponHeight = (viewHeight * 0.8).toInt(); int startX = (width ~/ 2) - (weaponWidth ~/ 2); int startY = viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4); for (int dy = 0; dy < weaponHeight; dy++) { for (int dx = 0; dx < weaponWidth; dx++) { int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); int colorByte = weaponSprite.pixels[texX * 64 + texY]; if (colorByte != 255) { int drawX = startX + dx; int drawY = startY + dy; if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) { _screen[drawY][drawX] = ColoredChar( activeTheme.solid, ColorPalette.vga32Bit[colorByte], ); } } } } } // --- PRIVATE HUD DRAWING HELPER --- /// Injects a pure text string directly into the rasterizer grid void _writeString(int startX, int y, String text, int color) { 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); } } } @override 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) { _drawFullVgaHud(engine); } else { _drawSimpleHud(engine); } } void _drawSimpleHud(WolfEngine engine) { // 1. Clear the HUD area so stray wall characters don't bleed in for (int y = viewHeight; y < height; y++) { for (int x = 0; x < width; x++) { _screen[y][x] = ColoredChar(' ', 0xFF000000); } } // 2. Draw a decorative border to separate the 3D view from the HUD int borderColor = 0xFF555555; // Dark Gray for (int x = 0; x < width; x++) { _screen[viewHeight][x] = ColoredChar('=', borderColor); } // 3. Define some clean terminal colors int white = 0xFFFFFFFF; int yellow = 0xFFFFFF55; int red = 0xFFFF5555; // Turn health text red if dying int healthColor = engine.player.health > 25 ? white : red; // Format the strings nicely String score = "SCORE: ${engine.player.score.toString().padLeft(6, '0')}"; String health = "HEALTH: ${engine.player.health}%"; String ammo = "AMMO: ${engine.player.ammo}"; int textY = viewHeight + 4; // Center it vertically in the HUD area // 4. Print the stats evenly spaced across the bottom _writeString(6, textY, "FLOOR 1", white); _writeString(22, textY, score, white); _writeString(44, textY, "LIVES: 3", white); // 5. Reactive ASCII Face String face = " :-) "; if (engine.player.health <= 0) { face = " X-x "; } else if (engine.player.health <= 25) { face = " :-( "; } else if (engine.player.health <= 60) { face = " :-| "; } _writeString(62, textY, "[$face]", yellow); _writeString(78, textY, health, healthColor); _writeString(100, textY, ammo, white); } void _drawFullVgaHud(WolfEngine engine) { int statusBarIndex = engine.data.vgaImages.indexWhere( (img) => img.width == 320 && img.height == 40, ); if (statusBarIndex == -1) return; // 1. Draw Background _blitVgaImageAscii(engine.data.vgaImages[statusBarIndex], 0, 160); // 2. Draw Stats _drawNumberAscii(1, 32, 176, engine.data.vgaImages); // Floor _drawNumberAscii( engine.player.score, 96, 176, engine.data.vgaImages, ); // Score _drawNumberAscii(3, 120, 176, engine.data.vgaImages); // Lives _drawNumberAscii( engine.player.health, 192, 176, engine.data.vgaImages, ); // Health _drawNumberAscii( engine.player.ammo, 232, 176, engine.data.vgaImages, ); // Ammo // 3. Draw BJ's Face & Current Weapon _drawFaceAscii(engine); _drawWeaponIconAscii(engine); } void _drawNumberAscii( int value, int rightAlignX, int startY, List vgaImages, ) { const int zeroIndex = 96; String numStr = value.toString(); int currentX = rightAlignX - (numStr.length * 8); for (int i = 0; i < numStr.length; i++) { int digit = int.parse(numStr[i]); if (zeroIndex + digit < vgaImages.length) { _blitVgaImageAscii(vgaImages[zeroIndex + digit], currentX, startY); } currentX += 8; } } void _drawFaceAscii(WolfEngine engine) { int health = engine.player.health; int faceIndex; if (health <= 0) { faceIndex = 127; } else { int healthTier = ((100 - health) ~/ 16).clamp(0, 6); faceIndex = 106 + (healthTier * 3); } if (faceIndex < engine.data.vgaImages.length) { _blitVgaImageAscii(engine.data.vgaImages[faceIndex], 136, 164); } } void _drawWeaponIconAscii(WolfEngine engine) { int weaponIndex = 89; if (engine.player.hasChainGun) { weaponIndex = 91; } else if (engine.player.hasMachineGun) { weaponIndex = 90; } if (weaponIndex < engine.data.vgaImages.length) { _blitVgaImageAscii(engine.data.vgaImages[weaponIndex], 256, 164); } } @override dynamic finalizeFrame() { if (_engine.player.damageFlash > 0.0) { _applyDamageFlash(); } return _screen; } // --- PRIVATE HUD DRAWING HELPERS --- void _blitVgaImageAscii(VgaImage image, int startX_320, int startY_200) { int planeWidth = image.width ~/ 4; int planeSize = planeWidth * image.height; double scaleX = width / 320.0; double scaleY = height / 200.0; int destStartX = (startX_320 * scaleX).toInt(); int destStartY = (startY_200 * scaleY).toInt(); int destWidth = (image.width * scaleX).toInt(); int destHeight = (image.height * scaleY).toInt(); for (int dy = 0; dy < destHeight; dy++) { for (int dx = 0; dx < destWidth; dx++) { int drawX = destStartX + dx; int drawY = destStartY + dy; if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1); int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1); int plane = srcX % 4; int sx = srcX ~/ 4; int index = (plane * planeSize) + (srcY * planeWidth) + sx; int colorByte = image.pixels[index]; if (colorByte != 255) { _screen[drawY][drawX] = ColoredChar( activeTheme.solid, ColorPalette.vga32Bit[colorByte], ); } } } } } // --- DAMAGE FLASH --- void _applyDamageFlash() { double intensity = _engine.player.damageFlash; int redBoost = (150 * intensity).toInt(); double colorDrop = 1.0 - (0.5 * intensity); for (int y = 0; y < viewHeight; y++) { for (int x = 0; x < width; x++) { ColoredChar cell = _screen[y][x]; // Use our safe getters! int r = cell.r; int g = cell.g; int b = cell.b; 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); } } } /// Converts the current frame to a single printable ANSI string String toAnsiString() { StringBuffer buffer = StringBuffer(); int lastR = -1; int lastG = -1; int lastB = -1; 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) { buffer.write('\x1b[38;2;${cell.r};${cell.g};${cell.b}m'); lastR = cell.r; lastG = cell.g; lastB = cell.b; } buffer.write(cell.char); } // Only print a newline if we are NOT on the very last row. // This stops the terminal from scrolling down! if (y < _screen.length - 1) { buffer.write('\n'); } } // Reset the terminal color at the very end buffer.write('\x1b[0m'); return buffer.toString(); } }