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:
@@ -6,9 +6,9 @@ import 'dart:io';
|
||||
|
||||
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';
|
||||
|
||||
/// Runs the Wolf3D engine inside a terminal using CLI-specific rasterizers.
|
||||
/// Runs the Wolf3D engine inside a terminal using CLI-specific renderers.
|
||||
///
|
||||
/// The loop owns raw-stdin handling, renderer switching, terminal size checks,
|
||||
/// and frame pacing. It expects [engine.input] to be a [CliInput] instance so
|
||||
@@ -25,22 +25,22 @@ class CliGameLoop {
|
||||
'CliGameLoop requires a CliInput instance.',
|
||||
),
|
||||
|
||||
primaryRasterizer = AsciiRasterizer(
|
||||
mode: AsciiRasterizerMode.terminalAnsi,
|
||||
primaryRenderer = AsciiRenderer(
|
||||
mode: AsciiRendererMode.terminalAnsi,
|
||||
),
|
||||
secondaryRasterizer = SixelRasterizer() {
|
||||
_rasterizer = primaryRasterizer;
|
||||
secondaryRenderer = SixelRenderer() {
|
||||
_renderer = primaryRenderer;
|
||||
}
|
||||
|
||||
final WolfEngine engine;
|
||||
final CliRasterizer primaryRasterizer;
|
||||
final CliRasterizer secondaryRasterizer;
|
||||
final CliRendererBackend primaryRenderer;
|
||||
final CliRendererBackend secondaryRenderer;
|
||||
final CliInput input;
|
||||
final void Function(int code) onExit;
|
||||
|
||||
final Stopwatch _stopwatch = Stopwatch();
|
||||
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
|
||||
late CliRasterizer _rasterizer;
|
||||
late CliRendererBackend _renderer;
|
||||
StreamSubscription<List<int>>? _stdinSubscription;
|
||||
Timer? _timer;
|
||||
bool _isRunning = false;
|
||||
@@ -52,9 +52,9 @@ class CliGameLoop {
|
||||
return;
|
||||
}
|
||||
|
||||
if (primaryRasterizer is SixelRasterizer) {
|
||||
final sixel = primaryRasterizer as SixelRasterizer;
|
||||
sixel.isSixelSupported = await SixelRasterizer.checkTerminalSixelSupport(
|
||||
if (primaryRenderer is SixelRenderer) {
|
||||
final sixel = primaryRenderer as SixelRenderer;
|
||||
sixel.isSixelSupported = await SixelRenderer.checkTerminalSixelSupport(
|
||||
inputStream: _stdinStream,
|
||||
);
|
||||
}
|
||||
@@ -116,11 +116,11 @@ class CliGameLoop {
|
||||
}
|
||||
|
||||
if (bytes.contains(9)) {
|
||||
// Tab swaps between rasterizers so renderer debugging stays available
|
||||
// Tab swaps between renderers so renderer debugging stays available
|
||||
// without restarting the process.
|
||||
_rasterizer = identical(_rasterizer, secondaryRasterizer)
|
||||
? primaryRasterizer
|
||||
: secondaryRasterizer;
|
||||
_renderer = identical(_renderer, secondaryRenderer)
|
||||
? primaryRenderer
|
||||
: secondaryRenderer;
|
||||
stdout.write('\x1b[2J\x1b[H');
|
||||
return;
|
||||
}
|
||||
@@ -136,7 +136,7 @@ class CliGameLoop {
|
||||
if (stdout.hasTerminal) {
|
||||
final int cols = stdout.terminalColumns;
|
||||
final int rows = stdout.terminalLines;
|
||||
if (!_rasterizer.prepareTerminalFrame(
|
||||
if (!_renderer.prepareTerminalFrame(
|
||||
engine,
|
||||
columns: cols,
|
||||
rows: rows,
|
||||
@@ -145,7 +145,7 @@ class CliGameLoop {
|
||||
// game does not keep advancing while the user resizes the terminal.
|
||||
stdout.write('\x1b[2J\x1b[H');
|
||||
stdout.write(
|
||||
_rasterizer.buildTerminalSizeWarning(columns: cols, rows: rows),
|
||||
_renderer.buildTerminalSizeWarning(columns: cols, rows: rows),
|
||||
);
|
||||
|
||||
_lastTick = _stopwatch.elapsed;
|
||||
@@ -160,6 +160,6 @@ class CliGameLoop {
|
||||
stdout.write('\x1b[H');
|
||||
|
||||
engine.tick(elapsed);
|
||||
stdout.write(_rasterizer.render(engine));
|
||||
stdout.write(_renderer.render(engine));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
33
packages/wolf_3d_dart/lib/src/raycasting/projection.dart
Normal file
33
packages/wolf_3d_dart/lib/src/raycasting/projection.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
419
packages/wolf_3d_dart/lib/src/raycasting/raycaster.dart
Normal file
419
packages/wolf_3d_dart/lib/src/raycasting/raycaster.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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.
|
||||
231
packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart
Normal file
231
packages/wolf_3d_dart/lib/src/rendering/renderer_backend.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
10
packages/wolf_3d_dart/lib/wolf_3d_renderer.dart
Normal file
10
packages/wolf_3d_dart/lib/wolf_3d_renderer.dart
Normal 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';
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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].
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user