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_engine.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_input.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,
|
/// The loop owns raw-stdin handling, renderer switching, terminal size checks,
|
||||||
/// and frame pacing. It expects [engine.input] to be a [CliInput] instance so
|
/// and frame pacing. It expects [engine.input] to be a [CliInput] instance so
|
||||||
@@ -25,22 +25,22 @@ class CliGameLoop {
|
|||||||
'CliGameLoop requires a CliInput instance.',
|
'CliGameLoop requires a CliInput instance.',
|
||||||
),
|
),
|
||||||
|
|
||||||
primaryRasterizer = AsciiRasterizer(
|
primaryRenderer = AsciiRenderer(
|
||||||
mode: AsciiRasterizerMode.terminalAnsi,
|
mode: AsciiRendererMode.terminalAnsi,
|
||||||
),
|
),
|
||||||
secondaryRasterizer = SixelRasterizer() {
|
secondaryRenderer = SixelRenderer() {
|
||||||
_rasterizer = primaryRasterizer;
|
_renderer = primaryRenderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
final WolfEngine engine;
|
final WolfEngine engine;
|
||||||
final CliRasterizer primaryRasterizer;
|
final CliRendererBackend primaryRenderer;
|
||||||
final CliRasterizer secondaryRasterizer;
|
final CliRendererBackend secondaryRenderer;
|
||||||
final CliInput input;
|
final CliInput input;
|
||||||
final void Function(int code) onExit;
|
final void Function(int code) onExit;
|
||||||
|
|
||||||
final Stopwatch _stopwatch = Stopwatch();
|
final Stopwatch _stopwatch = Stopwatch();
|
||||||
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
|
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
|
||||||
late CliRasterizer _rasterizer;
|
late CliRendererBackend _renderer;
|
||||||
StreamSubscription<List<int>>? _stdinSubscription;
|
StreamSubscription<List<int>>? _stdinSubscription;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _isRunning = false;
|
bool _isRunning = false;
|
||||||
@@ -52,9 +52,9 @@ class CliGameLoop {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (primaryRasterizer is SixelRasterizer) {
|
if (primaryRenderer is SixelRenderer) {
|
||||||
final sixel = primaryRasterizer as SixelRasterizer;
|
final sixel = primaryRenderer as SixelRenderer;
|
||||||
sixel.isSixelSupported = await SixelRasterizer.checkTerminalSixelSupport(
|
sixel.isSixelSupported = await SixelRenderer.checkTerminalSixelSupport(
|
||||||
inputStream: _stdinStream,
|
inputStream: _stdinStream,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,11 +116,11 @@ class CliGameLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (bytes.contains(9)) {
|
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.
|
// without restarting the process.
|
||||||
_rasterizer = identical(_rasterizer, secondaryRasterizer)
|
_renderer = identical(_renderer, secondaryRenderer)
|
||||||
? primaryRasterizer
|
? primaryRenderer
|
||||||
: secondaryRasterizer;
|
: secondaryRenderer;
|
||||||
stdout.write('\x1b[2J\x1b[H');
|
stdout.write('\x1b[2J\x1b[H');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ class CliGameLoop {
|
|||||||
if (stdout.hasTerminal) {
|
if (stdout.hasTerminal) {
|
||||||
final int cols = stdout.terminalColumns;
|
final int cols = stdout.terminalColumns;
|
||||||
final int rows = stdout.terminalLines;
|
final int rows = stdout.terminalLines;
|
||||||
if (!_rasterizer.prepareTerminalFrame(
|
if (!_renderer.prepareTerminalFrame(
|
||||||
engine,
|
engine,
|
||||||
columns: cols,
|
columns: cols,
|
||||||
rows: rows,
|
rows: rows,
|
||||||
@@ -145,7 +145,7 @@ class CliGameLoop {
|
|||||||
// game does not keep advancing while the user resizes the terminal.
|
// game does not keep advancing while the user resizes the terminal.
|
||||||
stdout.write('\x1b[2J\x1b[H');
|
stdout.write('\x1b[2J\x1b[H');
|
||||||
stdout.write(
|
stdout.write(
|
||||||
_rasterizer.buildTerminalSizeWarning(columns: cols, rows: rows),
|
_renderer.buildTerminalSizeWarning(columns: cols, rows: rows),
|
||||||
);
|
);
|
||||||
|
|
||||||
_lastTick = _stopwatch.elapsed;
|
_lastTick = _stopwatch.elapsed;
|
||||||
@@ -160,6 +160,6 @@ class CliGameLoop {
|
|||||||
stdout.write('\x1b[H');
|
stdout.write('\x1b[H');
|
||||||
|
|
||||||
engine.tick(elapsed);
|
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
|
/// Callers are expected to provide coordinates already clamped to the image
|
||||||
/// bounds. The Wolf3D VGA format stores image bytes in 4 interleaved planes;
|
/// 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) {
|
int decodePixel(int srcX, int srcY) {
|
||||||
final int planeWidth = width ~/ 4;
|
final int planeWidth = width ~/ 4;
|
||||||
final int planeSize = planeWidth * height;
|
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
|
/// door's state changes (e.g., starts closing), the appropriate global
|
||||||
/// game events (like sound effects) are triggered.
|
/// game events (like sound effects) are triggered.
|
||||||
class DoorManager {
|
class DoorManager {
|
||||||
/// A lookup table for doors, keyed by their grid coordinates: "$x,$y".
|
/// A lookup table for doors, keyed by packed grid coordinates.
|
||||||
final Map<String, Door> doors = {};
|
final Map<int, Door> doors = {};
|
||||||
|
|
||||||
/// Callback used to trigger sound effects without tight coupling
|
/// Callback used to trigger sound effects without tight coupling
|
||||||
/// to a specific audio engine implementation.
|
/// to a specific audio engine implementation.
|
||||||
final void Function(int sfxId) onPlaySound;
|
final void Function(int sfxId) onPlaySound;
|
||||||
|
|
||||||
|
static int _key(int x, int y) => ((y & 0xFFFF) << 16) | (x & 0xFFFF);
|
||||||
|
|
||||||
DoorManager({required this.onPlaySound});
|
DoorManager({required this.onPlaySound});
|
||||||
|
|
||||||
/// Scans the [wallGrid] for tile IDs >= 90 and initializes [Door] instances.
|
/// 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++) {
|
for (int x = 0; x < wallGrid[y].length; x++) {
|
||||||
int id = wallGrid[y][x];
|
int id = wallGrid[y][x];
|
||||||
if (id >= 90) {
|
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 targetX = (playerX + math.cos(playerAngle)).toInt();
|
||||||
int targetY = (playerY + math.sin(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.containsKey(key)) {
|
||||||
if (doors[key]!.interact()) {
|
if (doors[key]!.interact()) {
|
||||||
onPlaySound(WolfSound.openDoor);
|
onPlaySound(WolfSound.openDoor);
|
||||||
@@ -58,7 +60,7 @@ class DoorManager {
|
|||||||
|
|
||||||
/// Attempted by AI entities to open a door blocking their path.
|
/// Attempted by AI entities to open a door blocking their path.
|
||||||
void tryOpenDoor(int x, int y) {
|
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).
|
// AI only interacts if the door is currently fully closed (offset == 0).
|
||||||
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
|
if (doors.containsKey(key) && doors[key]!.offset == 0.0) {
|
||||||
if (doors[key]!.interact()) {
|
if (doors[key]!.interact()) {
|
||||||
@@ -67,21 +69,16 @@ class DoorManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method for the raycaster
|
/// Returns the current open offset for the door at [x],[y].
|
||||||
Map<String, double> getOffsetsForRenderer() {
|
/// Returns 0.0 when there is no active/opening door at that tile.
|
||||||
Map<String, double> offsets = {};
|
double doorOffsetAt(int x, int y) {
|
||||||
for (var entry in doors.entries) {
|
return doors[_key(x, y)]?.offset ?? 0.0;
|
||||||
if (entry.value.offset > 0.0) {
|
|
||||||
offsets[entry.key] = entry.value.offset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return offsets;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the door at [x], [y] is sufficiently open for
|
/// Returns true if the door at [x], [y] is sufficiently open for
|
||||||
/// an entity (player or enemy) to walk through.
|
/// an entity (player or enemy) to walk through.
|
||||||
bool isDoorOpenEnough(int x, int y) {
|
bool isDoorOpenEnough(int x, int y) {
|
||||||
String key = '$x,$y';
|
final int key = _key(x, y);
|
||||||
if (doors.containsKey(key)) {
|
if (doors.containsKey(key)) {
|
||||||
// 0.7 (70% open) is the standard collision threshold.
|
// 0.7 (70% open) is the standard collision threshold.
|
||||||
return doors[key]!.offset > 0.7;
|
return doors[key]!.offset > 0.7;
|
||||||
|
|||||||
@@ -387,7 +387,9 @@ abstract class Enemy extends Entity {
|
|||||||
if (matchedType.mapData.isPatrol(normalizedId)) {
|
if (matchedType.mapData.isPatrol(normalizedId)) {
|
||||||
spawnState = EntityState.patrolling;
|
spawnState = EntityState.patrolling;
|
||||||
} else if (matchedType.mapData.isStatic(normalizedId)) {
|
} 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 {
|
} else {
|
||||||
return null;
|
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_engine.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
import 'cli_rasterizer.dart';
|
import 'cli_renderer_backend.dart';
|
||||||
import 'menu_font.dart';
|
import 'menu_font.dart';
|
||||||
|
|
||||||
class AsciiTheme {
|
class AsciiTheme {
|
||||||
@@ -74,14 +74,14 @@ class ColoredChar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AsciiRasterizerMode {
|
enum AsciiRendererMode {
|
||||||
terminalAnsi,
|
terminalAnsi,
|
||||||
terminalGrid,
|
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.
|
/// grid model of colored characters.
|
||||||
class AsciiRasterizer extends CliRasterizer<dynamic> {
|
class AsciiRenderer extends CliRendererBackend<dynamic> {
|
||||||
static const double _targetAspectRatio = 4 / 3;
|
static const double _targetAspectRatio = 4 / 3;
|
||||||
static const int _terminalBackdropPaletteIndex = 153;
|
static const int _terminalBackdropPaletteIndex = 153;
|
||||||
static const int _minimumTerminalColumns = 80;
|
static const int _minimumTerminalColumns = 80;
|
||||||
@@ -93,21 +93,21 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
static const int _menuHintLabelPaletteIndex = 4;
|
static const int _menuHintLabelPaletteIndex = 4;
|
||||||
static const int _menuHintBackgroundPaletteIndex = 0;
|
static const int _menuHintBackgroundPaletteIndex = 0;
|
||||||
|
|
||||||
AsciiRasterizer({
|
AsciiRenderer({
|
||||||
this.activeTheme = AsciiThemes.blocks,
|
this.activeTheme = AsciiThemes.blocks,
|
||||||
this.mode = AsciiRasterizerMode.terminalGrid,
|
this.mode = AsciiRendererMode.terminalGrid,
|
||||||
this.useTerminalLayout = true,
|
this.useTerminalLayout = true,
|
||||||
this.aspectMultiplier = 1.0,
|
this.aspectMultiplier = 1.0,
|
||||||
this.verticalStretch = 1.0,
|
this.verticalStretch = 1.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
AsciiTheme activeTheme = AsciiThemes.blocks;
|
AsciiTheme activeTheme = AsciiThemes.blocks;
|
||||||
final AsciiRasterizerMode mode;
|
final AsciiRendererMode mode;
|
||||||
final bool useTerminalLayout;
|
final bool useTerminalLayout;
|
||||||
|
|
||||||
bool get _usesTerminalLayout => 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<ColoredChar>> _screen;
|
||||||
late List<List<int>> _scenePixels;
|
late List<List<int>> _scenePixels;
|
||||||
@@ -323,7 +323,7 @@ class AsciiRasterizer extends CliRasterizer<dynamic> {
|
|||||||
|
|
||||||
// --- PRIVATE HUD DRAWING HELPER ---
|
// --- 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(
|
void _writeString(
|
||||||
int startX,
|
int startX,
|
||||||
int y,
|
int y,
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:wolf_3d_dart/src/engine/wolf_3d_engine_base.dart';
|
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.
|
/// Shared terminal orchestration for CLI renderers.
|
||||||
abstract class CliRasterizer<T> extends Rasterizer<T> {
|
abstract class CliRendererBackend<T> extends RendererBackend<T> {
|
||||||
/// Resolves the framebuffer dimensions required by this renderer.
|
/// Resolves the framebuffer dimensions required by this renderer.
|
||||||
///
|
///
|
||||||
/// The default uses the full terminal size.
|
/// 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;
|
library;
|
||||||
|
|
||||||
import 'dart:async';
|
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_engine.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
import 'package:wolf_3d_dart/wolf_3d_menu.dart';
|
||||||
|
|
||||||
import 'cli_rasterizer.dart';
|
import 'cli_renderer_backend.dart';
|
||||||
import 'menu_font.dart';
|
import 'menu_font.dart';
|
||||||
|
|
||||||
/// Renders the game into an indexed off-screen buffer and emits Sixel output.
|
/// 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
|
/// preserving a 4:3 presentation while falling back to size warnings when the
|
||||||
/// terminal is too small.
|
/// terminal is too small.
|
||||||
class SixelRasterizer extends CliRasterizer<String> {
|
class SixelRenderer extends CliRendererBackend<String> {
|
||||||
static const double _targetAspectRatio = 4 / 3;
|
static const double _targetAspectRatio = 4 / 3;
|
||||||
static const int _defaultLineHeightPx = 18;
|
static const int _defaultLineHeightPx = 18;
|
||||||
static const double _defaultCellWidthToHeight = 0.55;
|
static const double _defaultCellWidthToHeight = 0.55;
|
||||||
@@ -231,7 +231,7 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
|
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
|
||||||
|
|
||||||
// Sixel output references palette indices directly, so there is no need to
|
// 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);
|
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
|
||||||
engine.frameBuffer = scaledBuffer;
|
engine.frameBuffer = scaledBuffer;
|
||||||
try {
|
try {
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:wolf_3d_dart/src/menu/menu_manager.dart';
|
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/rendering/menu_font.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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_menu.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
|
/// This is the canonical "modern framebuffer" implementation and serves as a
|
||||||
/// visual reference for terminal renderers.
|
/// visual reference for terminal renderers.
|
||||||
class SoftwareRasterizer extends Rasterizer<FrameBuffer> {
|
class SoftwareRenderer extends RendererBackend<FrameBuffer> {
|
||||||
static const int _menuFooterY = 184;
|
static const int _menuFooterY = 184;
|
||||||
static const int _menuFooterHeight = 12;
|
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(140).angle, CardinalDirection.west.radians);
|
||||||
expect(_spawnEnemy(141).angle, CardinalDirection.south.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: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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.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});
|
_TestRasterizer({required this.customProjectionViewHeight});
|
||||||
|
|
||||||
final int 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_data_types.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_engine.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_input.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_rasterizer.dart';
|
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('Pushwall rasterization', () {
|
group('Pushwall rasterization', () {
|
||||||
@@ -64,7 +64,7 @@ void main() {
|
|||||||
..offset = 0.5;
|
..offset = 0.5;
|
||||||
engine.pushwallManager.activePushwall = pushwall;
|
engine.pushwallManager.activePushwall = pushwall;
|
||||||
|
|
||||||
final frame = SoftwareRasterizer().render(engine);
|
final frame = SoftwareRenderer().render(engine);
|
||||||
final centerIndex =
|
final centerIndex =
|
||||||
(frame.height ~/ 2) * frame.width + (frame.width ~/ 2);
|
(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;
|
library;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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';
|
import 'package:wolf_3d_renderer/base_renderer.dart';
|
||||||
|
|
||||||
/// Displays the game using a text-mode approximation of the original renderer.
|
/// 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;
|
static const int _renderHeight = 100;
|
||||||
|
|
||||||
List<List<ColoredChar>> _asciiFrame = [];
|
List<List<ColoredChar>> _asciiFrame = [];
|
||||||
final AsciiRasterizer _asciiRasterizer = AsciiRasterizer(
|
final AsciiRenderer _asciiRenderer = AsciiRenderer(
|
||||||
mode: AsciiRasterizerMode.terminalGrid,
|
mode: AsciiRendererMode.terminalGrid,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -45,7 +45,7 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
|
|||||||
@override
|
@override
|
||||||
void performRender() {
|
void performRender() {
|
||||||
setState(() {
|
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.
|
/// Paints a pre-rasterized ASCII frame using grouped text spans per color run.
|
||||||
class AsciiFrameWidget extends StatelessWidget {
|
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;
|
final List<List<ColoredChar>> frameData;
|
||||||
|
|
||||||
/// Creates a widget that displays [frameData].
|
/// Creates a widget that displays [frameData].
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import 'dart:ui' as ui;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:wolf_3d_dart/wolf_3d_data_types.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/base_renderer.dart';
|
||||||
import 'package:wolf_3d_renderer/wolf_3d_asset_painter.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 {
|
class WolfFlutterRenderer extends BaseWolfRenderer {
|
||||||
/// Creates a pixel renderer bound to [engine].
|
/// Creates a pixel renderer bound to [engine].
|
||||||
const WolfFlutterRenderer({
|
const WolfFlutterRenderer({
|
||||||
@@ -25,7 +25,7 @@ class _WolfFlutterRendererState
|
|||||||
extends BaseWolfRendererState<WolfFlutterRenderer> {
|
extends BaseWolfRendererState<WolfFlutterRenderer> {
|
||||||
static const int _renderWidth = 320;
|
static const int _renderWidth = 320;
|
||||||
static const int _renderHeight = 200;
|
static const int _renderHeight = 200;
|
||||||
final SoftwareRasterizer _rasterizer = SoftwareRasterizer();
|
final SoftwareRenderer _renderer = SoftwareRenderer();
|
||||||
|
|
||||||
ui.Image? _renderedFrame;
|
ui.Image? _renderedFrame;
|
||||||
bool _isRendering = false;
|
bool _isRendering = false;
|
||||||
@@ -51,7 +51,7 @@ class _WolfFlutterRendererState
|
|||||||
_isRendering = true;
|
_isRendering = true;
|
||||||
|
|
||||||
final FrameBuffer frameBuffer = widget.engine.frameBuffer;
|
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
|
// Convert the engine-owned framebuffer into a GPU-friendly ui.Image on
|
||||||
// the Flutter side while preserving nearest-neighbor pixel fidelity.
|
// the Flutter side while preserving nearest-neighbor pixel fidelity.
|
||||||
|
|||||||
Reference in New Issue
Block a user