Added a new ASCII renderer
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -3,7 +3,6 @@
|
|||||||
/// More dartdocs go here.
|
/// More dartdocs go here.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/ascii_rasterizer.dart';
|
|
||||||
export 'src/engine_audio.dart';
|
export 'src/engine_audio.dart';
|
||||||
export 'src/engine_input.dart';
|
export 'src/engine_input.dart';
|
||||||
export 'src/managers/door_manager.dart';
|
export 'src/managers/door_manager.dart';
|
||||||
|
|||||||
241
packages/wolf_3d_renderer/lib/ascii_rasterizer.dart
Normal file
241
packages/wolf_3d_renderer/lib/ascii_rasterizer.dart
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
|
||||||
|
import 'package:wolf_3d_entities/wolf_3d_entities.dart';
|
||||||
|
|
||||||
|
class ColoredChar {
|
||||||
|
final String char;
|
||||||
|
final Color color;
|
||||||
|
ColoredChar(this.char, this.color);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AsciiRasterizer {
|
||||||
|
static const String _charset = "@%#*+=-:. ";
|
||||||
|
|
||||||
|
// NEW: Helper to safely convert and artificially boost your raw memory colors
|
||||||
|
Color _vgaToColor(int vgaColor, {double brightnessBoost = 2.0}) {
|
||||||
|
int r = vgaColor & 0xFF;
|
||||||
|
int g = (vgaColor >> 8) & 0xFF;
|
||||||
|
int b = (vgaColor >> 16) & 0xFF;
|
||||||
|
|
||||||
|
// Apply the boost and clamp to 255 to prevent color overflow
|
||||||
|
r = (r * brightnessBoost).toInt().clamp(0, 255);
|
||||||
|
g = (g * brightnessBoost).toInt().clamp(0, 255);
|
||||||
|
b = (b * brightnessBoost).toInt().clamp(0, 255);
|
||||||
|
|
||||||
|
// Force Alpha to 255 (fully opaque)
|
||||||
|
return Color.fromARGB(255, r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<List<ColoredChar>> render(WolfEngine engine, FrameBuffer framebuffer) {
|
||||||
|
final int width = framebuffer.width;
|
||||||
|
final int height = framebuffer.height;
|
||||||
|
|
||||||
|
// Grab ceiling and floor colors from the original palette
|
||||||
|
final Color ceilingColor = _vgaToColor(ColorPalette.vga32Bit[25]);
|
||||||
|
final Color floorColor = _vgaToColor(ColorPalette.vga32Bit[29]);
|
||||||
|
|
||||||
|
final List<List<ColoredChar>> screen = List.generate(
|
||||||
|
height,
|
||||||
|
(_) => List.filled(width, ColoredChar(' ', ceilingColor)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<double> zBuffer = List.filled(width, 0.0);
|
||||||
|
|
||||||
|
final Player player = engine.player;
|
||||||
|
final SpriteMap map = engine.currentLevel;
|
||||||
|
final List<Sprite> wallTextures = engine.data.walls;
|
||||||
|
|
||||||
|
final double fov = math.pi / 3;
|
||||||
|
Coordinate2D dir = Coordinate2D(
|
||||||
|
math.cos(player.angle),
|
||||||
|
math.sin(player.angle),
|
||||||
|
);
|
||||||
|
Coordinate2D plane = Coordinate2D(-dir.y, dir.x) * math.tan(fov / 2);
|
||||||
|
|
||||||
|
// 1. CAST WALLS
|
||||||
|
for (int x = 0; x < width; x++) {
|
||||||
|
double cameraX = 2 * x / width - 1.0;
|
||||||
|
Coordinate2D rayDir = dir + (plane * cameraX);
|
||||||
|
|
||||||
|
int mapX = player.x.toInt();
|
||||||
|
int mapY = player.y.toInt();
|
||||||
|
|
||||||
|
double deltaDistX = (rayDir.x == 0) ? 1e30 : (1.0 / rayDir.x).abs();
|
||||||
|
double deltaDistY = (rayDir.y == 0) ? 1e30 : (1.0 / rayDir.y).abs();
|
||||||
|
|
||||||
|
double sideDistX, sideDistY;
|
||||||
|
int stepX, stepY, side = 0, hitWallId = 0;
|
||||||
|
bool hit = false;
|
||||||
|
|
||||||
|
if (rayDir.x < 0) {
|
||||||
|
stepX = -1;
|
||||||
|
sideDistX = (player.x - mapX) * deltaDistX;
|
||||||
|
} else {
|
||||||
|
stepX = 1;
|
||||||
|
sideDistX = (mapX + 1.0 - player.x) * deltaDistX;
|
||||||
|
}
|
||||||
|
if (rayDir.y < 0) {
|
||||||
|
stepY = -1;
|
||||||
|
sideDistY = (player.y - mapY) * deltaDistY;
|
||||||
|
} else {
|
||||||
|
stepY = 1;
|
||||||
|
sideDistY = (mapY + 1.0 - player.y) * deltaDistY;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (!hit) {
|
||||||
|
if (sideDistX < sideDistY) {
|
||||||
|
sideDistX += deltaDistX;
|
||||||
|
mapX += stepX;
|
||||||
|
side = 0;
|
||||||
|
} else {
|
||||||
|
sideDistY += deltaDistY;
|
||||||
|
mapY += stepY;
|
||||||
|
side = 1;
|
||||||
|
}
|
||||||
|
if (mapY < 0 ||
|
||||||
|
mapY >= map.length ||
|
||||||
|
mapX < 0 ||
|
||||||
|
mapX >= map[0].length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (map[mapY][mapX] > 0) {
|
||||||
|
hit = true;
|
||||||
|
hitWallId = map[mapY][mapX];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
double perpWallDist = (side == 0)
|
||||||
|
? (sideDistX - deltaDistX)
|
||||||
|
: (sideDistY - deltaDistY);
|
||||||
|
if (perpWallDist < 0.1) perpWallDist = 0.1;
|
||||||
|
|
||||||
|
zBuffer[x] = perpWallDist;
|
||||||
|
|
||||||
|
double wallX = (side == 0)
|
||||||
|
? player.y + perpWallDist * rayDir.y
|
||||||
|
: player.x + perpWallDist * rayDir.x;
|
||||||
|
wallX -= wallX.floor();
|
||||||
|
int texX = (wallX * 64).toInt().clamp(0, 63);
|
||||||
|
|
||||||
|
int texNum = ((hitWallId - 1) * 2).clamp(0, wallTextures.length - 2);
|
||||||
|
if (side == 1) texNum += 1;
|
||||||
|
Sprite texture = wallTextures[texNum];
|
||||||
|
|
||||||
|
int columnHeight = (height / perpWallDist).toInt();
|
||||||
|
int drawStart = (-columnHeight ~/ 2 + height ~/ 2).clamp(0, height);
|
||||||
|
int drawEnd = (columnHeight ~/ 2 + height ~/ 2).clamp(0, height);
|
||||||
|
|
||||||
|
double brightness = (1.5 / (perpWallDist + 1.0)).clamp(0.0, 1.0);
|
||||||
|
String wallChar =
|
||||||
|
_charset[((1.0 - brightness) * (_charset.length - 1)).toInt().clamp(
|
||||||
|
0,
|
||||||
|
_charset.length - 1,
|
||||||
|
)];
|
||||||
|
|
||||||
|
for (int y = 0; y < height; y++) {
|
||||||
|
if (y >= drawStart && y < drawEnd) {
|
||||||
|
double relativeY = (y - drawStart) / (drawEnd - drawStart);
|
||||||
|
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
||||||
|
|
||||||
|
int colorByte = texture.pixels[texX * 64 + texY];
|
||||||
|
|
||||||
|
// Use our new color conversion!
|
||||||
|
Color pixelColor = _vgaToColor(ColorPalette.vga32Bit[colorByte]);
|
||||||
|
|
||||||
|
// Optional: slightly darken the Y-side walls for a faux-lighting effect
|
||||||
|
// if (side == 1) {
|
||||||
|
// pixelColor = Color.fromARGB(
|
||||||
|
// 255,
|
||||||
|
// (pixelColor.r * 0.7).toInt(),
|
||||||
|
// (pixelColor.g * 0.7).toInt(),
|
||||||
|
// (pixelColor.b * 0.7).toInt(),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
screen[y][x] = ColoredChar(wallChar, pixelColor);
|
||||||
|
} else if (y >= drawEnd) {
|
||||||
|
// Floor
|
||||||
|
screen[y][x] = ColoredChar('.', floorColor);
|
||||||
|
} else {
|
||||||
|
// Ceiling
|
||||||
|
screen[y][x] = ColoredChar(' ', ceilingColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. CAST SPRITES (Enemies/Items)
|
||||||
|
final List<Entity> activeSprites = List.from(engine.entities);
|
||||||
|
activeSprites.sort((a, b) {
|
||||||
|
double distA = player.position.distanceTo(a.position);
|
||||||
|
double distB = player.position.distanceTo(b.position);
|
||||||
|
return distB.compareTo(distA);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (Entity entity in activeSprites) {
|
||||||
|
Coordinate2D spritePos = entity.position - player.position;
|
||||||
|
|
||||||
|
double invDet = 1.0 / (plane.x * dir.y - dir.x * plane.y);
|
||||||
|
double transformX = invDet * (dir.y * spritePos.x - dir.x * spritePos.y);
|
||||||
|
double transformY =
|
||||||
|
invDet * (-plane.y * spritePos.x + plane.x * spritePos.y);
|
||||||
|
|
||||||
|
if (transformY > 0) {
|
||||||
|
int spriteScreenX = ((width / 2) * (1 + transformX / transformY))
|
||||||
|
.toInt();
|
||||||
|
int spriteHeight = (height / transformY).abs().toInt();
|
||||||
|
int spriteWidth = (spriteHeight * (width / height) * 0.6).toInt();
|
||||||
|
|
||||||
|
int drawStartY = -spriteHeight ~/ 2 + height ~/ 2;
|
||||||
|
int drawEndY = spriteHeight ~/ 2 + height ~/ 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 safeIndex = entity.spriteIndex.clamp(
|
||||||
|
0,
|
||||||
|
engine.data.sprites.length - 1,
|
||||||
|
);
|
||||||
|
Sprite spritePixels = engine.data.sprites[safeIndex];
|
||||||
|
|
||||||
|
double brightness = (1.5 / (transformY + 1.0)).clamp(0.0, 1.0);
|
||||||
|
String spriteChar =
|
||||||
|
_charset[((1.0 - brightness) * (_charset.length - 1)).toInt().clamp(
|
||||||
|
0,
|
||||||
|
_charset.length - 1,
|
||||||
|
)];
|
||||||
|
|
||||||
|
for (int stripe = clipStartX; stripe < clipEndX; stripe++) {
|
||||||
|
if (transformY < zBuffer[stripe]) {
|
||||||
|
int texX = ((stripe - drawStartX) * 64 ~/ spriteWidth).clamp(0, 63);
|
||||||
|
|
||||||
|
for (
|
||||||
|
int y = math.max(0, drawStartY);
|
||||||
|
y < math.min(height, drawEndY);
|
||||||
|
y++
|
||||||
|
) {
|
||||||
|
double relativeY = (y - drawStartY) / (drawEndY - drawStartY);
|
||||||
|
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
||||||
|
|
||||||
|
int colorByte = spritePixels.pixels[texX * 64 + texY];
|
||||||
|
|
||||||
|
if (colorByte != 255) {
|
||||||
|
// Apply the safe color conversion here as well
|
||||||
|
Color pixelColor = _vgaToColor(
|
||||||
|
ColorPalette.vga32Bit[colorByte],
|
||||||
|
);
|
||||||
|
screen[y][stripe] = ColoredChar(spriteChar, pixelColor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart
Normal file
120
packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
|
||||||
|
import 'package:wolf_3d_engine/wolf_3d_engine.dart';
|
||||||
|
import 'package:wolf_3d_input/wolf_3d_input.dart';
|
||||||
|
import 'package:wolf_3d_renderer/ascii_rasterizer.dart';
|
||||||
|
|
||||||
|
class WolfAsciiRenderer extends StatefulWidget {
|
||||||
|
const WolfAsciiRenderer(
|
||||||
|
this.data, {
|
||||||
|
required this.difficulty,
|
||||||
|
required this.startingEpisode,
|
||||||
|
required this.audio,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final WolfensteinData data;
|
||||||
|
final Difficulty difficulty;
|
||||||
|
final int startingEpisode;
|
||||||
|
final EngineAudio audio;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WolfAsciiRenderer> createState() => _WolfAsciiRendererState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WolfAsciiRendererState extends State<WolfAsciiRenderer>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final WolfInput inputManager = WolfInput();
|
||||||
|
late final WolfEngine engine;
|
||||||
|
late Ticker _gameLoop;
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
|
||||||
|
// Changed from String to List<List<ColoredChar>>
|
||||||
|
List<List<ColoredChar>> _asciiFrame = [];
|
||||||
|
final AsciiRasterizer _asciiRasterizer = AsciiRasterizer();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
engine = WolfEngine(
|
||||||
|
data: widget.data,
|
||||||
|
difficulty: widget.difficulty,
|
||||||
|
startingEpisode: widget.startingEpisode,
|
||||||
|
audio: widget.audio,
|
||||||
|
onGameWon: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
engine.init();
|
||||||
|
|
||||||
|
_gameLoop = createTicker(_tick)..start();
|
||||||
|
_focusNode.requestFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tick(Duration elapsed) {
|
||||||
|
if (!engine.isInitialized) return;
|
||||||
|
|
||||||
|
inputManager.update();
|
||||||
|
engine.tick(elapsed, inputManager.currentInput);
|
||||||
|
|
||||||
|
// Calculate frame synchronously and trigger UI rebuild
|
||||||
|
setState(() {
|
||||||
|
// 120x40 is a great sweet spot for text density vs aspect ratio
|
||||||
|
_asciiFrame = _asciiRasterizer.render(engine, FrameBuffer(120, 40));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_gameLoop.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!engine.isInitialized) {
|
||||||
|
return const Center(child: CircularProgressIndicator(color: Colors.teal));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: KeyboardListener(
|
||||||
|
focusNode: _focusNode,
|
||||||
|
autofocus: true,
|
||||||
|
onKeyEvent: (_) {},
|
||||||
|
// Added the missing argument here
|
||||||
|
child: _asciiFrame.isEmpty
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: _buildAsciiFrame(_asciiFrame),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAsciiFrame(List<List<ColoredChar>> frameData) {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: frameData.map((row) {
|
||||||
|
return RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Courier',
|
||||||
|
fontSize: 8, // Bumped slightly for better visibility
|
||||||
|
height: 1.0,
|
||||||
|
letterSpacing: 2.0,
|
||||||
|
),
|
||||||
|
children: row.map((cell) {
|
||||||
|
return TextSpan(
|
||||||
|
text: cell.char,
|
||||||
|
style: TextStyle(color: cell.color),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user