Refactor rendering architecture and replace rasterizer with renderer

- Introduced SoftwareRenderer as a pixel-accurate software rendering backend.
- Removed the obsolete wolf_3d_rasterizer.dart file.
- Created a new wolf_3d_renderer.dart file to centralize rendering exports.
- Updated tests to accommodate the new rendering structure, including pushwall and projection sampling tests.
- Modified the WolfAsciiRenderer and WolfFlutterRenderer to utilize the new SoftwareRenderer.
- Enhanced enemy spawn tests to include new enemy states.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-19 11:38:07 +01:00
parent ac6edb030e
commit 786ba4b450
22 changed files with 952 additions and 684 deletions

View File

@@ -29,7 +29,7 @@ class VgaImage {
///
/// Callers are expected to provide coordinates already clamped to the image
/// bounds. The Wolf3D VGA format stores image bytes in 4 interleaved planes;
/// this helper centralizes that mapping so rasterizers do not duplicate it.
/// this helper centralizes that mapping so renderers do not duplicate it.
int decodePixel(int srcX, int srcY) {
final int planeWidth = width ~/ 4;
final int planeSize = planeWidth * height;

View File

@@ -9,13 +9,15 @@ import 'package:wolf_3d_dart/wolf_3d_entities.dart';
/// door's state changes (e.g., starts closing), the appropriate global
/// game events (like sound effects) are triggered.
class DoorManager {
/// A lookup table for doors, keyed by their grid coordinates: "$x,$y".
final Map<String, Door> doors = {};
/// A lookup table for doors, keyed by packed grid coordinates.
final Map<int, Door> doors = {};
/// Callback used to trigger sound effects without tight coupling
/// to a specific audio engine implementation.
final void Function(int sfxId) onPlaySound;
static int _key(int x, int y) => ((y & 0xFFFF) << 16) | (x & 0xFFFF);
DoorManager({required this.onPlaySound});
/// Scans the [wallGrid] for tile IDs >= 90 and initializes [Door] instances.
@@ -25,7 +27,7 @@ class DoorManager {
for (int x = 0; x < wallGrid[y].length; x++) {
int id = wallGrid[y][x];
if (id >= 90) {
doors['$x,$y'] = Door(x: x, y: y, mapId: id);
doors[_key(x, y)] = Door(x: x, y: y, mapId: id);
}
}
}
@@ -48,7 +50,7 @@ class DoorManager {
int targetX = (playerX + math.cos(playerAngle)).toInt();
int targetY = (playerY + math.sin(playerAngle)).toInt();
String key = '$targetX,$targetY';
final int key = _key(targetX, targetY);
if (doors.containsKey(key)) {
if (doors[key]!.interact()) {
onPlaySound(WolfSound.openDoor);
@@ -58,7 +60,7 @@ class DoorManager {
/// Attempted by AI entities to open a door blocking their path.
void tryOpenDoor(int x, int y) {
String key = '$x,$y';
final int key = _key(x, y);
// AI only interacts if the door is currently fully closed (offset == 0).
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
if (doors[key]!.interact()) {
@@ -67,21 +69,16 @@ class DoorManager {
}
}
// Helper method for the raycaster
Map<String, double> getOffsetsForRenderer() {
Map<String, double> offsets = {};
for (var entry in doors.entries) {
if (entry.value.offset > 0.0) {
offsets[entry.key] = entry.value.offset;
}
}
return offsets;
/// Returns the current open offset for the door at [x],[y].
/// Returns 0.0 when there is no active/opening door at that tile.
double doorOffsetAt(int x, int y) {
return doors[_key(x, y)]?.offset ?? 0.0;
}
/// Returns true if the door at [x], [y] is sufficiently open for
/// an entity (player or enemy) to walk through.
bool isDoorOpenEnough(int x, int y) {
String key = '$x,$y';
final int key = _key(x, y);
if (doors.containsKey(key)) {
// 0.7 (70% open) is the standard collision threshold.
return doors[key]!.offset > 0.7;

View File

@@ -387,7 +387,9 @@ abstract class Enemy extends Entity {
if (matchedType.mapData.isPatrol(normalizedId)) {
spawnState = EntityState.patrolling;
} else if (matchedType.mapData.isStatic(normalizedId)) {
spawnState = EntityState.idle;
// Standing map placements are directional ambush actors in Wolf3D.
// Using ambushing keeps wake-up behavior aligned with placed facing.
spawnState = EntityState.ambushing;
} else {
return null;
}

View File

@@ -1,605 +0,0 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
/// Shared rendering pipeline and math utilities for all Wolf3D rasterizers.
///
/// Subclasses implement draw primitives for their output target (software
/// framebuffer, ANSI text, Sixel, etc), while this base class coordinates
/// raycasting, sprite projection, and common HUD calculations.
abstract class Rasterizer<T> {
late List<double> zBuffer;
late int width;
late int height;
late int viewHeight;
/// The current engine instance; set at the start of every [render] call.
late WolfEngine engine;
/// A multiplier to adjust the width of sprites.
/// Pixel renderers usually keep this at 1.0.
/// ASCII renderers can override this (e.g., 0.6) to account for tall characters.
double get aspectMultiplier => 1.0;
/// A multiplier to counteract tall pixel formats (like 1:2 terminal fonts).
/// Defaults to 1.0 (no squish) for standard pixel rendering.
double get verticalStretch => 1.0;
/// The logical width of the projection area used for raycasting and sprites.
/// Most renderers use the full buffer width.
int get projectionWidth => width;
/// Horizontal offset of the projection area within the output buffer.
int get projectionOffsetX => 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;
/// Whether the current terminal dimensions are supported by this renderer.
/// Default renderers accept all sizes.
bool isTerminalSizeSupported(int columns, int rows) => true;
/// Human-readable requirement text used by the host app when size checks fail.
String get terminalSizeRequirement => 'Please resize your terminal window.';
/// The main entry point called by the game loop.
/// Orchestrates the mathematical rendering pipeline.
T render(WolfEngine engine) {
this.engine = engine;
width = engine.frameBuffer.width;
height = engine.frameBuffer.height;
// The 3D view typically takes up the top 80% of the screen
viewHeight = (height * 0.8).toInt();
zBuffer = List.filled(projectionWidth, 0.0);
// 1. Setup the frame (clear screen, draw floor/ceiling)
prepareFrame(engine);
if (engine.difficulty == null) {
drawMenu(engine);
return finalizeFrame();
}
// 2. Do the heavy math for Raycasting Walls
_castWalls(engine);
// 3. Do the heavy math for Projecting Sprites
_castSprites(engine);
// 4. Draw 2D Overlays
drawWeapon(engine);
drawHud(engine);
// 5. Finalize and return the frame data (Buffer or String/List)
return finalizeFrame();
}
// ===========================================================================
// ABSTRACT METHODS (Implemented by the child renderers)
// ===========================================================================
/// Initialize buffers, clear the screen, and draw the floor/ceiling.
void prepareFrame(WolfEngine engine);
/// Draw a single vertical column of a wall.
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
);
/// Draw a single vertical stripe of a sprite (enemy/item).
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
);
/// Draw the player's weapon overlay at the bottom of the 3D view.
void drawWeapon(WolfEngine engine);
/// Draw the 2D status bar at the bottom 20% of the screen.
void drawHud(WolfEngine engine);
/// Return the finished frame (e.g., the FrameBuffer itself, or an ASCII list).
T finalizeFrame();
/// Draws a non-world menu frame when the engine is awaiting configuration.
///
/// Default implementation is a no-op for renderers that don't support menus.
void drawMenu(WolfEngine engine) {}
/// Plots a VGA image into this renderer's HUD coordinate space.
///
/// Coordinates are in the original 320x200 HUD space. Renderers that support
/// shared HUD composition should override this.
void blitHudVgaImage(VgaImage image, int startX320, int startY200) {}
/// Shared Wolf3D VGA HUD sequence used by software/sixel/ASCII-full-HUD.
///
/// Coordinates are intentionally in original 320x200 HUD space so each
/// renderer can scale/map them consistently via [blitHudVgaImage].
void drawStandardVgaHud(WolfEngine engine) {
final List<VgaImage> vgaImages = engine.data.vgaImages;
final int statusBarIndex = vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
blitHudVgaImage(vgaImages[statusBarIndex], 0, 160);
_drawHudNumber(vgaImages, 1, 32, 176);
_drawHudNumber(vgaImages, engine.player.score, 96, 176);
_drawHudNumber(vgaImages, 3, 120, 176);
_drawHudNumber(vgaImages, engine.player.health, 192, 176);
_drawHudNumber(vgaImages, engine.player.ammo, 232, 176);
_drawHudFace(engine, vgaImages);
_drawHudWeaponIcon(engine, vgaImages);
}
void _drawHudNumber(
List<VgaImage> vgaImages,
int value,
int rightAlignX,
int startY,
) {
// HUD numbers are rendered with fixed-width VGA glyphs (8 px advance).
const int zeroIndex = 96;
final String numStr = value.toString();
int currentX = rightAlignX - (numStr.length * 8);
for (int i = 0; i < numStr.length; i++) {
final int digit = int.parse(numStr[i]);
final int imageIndex = zeroIndex + digit;
if (imageIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[imageIndex], currentX, startY);
}
currentX += 8;
}
}
void _drawHudFace(WolfEngine engine, List<VgaImage> vgaImages) {
final int faceIndex = hudFaceVgaIndex(engine.player.health);
if (faceIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[faceIndex], 136, 164);
}
}
void _drawHudWeaponIcon(WolfEngine engine, List<VgaImage> vgaImages) {
final int weaponIndex = hudWeaponVgaIndex(engine);
if (weaponIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[weaponIndex], 256, 164);
}
}
// ===========================================================================
// SHARED LIGHTING MATH
// ===========================================================================
/// Calculates depth-based lighting falloff (0.0 to 1.0).
/// While the original Wolf3D didn't use depth fog, this provides a great
/// atmospheric effect for custom renderers (like ASCII dithering).
double calculateDepthBrightness(double distance) {
return (10.0 / (distance + 2.0)).clamp(0.0, 1.0);
}
// ===========================================================================
// SHARED PROJECTION MATH
// ===========================================================================
/// Returns the texture Y coordinate for the given screen row inside a wall
/// column. Works for both pixel and terminal renderers.
int wallTexY(int y, int columnHeight) {
// Anchor sampling to the same projection center used when computing
// drawStart/drawEnd. This keeps wall textures stable for renderers that
// use a taller logical projection (for example terminal ASCII mode).
final int projectionCenterY = projectionViewHeight ~/ 2;
final double relativeY =
(y - (-columnHeight ~/ 2 + projectionCenterY)) / columnHeight;
return (relativeY * 64).toInt().clamp(0, 63);
}
/// Returns the texture Y coordinate for the given screen row inside a sprite
/// stripe.
int spriteTexY(int y, int drawStartY, int spriteHeight) {
final double relativeY = (y - drawStartY) / spriteHeight;
return (relativeY * 64).toInt().clamp(0, 63);
}
/// Returns the screen-space bounds for the player's weapon overlay.
///
/// [weaponWidth] and [weaponHeight] are in pixels; [startX]/[startY] are
/// the top-left draw origin. Uses [projectionWidth] so that renderers
/// with a narrower projection area (e.g. ASCII terminal) are handled
/// correctly.
({int weaponWidth, int weaponHeight, int startX, int startY})
weaponScreenBounds(WolfEngine engine) {
final int ww = (projectionWidth * 0.5).toInt();
final int wh = (viewHeight * 0.8).toInt();
final int sx = projectionOffsetX + (projectionWidth ~/ 2) - (ww ~/ 2);
final int sy = viewHeight - wh + (engine.player.weaponAnimOffset ~/ 4);
return (weaponWidth: ww, weaponHeight: wh, startX: sx, startY: sy);
}
/// Returns the VGA image index for BJ's face sprite based on player health.
int hudFaceVgaIndex(int health) {
if (health <= 0) return 127;
return 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
}
/// Returns the VGA image index for the current weapon icon in the HUD.
int hudWeaponVgaIndex(WolfEngine engine) {
if (engine.player.hasChainGun) return 91;
if (engine.player.hasMachineGun) return 90;
return 89;
}
({double distance, int side, int hitWallId, double wallX})?
_intersectActivePushwall(
Player player,
Coordinate2D rayDir,
Pushwall activePushwall,
) {
double minX = activePushwall.x.toDouble();
double maxX = activePushwall.x + 1.0;
double minY = activePushwall.y.toDouble();
double maxY = activePushwall.y + 1.0;
if (activePushwall.dirX != 0) {
final double delta = activePushwall.dirX * activePushwall.offset;
minX += delta;
maxX += delta;
}
if (activePushwall.dirY != 0) {
final double delta = activePushwall.dirY * activePushwall.offset;
minY += delta;
maxY += delta;
}
const double epsilon = 1e-9;
double tMinX = double.negativeInfinity;
double tMaxX = double.infinity;
if (rayDir.x.abs() < epsilon) {
if (player.x < minX || player.x > maxX) {
return null;
}
} else {
final double tx1 = (minX - player.x) / rayDir.x;
final double tx2 = (maxX - player.x) / rayDir.x;
tMinX = math.min(tx1, tx2);
tMaxX = math.max(tx1, tx2);
}
double tMinY = double.negativeInfinity;
double tMaxY = double.infinity;
if (rayDir.y.abs() < epsilon) {
if (player.y < minY || player.y > maxY) {
return null;
}
} else {
final double ty1 = (minY - player.y) / rayDir.y;
final double ty2 = (maxY - player.y) / rayDir.y;
tMinY = math.min(ty1, ty2);
tMaxY = math.max(ty1, ty2);
}
final double entryDistance = math.max(tMinX, tMinY);
final double exitDistance = math.min(tMaxX, tMaxY);
if (exitDistance < 0 || entryDistance > exitDistance) {
return null;
}
final double hitDistance = entryDistance >= 0
? entryDistance
: exitDistance;
if (hitDistance < 0) {
return null;
}
final int side = tMinX > tMinY ? 0 : 1;
final double wallCoord = side == 0
? player.y + hitDistance * rayDir.y
: player.x + hitDistance * rayDir.x;
return (
distance: hitDistance,
side: side,
hitWallId: activePushwall.mapId,
wallX: wallCoord - wallCoord.floor(),
);
}
// ===========================================================================
// CORE ENGINE MATH (Shared across all renderers)
// ===========================================================================
void _castWalls(WolfEngine engine) {
final Player player = engine.player;
final SpriteMap map = engine.currentLevel;
final List<Sprite> wallTextures = engine.data.walls;
final int sceneWidth = projectionWidth;
final int sceneHeight = projectionViewHeight;
final Map<String, double> doorOffsets = engine.doorManager
.getOffsetsForRenderer();
final Pushwall? activePushwall = engine.pushwallManager.activePushwall;
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);
for (int x = 0; x < sceneWidth; x++) {
double cameraX = 2 * x / sceneWidth - 1.0;
Coordinate2D rayDir = dir + (plane * cameraX);
final pushwallHit = activePushwall == null
? null
: _intersectActivePushwall(player, rayDir, activePushwall);
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, perpWallDist = 0.0;
int stepX, stepY, side = 0, hitWallId = 0;
bool hit = false, hitOutOfBounds = false, customDistCalculated = false;
double textureOffset = 0.0;
double? wallXOverride;
Set<String> ignoredDoors = {};
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;
}
// DDA Loop
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) {
hit = true;
hitOutOfBounds = true;
} else if (map[mapY][mapX] > 0) {
if (activePushwall != null &&
mapX == activePushwall.x &&
mapY == activePushwall.y) {
continue;
}
String mapKey = '$mapX,$mapY';
// DOOR LOGIC
if (map[mapY][mapX] >= 90 && !ignoredDoors.contains(mapKey)) {
double currentOffset = doorOffsets[mapKey] ?? 0.0;
if (currentOffset > 0.0) {
double perpWallDistTemp = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
double wallXTemp = (side == 0)
? player.y + perpWallDistTemp * rayDir.y
: player.x + perpWallDistTemp * rayDir.x;
wallXTemp -= wallXTemp.floor();
if (wallXTemp < currentOffset) {
ignoredDoors.add(mapKey);
continue; // Ray passes through the open part of the door
}
}
hit = true;
hitWallId = map[mapY][mapX];
textureOffset = currentOffset;
} else {
hit = true;
hitWallId = map[mapY][mapX];
}
}
}
if (hitOutOfBounds || !hit) {
if (pushwallHit == null) {
continue;
}
customDistCalculated = true;
perpWallDist = pushwallHit.distance;
side = pushwallHit.side;
hitWallId = pushwallHit.hitWallId;
wallXOverride = pushwallHit.wallX;
textureOffset = 0.0;
hit = true;
hitOutOfBounds = false;
}
if (!customDistCalculated) {
perpWallDist = (side == 0)
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
}
if (pushwallHit != null && pushwallHit.distance < perpWallDist) {
customDistCalculated = true;
perpWallDist = pushwallHit.distance;
side = pushwallHit.side;
hitWallId = pushwallHit.hitWallId;
wallXOverride = pushwallHit.wallX;
textureOffset = 0.0;
}
if (perpWallDist < 0.1) perpWallDist = 0.1;
// Save for sprite depth checks
zBuffer[x] = perpWallDist;
// Calculate Texture X Coordinate
double wallX =
wallXOverride ??
((side == 0)
? player.y + perpWallDist * rayDir.y
: player.x + perpWallDist * rayDir.x);
wallX -= wallX.floor();
int texNum;
if (hitWallId >= 90) {
texNum = 98.clamp(0, wallTextures.length - 1);
} else {
texNum = ((hitWallId - 1) * 2).clamp(0, wallTextures.length - 2);
if (side == 1) texNum += 1;
}
Sprite texture = wallTextures[texNum];
// Texture flipping for specific orientations
int texX = (((wallX - textureOffset) % 1.0) * 64).toInt().clamp(0, 63);
if (side == 0 && math.cos(player.angle) > 0) texX = 63 - texX;
if (side == 1 && math.sin(player.angle) < 0) texX = 63 - texX;
// Calculate drawing dimensions
int columnHeight = ((sceneHeight / perpWallDist) * verticalStretch)
.toInt();
int drawStart = (-columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
0,
sceneHeight,
);
int drawEnd = (columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
0,
sceneHeight,
);
// Tell the implementation to draw this column
drawWallColumn(
projectionOffsetX + x,
drawStart,
drawEnd,
columnHeight,
texture,
texX,
perpWallDist,
side,
);
}
}
void _castSprites(WolfEngine engine) {
final Player player = engine.player;
final List<Entity> activeSprites = List.from(engine.entities);
final int sceneWidth = projectionWidth;
final int sceneHeight = projectionViewHeight;
// Sort from furthest to closest (Painter's Algorithm)
activeSprites.sort((a, b) {
double distA = player.position.distanceTo(a.position);
double distB = player.position.distanceTo(b.position);
return distB.compareTo(distA);
});
Coordinate2D dir = Coordinate2D(
math.cos(player.angle),
math.sin(player.angle),
);
Coordinate2D plane =
Coordinate2D(-dir.y, dir.x) * math.tan((math.pi / 3) / 2);
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);
// Only process if the sprite is in front of the camera
if (transformY > 0) {
int spriteScreenX = ((sceneWidth / 2) * (1 + transformX / transformY))
.toInt();
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 =
(displayedSpriteHeight * aspectMultiplier / verticalStretch)
.toInt();
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(sceneWidth, drawEndX);
int safeIndex = entity.spriteIndex.clamp(
0,
engine.data.sprites.length - 1,
);
Sprite texture = engine.data.sprites[safeIndex];
// Loop through the visible vertical stripes
for (int stripe = clipStartX; stripe < clipEndX; stripe++) {
// Check the Z-Buffer to see if a wall is in front of this stripe
if (transformY < zBuffer[stripe]) {
int texX = ((stripe - drawStartX) * 64 ~/ spriteWidth).clamp(0, 63);
// Tell the implementation to draw this stripe
drawSpriteStripe(
projectionOffsetX + stripe,
drawStartY,
drawEndY,
spriteHeight,
texture,
texX,
transformY,
);
}
}
}
}
}
/// Darkens a 32-bit 0xAABBGGRR color by roughly 30% without touching Alpha
int shadeColor(int color) {
int r = (color & 0xFF) * 7 ~/ 10;
int g = ((color >> 8) & 0xFF) * 7 ~/ 10;
int b = ((color >> 16) & 0xFF) * 7 ~/ 10;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
}

View File

@@ -0,0 +1,33 @@
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Shared projection helpers used by render backends and the raycaster.
mixin ProjectionMath {
int get projectionWidth;
int get projectionOffsetX;
int get projectionViewHeight;
int get viewHeight;
/// Returns the texture Y coordinate for a wall sample row.
int wallTexY(int y, int columnHeight) {
final int projectionCenterY = projectionViewHeight ~/ 2;
final double relativeY =
(y - (-columnHeight ~/ 2 + projectionCenterY)) / columnHeight;
return (relativeY * 64).toInt().clamp(0, 63);
}
/// Returns the texture Y coordinate for a sprite sample row.
int spriteTexY(int y, int drawStartY, int spriteHeight) {
final double relativeY = (y - drawStartY) / spriteHeight;
return (relativeY * 64).toInt().clamp(0, 63);
}
/// Returns the screen-space bounds for the player's weapon overlay.
({int weaponWidth, int weaponHeight, int startX, int startY})
weaponScreenBounds(WolfEngine engine) {
final int ww = (projectionWidth * 0.5).toInt();
final int wh = (viewHeight * 0.8).toInt();
final int sx = projectionOffsetX + (projectionWidth ~/ 2) - (ww ~/ 2);
final int sy = viewHeight - wh + (engine.player.weaponAnimOffset ~/ 4);
return (weaponWidth: ww, weaponHeight: wh, startX: sx, startY: sy);
}
}

View File

@@ -0,0 +1,419 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_entities.dart';
abstract class RaycastBackend {
List<double> get zBuffer;
int get projectionWidth;
int get projectionOffsetX;
int get projectionViewHeight;
int get viewHeight;
double get aspectMultiplier;
double get verticalStretch;
int wallTexY(int y, int columnHeight);
int spriteTexY(int y, int drawStartY, int spriteHeight);
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
);
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
);
}
/// Shared Wolf3D wall DDA and sprite projection math.
class Raycaster {
final List<Entity> _spriteScratch = <Entity>[];
void castWorld(WolfEngine engine, RaycastBackend backend) {
_castWalls(engine, backend);
_castSprites(engine, backend);
}
({double distance, int side, int hitWallId, double wallX})?
_intersectActivePushwall(
Player player,
double rayDirX,
double rayDirY,
Pushwall activePushwall,
) {
double minX = activePushwall.x.toDouble();
double maxX = activePushwall.x + 1.0;
double minY = activePushwall.y.toDouble();
double maxY = activePushwall.y + 1.0;
if (activePushwall.dirX != 0) {
final double delta = activePushwall.dirX * activePushwall.offset;
minX += delta;
maxX += delta;
}
if (activePushwall.dirY != 0) {
final double delta = activePushwall.dirY * activePushwall.offset;
minY += delta;
maxY += delta;
}
const double epsilon = 1e-9;
double tMinX = double.negativeInfinity;
double tMaxX = double.infinity;
if (rayDirX.abs() < epsilon) {
if (player.x < minX || player.x > maxX) {
return null;
}
} else {
final double tx1 = (minX - player.x) / rayDirX;
final double tx2 = (maxX - player.x) / rayDirX;
tMinX = math.min(tx1, tx2);
tMaxX = math.max(tx1, tx2);
}
double tMinY = double.negativeInfinity;
double tMaxY = double.infinity;
if (rayDirY.abs() < epsilon) {
if (player.y < minY || player.y > maxY) {
return null;
}
} else {
final double ty1 = (minY - player.y) / rayDirY;
final double ty2 = (maxY - player.y) / rayDirY;
tMinY = math.min(ty1, ty2);
tMaxY = math.max(ty1, ty2);
}
final double entryDistance = math.max(tMinX, tMinY);
final double exitDistance = math.min(tMaxX, tMaxY);
if (exitDistance < 0 || entryDistance > exitDistance) {
return null;
}
final double hitDistance = entryDistance >= 0
? entryDistance
: exitDistance;
if (hitDistance < 0) {
return null;
}
final int side = tMinX > tMinY ? 0 : 1;
final double wallCoord = side == 0
? player.y + hitDistance * rayDirY
: player.x + hitDistance * rayDirX;
return (
distance: hitDistance,
side: side,
hitWallId: activePushwall.mapId,
wallX: wallCoord - wallCoord.floor(),
);
}
void _castWalls(WolfEngine engine, RaycastBackend backend) {
final Player player = engine.player;
final SpriteMap map = engine.currentLevel;
final List<Sprite> wallTextures = engine.data.walls;
final int sceneWidth = backend.projectionWidth;
final int sceneHeight = backend.projectionViewHeight;
final Pushwall? activePushwall = engine.pushwallManager.activePushwall;
const double fovHalfTan = 0.5773502691896257; // tan(PI / 6)
final double cosAngle = math.cos(player.angle);
final double sinAngle = math.sin(player.angle);
final double dirX = cosAngle;
final double dirY = sinAngle;
final double planeX = -dirY * fovHalfTan;
final double planeY = dirX * fovHalfTan;
for (int x = 0; x < sceneWidth; x++) {
final double cameraX = 2 * x / sceneWidth - 1.0;
final double rayDirX = dirX + (planeX * cameraX);
final double rayDirY = dirY + (planeY * cameraX);
final pushwallHit = activePushwall == null
? null
: _intersectActivePushwall(player, rayDirX, rayDirY, activePushwall);
int mapX = player.x.toInt();
int mapY = player.y.toInt();
final double deltaDistX = (rayDirX == 0) ? 1e30 : (1.0 / rayDirX).abs();
final double deltaDistY = (rayDirY == 0) ? 1e30 : (1.0 / rayDirY).abs();
late double sideDistX;
late double sideDistY;
double perpWallDist = 0.0;
int stepX;
int stepY;
int side = 0;
int hitWallId = 0;
bool hit = false;
bool hitOutOfBounds = false;
bool customDistCalculated = false;
double textureOffset = 0.0;
double? wallXOverride;
if (rayDirX < 0) {
stepX = -1;
sideDistX = (player.x - mapX) * deltaDistX;
} else {
stepX = 1;
sideDistX = (mapX + 1.0 - player.x) * deltaDistX;
}
if (rayDirY < 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) {
hit = true;
hitOutOfBounds = true;
} else if (map[mapY][mapX] > 0) {
if (activePushwall != null &&
mapX == activePushwall.x &&
mapY == activePushwall.y) {
continue;
}
if (map[mapY][mapX] >= 90) {
final double currentOffset = engine.doorManager.doorOffsetAt(
mapX,
mapY,
);
if (currentOffset > 0.0) {
final double perpWallDistTemp = side == 0
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
double wallXTemp = side == 0
? player.y + perpWallDistTemp * rayDirY
: player.x + perpWallDistTemp * rayDirX;
wallXTemp -= wallXTemp.floor();
if (wallXTemp < currentOffset) {
continue;
}
}
hit = true;
hitWallId = map[mapY][mapX];
textureOffset = currentOffset;
} else {
hit = true;
hitWallId = map[mapY][mapX];
}
}
}
if (hitOutOfBounds || !hit) {
if (pushwallHit == null) {
continue;
}
customDistCalculated = true;
perpWallDist = pushwallHit.distance;
side = pushwallHit.side;
hitWallId = pushwallHit.hitWallId;
wallXOverride = pushwallHit.wallX;
textureOffset = 0.0;
}
if (!customDistCalculated) {
perpWallDist = side == 0
? (sideDistX - deltaDistX)
: (sideDistY - deltaDistY);
}
if (pushwallHit != null && pushwallHit.distance < perpWallDist) {
perpWallDist = pushwallHit.distance;
side = pushwallHit.side;
hitWallId = pushwallHit.hitWallId;
wallXOverride = pushwallHit.wallX;
textureOffset = 0.0;
}
if (perpWallDist < 0.1) {
perpWallDist = 0.1;
}
backend.zBuffer[x] = perpWallDist;
double wallX =
wallXOverride ??
(side == 0
? player.y + perpWallDist * rayDirY
: player.x + perpWallDist * rayDirX);
wallX -= wallX.floor();
int texNum;
if (hitWallId >= 90) {
texNum = 98.clamp(0, wallTextures.length - 1);
} else {
texNum = ((hitWallId - 1) * 2).clamp(0, wallTextures.length - 2);
if (side == 1) {
texNum += 1;
}
}
final Sprite texture = wallTextures[texNum];
int texX = (((wallX - textureOffset) % 1.0) * 64).toInt().clamp(0, 63);
if (side == 0 && cosAngle > 0) {
texX = 63 - texX;
}
if (side == 1 && sinAngle < 0) {
texX = 63 - texX;
}
final int columnHeight =
((sceneHeight / perpWallDist) * backend.verticalStretch).toInt();
final int drawStart = (-columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
0,
sceneHeight,
);
final int drawEnd = (columnHeight ~/ 2 + sceneHeight ~/ 2).clamp(
0,
sceneHeight,
);
backend.drawWallColumn(
backend.projectionOffsetX + x,
drawStart,
drawEnd,
columnHeight,
texture,
texX,
perpWallDist,
side,
);
}
}
void _castSprites(WolfEngine engine, RaycastBackend backend) {
final Player player = engine.player;
final int sceneWidth = backend.projectionWidth;
final int sceneHeight = backend.projectionViewHeight;
final double playerX = player.position.x;
final double playerY = player.position.y;
final double cosAngle = math.cos(player.angle);
final double sinAngle = math.sin(player.angle);
const double fovHalfTan = 0.5773502691896257; // tan(PI / 6)
final double dirX = cosAngle;
final double dirY = sinAngle;
final double planeX = -dirY * fovHalfTan;
final double planeY = dirX * fovHalfTan;
_spriteScratch.clear();
for (final Entity entity in engine.entities) {
final double toSpriteX = entity.position.x - playerX;
final double toSpriteY = entity.position.y - playerY;
// Reject sprites behind the player before sort/projection work.
if ((toSpriteX * dirX + toSpriteY * dirY) > 0) {
_spriteScratch.add(entity);
}
}
_spriteScratch.sort((a, b) {
final double adx = a.position.x - playerX;
final double ady = a.position.y - playerY;
final double bdx = b.position.x - playerX;
final double bdy = b.position.y - playerY;
final double distA = (adx * adx) + (ady * ady);
final double distB = (bdx * bdx) + (bdy * bdy);
return distB.compareTo(distA);
});
for (final Entity entity in _spriteScratch) {
final double spriteX = entity.position.x - player.position.x;
final double spriteY = entity.position.y - player.position.y;
final double invDet = 1.0 / (planeX * dirY - dirX * planeY);
final double transformX = invDet * (dirY * spriteX - dirX * spriteY);
final double transformY = invDet * (-planeY * spriteX + planeX * spriteY);
if (transformY <= 0) {
continue;
}
final int spriteScreenX =
((sceneWidth / 2) * (1 + transformX / transformY)).toInt();
final int spriteHeight =
((sceneHeight / transformY).abs() * backend.verticalStretch).toInt();
final int displayedSpriteHeight =
((backend.viewHeight / transformY).abs() * backend.verticalStretch)
.toInt();
final int spriteWidth =
(displayedSpriteHeight *
backend.aspectMultiplier /
backend.verticalStretch)
.toInt();
final int drawStartY = -spriteHeight ~/ 2 + sceneHeight ~/ 2;
final int drawEndY = spriteHeight ~/ 2 + sceneHeight ~/ 2;
final int drawStartX = -spriteWidth ~/ 2 + spriteScreenX;
final int drawEndX = spriteWidth ~/ 2 + spriteScreenX;
final int clipStartX = math.max(0, drawStartX);
final int clipEndX = math.min(sceneWidth, drawEndX);
final int safeIndex = entity.spriteIndex.clamp(
0,
engine.data.sprites.length - 1,
);
final Sprite texture = engine.data.sprites[safeIndex];
for (int stripe = clipStartX; stripe < clipEndX; stripe++) {
if (transformY < backend.zBuffer[stripe]) {
final int texX = ((stripe - drawStartX) * 64 ~/ spriteWidth).clamp(
0,
63,
);
backend.drawSpriteStripe(
backend.projectionOffsetX + stripe,
drawStartY,
drawEndY,
spriteHeight,
texture,
texX,
transformY,
);
}
}
}
}
}

