463 lines
13 KiB
Dart
463 lines
13 KiB
Dart
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<List<ColoredChar>> _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<VgaImage> 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<ColoredChar> 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();
|
|
}
|
|
}
|