View File

@@ -6,7 +6,7 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
import 'cli_rasterizer.dart';
import 'cli_renderer_backend.dart';
import 'menu_font.dart';
class AsciiTheme {
@@ -74,14 +74,14 @@ class ColoredChar {
}
}
enum AsciiRasterizerMode {
enum AsciiRendererMode {
terminalAnsi,
terminalGrid,
}
/// Text-mode rasterizer that can render to ANSI escape output or a Flutter
/// Text-mode renderer that can render to ANSI escape output or a Flutter
/// grid model of colored characters.
class AsciiRasterizer extends CliRasterizer<dynamic> {
class AsciiRenderer extends CliRendererBackend<dynamic> {
static const double _targetAspectRatio = 4 / 3;
static const int _terminalBackdropPaletteIndex = 153;
static const int _minimumTerminalColumns = 80;
@@ -93,21 +93,21 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
static const int _menuHintLabelPaletteIndex = 4;
static const int _menuHintBackgroundPaletteIndex = 0;
AsciiRasterizer({
AsciiRenderer({
this.activeTheme = AsciiThemes.blocks,
this.mode = AsciiRasterizerMode.terminalGrid,
this.mode = AsciiRendererMode.terminalGrid,
this.useTerminalLayout = true,
this.aspectMultiplier = 1.0,
this.verticalStretch = 1.0,
});
AsciiTheme activeTheme = AsciiThemes.blocks;
final AsciiRasterizerMode mode;
final AsciiRendererMode mode;
final bool useTerminalLayout;
bool get _usesTerminalLayout => useTerminalLayout;
bool get _emitAnsi => mode == AsciiRasterizerMode.terminalAnsi;
bool get _emitAnsi => mode == AsciiRendererMode.terminalAnsi;
late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels;
@@ -323,7 +323,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
// --- PRIVATE HUD DRAWING HELPER ---
/// Injects a pure text string directly into the rasterizer grid
/// Injects a pure text string directly into the renderer grid
void _writeString(
int startX,
int y,

View File

@@ -1,9 +1,9 @@
import 'package:wolf_3d_dart/src/engine/wolf_3d_engine_base.dart';
import 'rasterizer.dart';
import 'renderer_backend.dart';
/// Shared terminal orchestration for CLI rasterizers.
abstract class CliRasterizer<T> extends Rasterizer<T> {
/// Shared terminal orchestration for CLI renderers.
abstract class CliRendererBackend<T> extends RendererBackend<T> {
/// Resolves the framebuffer dimensions required by this renderer.
///
/// The default uses the full terminal size.

View File

@@ -0,0 +1,231 @@
import 'package:wolf_3d_dart/src/raycasting/projection.dart';
import 'package:wolf_3d_dart/src/raycasting/raycaster.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
/// Shared rendering pipeline for Wolf3D backends.
///
/// Subclasses implement draw primitives for their output target (software
/// framebuffer, ANSI text, Sixel, etc), while this backend coordinates frame
/// orchestration and delegates DDA/sprite math to [Raycaster].
abstract class RendererBackend<T>
with ProjectionMath
implements RaycastBackend {
@override
List<double> zBuffer = <double>[];
late int width;
late int height;
@override
late int viewHeight;
/// The current engine instance; set at the start of every [render] call.
late WolfEngine engine;
final Raycaster _raycaster = Raycaster();
/// A multiplier to adjust the width of sprites.
/// Pixel renderers usually keep this at 1.0.
/// ASCII renderers can override this (e.g., 0.6) to account for tall characters.
@override
double get aspectMultiplier => 1.0;
/// A multiplier to counteract tall pixel formats (like 1:2 terminal fonts).
/// Defaults to 1.0 (no squish) for standard pixel rendering.
@override
double get verticalStretch => 1.0;
/// The logical width of the projection area used for raycasting and sprites.
/// Most renderers use the full buffer width.
@override
int get projectionWidth => width;
/// Horizontal offset of the projection area within the output buffer.
@override
int get projectionOffsetX => 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.
@override
int get projectionViewHeight => viewHeight;
/// Whether the current terminal dimensions are supported by this renderer.
/// Default renderers accept all sizes.
bool isTerminalSizeSupported(int columns, int rows) => true;
/// Human-readable requirement text used by the host app when size checks fail.
String get terminalSizeRequirement => 'Please resize your terminal window.';
void _ensureZBuffer() {
if (zBuffer.length != projectionWidth) {
zBuffer = List<double>.filled(projectionWidth, 0.0);
return;
}
zBuffer.fillRange(0, zBuffer.length, 0.0);
}
/// The main entry point called by the game loop.
/// Orchestrates the rendering pipeline.
T render(WolfEngine engine) {
this.engine = engine;
width = engine.frameBuffer.width;
height = engine.frameBuffer.height;
// The 3D view typically takes up the top 80% of the screen.
viewHeight = (height * 0.8).toInt();
_ensureZBuffer();
// 1. Setup the frame (clear screen, draw floor/ceiling).
prepareFrame(engine);
if (engine.difficulty == null) {
drawMenu(engine);
return finalizeFrame();
}
// 2. Do the heavy math for wall and sprite casting.
_raycaster.castWorld(engine, this);
// 3. Draw 2D overlays.
drawWeapon(engine);
drawHud(engine);
// 4. Finalize and return the frame data (Buffer or String/List).
return finalizeFrame();
}
// ===========================================================================
// ABSTRACT METHODS (Implemented by backend subclasses)
// ===========================================================================
/// Initialize buffers, clear the screen, and draw the floor/ceiling.
void prepareFrame(WolfEngine engine);
/// Draw a single vertical column of a wall.
@override
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
);
/// Draw a single vertical stripe of a sprite (enemy/item).
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
);
/// Draw the player's weapon overlay at the bottom of the 3D view.
void drawWeapon(WolfEngine engine);
/// Draw the 2D status bar at the bottom 20% of the screen.
void drawHud(WolfEngine engine);
/// Return the finished frame (e.g., the FrameBuffer itself, or an ASCII list).
T finalizeFrame();
/// Draws a non-world menu frame when the engine is awaiting configuration.
///
/// Default implementation is a no-op for backends that don't support menus.
void drawMenu(WolfEngine engine) {}
/// Plots a VGA image into this backend's HUD coordinate space.
///
/// Coordinates are in the original 320x200 HUD space. Backends that support
/// shared HUD composition should override this.
void blitHudVgaImage(VgaImage image, int startX320, int startY200) {}
/// Shared Wolf3D VGA HUD sequence used by software/sixel/ASCII-full-HUD.
///
/// Coordinates are intentionally in original 320x200 HUD space so each
/// backend can scale/map them consistently via [blitHudVgaImage].
void drawStandardVgaHud(WolfEngine engine) {
final List<VgaImage> vgaImages = engine.data.vgaImages;
final int statusBarIndex = vgaImages.indexWhere(
(img) => img.width == 320 && img.height == 40,
);
if (statusBarIndex == -1) return;
blitHudVgaImage(vgaImages[statusBarIndex], 0, 160);
_drawHudNumber(vgaImages, 1, 32, 176);
_drawHudNumber(vgaImages, engine.player.score, 96, 176);
_drawHudNumber(vgaImages, 3, 120, 176);
_drawHudNumber(vgaImages, engine.player.health, 192, 176);
_drawHudNumber(vgaImages, engine.player.ammo, 232, 176);
_drawHudFace(engine, vgaImages);
_drawHudWeaponIcon(engine, vgaImages);
}
void _drawHudNumber(
List<VgaImage> vgaImages,
int value,
int rightAlignX,
int startY,
) {
// HUD numbers are rendered with fixed-width VGA glyphs (8 px advance).
const int zeroIndex = 96;
final String numStr = value.toString();
int currentX = rightAlignX - (numStr.length * 8);
for (int i = 0; i < numStr.length; i++) {
final int digit = int.parse(numStr[i]);
final int imageIndex = zeroIndex + digit;
if (imageIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[imageIndex], currentX, startY);
}
currentX += 8;
}
}
void _drawHudFace(WolfEngine engine, List<VgaImage> vgaImages) {
final int faceIndex = hudFaceVgaIndex(engine.player.health);
if (faceIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[faceIndex], 136, 164);
}
}
void _drawHudWeaponIcon(WolfEngine engine, List<VgaImage> vgaImages) {
final int weaponIndex = hudWeaponVgaIndex(engine);
if (weaponIndex < vgaImages.length) {
blitHudVgaImage(vgaImages[weaponIndex], 256, 164);
}
}
/// Calculates depth-based lighting falloff (0.0 to 1.0).
/// While the original Wolf3D didn't use depth fog, this provides a great
/// atmospheric effect for custom backends (like ASCII dithering).
double calculateDepthBrightness(double distance) {
return (10.0 / (distance + 2.0)).clamp(0.0, 1.0);
}
/// Returns the VGA image index for BJ's face sprite based on player health.
int hudFaceVgaIndex(int health) {
if (health <= 0) return 127;
return 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3);
}
/// Returns the VGA image index for the current weapon icon in the HUD.
int hudWeaponVgaIndex(WolfEngine engine) {
if (engine.player.hasChainGun) return 91;
if (engine.player.hasMachineGun) return 90;
return 89;
}
/// Darkens a 32-bit 0xAABBGGRR color by roughly 30% without touching alpha.
int shadeColor(int color) {
final int r = (color & 0xFF) * 7 ~/ 10;
final int g = ((color >> 8) & 0xFF) * 7 ~/ 10;
final int b = ((color >> 16) & 0xFF) * 7 ~/ 10;
return (0xFF000000) | (b << 16) | (g << 8) | r;
}
}

View File

@@ -1,4 +1,4 @@
/// Terminal rasterizer that encodes engine frames as Sixel graphics.
/// Terminal renderer that encodes engine frames as Sixel graphics.
library;
import 'dart:async';
@@ -11,15 +11,15 @@ import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
import 'cli_rasterizer.dart';
import 'cli_renderer_backend.dart';
import 'menu_font.dart';
/// Renders the game into an indexed off-screen buffer and emits Sixel output.
///
/// The rasterizer adapts the engine framebuffer to the current terminal size,
/// The renderer adapts the engine framebuffer to the current terminal size,
/// preserving a 4:3 presentation while falling back to size warnings when the
/// terminal is too small.
class SixelRasterizer extends CliRasterizer<String> {
class SixelRenderer extends CliRendererBackend<String> {
static const double _targetAspectRatio = 4 / 3;
static const int _defaultLineHeightPx = 18;
static const double _defaultCellWidthToHeight = 0.55;
@@ -231,7 +231,7 @@ class SixelRasterizer extends CliRasterizer<String> {
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
// Sixel output references palette indices directly, so there is no need to
// materialize a 32-bit RGBA buffer during the rasterization pass.
// materialize a 32-bit RGBA buffer during the rendering pass.
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
engine.frameBuffer = scaledBuffer;
try {

View File

@@ -1,17 +1,17 @@
import 'dart:math' as math;
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
import 'package:wolf_3d_dart/src/rasterizer/menu_font.dart';
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/src/rendering/menu_font.dart';
import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
/// Pixel-accurate software rasterizer that writes directly into [FrameBuffer].
/// Pixel-accurate software renderer that writes directly into [FrameBuffer].
///
/// This is the canonical "modern framebuffer" implementation and serves as a
/// visual reference for terminal renderers.
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
class SoftwareRenderer extends RendererBackend<FrameBuffer> {
static const int _menuFooterY = 184;
static const int _menuFooterHeight = 12;

View File

@@ -1,8 +0,0 @@
library;
export 'src/rasterizer/ascii_rasterizer.dart'
show AsciiRasterizer, AsciiRasterizerMode, ColoredChar;
export 'src/rasterizer/cli_rasterizer.dart';
export 'src/rasterizer/rasterizer.dart';
export 'src/rasterizer/sixel_rasterizer.dart';
export 'src/rasterizer/software_rasterizer.dart';

View File

@@ -0,0 +1,10 @@
library;
export 'src/raycasting/projection.dart';
export 'src/raycasting/raycaster.dart';
export 'src/rendering/ascii_renderer.dart'
show AsciiRenderer, AsciiRendererMode, ColoredChar;
export 'src/rendering/cli_renderer_backend.dart';
export 'src/rendering/renderer_backend.dart';
export 'src/rendering/sixel_renderer.dart';
export 'src/rendering/software_renderer.dart';

View File

@@ -40,6 +40,20 @@ void main() {
expect(_spawnEnemy(140).angle, CardinalDirection.west.radians);
expect(_spawnEnemy(141).angle, CardinalDirection.south.radians);
});
test('spawns standing variants as ambushing', () {
expect(_spawnEnemy(134).state, EntityState.ambushing);
expect(_spawnEnemy(135).state, EntityState.ambushing);
expect(_spawnEnemy(136).state, EntityState.ambushing);
expect(_spawnEnemy(137).state, EntityState.ambushing);
});
test('spawns patrol variants as patrolling', () {
expect(_spawnEnemy(138).state, EntityState.patrolling);
expect(_spawnEnemy(139).state, EntityState.patrolling);
expect(_spawnEnemy(140).state, EntityState.patrolling);
expect(_spawnEnemy(141).state, EntityState.patrolling);
});
});
}

View File

@@ -1,5 +1,5 @@
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart';
import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
@@ -28,7 +28,7 @@ void main() {
});
}
class _TestRasterizer extends Rasterizer<FrameBuffer> {
class _TestRasterizer extends RendererBackend<FrameBuffer> {
_TestRasterizer({required this.customProjectionViewHeight});
final int customProjectionViewHeight;

View File

@@ -4,7 +4,7 @@ import 'package:test/test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
void main() {
group('Pushwall rasterization', () {
@@ -64,7 +64,7 @@ void main() {
..offset = 0.5;
engine.pushwallManager.activePushwall = pushwall;
final frame = SoftwareRasterizer().render(engine);
final frame = SoftwareRenderer().render(engine);
final centerIndex =
(frame.height ~/ 2) * frame.width + (frame.width ~/ 2);

View File

@@ -0,0 +1,85 @@
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/rendering/renderer_backend.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
void main() {
group('Renderer wall texture sampling', () {
test('anchors wall texel sampling to projection height center', () {
final renderer = _TestRenderer(customProjectionViewHeight: 40);
renderer.configureViewGeometry(width: 64, height: 64, viewHeight: 20);
// With sceneHeight=40 and columnHeight=20, projected wall spans y=10..30.
// Top pixel should sample from top texel row.
expect(renderer.wallTexY(10, 20), 0);
// Bottom visible pixel should sample close to bottom texel row.
expect(renderer.wallTexY(29, 20), inInclusiveRange(60, 63));
});
test('keeps legacy behavior when projection height equals view height', () {
final renderer = _TestRenderer(customProjectionViewHeight: 20);
renderer.configureViewGeometry(width: 64, height: 64, viewHeight: 20);
// With sceneHeight=viewHeight=20 and columnHeight=20, top starts at y=0.
expect(renderer.wallTexY(0, 20), 0);
expect(renderer.wallTexY(19, 20), inInclusiveRange(60, 63));
});
});
}
class _TestRenderer extends RendererBackend<FrameBuffer> {
_TestRenderer({required this.customProjectionViewHeight});
final int customProjectionViewHeight;
@override
int get projectionViewHeight => customProjectionViewHeight;
void configureViewGeometry({
required int width,
required int height,
required int viewHeight,
}) {
this.width = width;
this.height = height;
this.viewHeight = viewHeight;
}
@override
void prepareFrame(WolfEngine engine) {}
@override
void drawWallColumn(
int x,
int drawStart,
int drawEnd,
int columnHeight,
Sprite texture,
int texX,
double perpWallDist,
int side,
) {}
@override
void drawSpriteStripe(
int stripeX,
int drawStartY,
int drawEndY,
int spriteHeight,
Sprite texture,
int texX,
double transformY,
) {}
@override
void drawWeapon(WolfEngine engine) {}
@override
void drawHud(WolfEngine engine) {}
@override
FrameBuffer finalizeFrame() {
return FrameBuffer(1, 1);
}
}

View File

@@ -0,0 +1,90 @@
import 'dart:typed_data';
import 'package:test/test.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_input.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
void main() {
group('Pushwall rendering', () {
test('active pushwall occludes the wall behind it while sliding', () {
final wallGrid = _buildGrid();
final objectGrid = _buildGrid();
_fillBoundaries(wallGrid, 2);
objectGrid[2][2] = MapObject.playerEast;
wallGrid[2][4] = 1;
objectGrid[2][4] = MapObject.pushwallTrigger;
wallGrid[2][6] = 2;
final engine = WolfEngine(
data: WolfensteinData(
version: GameVersion.shareware,
walls: [
_solidSprite(1),
_solidSprite(1),
_solidSprite(2),
_solidSprite(2),
],
sprites: List.generate(436, (_) => _solidSprite(255)),
sounds: [],
adLibSounds: [],
music: [],
vgaImages: [],
episodes: [
Episode(
name: 'Episode 1',
levels: [
WolfLevel(
name: 'Test Level',
wallGrid: wallGrid,
objectGrid: objectGrid,
musicIndex: 0,
),
],
),
],
),
difficulty: Difficulty.medium,
startingEpisode: 0,
frameBuffer: FrameBuffer(64, 64),
input: CliInput(),
onGameWon: () {},
);
engine.init();
final pushwall = engine.pushwallManager.pushwalls['4,2']!;
pushwall
..dirX = 1
..dirY = 0
..offset = 0.5;
engine.pushwallManager.activePushwall = pushwall;
final frame = SoftwareRenderer().render(engine);
final centerIndex =
(frame.height ~/ 2) * frame.width + (frame.width ~/ 2);
expect(frame.pixels[centerIndex], ColorPalette.vga32Bit[1]);
expect(frame.pixels[centerIndex], isNot(ColorPalette.vga32Bit[2]));
});
});
}
SpriteMap _buildGrid() => List.generate(64, (_) => List.filled(64, 0));
void _fillBoundaries(SpriteMap grid, int wallId) {
for (int i = 0; i < 64; i++) {
grid[0][i] = wallId;
grid[63][i] = wallId;
grid[i][0] = wallId;
grid[i][63] = wallId;
}
}
Sprite _solidSprite(int colorIndex) {
return Sprite(Uint8List.fromList(List.filled(64 * 64, colorIndex)));
}

View File

@@ -1,8 +1,8 @@
/// Flutter widget that renders Wolf3D frames using the ASCII rasterizer.
/// Flutter widget that renders Wolf3D frames using the ASCII renderer.
library;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_renderer/base_renderer.dart';
/// Displays the game using a text-mode approximation of the original renderer.
@@ -22,8 +22,8 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
static const int _renderHeight = 100;
List<List<ColoredChar>> _asciiFrame = [];
final AsciiRasterizer _asciiRasterizer = AsciiRasterizer(
mode: AsciiRasterizerMode.terminalGrid,
final AsciiRenderer _asciiRenderer = AsciiRenderer(
mode: AsciiRendererMode.terminalGrid,
);
@override
@@ -45,7 +45,7 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
@override
void performRender() {
setState(() {
_asciiFrame = _asciiRasterizer.render(widget.engine);
_asciiFrame = _asciiRenderer.render(widget.engine);
});
}
@@ -63,7 +63,7 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
/// Paints a pre-rasterized ASCII frame using grouped text spans per color run.
class AsciiFrameWidget extends StatelessWidget {
/// Two-dimensional text grid generated by [AsciiRasterizer.render].
/// Two-dimensional text grid generated by [AsciiRenderer.render].
final List<List<ColoredChar>> frameData;
/// Creates a widget that displays [frameData].

View File

@@ -5,11 +5,11 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_rasterizer.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_renderer/base_renderer.dart';
import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart';
/// Presents the software rasterizer output by decoding the shared framebuffer.
/// Presents the software renderer output by decoding the shared framebuffer.
class WolfFlutterRenderer extends BaseWolfRenderer {
/// Creates a pixel renderer bound to [engine].
const WolfFlutterRenderer({
@@ -25,7 +25,7 @@ class _WolfFlutterRendererState
extends BaseWolfRendererState<WolfFlutterRenderer> {
static const int _renderWidth = 320;
static const int _renderHeight = 200;
final SoftwareRasterizer _rasterizer = SoftwareRasterizer();
final SoftwareRenderer _renderer = SoftwareRenderer();
ui.Image? _renderedFrame;
bool _isRendering = false;
@@ -51,7 +51,7 @@ class _WolfFlutterRendererState
_isRendering = true;
final FrameBuffer frameBuffer = widget.engine.frameBuffer;
_rasterizer.render(widget.engine);
_renderer.render(widget.engine);
// Convert the engine-owned framebuffer into a GPU-friendly ui.Image on
// the Flutter side while preserving nearest-neighbor pixel fidelity.