diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index 0642d75..bce02a5 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -1,6 +1,6 @@ -import 'dart:async'; import 'dart:io'; +import 'package:wolf_3d_cli/cli_game_loop.dart'; import 'package:wolf_3d_dart/wolf_3d_data.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; @@ -15,13 +15,7 @@ void exitCleanly(int code) { } void main() async { - stdin.echoMode = false; - stdin.lineMode = false; - - // HIDE the blinking cursor and clear the screen to prep the canvas - stdout.write('\x1b[?25l\x1b[2J'); - - print("Discovering game data..."); + stdout.write("Discovering game data..."); // 1. Get the absolute URI of where this exact script lives final scriptUri = Platform.script; @@ -36,76 +30,31 @@ void main() async { recursive: true, ); - final AsciiRasterizer asciiRasterizer = AsciiRasterizer(isTerminal: true); - final SixelRasterizer sixelRasterizer = SixelRasterizer(); - CliRasterizer rasterizer = sixelRasterizer; + CliGameLoop? gameLoop; - final FrameBuffer initialFrameBuffer = FrameBuffer( - stdout.terminalColumns, - stdout.terminalLines, - ); + void stopAndExit(int code) { + gameLoop?.stop(); + exitCleanly(code); + } final engine = WolfEngine( data: availableGames.values.first, difficulty: Difficulty.medium, startingEpisode: 0, - frameBuffer: initialFrameBuffer, - audio: CliSilentAudio(), + frameBuffer: FrameBuffer( + stdout.terminalColumns, + stdout.terminalLines, + ), input: CliInput(), - onGameWon: () { - exitCleanly(0); - print("YOU WON!"); - }, + onGameWon: () => stopAndExit(0), ); engine.init(); - stdin.listen((List bytes) { - if (bytes.contains(113) || bytes.contains(27)) { - exitCleanly(0); - } - - if (bytes.contains(9)) { - rasterizer = identical(rasterizer, sixelRasterizer) - ? asciiRasterizer - : sixelRasterizer; - stdout.write('\x1b[2J\x1b[H'); - return; - } - - (engine.input as CliInput).handleKey(bytes); - }); - - Stopwatch stopwatch = Stopwatch()..start(); - Duration lastTick = Duration.zero; - - Timer.periodic(const Duration(milliseconds: 33), (timer) { - // 1. Terminal Size Safety Check - if (stdout.hasTerminal) { - int cols = stdout.terminalColumns; - int rows = stdout.terminalLines; - if (!rasterizer.prepareTerminalFrame(engine, columns: cols, rows: rows)) { - // Clear the screen and print the warning at the top left - stdout.write('\x1b[2J\x1b[H'); - stdout.write( - rasterizer.buildTerminalSizeWarning(columns: cols, rows: rows), - ); - - // Prevent the engine from simulating a massive time jump when resumed - lastTick = stopwatch.elapsed; - return; - } - } - - // 2. Normal Game Loop - Duration currentTick = stopwatch.elapsed; - Duration elapsed = currentTick - lastTick; - lastTick = currentTick; - - // Move cursor to top-left (0,0) before drawing the frame - stdout.write('\x1b[H'); - - engine.tick(elapsed); - stdout.write(rasterizer.render(engine)); - }); + gameLoop = CliGameLoop( + engine: engine, + input: engine.input as CliInput, + onExit: stopAndExit, + ); + gameLoop.start(); } diff --git a/apps/wolf_3d_cli/lib/cli_game_loop.dart b/apps/wolf_3d_cli/lib/cli_game_loop.dart new file mode 100644 index 0000000..0829e8d --- /dev/null +++ b/apps/wolf_3d_cli/lib/cli_game_loop.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +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'; + +class CliGameLoop { + CliGameLoop({ + required this.engine, + required this.input, + required this.onExit, + }) : primaryRasterizer = SixelRasterizer(), + secondaryRasterizer = AsciiRasterizer(isTerminal: true) { + _rasterizer = primaryRasterizer; + } + + final WolfEngine engine; + final CliRasterizer primaryRasterizer; + final CliRasterizer secondaryRasterizer; + final CliInput input; + final void Function(int code) onExit; + + final Stopwatch _stopwatch = Stopwatch(); + late CliRasterizer _rasterizer; + StreamSubscription>? _stdinSubscription; + Timer? _timer; + bool _isRunning = false; + Duration _lastTick = Duration.zero; + + void start() { + if (_isRunning) { + return; + } + + stdin.echoMode = false; + stdin.lineMode = false; + + stdout.write('\x1b[?25l\x1b[2J'); + + _stdinSubscription = stdin.listen(_handleInput); + _stopwatch.start(); + _timer = Timer.periodic(const Duration(milliseconds: 33), _tick); + _isRunning = true; + } + + void stop() { + if (!_isRunning) { + return; + } + + _timer?.cancel(); + _timer = null; + _stdinSubscription?.cancel(); + _stdinSubscription = null; + + if (_stopwatch.isRunning) { + _stopwatch.stop(); + } + + if (stdin.hasTerminal) { + stdin.echoMode = true; + stdin.lineMode = true; + } + + if (stdout.hasTerminal) { + stdout.write('\x1b[0m\x1b[?25h'); + } + + _isRunning = false; + } + + void _handleInput(List bytes) { + if (bytes.contains(113) || bytes.contains(27)) { + stop(); + onExit(0); + return; + } + + if (bytes.contains(9)) { + _rasterizer = identical(_rasterizer, secondaryRasterizer) + ? primaryRasterizer + : secondaryRasterizer; + stdout.write('\x1b[2J\x1b[H'); + return; + } + + input.handleKey(bytes); + } + + void _tick(Timer timer) { + if (!_isRunning) { + return; + } + + if (stdout.hasTerminal) { + final int cols = stdout.terminalColumns; + final int rows = stdout.terminalLines; + if (!_rasterizer.prepareTerminalFrame( + engine, + columns: cols, + rows: rows, + )) { + stdout.write('\x1b[2J\x1b[H'); + stdout.write( + _rasterizer.buildTerminalSizeWarning(columns: cols, rows: rows), + ); + + _lastTick = _stopwatch.elapsed; + return; + } + } + + final Duration currentTick = _stopwatch.elapsed; + final Duration elapsed = currentTick - _lastTick; + _lastTick = currentTick; + + stdout.write('\x1b[H'); + + engine.tick(elapsed); + stdout.write(_rasterizer.render(engine)); + } +} diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart index b65c94e..7b80c4c 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:arcane_helper_utils/arcane_helper_utils.dart'; +import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart index c33be5f..9633081 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'dart:typed_data'; +import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/software_rasterizer.dart index bccf246..847b7f6 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/software_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/engine/rasterizer/software_rasterizer.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 1503998..9bb0066 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -16,11 +16,12 @@ class WolfEngine { required this.difficulty, required this.startingEpisode, required this.onGameWon, - required this.audio, required this.input, required this.frameBuffer, - }) : doorManager = DoorManager( - onPlaySound: (sfxId) => audio.playSoundEffect(sfxId), + EngineAudio? audio, + }) : audio = audio ?? CliSilentAudio(), + doorManager = DoorManager( + onPlaySound: (sfxId) => audio?.playSoundEffect(sfxId), ); /// Total milliseconds elapsed since the engine was initialized. @@ -36,7 +37,7 @@ class WolfEngine { final int startingEpisode; /// Handles music and sound effect playback. - final EngineAudio audio; + late final EngineAudio audio; /// Callback triggered when the final level of an episode is completed. final void Function() onGameWon; diff --git a/packages/wolf_3d_dart/lib/src/input/cli_input.dart b/packages/wolf_3d_dart/lib/src/input/cli_input.dart index 513c089..1d9f5cb 100644 --- a/packages/wolf_3d_dart/lib/src/input/cli_input.dart +++ b/packages/wolf_3d_dart/lib/src/input/cli_input.dart @@ -1,5 +1,5 @@ -import 'package:wolf_3d_dart/wolf_3d_entities.dart'; import 'package:wolf_3d_dart/src/input/wolf_3d_input.dart'; +import 'package:wolf_3d_dart/wolf_3d_entities.dart'; class CliInput extends Wolf3dInput { // Pending buffer for asynchronous stdin events diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart new file mode 100644 index 0000000..b14cd71 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart @@ -0,0 +1,838 @@ +import 'dart:math' as math; + +import 'package:arcane_helper_utils/arcane_helper_utils.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +import 'cli_rasterizer.dart'; + +class AsciiTheme { + final String name; + + /// The character ramp, ordered from most dense (index 0) to least dense (last index). + final String ramp; + + const AsciiTheme(this.name, this.ramp); + + /// Always returns the densest character (e.g., for walls, UI, floors) + String get solid => ramp[0]; + + /// Always returns the completely empty character (e.g., for pitch black darkness) + String get empty => ramp[ramp.length - 1]; + + /// Returns a character based on a 0.0 to 1.0 brightness scale. + /// 1.0 returns the [solid] character, 0.0 returns the [empty] character. + String getByBrightness(double brightness) { + double b = brightness.clamp(0.0, 1.0); + int index = ((1.0 - b) * (ramp.length - 1)).round(); + return ramp[index]; + } +} + +/// A collection of pre-defined character sets +abstract class AsciiThemes { + static const AsciiTheme blocks = AsciiTheme('Blocks', "█▓▒░ "); + static const AsciiTheme classic = AsciiTheme('Classic', "@%#*+=-:. "); + + static const List values = [blocks, classic]; + + static AsciiTheme nextOf(AsciiTheme current) { + final int currentIndex = values.indexOf(current); + final int nextIndex = currentIndex == -1 + ? 0 + : (currentIndex + 1) % values.length; + return values[nextIndex]; + } +} + +class ColoredChar { + final String char; + final int rawColor; // Stores the AABBGGRR integer from the palette + final int? rawBackgroundColor; + + ColoredChar(this.char, this.rawColor, [this.rawBackgroundColor]); + + // Safely extract the exact RGB channels regardless of framework + int get r => rawColor & 0xFF; + int get g => (rawColor >> 8) & 0xFF; + int get b => (rawColor >> 16) & 0xFF; + + // Outputs standard AARRGGBB for Flutter's Color(int) constructor + int get argb => (0xFF000000) | (r << 16) | (g << 8) | b; +} + +class AsciiRasterizer extends CliRasterizer { + static const double _targetAspectRatio = 4 / 3; + static const int _terminalBackdropArgb = 0xFF009688; + static const int _minimumTerminalColumns = 80; + static const int _minimumTerminalRows = 24; + static const int _simpleHudMinWidth = 84; + static const int _simpleHudMinRows = 7; + + AsciiRasterizer({ + this.activeTheme = AsciiThemes.blocks, + this.isTerminal = false, + this.aspectMultiplier = 1.0, + this.verticalStretch = 1.0, + }); + + AsciiTheme activeTheme = AsciiThemes.blocks; + final bool isTerminal; + + late List> _screen; + late List> _scenePixels; + late WolfEngine _engine; + + @override + final double aspectMultiplier; + @override + final double verticalStretch; + + @override + int get projectionWidth => isTerminal + ? math.max( + 1, + math.min(width, (_terminalPixelHeight * _targetAspectRatio).floor()), + ) + : width; + + @override + int get projectionOffsetX => isTerminal ? (width - projectionWidth) ~/ 2 : 0; + + @override + int get projectionViewHeight => isTerminal ? viewHeight * 2 : viewHeight; + + @override + bool isTerminalSizeSupported(int columns, int rows) { + if (!isTerminal) { + return true; + } + return columns >= _minimumTerminalColumns && rows >= _minimumTerminalRows; + } + + @override + String get terminalSizeRequirement => + 'ASCII renderer requires a minimum resolution of ' + '${_minimumTerminalColumns}x$_minimumTerminalRows.'; + + int get _terminalPixelHeight => isTerminal ? height * 2 : height; + + int get _viewportRightX => projectionOffsetX + projectionWidth; + + int get _terminalBackdropColor => _argbToRawColor(_terminalBackdropArgb); + + // Intercept the base render call to initialize our text grid + @override + dynamic render(WolfEngine engine) { + _engine = engine; + _screen = List.generate( + engine.frameBuffer.height, + (_) => List.filled( + engine.frameBuffer.width, + ColoredChar(' ', ColorPalette.vga32Bit[0]), + ), + ); + return super.render(engine); + } + + @override + void prepareFrame(WolfEngine engine) { + // Just grab the raw ints! + final int ceilingColor = ColorPalette.vga32Bit[25]; + final int floorColor = ColorPalette.vga32Bit[29]; + final int backdropColor = isTerminal + ? _terminalBackdropColor + : ColorPalette.vga32Bit[0]; + + _scenePixels = List.generate( + _terminalPixelHeight, + (_) => List.filled(width, backdropColor), + ); + + for (int y = 0; y < projectionViewHeight; y++) { + final int color = y < projectionViewHeight / 2 + ? ceilingColor + : floorColor; + for (int x = projectionOffsetX; x < _viewportRightX; x++) { + _scenePixels[y][x] = color; + } + } + + if (!isTerminal) { + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + if (y < viewHeight / 2) { + _screen[y][x] = ColoredChar(activeTheme.solid, ceilingColor); + } else if (y < viewHeight) { + _screen[y][x] = ColoredChar(activeTheme.solid, floorColor); + } + } + } + } + } + + @override + void drawWallColumn( + int x, + int drawStart, + int drawEnd, + int columnHeight, + Sprite texture, + int texX, + double perpWallDist, + int side, + ) { + double brightness = calculateDepthBrightness(perpWallDist); + + for (int y = drawStart; y < drawEnd; y++) { + double relativeY = + (y - (-columnHeight ~/ 2 + projectionViewHeight ~/ 2)) / columnHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + int pixelColor = ColorPalette.vga32Bit[colorByte]; // Raw int + + // Faux directional lighting using your new base class method + if (side == 1) { + pixelColor = shadeColor(pixelColor); + } + + if (isTerminal) { + _scenePixels[y][x] = _scaleColor(pixelColor, brightness); + } else { + String wallChar = activeTheme.getByBrightness(brightness); + _screen[y][x] = ColoredChar(wallChar, pixelColor); + } + } + } + + @override + void drawSpriteStripe( + int stripeX, + int drawStartY, + int drawEndY, + int spriteHeight, + Sprite texture, + int texX, + double transformY, + ) { + double brightness = calculateDepthBrightness(transformY); + + for ( + int y = math.max(0, drawStartY); + y < math.min(projectionViewHeight, drawEndY); + y++ + ) { + double relativeY = (y - drawStartY) / spriteHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int rawColor = ColorPalette.vga32Bit[colorByte]; + + // Shade the sprite's actual RGB color based on distance + int r = (rawColor & 0xFF); + int g = ((rawColor >> 8) & 0xFF); + int b = ((rawColor >> 16) & 0xFF); + + r = (r * brightness).toInt(); + g = (g * brightness).toInt(); + b = (b * brightness).toInt(); + + int shadedColor = (0xFF000000) | (b << 16) | (g << 8) | r; + + if (isTerminal) { + _scenePixels[y][stripeX] = shadedColor; + } else { + // Force sprites to be SOLID so they don't vanish into the terminal background + _screen[y][stripeX] = ColoredChar(activeTheme.solid, shadedColor); + } + } + } + } + + @override + void drawWeapon(WolfEngine engine) { + int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( + engine.data.sprites.length, + ); + Sprite weaponSprite = engine.data.sprites[spriteIndex]; + + int weaponWidth = (projectionWidth * 0.5).toInt(); + int weaponHeight = ((projectionViewHeight * 0.8)).toInt(); + + int startX = + projectionOffsetX + (projectionWidth ~/ 2) - (weaponWidth ~/ 2); + int startY = + projectionViewHeight - + weaponHeight + + (engine.player.weaponAnimOffset * (isTerminal ? 2 : 1) ~/ 4); + + for (int dy = 0; dy < weaponHeight; dy++) { + for (int dx = 0; dx < weaponWidth; dx++) { + int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); + int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); + + int colorByte = weaponSprite.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int sceneX = startX + dx; + int drawY = startY + dy; + if (sceneX >= projectionOffsetX && + sceneX < _viewportRightX && + drawY >= 0) { + if (isTerminal && drawY < projectionViewHeight) { + _scenePixels[drawY][sceneX] = ColorPalette.vga32Bit[colorByte]; + } else if (!isTerminal && drawY < viewHeight) { + _screen[drawY][sceneX] = ColoredChar( + activeTheme.solid, + ColorPalette.vga32Bit[colorByte], + ); + } + } + } + } + } + } + + // --- PRIVATE HUD DRAWING HELPER --- + + /// Injects a pure text string directly into the rasterizer grid + void _writeString( + int startX, + int y, + String text, + int color, [ + int? backgroundColor, + ]) { + for (int i = 0; i < text.length; i++) { + int x = startX + i; + if (x >= 0 && x < width && y >= 0 && y < height) { + _screen[y][x] = ColoredChar(text[i], color, backgroundColor); + } + } + } + + @override + void drawHud(WolfEngine engine) { + // If the terminal is at least 160 columns wide and 50 rows tall, + // there are enough "pixels" to downscale the VGA image clearly. + int hudWidth = isTerminal ? projectionWidth : width; + if (hudWidth >= 160 && height >= 50) { + _drawFullVgaHud(engine); + } else { + _drawSimpleHud(engine); + } + } + + void _drawSimpleHud(WolfEngine engine) { + final int hudWidth = isTerminal ? projectionWidth : width; + final int hudRows = height - viewHeight; + if (hudWidth < _simpleHudMinWidth || hudRows < _simpleHudMinRows) { + _drawMinimalHud(engine); + return; + } + + // 1. Pull Retro Colors + final int vgaStatusBarBlue = ColorPalette.vga32Bit[153]; + final int vgaPanelDark = ColorPalette.vga32Bit[0]; + final int white = ColorPalette.vga32Bit[15]; + final int yellow = ColorPalette.vga32Bit[11]; + final int red = ColorPalette.vga32Bit[4]; + + // Compact full simple HUD layout. + const int floorW = 10; + const int scoreW = 14; + const int livesW = 9; + const int faceW = 10; + const int healthW = 12; + const int ammoW = 10; + const int weaponW = 13; + const int gap = 1; + const int hudContentWidth = + floorW + + scoreW + + livesW + + faceW + + healthW + + ammoW + + weaponW + + (gap * 6); + + final int offsetX = + projectionOffsetX + + ((projectionWidth - hudContentWidth) ~/ 2).clamp(0, projectionWidth); + final int baseY = viewHeight + 1; + + // 3. Clear HUD Base + if (isTerminal) { + _fillTerminalRect( + projectionOffsetX, + viewHeight * 2, + projectionWidth, + hudRows * 2, + vgaStatusBarBlue, + ); + _fillTerminalRect( + projectionOffsetX, + viewHeight * 2, + projectionWidth, + 1, + white, + ); + } else { + _fillRect( + 0, + viewHeight, + width, + height - viewHeight, + ' ', + vgaStatusBarBlue, + ); + _writeString(0, viewHeight, "═" * width, white); + } + + // 4. Panel Drawing Helper + void drawBorderedPanel(int startX, int startY, int w, int h) { + if (isTerminal) { + _fillTerminalRect(startX, startY * 2, w, h * 2, vgaPanelDark); + _fillTerminalRect(startX, startY * 2, w, 1, white); + _fillTerminalRect(startX, (startY + h) * 2 - 1, w, 1, white); + _fillTerminalRect(startX, startY * 2, 1, h * 2, white); + _fillTerminalRect(startX + w - 1, startY * 2, 1, h * 2, white); + } else { + _fillRect(startX, startY, w, h, ' ', vgaPanelDark); + // Horizontal lines + _writeString(startX, startY, "┌${"─" * (w - 2)}┐", white); + _writeString(startX, startY + h - 1, "└${"─" * (w - 2)}┘", white); + // Vertical sides + for (int i = 1; i < h - 1; i++) { + _writeString(startX, startY + i, "│", white); + _writeString(startX + w - 1, startY + i, "│", white); + } + } + } + + // 5. Draw compact panels. + int cursorX = offsetX; + + drawBorderedPanel(cursorX, baseY + 1, floorW, 4); + _writeString(cursorX + 2, baseY + 2, "FLR", white, vgaPanelDark); + String floorLabel = engine.activeLevel.name.split(' ').last; + if (floorLabel.length > 4) { + floorLabel = floorLabel.substring(floorLabel.length - 4); + } + _writeString(cursorX + 2, baseY + 3, floorLabel, white, vgaPanelDark); + cursorX += floorW + gap; + + drawBorderedPanel(cursorX, baseY + 1, scoreW, 4); + _writeString(cursorX + 4, baseY + 2, "SCORE", white, vgaPanelDark); + _writeString( + cursorX + 4, + baseY + 3, + engine.player.score.toString().padLeft(6, '0'), + white, + vgaPanelDark, + ); + cursorX += scoreW + gap; + + drawBorderedPanel(cursorX, baseY + 1, livesW, 4); + _writeString(cursorX + 2, baseY + 2, "LIV", white, vgaPanelDark); + _writeString(cursorX + 3, baseY + 3, "3", white, vgaPanelDark); + cursorX += livesW + gap; + + drawBorderedPanel(cursorX, baseY, faceW, 5); + String face = "ಠ⌣ಠ"; + if (engine.player.health <= 0) { + face = "x⸑x"; + } else if (engine.player.damageFlash > 0.1) { + face = "ಠoಠ"; + } else if (engine.player.health <= 25) { + face = "ಥ_ಥ"; + } else if (engine.player.health <= 60) { + face = "ಠ~ಠ"; + } + _writeString(cursorX + 3, baseY + 2, face, yellow, vgaPanelDark); + cursorX += faceW + gap; + + int healthColor = engine.player.health > 25 ? white : red; + drawBorderedPanel(cursorX, baseY + 1, healthW, 4); + _writeString(cursorX + 2, baseY + 2, "HEALTH", white, vgaPanelDark); + _writeString( + cursorX + 3, + baseY + 3, + "${engine.player.health}%", + healthColor, + vgaPanelDark, + ); + cursorX += healthW + gap; + + drawBorderedPanel(cursorX, baseY + 1, ammoW, 4); + _writeString(cursorX + 2, baseY + 2, "AMMO", white, vgaPanelDark); + _writeString( + cursorX + 2, + baseY + 3, + "${engine.player.ammo}", + white, + vgaPanelDark, + ); + cursorX += ammoW + gap; + + drawBorderedPanel(cursorX, baseY + 1, weaponW, 4); + String weapon = engine.player.currentWeapon.type.name.spacePascalCase! + .toUpperCase(); + if (weapon.length > weaponW - 2) { + weapon = weapon.substring(0, weaponW - 2); + } + _writeString(cursorX + 1, baseY + 3, weapon, white, vgaPanelDark); + } + + void _drawMinimalHud(WolfEngine engine) { + final int vgaStatusBarBlue = ColorPalette.vga32Bit[153]; + final int white = ColorPalette.vga32Bit[15]; + final int red = ColorPalette.vga32Bit[4]; + + final int hudRows = height - viewHeight; + if (isTerminal) { + _fillTerminalRect( + projectionOffsetX, + viewHeight * 2, + projectionWidth, + hudRows * 2, + vgaStatusBarBlue, + ); + _fillTerminalRect( + projectionOffsetX, + viewHeight * 2, + projectionWidth, + 1, + white, + ); + } else { + _fillRect(0, viewHeight, width, hudRows, ' ', vgaStatusBarBlue); + _writeString(0, viewHeight, "═" * width, white); + } + + final int healthColor = engine.player.health > 25 ? white : red; + String weapon = engine.player.currentWeapon.type.name.spacePascalCase! + .toUpperCase(); + if (weapon.length > 8) { + weapon = weapon.substring(0, 8); + } + final String hudText = + 'H:${engine.player.health}% A:${engine.player.ammo} S:${engine.player.score} W:$weapon'; + + final int lineY = viewHeight + 1; + if (lineY >= height) return; + + final int drawStartX = isTerminal ? projectionOffsetX : 0; + final int drawWidth = isTerminal ? projectionWidth : width; + final int maxTextLen = math.max(0, drawWidth - 2); + String clipped = hudText; + if (clipped.length > maxTextLen) { + clipped = clipped.substring(0, maxTextLen); + } + + final int startX = drawStartX + ((drawWidth - clipped.length) ~/ 2); + _writeString(startX, lineY, clipped, healthColor, vgaStatusBarBlue); + } + + void _drawFullVgaHud(WolfEngine engine) { + int statusBarIndex = engine.data.vgaImages.indexWhere( + (img) => img.width == 320 && img.height == 40, + ); + if (statusBarIndex == -1) return; + + // 1. Draw Background + _blitVgaImageAscii(engine.data.vgaImages[statusBarIndex], 0, 160); + + // 2. Draw Stats + _drawNumberAscii(1, 32, 176, engine.data.vgaImages); // Floor + _drawNumberAscii( + engine.player.score, + 96, + 176, + engine.data.vgaImages, + ); // Score + _drawNumberAscii(3, 120, 176, engine.data.vgaImages); // Lives + _drawNumberAscii( + engine.player.health, + 192, + 176, + engine.data.vgaImages, + ); // Health + _drawNumberAscii( + engine.player.ammo, + 232, + 176, + engine.data.vgaImages, + ); // Ammo + + // 3. Draw BJ's Face & Current Weapon + _drawFaceAscii(engine); + _drawWeaponIconAscii(engine); + } + + void _drawNumberAscii( + int value, + int rightAlignX, + int startY, + List vgaImages, + ) { + const int zeroIndex = 96; + String numStr = value.toString(); + int currentX = rightAlignX - (numStr.length * 8); + + for (int i = 0; i < numStr.length; i++) { + int digit = int.parse(numStr[i]); + if (zeroIndex + digit < vgaImages.length) { + _blitVgaImageAscii(vgaImages[zeroIndex + digit], currentX, startY); + } + currentX += 8; + } + } + + void _drawFaceAscii(WolfEngine engine) { + int health = engine.player.health; + int faceIndex; + + if (health <= 0) { + faceIndex = 127; + } else { + int healthTier = ((100 - health) ~/ 16).clamp(0, 6); + faceIndex = 106 + (healthTier * 3); + } + + if (faceIndex < engine.data.vgaImages.length) { + _blitVgaImageAscii(engine.data.vgaImages[faceIndex], 136, 164); + } + } + + void _drawWeaponIconAscii(WolfEngine engine) { + int weaponIndex = 89; + if (engine.player.hasChainGun) { + weaponIndex = 91; + } else if (engine.player.hasMachineGun) { + weaponIndex = 90; + } + + if (weaponIndex < engine.data.vgaImages.length) { + _blitVgaImageAscii(engine.data.vgaImages[weaponIndex], 256, 164); + } + } + + /// Helper to fill a rectangular area with a specific char and background color + void _fillRect(int startX, int startY, int w, int h, String char, int color) { + for (int dy = 0; dy < h; dy++) { + for (int dx = 0; dx < w; dx++) { + int x = startX + dx; + int y = startY + dy; + if (x >= 0 && x < width && y >= 0 && y < height) { + _screen[y][x] = ColoredChar(char, color); + } + } + } + } + + @override + dynamic finalizeFrame() { + if (_engine.player.damageFlash > 0.0) { + if (isTerminal) { + _applyDamageFlashToScene(); + } else { + _applyDamageFlash(); + } + } + if (isTerminal) { + _composeTerminalScene(); + return toAnsiString(); + } + return _screen; + } + + // --- PRIVATE HUD DRAWING HELPERS --- + + void _blitVgaImageAscii(VgaImage image, int startX_320, int startY_200) { + int planeWidth = image.width ~/ 4; + int planeSize = planeWidth * image.height; + int maxDrawHeight = isTerminal ? _terminalPixelHeight : height; + int maxDrawWidth = isTerminal ? _viewportRightX : width; + + double scaleX = (isTerminal ? projectionWidth : width) / 320.0; + double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0; + + int destStartX = + (isTerminal ? projectionOffsetX : 0) + (startX_320 * scaleX).toInt(); + int destStartY = (startY_200 * scaleY).toInt(); + int destWidth = (image.width * scaleX).toInt(); + int destHeight = (image.height * scaleY).toInt(); + + for (int dy = 0; dy < destHeight; dy++) { + for (int dx = 0; dx < destWidth; dx++) { + int drawX = destStartX + dx; + int drawY = destStartY + dy; + + if (drawX >= 0 && + drawX < maxDrawWidth && + drawY >= 0 && + drawY < maxDrawHeight) { + int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1); + int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1); + + int plane = srcX % 4; + int sx = srcX ~/ 4; + int index = (plane * planeSize) + (srcY * planeWidth) + sx; + + int colorByte = image.pixels[index]; + if (colorByte != 255) { + if (isTerminal) { + _scenePixels[drawY][drawX] = ColorPalette.vga32Bit[colorByte]; + } else { + _screen[drawY][drawX] = ColoredChar( + activeTheme.solid, + ColorPalette.vga32Bit[colorByte], + ); + } + } + } + } + } + } + + void _fillTerminalRect(int startX, int startY, int w, int h, int color) { + for (int dy = 0; dy < h; dy++) { + for (int dx = 0; dx < w; dx++) { + int x = startX + dx; + int y = startY + dy; + if (x >= 0 && x < width && y >= 0 && y < _terminalPixelHeight) { + _scenePixels[y][x] = color; + } + } + } + } + + // --- DAMAGE FLASH --- + void _applyDamageFlash() { + for (int y = 0; y < viewHeight; y++) { + for (int x = 0; x < width; x++) { + ColoredChar cell = _screen[y][x]; + _screen[y][x] = ColoredChar( + cell.char, + _applyDamageFlashToColor(cell.rawColor), + cell.rawBackgroundColor == null + ? null + : _applyDamageFlashToColor(cell.rawBackgroundColor!), + ); + } + } + } + + void _applyDamageFlashToScene() { + for (int y = 0; y < _terminalPixelHeight; y++) { + for (int x = projectionOffsetX; x < _viewportRightX; x++) { + _scenePixels[y][x] = _applyDamageFlashToColor(_scenePixels[y][x]); + } + } + } + + int _argbToRawColor(int argb) { + int r = (argb >> 16) & 0xFF; + int g = (argb >> 8) & 0xFF; + int b = argb & 0xFF; + return (0xFF000000) | (b << 16) | (g << 8) | r; + } + + int _applyDamageFlashToColor(int color) { + double intensity = _engine.player.damageFlash; + int redBoost = (150 * intensity).toInt(); + double colorDrop = 1.0 - (0.5 * intensity); + + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + r = (r + redBoost).clamp(0, 255); + g = (g * colorDrop).toInt().clamp(0, 255); + b = (b * colorDrop).toInt().clamp(0, 255); + + return (0xFF000000) | (b << 16) | (g << 8) | r; + } + + int _scaleColor(int color, double brightness) { + int r = ((color & 0xFF) * brightness).toInt().clamp(0, 255); + int g = (((color >> 8) & 0xFF) * brightness).toInt().clamp(0, 255); + int b = (((color >> 16) & 0xFF) * brightness).toInt().clamp(0, 255); + return (0xFF000000) | (b << 16) | (g << 8) | r; + } + + void _composeTerminalScene() { + for (int y = 0; y < height; y++) { + int topY = y * 2; + int bottomY = math.min(topY + 1, _terminalPixelHeight - 1); + for (int x = 0; x < width; x++) { + int topColor = _scenePixels[topY][x]; + int bottomColor = _scenePixels[bottomY][x]; + + ColoredChar overlay = _screen[y][x]; + if (overlay.char != ' ') { + if (overlay.rawBackgroundColor == null) { + _screen[y][x] = ColoredChar( + overlay.char, + overlay.rawColor, + bottomColor, + ); + } + continue; + } + + _screen[y][x] = topColor == bottomColor + ? ColoredChar('█', topColor) + : ColoredChar('▀', topColor, bottomColor); + } + } + } + + /// Converts the current frame to a single printable ANSI string + StringBuffer toAnsiString() { + StringBuffer buffer = StringBuffer(); + + int? lastForeground; + int? lastBackground; + + for (int y = 0; y < _screen.length; y++) { + List row = _screen[y]; + for (ColoredChar cell in row) { + if (cell.rawColor != lastForeground) { + buffer.write('\x1b[38;2;${cell.r};${cell.g};${cell.b}m'); + lastForeground = cell.rawColor; + } + if (cell.rawBackgroundColor != lastBackground) { + if (cell.rawBackgroundColor == null) { + buffer.write('\x1b[49m'); + } else { + int background = cell.rawBackgroundColor!; + int bgR = background & 0xFF; + int bgG = (background >> 8) & 0xFF; + int bgB = (background >> 16) & 0xFF; + buffer.write( + '\x1b[48;2;$bgR;$bgG;$bgB' + 'm', + ); + } + lastBackground = cell.rawBackgroundColor; + } + buffer.write(cell.char); + } + + // Only print a newline if we are NOT on the very last row. + // This stops the terminal from scrolling down! + if (y < _screen.length - 1) { + buffer.write('\n'); + } + } + + // Reset the terminal color at the very end + buffer.write('\x1b[0m'); + + return buffer; + } +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/cli_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/cli_rasterizer.dart new file mode 100644 index 0000000..ded98b4 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/cli_rasterizer.dart @@ -0,0 +1,38 @@ +import 'package:wolf_3d_dart/src/engine/wolf_3d_engine_base.dart'; + +import 'rasterizer.dart'; + +/// Shared terminal orchestration for CLI rasterizers. +abstract class CliRasterizer extends Rasterizer { + /// Resolves the framebuffer dimensions required by this renderer. + /// + /// The default uses the full terminal size. + ({int width, int height}) terminalFrameBufferSize(int columns, int rows) { + return (width: columns, height: rows); + } + + /// Applies terminal-size policy and updates the engine framebuffer. + /// + /// Returns `false` when the terminal is too small for this renderer. + bool prepareTerminalFrame( + WolfEngine engine, { + required int columns, + required int rows, + }) { + if (!isTerminalSizeSupported(columns, rows)) { + return false; + } + + final size = terminalFrameBufferSize(columns, rows); + engine.setFrameBuffer(size.width, size.height); + return true; + } + + /// Builds the standard terminal size warning shown by the CLI host. + String buildTerminalSizeWarning({required int columns, required int rows}) { + return '\x1b[31m[ ERROR ] TERMINAL TOO SMALL\x1b[0m\n\n' + '$terminalSizeRequirement\n' + 'Current size: \x1b[33m${columns}x$rows\x1b[0m\n\n' + 'Please resize your window to resume the game...'; + } +} diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart similarity index 78% rename from packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart rename to packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart index de63473..b72093e 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart @@ -114,6 +114,84 @@ abstract class Rasterizer { return (10.0 / (distance + 2.0)).clamp(0.0, 1.0); } + ({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) // =========================================================================== @@ -139,6 +217,9 @@ abstract class Rasterizer { 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(); @@ -150,6 +231,7 @@ abstract class Rasterizer { int stepX, stepY, side = 0, hitWallId = 0; bool hit = false, hitOutOfBounds = false, customDistCalculated = false; double textureOffset = 0.0; + double? wallXOverride; Set ignoredDoors = {}; if (rayDir.x < 0) { @@ -186,6 +268,12 @@ abstract class Rasterizer { 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 @@ -207,63 +295,6 @@ abstract class Rasterizer { hit = true; hitWallId = map[mapY][mapX]; textureOffset = currentOffset; - } - // PUSHWALL LOGIC - else if (activePushwall != null && - mapX == activePushwall.x && - mapY == activePushwall.y) { - hit = true; - hitWallId = map[mapY][mapX]; - - double pOffset = activePushwall.offset; - int pDirX = activePushwall.dirX; - int pDirY = activePushwall.dirY; - - perpWallDist = (side == 0) - ? (sideDistX - deltaDistX) - : (sideDistY - deltaDistY); - - if (side == 0 && pDirX != 0) { - if (pDirX == stepX) { - double intersect = perpWallDist + pOffset * deltaDistX; - if (intersect < sideDistY) { - perpWallDist = intersect; - } else { - side = 1; - perpWallDist = sideDistY - deltaDistY; - } - } else { - perpWallDist -= (1.0 - pOffset) * deltaDistX; - } - } else if (side == 1 && pDirY != 0) { - if (pDirY == stepY) { - double intersect = perpWallDist + pOffset * deltaDistY; - if (intersect < sideDistX) { - perpWallDist = intersect; - } else { - side = 0; - perpWallDist = sideDistX - deltaDistX; - } - } else { - perpWallDist -= (1.0 - pOffset) * deltaDistY; - } - } else { - double wallFraction = (side == 0) - ? player.y + perpWallDist * rayDir.y - : player.x + perpWallDist * rayDir.x; - wallFraction -= wallFraction.floor(); - if (side == 0) { - if (pDirY == 1 && wallFraction < pOffset) hit = false; - if (pDirY == -1 && wallFraction > (1.0 - pOffset)) hit = false; - if (hit) textureOffset = pOffset * pDirY; - } else { - if (pDirX == 1 && wallFraction < pOffset) hit = false; - if (pDirX == -1 && wallFraction > (1.0 - pOffset)) hit = false; - if (hit) textureOffset = pOffset * pDirX; - } - } - if (!hit) continue; - customDistCalculated = true; } else { hit = true; hitWallId = map[mapY][mapX]; @@ -271,22 +302,47 @@ abstract class Rasterizer { } } - if (hitOutOfBounds) continue; + 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 = (side == 0) - ? player.y + perpWallDist * rayDir.y - : player.x + perpWallDist * rayDir.x; + double wallX = + wallXOverride ?? + ((side == 0) + ? player.y + perpWallDist * rayDir.y + : player.x + perpWallDist * rayDir.x); wallX -= wallX.floor(); int texNum; diff --git a/packages/wolf_3d_dart/lib/src/engine/rasterizer/cli_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/cli_rasterizer.dart similarity index 95% rename from packages/wolf_3d_dart/lib/src/engine/rasterizer/cli_rasterizer.dart rename to packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/cli_rasterizer.dart index ce43030..564e2f1 100644 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/cli_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/cli_rasterizer.dart @@ -1,3 +1,4 @@ +import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; /// Shared terminal orchestration for CLI rasterizers. diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/sixel_rasterizer.dart new file mode 100644 index 0000000..9633081 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/sixel_rasterizer.dart @@ -0,0 +1,423 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +class SixelRasterizer extends CliRasterizer { + static const double _targetAspectRatio = 4 / 3; + static const int _defaultLineHeightPx = 18; + static const double _defaultCellWidthToHeight = 0.55; + static const int _minimumTerminalColumns = 117; + static const int _minimumTerminalRows = 34; + static const int _maxRenderWidth = 320; + static const int _maxRenderHeight = 240; + static const String _terminalTealBackground = '\x1b[48;2;0;150;136m'; + + late Uint8List _screen; + late WolfEngine _engine; + int _offsetColumns = 0; + int _offsetRows = 0; + int _outputWidth = 1; + int _outputHeight = 1; + bool _needsBackgroundClear = true; + + FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) { + final int previousOffsetColumns = _offsetColumns; + final int previousOffsetRows = _offsetRows; + final int previousOutputWidth = _outputWidth; + final int previousOutputHeight = _outputHeight; + + final double fitScale = math.min( + terminalBuffer.width / _minimumTerminalColumns, + terminalBuffer.height / _minimumTerminalRows, + ); + + final int targetColumns = math.max( + 1, + (_minimumTerminalColumns * fitScale).floor(), + ); + final int targetRows = math.max( + 1, + (_minimumTerminalRows * fitScale).floor(), + ); + + _offsetColumns = math.max(0, (terminalBuffer.width - targetColumns) ~/ 2); + _offsetRows = math.max(0, (terminalBuffer.height - targetRows) ~/ 2); + + final int boundsPixelWidth = math.max( + 1, + (targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight) + .floor(), + ); + final int boundsPixelHeight = math.max( + 1, + targetRows * _defaultLineHeightPx, + ); + + final double boundsAspect = boundsPixelWidth / boundsPixelHeight; + if (boundsAspect > _targetAspectRatio) { + _outputHeight = boundsPixelHeight; + _outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor()); + } else { + _outputWidth = boundsPixelWidth; + _outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor()); + } + + if (_offsetColumns != previousOffsetColumns || + _offsetRows != previousOffsetRows || + _outputWidth != previousOutputWidth || + _outputHeight != previousOutputHeight) { + _needsBackgroundClear = true; + } + + final double renderScale = math.min( + 1.0, + math.min( + _maxRenderWidth / _outputWidth, + _maxRenderHeight / _outputHeight, + ), + ); + final int renderWidth = math.max(1, (_outputWidth * renderScale).floor()); + final int renderHeight = math.max(1, (_outputHeight * renderScale).floor()); + + return FrameBuffer(renderWidth, renderHeight); + } + + @override + String render(WolfEngine engine) { + _engine = engine; + final FrameBuffer originalBuffer = engine.frameBuffer; + final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer); + // We only need 8-bit indices for the 256 VGA colors + _screen = Uint8List(scaledBuffer.width * scaledBuffer.height); + engine.frameBuffer = scaledBuffer; + try { + return super.render(engine); + } finally { + engine.frameBuffer = originalBuffer; + } + } + + @override + void prepareFrame(WolfEngine engine) { + // Top half is ceiling color index (25), bottom half is floor color index (29) + for (int y = 0; y < viewHeight; y++) { + int colorIndex = (y < viewHeight / 2) ? 25 : 29; + for (int x = 0; x < width; x++) { + _screen[y * width + x] = colorIndex; + } + } + } + + @override + void drawWallColumn( + int x, + int drawStart, + int drawEnd, + int columnHeight, + Sprite texture, + int texX, + double perpWallDist, + int side, + ) { + for (int y = drawStart; y < drawEnd; y++) { + double relativeY = + (y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // Note: Directional shading is omitted here to preserve strict VGA palette indices. + // Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table. + _screen[y * width + x] = colorByte; + } + } + + @override + void drawSpriteStripe( + int stripeX, + int drawStartY, + int drawEndY, + int spriteHeight, + Sprite texture, + int texX, + double transformY, + ) { + for ( + int y = math.max(0, drawStartY); + y < math.min(viewHeight, drawEndY); + y++ + ) { + double relativeY = (y - drawStartY) / spriteHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // 255 is the "transparent" color index + if (colorByte != 255) { + _screen[y * width + stripeX] = colorByte; + } + } + } + + @override + void drawWeapon(WolfEngine engine) { + int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( + engine.data.sprites.length, + ); + Sprite weaponSprite = engine.data.sprites[spriteIndex]; + + int weaponWidth = (width * 0.5).toInt(); + int weaponHeight = (viewHeight * 0.8).toInt(); + + int startX = (width ~/ 2) - (weaponWidth ~/ 2); + int startY = + viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4); + + for (int dy = 0; dy < weaponHeight; dy++) { + for (int dx = 0; dx < weaponWidth; dx++) { + int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); + int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); + + int colorByte = weaponSprite.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int drawX = startX + dx; + int drawY = startY + dy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) { + _screen[drawY * width + drawX] = colorByte; + } + } + } + } + } + + @override + void drawHud(WolfEngine engine) { + int statusBarIndex = engine.data.vgaImages.indexWhere( + (img) => img.width == 320 && img.height == 40, + ); + if (statusBarIndex == -1) return; + + _blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160); + + _drawNumber(1, 32, 176, engine.data.vgaImages); + _drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); + _drawNumber(3, 120, 176, engine.data.vgaImages); + _drawNumber(engine.player.health, 192, 176, engine.data.vgaImages); + _drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); + + _drawFace(engine); + _drawWeaponIcon(engine); + } + + @override + String finalizeFrame() { + final String clearPrefix = _needsBackgroundClear + ? '$_terminalTealBackground\x1b[2J\x1b[0m' + : ''; + _needsBackgroundClear = false; + return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}'; + } + + // =========================================================================== + // SIXEL ENCODER + // =========================================================================== + + /// Converts the 8-bit index buffer into a standard Sixel sequence + String toSixelString() { + StringBuffer sb = StringBuffer(); + + // Start Sixel sequence (q = Sixel format) + sb.write('\x1bPq'); + + // 1. Define the Palette (and apply damage flash directly to the palette!) + double damageIntensity = _engine.player.damageFlash; + int redBoost = (150 * damageIntensity).toInt(); + double colorDrop = 1.0 - (0.5 * damageIntensity); + + for (int i = 0; i < 256; i++) { + int color = ColorPalette.vga32Bit[i]; + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + if (damageIntensity > 0) { + r = (r + redBoost).clamp(0, 255); + g = (g * colorDrop).toInt().clamp(0, 255); + b = (b * colorDrop).toInt().clamp(0, 255); + } + + // Sixel RGB ranges from 0 to 100 + int sixelR = (r * 100) ~/ 255; + int sixelG = (g * 100) ~/ 255; + int sixelB = (b * 100) ~/ 255; + + sb.write('#$i;2;$sixelR;$sixelG;$sixelB'); + } + + // 2. Encode scaled image in 6-pixel vertical bands. + for (int band = 0; band < _outputHeight; band += 6) { + Map colorMap = {}; + + // Map out which pixels use which color in this 6px high band + for (int x = 0; x < _outputWidth; x++) { + for (int yOffset = 0; yOffset < 6; yOffset++) { + int y = band + yOffset; + if (y >= _outputHeight) break; + + int colorIdx = _sampleScaledPixel(x, y); + if (!colorMap.containsKey(colorIdx)) { + colorMap[colorIdx] = Uint8List(_outputWidth); + } + // Set the bit corresponding to the vertical position (0-5) + colorMap[colorIdx]![x] |= (1 << yOffset); + } + } + + // Write the encoded Sixel characters for each color present in the band + bool firstColor = true; + for (var entry in colorMap.entries) { + if (!firstColor) { + // Carriage return to overlay colors on the same band + sb.write('\$'); + } + firstColor = false; + + // Select color index + sb.write('#${entry.key}'); + + Uint8List cols = entry.value; + int currentVal = -1; + int runLength = 0; + + // Run-Length Encoding (RLE) loop + for (int x = 0; x < _outputWidth; x++) { + int val = cols[x]; + if (val == currentVal) { + runLength++; + } else { + if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); + currentVal = val; + runLength = 1; + } + } + if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); + } + + if (band + 6 < _outputHeight) { + sb.write('-'); + } + } + + // End Sixel sequence + sb.write('\x1b\\'); + return sb.toString(); + } + + int _sampleScaledPixel(int outX, int outY) { + final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5) + .round() + .clamp( + 0, + width - 1, + ); + final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5) + .round() + .clamp( + 0, + height - 1, + ); + return _screen[srcY * width + srcX]; + } + + void _writeSixelRle(StringBuffer sb, int value, int runLength) { + String char = String.fromCharCode(value + 63); + // Sixel RLE format: ! (only worth it if count > 3) + if (runLength > 3) { + sb.write('!$runLength$char'); + } else { + sb.write(char * runLength); + } + } + + // =========================================================================== + // PRIVATE HUD HELPERS (Adapted for 8-bit index buffer) + // =========================================================================== + + void _blitVgaImage(VgaImage image, int startX, int startY) { + int planeWidth = image.width ~/ 4; + int planeSize = planeWidth * image.height; + final double scaleX = width / 320.0; + final double scaleY = height / 200.0; + + final int destStartX = (startX * scaleX).toInt(); + final int destStartY = (startY * scaleY).toInt(); + final int destWidth = math.max(1, (image.width * scaleX).toInt()); + final int destHeight = math.max(1, (image.height * scaleY).toInt()); + + for (int dy = 0; dy < destHeight; dy++) { + for (int dx = 0; dx < destWidth; dx++) { + int drawX = destStartX + dx; + int drawY = destStartY + dy; + + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1); + int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1); + + int plane = srcX % 4; + int sx = srcX ~/ 4; + int index = (plane * planeSize) + (srcY * planeWidth) + sx; + + int colorByte = image.pixels[index]; + if (colorByte != 255) { + _screen[drawY * width + drawX] = colorByte; + } + } + } + } + } + + void _drawNumber( + int value, + int rightAlignX, + int startY, + List vgaImages, + ) { + const int zeroIndex = 96; + String numStr = value.toString(); + int currentX = rightAlignX - (numStr.length * 8); + + for (int i = 0; i < numStr.length; i++) { + int digit = int.parse(numStr[i]); + if (zeroIndex + digit < vgaImages.length) { + _blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY); + } + currentX += 8; + } + } + + void _drawFace(WolfEngine engine) { + int health = engine.player.health; + int faceIndex = (health <= 0) + ? 127 + : 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3); + if (faceIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164); + } + } + + void _drawWeaponIcon(WolfEngine engine) { + int weaponIndex = 89; + if (engine.player.hasChainGun) { + weaponIndex = 91; + } else if (engine.player.hasMachineGun) { + weaponIndex = 90; + } + + if (weaponIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164); + } + } +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/software_rasterizer.dart new file mode 100644 index 0000000..847b7f6 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/software_rasterizer.dart @@ -0,0 +1,265 @@ +import 'dart:math' as math; + +import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +class SoftwareRasterizer extends Rasterizer { + late FrameBuffer _buffer; + late WolfEngine _engine; + + // Intercept the base render call to store our references + @override + FrameBuffer render(WolfEngine engine) { + _engine = engine; + _buffer = engine.frameBuffer; + return super.render(engine); + } + + @override + void prepareFrame(WolfEngine engine) { + // Top half is ceiling color (25), bottom half is floor color (29) + int ceilingColor = ColorPalette.vga32Bit[25]; + int floorColor = ColorPalette.vga32Bit[29]; + + for (int y = 0; y < viewHeight; y++) { + int color = (y < viewHeight / 2) ? ceilingColor : floorColor; + for (int x = 0; x < width; x++) { + _buffer.pixels[y * width + x] = color; + } + } + } + + @override + void drawWallColumn( + int x, + int drawStart, + int drawEnd, + int columnHeight, + Sprite texture, + int texX, + double perpWallDist, + int side, + ) { + for (int y = drawStart; y < drawEnd; y++) { + // Calculate which Y pixel of the texture to sample + double relativeY = + (y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + int pixelColor = ColorPalette.vga32Bit[colorByte]; + + // Darken Y-side walls for faux directional lighting + if (side == 1) { + pixelColor = shadeColor(pixelColor); + } + + _buffer.pixels[y * width + x] = pixelColor; + } + } + + @override + void drawSpriteStripe( + int stripeX, + int drawStartY, + int drawEndY, + int spriteHeight, + Sprite texture, + int texX, + double transformY, + ) { + for ( + int y = math.max(0, drawStartY); + y < math.min(viewHeight, drawEndY); + y++ + ) { + double relativeY = (y - drawStartY) / spriteHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // 255 is the "transparent" color index in VGA Wolfenstein + if (colorByte != 255) { + _buffer.pixels[y * width + stripeX] = ColorPalette.vga32Bit[colorByte]; + } + } + } + + @override + void drawWeapon(WolfEngine engine) { + int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( + engine.data.sprites.length, + ); + Sprite weaponSprite = engine.data.sprites[spriteIndex]; + + int weaponWidth = (width * 0.5).toInt(); + int weaponHeight = (viewHeight * 0.8).toInt(); + + int startX = (width ~/ 2) - (weaponWidth ~/ 2); + int startY = + viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4); + + for (int dy = 0; dy < weaponHeight; dy++) { + for (int dx = 0; dx < weaponWidth; dx++) { + int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); + int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); + + int colorByte = weaponSprite.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int drawX = startX + dx; + int drawY = startY + dy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) { + _buffer.pixels[drawY * width + drawX] = + ColorPalette.vga32Bit[colorByte]; + } + } + } + } + } + + @override + void drawHud(WolfEngine engine) { + int statusBarIndex = engine.data.vgaImages.indexWhere( + (img) => img.width == 320 && img.height == 40, + ); + if (statusBarIndex == -1) return; + + // 1. Draw Background + _blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160); + + // 2. Draw Stats (100% mathematically accurate right-aligned coordinates) + _drawNumber(1, 32, 176, engine.data.vgaImages); // Floor + _drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); // Score + _drawNumber(3, 120, 176, engine.data.vgaImages); // Lives + _drawNumber( + engine.player.health, + 192, + 176, + engine.data.vgaImages, + ); // Health + _drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); // Ammo + + // 3. Draw BJ's Face & Current Weapon + _drawFace(engine); + _drawWeaponIcon(engine); + } + + @override + FrameBuffer finalizeFrame() { + // If the player took damage, overlay a red tint across the 3D view + if (_engine.player.damageFlash > 0) { + _applyDamageFlash(); + } + return _buffer; // Return the fully painted pixel array + } + + // =========================================================================== + // PRIVATE HELPER METHODS + // =========================================================================== + + /// Maps the planar VGA image data directly to 32-bit pixels. + /// (Assuming a 1:1 scale, which is standard for the 320x200 software renderer). + void _blitVgaImage(VgaImage image, int startX, int startY) { + int planeWidth = image.width ~/ 4; + int planeSize = planeWidth * image.height; + + for (int dy = 0; dy < image.height; dy++) { + for (int dx = 0; dx < image.width; dx++) { + int drawX = startX + dx; + int drawY = startY + dy; + + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + int srcX = dx.clamp(0, image.width - 1); + int srcY = dy.clamp(0, image.height - 1); + + int plane = srcX % 4; + int sx = srcX ~/ 4; + int index = (plane * planeSize) + (srcY * planeWidth) + sx; + + int colorByte = image.pixels[index]; + if (colorByte != 255) { + _buffer.pixels[drawY * width + drawX] = + ColorPalette.vga32Bit[colorByte]; + } + } + } + } + } + + void _drawNumber( + int value, + int rightAlignX, + int startY, + List vgaImages, + ) { + const int zeroIndex = 96; + String numStr = value.toString(); + int currentX = rightAlignX - (numStr.length * 8); + + for (int i = 0; i < numStr.length; i++) { + int digit = int.parse(numStr[i]); + if (zeroIndex + digit < vgaImages.length) { + _blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY); + } + currentX += 8; + } + } + + void _drawFace(WolfEngine engine) { + int health = engine.player.health; + int faceIndex; + + if (health <= 0) { + faceIndex = 127; // Dead face + } else { + int healthTier = ((100 - health) ~/ 16).clamp(0, 6); + faceIndex = 106 + (healthTier * 3); + } + + if (faceIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164); + } + } + + void _drawWeaponIcon(WolfEngine engine) { + int weaponIndex = 89; // Default to Pistol + + if (engine.player.hasChainGun) { + weaponIndex = 91; + } else if (engine.player.hasMachineGun) { + weaponIndex = 90; + } + + if (weaponIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164); + } + } + + /// Tints the top 80% of the screen red based on player.damageFlash intensity + void _applyDamageFlash() { + // Grab the intensity (0.0 to 1.0) + double intensity = _engine.player.damageFlash; + + // Calculate how much to boost red and drop green/blue + int redBoost = (150 * intensity).toInt(); + double colorDrop = 1.0 - (0.5 * intensity); + + for (int y = 0; y < viewHeight; y++) { + for (int x = 0; x < width; x++) { + int index = y * width + x; + int color = _buffer.pixels[index]; + + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + r = (r + redBoost).clamp(0, 255); + g = (g * colorDrop).toInt(); + b = (b * colorDrop).toInt(); + + _buffer.pixels[index] = (0xFF000000) | (b << 16) | (g << 8) | r; + } + } + } +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart new file mode 100644 index 0000000..3c052d4 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart @@ -0,0 +1,424 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +import 'cli_rasterizer.dart'; + +class SixelRasterizer extends CliRasterizer { + static const double _targetAspectRatio = 4 / 3; + static const int _defaultLineHeightPx = 18; + static const double _defaultCellWidthToHeight = 0.55; + static const int _minimumTerminalColumns = 117; + static const int _minimumTerminalRows = 34; + static const int _maxRenderWidth = 320; + static const int _maxRenderHeight = 240; + static const String _terminalTealBackground = '\x1b[48;2;0;150;136m'; + + late Uint8List _screen; + late WolfEngine _engine; + int _offsetColumns = 0; + int _offsetRows = 0; + int _outputWidth = 1; + int _outputHeight = 1; + bool _needsBackgroundClear = true; + + FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) { + final int previousOffsetColumns = _offsetColumns; + final int previousOffsetRows = _offsetRows; + final int previousOutputWidth = _outputWidth; + final int previousOutputHeight = _outputHeight; + + final double fitScale = math.min( + terminalBuffer.width / _minimumTerminalColumns, + terminalBuffer.height / _minimumTerminalRows, + ); + + final int targetColumns = math.max( + 1, + (_minimumTerminalColumns * fitScale).floor(), + ); + final int targetRows = math.max( + 1, + (_minimumTerminalRows * fitScale).floor(), + ); + + _offsetColumns = math.max(0, (terminalBuffer.width - targetColumns) ~/ 2); + _offsetRows = math.max(0, (terminalBuffer.height - targetRows) ~/ 2); + + final int boundsPixelWidth = math.max( + 1, + (targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight) + .floor(), + ); + final int boundsPixelHeight = math.max( + 1, + targetRows * _defaultLineHeightPx, + ); + + final double boundsAspect = boundsPixelWidth / boundsPixelHeight; + if (boundsAspect > _targetAspectRatio) { + _outputHeight = boundsPixelHeight; + _outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor()); + } else { + _outputWidth = boundsPixelWidth; + _outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor()); + } + + if (_offsetColumns != previousOffsetColumns || + _offsetRows != previousOffsetRows || + _outputWidth != previousOutputWidth || + _outputHeight != previousOutputHeight) { + _needsBackgroundClear = true; + } + + final double renderScale = math.min( + 1.0, + math.min( + _maxRenderWidth / _outputWidth, + _maxRenderHeight / _outputHeight, + ), + ); + final int renderWidth = math.max(1, (_outputWidth * renderScale).floor()); + final int renderHeight = math.max(1, (_outputHeight * renderScale).floor()); + + return FrameBuffer(renderWidth, renderHeight); + } + + @override + String render(WolfEngine engine) { + _engine = engine; + final FrameBuffer originalBuffer = engine.frameBuffer; + final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer); + // We only need 8-bit indices for the 256 VGA colors + _screen = Uint8List(scaledBuffer.width * scaledBuffer.height); + engine.frameBuffer = scaledBuffer; + try { + return super.render(engine); + } finally { + engine.frameBuffer = originalBuffer; + } + } + + @override + void prepareFrame(WolfEngine engine) { + // Top half is ceiling color index (25), bottom half is floor color index (29) + for (int y = 0; y < viewHeight; y++) { + int colorIndex = (y < viewHeight / 2) ? 25 : 29; + for (int x = 0; x < width; x++) { + _screen[y * width + x] = colorIndex; + } + } + } + + @override + void drawWallColumn( + int x, + int drawStart, + int drawEnd, + int columnHeight, + Sprite texture, + int texX, + double perpWallDist, + int side, + ) { + for (int y = drawStart; y < drawEnd; y++) { + double relativeY = + (y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // Note: Directional shading is omitted here to preserve strict VGA palette indices. + // Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table. + _screen[y * width + x] = colorByte; + } + } + + @override + void drawSpriteStripe( + int stripeX, + int drawStartY, + int drawEndY, + int spriteHeight, + Sprite texture, + int texX, + double transformY, + ) { + for ( + int y = math.max(0, drawStartY); + y < math.min(viewHeight, drawEndY); + y++ + ) { + double relativeY = (y - drawStartY) / spriteHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // 255 is the "transparent" color index + if (colorByte != 255) { + _screen[y * width + stripeX] = colorByte; + } + } + } + + @override + void drawWeapon(WolfEngine engine) { + int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( + engine.data.sprites.length, + ); + Sprite weaponSprite = engine.data.sprites[spriteIndex]; + + int weaponWidth = (width * 0.5).toInt(); + int weaponHeight = (viewHeight * 0.8).toInt(); + + int startX = (width ~/ 2) - (weaponWidth ~/ 2); + int startY = + viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4); + + for (int dy = 0; dy < weaponHeight; dy++) { + for (int dx = 0; dx < weaponWidth; dx++) { + int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); + int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); + + int colorByte = weaponSprite.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int drawX = startX + dx; + int drawY = startY + dy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) { + _screen[drawY * width + drawX] = colorByte; + } + } + } + } + } + + @override + void drawHud(WolfEngine engine) { + int statusBarIndex = engine.data.vgaImages.indexWhere( + (img) => img.width == 320 && img.height == 40, + ); + if (statusBarIndex == -1) return; + + _blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160); + + _drawNumber(1, 32, 176, engine.data.vgaImages); + _drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); + _drawNumber(3, 120, 176, engine.data.vgaImages); + _drawNumber(engine.player.health, 192, 176, engine.data.vgaImages); + _drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); + + _drawFace(engine); + _drawWeaponIcon(engine); + } + + @override + String finalizeFrame() { + final String clearPrefix = _needsBackgroundClear + ? '$_terminalTealBackground\x1b[2J\x1b[0m' + : ''; + _needsBackgroundClear = false; + return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}'; + } + + // =========================================================================== + // SIXEL ENCODER + // =========================================================================== + + /// Converts the 8-bit index buffer into a standard Sixel sequence + String toSixelString() { + StringBuffer sb = StringBuffer(); + + // Start Sixel sequence (q = Sixel format) + sb.write('\x1bPq'); + + // 1. Define the Palette (and apply damage flash directly to the palette!) + double damageIntensity = _engine.player.damageFlash; + int redBoost = (150 * damageIntensity).toInt(); + double colorDrop = 1.0 - (0.5 * damageIntensity); + + for (int i = 0; i < 256; i++) { + int color = ColorPalette.vga32Bit[i]; + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + if (damageIntensity > 0) { + r = (r + redBoost).clamp(0, 255); + g = (g * colorDrop).toInt().clamp(0, 255); + b = (b * colorDrop).toInt().clamp(0, 255); + } + + // Sixel RGB ranges from 0 to 100 + int sixelR = (r * 100) ~/ 255; + int sixelG = (g * 100) ~/ 255; + int sixelB = (b * 100) ~/ 255; + + sb.write('#$i;2;$sixelR;$sixelG;$sixelB'); + } + + // 2. Encode scaled image in 6-pixel vertical bands. + for (int band = 0; band < _outputHeight; band += 6) { + Map colorMap = {}; + + // Map out which pixels use which color in this 6px high band + for (int x = 0; x < _outputWidth; x++) { + for (int yOffset = 0; yOffset < 6; yOffset++) { + int y = band + yOffset; + if (y >= _outputHeight) break; + + int colorIdx = _sampleScaledPixel(x, y); + if (!colorMap.containsKey(colorIdx)) { + colorMap[colorIdx] = Uint8List(_outputWidth); + } + // Set the bit corresponding to the vertical position (0-5) + colorMap[colorIdx]![x] |= (1 << yOffset); + } + } + + // Write the encoded Sixel characters for each color present in the band + bool firstColor = true; + for (var entry in colorMap.entries) { + if (!firstColor) { + // Carriage return to overlay colors on the same band + sb.write('\$'); + } + firstColor = false; + + // Select color index + sb.write('#${entry.key}'); + + Uint8List cols = entry.value; + int currentVal = -1; + int runLength = 0; + + // Run-Length Encoding (RLE) loop + for (int x = 0; x < _outputWidth; x++) { + int val = cols[x]; + if (val == currentVal) { + runLength++; + } else { + if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); + currentVal = val; + runLength = 1; + } + } + if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); + } + + if (band + 6 < _outputHeight) { + sb.write('-'); + } + } + + // End Sixel sequence + sb.write('\x1b\\'); + return sb.toString(); + } + + int _sampleScaledPixel(int outX, int outY) { + final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5) + .round() + .clamp( + 0, + width - 1, + ); + final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5) + .round() + .clamp( + 0, + height - 1, + ); + return _screen[srcY * width + srcX]; + } + + void _writeSixelRle(StringBuffer sb, int value, int runLength) { + String char = String.fromCharCode(value + 63); + // Sixel RLE format: ! (only worth it if count > 3) + if (runLength > 3) { + sb.write('!$runLength$char'); + } else { + sb.write(char * runLength); + } + } + + // =========================================================================== + // PRIVATE HUD HELPERS (Adapted for 8-bit index buffer) + // =========================================================================== + + void _blitVgaImage(VgaImage image, int startX, int startY) { + int planeWidth = image.width ~/ 4; + int planeSize = planeWidth * image.height; + final double scaleX = width / 320.0; + final double scaleY = height / 200.0; + + final int destStartX = (startX * scaleX).toInt(); + final int destStartY = (startY * scaleY).toInt(); + final int destWidth = math.max(1, (image.width * scaleX).toInt()); + final int destHeight = math.max(1, (image.height * scaleY).toInt()); + + for (int dy = 0; dy < destHeight; dy++) { + for (int dx = 0; dx < destWidth; dx++) { + int drawX = destStartX + dx; + int drawY = destStartY + dy; + + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1); + int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1); + + int plane = srcX % 4; + int sx = srcX ~/ 4; + int index = (plane * planeSize) + (srcY * planeWidth) + sx; + + int colorByte = image.pixels[index]; + if (colorByte != 255) { + _screen[drawY * width + drawX] = colorByte; + } + } + } + } + } + + void _drawNumber( + int value, + int rightAlignX, + int startY, + List vgaImages, + ) { + const int zeroIndex = 96; + String numStr = value.toString(); + int currentX = rightAlignX - (numStr.length * 8); + + for (int i = 0; i < numStr.length; i++) { + int digit = int.parse(numStr[i]); + if (zeroIndex + digit < vgaImages.length) { + _blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY); + } + currentX += 8; + } + } + + void _drawFace(WolfEngine engine) { + int health = engine.player.health; + int faceIndex = (health <= 0) + ? 127 + : 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3); + if (faceIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164); + } + } + + void _drawWeaponIcon(WolfEngine engine) { + int weaponIndex = 89; + if (engine.player.hasChainGun) { + weaponIndex = 91; + } else if (engine.player.hasMachineGun) { + weaponIndex = 90; + } + + if (weaponIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164); + } + } +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart new file mode 100644 index 0000000..847b7f6 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart @@ -0,0 +1,265 @@ +import 'dart:math' as math; + +import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +class SoftwareRasterizer extends Rasterizer { + late FrameBuffer _buffer; + late WolfEngine _engine; + + // Intercept the base render call to store our references + @override + FrameBuffer render(WolfEngine engine) { + _engine = engine; + _buffer = engine.frameBuffer; + return super.render(engine); + } + + @override + void prepareFrame(WolfEngine engine) { + // Top half is ceiling color (25), bottom half is floor color (29) + int ceilingColor = ColorPalette.vga32Bit[25]; + int floorColor = ColorPalette.vga32Bit[29]; + + for (int y = 0; y < viewHeight; y++) { + int color = (y < viewHeight / 2) ? ceilingColor : floorColor; + for (int x = 0; x < width; x++) { + _buffer.pixels[y * width + x] = color; + } + } + } + + @override + void drawWallColumn( + int x, + int drawStart, + int drawEnd, + int columnHeight, + Sprite texture, + int texX, + double perpWallDist, + int side, + ) { + for (int y = drawStart; y < drawEnd; y++) { + // Calculate which Y pixel of the texture to sample + double relativeY = + (y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + int pixelColor = ColorPalette.vga32Bit[colorByte]; + + // Darken Y-side walls for faux directional lighting + if (side == 1) { + pixelColor = shadeColor(pixelColor); + } + + _buffer.pixels[y * width + x] = pixelColor; + } + } + + @override + void drawSpriteStripe( + int stripeX, + int drawStartY, + int drawEndY, + int spriteHeight, + Sprite texture, + int texX, + double transformY, + ) { + for ( + int y = math.max(0, drawStartY); + y < math.min(viewHeight, drawEndY); + y++ + ) { + double relativeY = (y - drawStartY) / spriteHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // 255 is the "transparent" color index in VGA Wolfenstein + if (colorByte != 255) { + _buffer.pixels[y * width + stripeX] = ColorPalette.vga32Bit[colorByte]; + } + } + } + + @override + void drawWeapon(WolfEngine engine) { + int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( + engine.data.sprites.length, + ); + Sprite weaponSprite = engine.data.sprites[spriteIndex]; + + int weaponWidth = (width * 0.5).toInt(); + int weaponHeight = (viewHeight * 0.8).toInt(); + + int startX = (width ~/ 2) - (weaponWidth ~/ 2); + int startY = + viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4); + + for (int dy = 0; dy < weaponHeight; dy++) { + for (int dx = 0; dx < weaponWidth; dx++) { + int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); + int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); + + int colorByte = weaponSprite.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int drawX = startX + dx; + int drawY = startY + dy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) { + _buffer.pixels[drawY * width + drawX] = + ColorPalette.vga32Bit[colorByte]; + } + } + } + } + } + + @override + void drawHud(WolfEngine engine) { + int statusBarIndex = engine.data.vgaImages.indexWhere( + (img) => img.width == 320 && img.height == 40, + ); + if (statusBarIndex == -1) return; + + // 1. Draw Background + _blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160); + + // 2. Draw Stats (100% mathematically accurate right-aligned coordinates) + _drawNumber(1, 32, 176, engine.data.vgaImages); // Floor + _drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); // Score + _drawNumber(3, 120, 176, engine.data.vgaImages); // Lives + _drawNumber( + engine.player.health, + 192, + 176, + engine.data.vgaImages, + ); // Health + _drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); // Ammo + + // 3. Draw BJ's Face & Current Weapon + _drawFace(engine); + _drawWeaponIcon(engine); + } + + @override + FrameBuffer finalizeFrame() { + // If the player took damage, overlay a red tint across the 3D view + if (_engine.player.damageFlash > 0) { + _applyDamageFlash(); + } + return _buffer; // Return the fully painted pixel array + } + + // =========================================================================== + // PRIVATE HELPER METHODS + // =========================================================================== + + /// Maps the planar VGA image data directly to 32-bit pixels. + /// (Assuming a 1:1 scale, which is standard for the 320x200 software renderer). + void _blitVgaImage(VgaImage image, int startX, int startY) { + int planeWidth = image.width ~/ 4; + int planeSize = planeWidth * image.height; + + for (int dy = 0; dy < image.height; dy++) { + for (int dx = 0; dx < image.width; dx++) { + int drawX = startX + dx; + int drawY = startY + dy; + + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + int srcX = dx.clamp(0, image.width - 1); + int srcY = dy.clamp(0, image.height - 1); + + int plane = srcX % 4; + int sx = srcX ~/ 4; + int index = (plane * planeSize) + (srcY * planeWidth) + sx; + + int colorByte = image.pixels[index]; + if (colorByte != 255) { + _buffer.pixels[drawY * width + drawX] = + ColorPalette.vga32Bit[colorByte]; + } + } + } + } + } + + void _drawNumber( + int value, + int rightAlignX, + int startY, + List vgaImages, + ) { + const int zeroIndex = 96; + String numStr = value.toString(); + int currentX = rightAlignX - (numStr.length * 8); + + for (int i = 0; i < numStr.length; i++) { + int digit = int.parse(numStr[i]); + if (zeroIndex + digit < vgaImages.length) { + _blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY); + } + currentX += 8; + } + } + + void _drawFace(WolfEngine engine) { + int health = engine.player.health; + int faceIndex; + + if (health <= 0) { + faceIndex = 127; // Dead face + } else { + int healthTier = ((100 - health) ~/ 16).clamp(0, 6); + faceIndex = 106 + (healthTier * 3); + } + + if (faceIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164); + } + } + + void _drawWeaponIcon(WolfEngine engine) { + int weaponIndex = 89; // Default to Pistol + + if (engine.player.hasChainGun) { + weaponIndex = 91; + } else if (engine.player.hasMachineGun) { + weaponIndex = 90; + } + + if (weaponIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164); + } + } + + /// Tints the top 80% of the screen red based on player.damageFlash intensity + void _applyDamageFlash() { + // Grab the intensity (0.0 to 1.0) + double intensity = _engine.player.damageFlash; + + // Calculate how much to boost red and drop green/blue + int redBoost = (150 * intensity).toInt(); + double colorDrop = 1.0 - (0.5 * intensity); + + for (int y = 0; y < viewHeight; y++) { + for (int x = 0; x < width; x++) { + int index = y * width + x; + int color = _buffer.pixels[index]; + + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + r = (r + redBoost).clamp(0, 255); + g = (g * colorDrop).toInt(); + b = (b * colorDrop).toInt(); + + _buffer.pixels[index] = (0xFF000000) | (b << 16) | (g << 8) | r; + } + } + } +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/src/rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/src/rasterizer.dart new file mode 100644 index 0000000..b72093e --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/src/rasterizer.dart @@ -0,0 +1,473 @@ +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 Rasterizer { + late List zBuffer; + late int width; + late int height; + late int viewHeight; + + /// 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) { + 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); + + // 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(); + + // =========================================================================== + // 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); + } + + ({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 wallTextures = engine.data.walls; + final int sceneWidth = projectionWidth; + final int sceneHeight = projectionViewHeight; + + final Map 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 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 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; + } +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/src/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/src/sixel_rasterizer.dart new file mode 100644 index 0000000..9633081 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/src/sixel_rasterizer.dart @@ -0,0 +1,423 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:wolf_3d_dart/src/rasterizer/cli_rasterizer.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +class SixelRasterizer extends CliRasterizer { + static const double _targetAspectRatio = 4 / 3; + static const int _defaultLineHeightPx = 18; + static const double _defaultCellWidthToHeight = 0.55; + static const int _minimumTerminalColumns = 117; + static const int _minimumTerminalRows = 34; + static const int _maxRenderWidth = 320; + static const int _maxRenderHeight = 240; + static const String _terminalTealBackground = '\x1b[48;2;0;150;136m'; + + late Uint8List _screen; + late WolfEngine _engine; + int _offsetColumns = 0; + int _offsetRows = 0; + int _outputWidth = 1; + int _outputHeight = 1; + bool _needsBackgroundClear = true; + + FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) { + final int previousOffsetColumns = _offsetColumns; + final int previousOffsetRows = _offsetRows; + final int previousOutputWidth = _outputWidth; + final int previousOutputHeight = _outputHeight; + + final double fitScale = math.min( + terminalBuffer.width / _minimumTerminalColumns, + terminalBuffer.height / _minimumTerminalRows, + ); + + final int targetColumns = math.max( + 1, + (_minimumTerminalColumns * fitScale).floor(), + ); + final int targetRows = math.max( + 1, + (_minimumTerminalRows * fitScale).floor(), + ); + + _offsetColumns = math.max(0, (terminalBuffer.width - targetColumns) ~/ 2); + _offsetRows = math.max(0, (terminalBuffer.height - targetRows) ~/ 2); + + final int boundsPixelWidth = math.max( + 1, + (targetColumns * _defaultLineHeightPx * _defaultCellWidthToHeight) + .floor(), + ); + final int boundsPixelHeight = math.max( + 1, + targetRows * _defaultLineHeightPx, + ); + + final double boundsAspect = boundsPixelWidth / boundsPixelHeight; + if (boundsAspect > _targetAspectRatio) { + _outputHeight = boundsPixelHeight; + _outputWidth = math.max(1, (_outputHeight * _targetAspectRatio).floor()); + } else { + _outputWidth = boundsPixelWidth; + _outputHeight = math.max(1, (_outputWidth / _targetAspectRatio).floor()); + } + + if (_offsetColumns != previousOffsetColumns || + _offsetRows != previousOffsetRows || + _outputWidth != previousOutputWidth || + _outputHeight != previousOutputHeight) { + _needsBackgroundClear = true; + } + + final double renderScale = math.min( + 1.0, + math.min( + _maxRenderWidth / _outputWidth, + _maxRenderHeight / _outputHeight, + ), + ); + final int renderWidth = math.max(1, (_outputWidth * renderScale).floor()); + final int renderHeight = math.max(1, (_outputHeight * renderScale).floor()); + + return FrameBuffer(renderWidth, renderHeight); + } + + @override + String render(WolfEngine engine) { + _engine = engine; + final FrameBuffer originalBuffer = engine.frameBuffer; + final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer); + // We only need 8-bit indices for the 256 VGA colors + _screen = Uint8List(scaledBuffer.width * scaledBuffer.height); + engine.frameBuffer = scaledBuffer; + try { + return super.render(engine); + } finally { + engine.frameBuffer = originalBuffer; + } + } + + @override + void prepareFrame(WolfEngine engine) { + // Top half is ceiling color index (25), bottom half is floor color index (29) + for (int y = 0; y < viewHeight; y++) { + int colorIndex = (y < viewHeight / 2) ? 25 : 29; + for (int x = 0; x < width; x++) { + _screen[y * width + x] = colorIndex; + } + } + } + + @override + void drawWallColumn( + int x, + int drawStart, + int drawEnd, + int columnHeight, + Sprite texture, + int texX, + double perpWallDist, + int side, + ) { + for (int y = drawStart; y < drawEnd; y++) { + double relativeY = + (y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // Note: Directional shading is omitted here to preserve strict VGA palette indices. + // Sixel uses a fixed 256-color palette, so real-time shading requires a lookup table. + _screen[y * width + x] = colorByte; + } + } + + @override + void drawSpriteStripe( + int stripeX, + int drawStartY, + int drawEndY, + int spriteHeight, + Sprite texture, + int texX, + double transformY, + ) { + for ( + int y = math.max(0, drawStartY); + y < math.min(viewHeight, drawEndY); + y++ + ) { + double relativeY = (y - drawStartY) / spriteHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // 255 is the "transparent" color index + if (colorByte != 255) { + _screen[y * width + stripeX] = colorByte; + } + } + } + + @override + void drawWeapon(WolfEngine engine) { + int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( + engine.data.sprites.length, + ); + Sprite weaponSprite = engine.data.sprites[spriteIndex]; + + int weaponWidth = (width * 0.5).toInt(); + int weaponHeight = (viewHeight * 0.8).toInt(); + + int startX = (width ~/ 2) - (weaponWidth ~/ 2); + int startY = + viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4); + + for (int dy = 0; dy < weaponHeight; dy++) { + for (int dx = 0; dx < weaponWidth; dx++) { + int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); + int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); + + int colorByte = weaponSprite.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int drawX = startX + dx; + int drawY = startY + dy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) { + _screen[drawY * width + drawX] = colorByte; + } + } + } + } + } + + @override + void drawHud(WolfEngine engine) { + int statusBarIndex = engine.data.vgaImages.indexWhere( + (img) => img.width == 320 && img.height == 40, + ); + if (statusBarIndex == -1) return; + + _blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160); + + _drawNumber(1, 32, 176, engine.data.vgaImages); + _drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); + _drawNumber(3, 120, 176, engine.data.vgaImages); + _drawNumber(engine.player.health, 192, 176, engine.data.vgaImages); + _drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); + + _drawFace(engine); + _drawWeaponIcon(engine); + } + + @override + String finalizeFrame() { + final String clearPrefix = _needsBackgroundClear + ? '$_terminalTealBackground\x1b[2J\x1b[0m' + : ''; + _needsBackgroundClear = false; + return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}'; + } + + // =========================================================================== + // SIXEL ENCODER + // =========================================================================== + + /// Converts the 8-bit index buffer into a standard Sixel sequence + String toSixelString() { + StringBuffer sb = StringBuffer(); + + // Start Sixel sequence (q = Sixel format) + sb.write('\x1bPq'); + + // 1. Define the Palette (and apply damage flash directly to the palette!) + double damageIntensity = _engine.player.damageFlash; + int redBoost = (150 * damageIntensity).toInt(); + double colorDrop = 1.0 - (0.5 * damageIntensity); + + for (int i = 0; i < 256; i++) { + int color = ColorPalette.vga32Bit[i]; + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + if (damageIntensity > 0) { + r = (r + redBoost).clamp(0, 255); + g = (g * colorDrop).toInt().clamp(0, 255); + b = (b * colorDrop).toInt().clamp(0, 255); + } + + // Sixel RGB ranges from 0 to 100 + int sixelR = (r * 100) ~/ 255; + int sixelG = (g * 100) ~/ 255; + int sixelB = (b * 100) ~/ 255; + + sb.write('#$i;2;$sixelR;$sixelG;$sixelB'); + } + + // 2. Encode scaled image in 6-pixel vertical bands. + for (int band = 0; band < _outputHeight; band += 6) { + Map colorMap = {}; + + // Map out which pixels use which color in this 6px high band + for (int x = 0; x < _outputWidth; x++) { + for (int yOffset = 0; yOffset < 6; yOffset++) { + int y = band + yOffset; + if (y >= _outputHeight) break; + + int colorIdx = _sampleScaledPixel(x, y); + if (!colorMap.containsKey(colorIdx)) { + colorMap[colorIdx] = Uint8List(_outputWidth); + } + // Set the bit corresponding to the vertical position (0-5) + colorMap[colorIdx]![x] |= (1 << yOffset); + } + } + + // Write the encoded Sixel characters for each color present in the band + bool firstColor = true; + for (var entry in colorMap.entries) { + if (!firstColor) { + // Carriage return to overlay colors on the same band + sb.write('\$'); + } + firstColor = false; + + // Select color index + sb.write('#${entry.key}'); + + Uint8List cols = entry.value; + int currentVal = -1; + int runLength = 0; + + // Run-Length Encoding (RLE) loop + for (int x = 0; x < _outputWidth; x++) { + int val = cols[x]; + if (val == currentVal) { + runLength++; + } else { + if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); + currentVal = val; + runLength = 1; + } + } + if (runLength > 0) _writeSixelRle(sb, currentVal, runLength); + } + + if (band + 6 < _outputHeight) { + sb.write('-'); + } + } + + // End Sixel sequence + sb.write('\x1b\\'); + return sb.toString(); + } + + int _sampleScaledPixel(int outX, int outY) { + final int srcX = ((((outX + 0.5) * width) / _outputWidth) - 0.5) + .round() + .clamp( + 0, + width - 1, + ); + final int srcY = ((((outY + 0.5) * height) / _outputHeight) - 0.5) + .round() + .clamp( + 0, + height - 1, + ); + return _screen[srcY * width + srcX]; + } + + void _writeSixelRle(StringBuffer sb, int value, int runLength) { + String char = String.fromCharCode(value + 63); + // Sixel RLE format: ! (only worth it if count > 3) + if (runLength > 3) { + sb.write('!$runLength$char'); + } else { + sb.write(char * runLength); + } + } + + // =========================================================================== + // PRIVATE HUD HELPERS (Adapted for 8-bit index buffer) + // =========================================================================== + + void _blitVgaImage(VgaImage image, int startX, int startY) { + int planeWidth = image.width ~/ 4; + int planeSize = planeWidth * image.height; + final double scaleX = width / 320.0; + final double scaleY = height / 200.0; + + final int destStartX = (startX * scaleX).toInt(); + final int destStartY = (startY * scaleY).toInt(); + final int destWidth = math.max(1, (image.width * scaleX).toInt()); + final int destHeight = math.max(1, (image.height * scaleY).toInt()); + + for (int dy = 0; dy < destHeight; dy++) { + for (int dx = 0; dx < destWidth; dx++) { + int drawX = destStartX + dx; + int drawY = destStartY + dy; + + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + int srcX = (dx / scaleX).toInt().clamp(0, image.width - 1); + int srcY = (dy / scaleY).toInt().clamp(0, image.height - 1); + + int plane = srcX % 4; + int sx = srcX ~/ 4; + int index = (plane * planeSize) + (srcY * planeWidth) + sx; + + int colorByte = image.pixels[index]; + if (colorByte != 255) { + _screen[drawY * width + drawX] = colorByte; + } + } + } + } + } + + void _drawNumber( + int value, + int rightAlignX, + int startY, + List vgaImages, + ) { + const int zeroIndex = 96; + String numStr = value.toString(); + int currentX = rightAlignX - (numStr.length * 8); + + for (int i = 0; i < numStr.length; i++) { + int digit = int.parse(numStr[i]); + if (zeroIndex + digit < vgaImages.length) { + _blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY); + } + currentX += 8; + } + } + + void _drawFace(WolfEngine engine) { + int health = engine.player.health; + int faceIndex = (health <= 0) + ? 127 + : 106 + (((100 - health) ~/ 16).clamp(0, 6) * 3); + if (faceIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164); + } + } + + void _drawWeaponIcon(WolfEngine engine) { + int weaponIndex = 89; + if (engine.player.hasChainGun) { + weaponIndex = 91; + } else if (engine.player.hasMachineGun) { + weaponIndex = 90; + } + + if (weaponIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164); + } + } +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/src/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/src/software_rasterizer.dart new file mode 100644 index 0000000..847b7f6 --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/rasterizer/src/software_rasterizer.dart @@ -0,0 +1,265 @@ +import 'dart:math' as math; + +import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +class SoftwareRasterizer extends Rasterizer { + late FrameBuffer _buffer; + late WolfEngine _engine; + + // Intercept the base render call to store our references + @override + FrameBuffer render(WolfEngine engine) { + _engine = engine; + _buffer = engine.frameBuffer; + return super.render(engine); + } + + @override + void prepareFrame(WolfEngine engine) { + // Top half is ceiling color (25), bottom half is floor color (29) + int ceilingColor = ColorPalette.vga32Bit[25]; + int floorColor = ColorPalette.vga32Bit[29]; + + for (int y = 0; y < viewHeight; y++) { + int color = (y < viewHeight / 2) ? ceilingColor : floorColor; + for (int x = 0; x < width; x++) { + _buffer.pixels[y * width + x] = color; + } + } + } + + @override + void drawWallColumn( + int x, + int drawStart, + int drawEnd, + int columnHeight, + Sprite texture, + int texX, + double perpWallDist, + int side, + ) { + for (int y = drawStart; y < drawEnd; y++) { + // Calculate which Y pixel of the texture to sample + double relativeY = + (y - (-columnHeight ~/ 2 + viewHeight ~/ 2)) / columnHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + int pixelColor = ColorPalette.vga32Bit[colorByte]; + + // Darken Y-side walls for faux directional lighting + if (side == 1) { + pixelColor = shadeColor(pixelColor); + } + + _buffer.pixels[y * width + x] = pixelColor; + } + } + + @override + void drawSpriteStripe( + int stripeX, + int drawStartY, + int drawEndY, + int spriteHeight, + Sprite texture, + int texX, + double transformY, + ) { + for ( + int y = math.max(0, drawStartY); + y < math.min(viewHeight, drawEndY); + y++ + ) { + double relativeY = (y - drawStartY) / spriteHeight; + int texY = (relativeY * 64).toInt().clamp(0, 63); + + int colorByte = texture.pixels[texX * 64 + texY]; + + // 255 is the "transparent" color index in VGA Wolfenstein + if (colorByte != 255) { + _buffer.pixels[y * width + stripeX] = ColorPalette.vga32Bit[colorByte]; + } + } + } + + @override + void drawWeapon(WolfEngine engine) { + int spriteIndex = engine.player.currentWeapon.getCurrentSpriteIndex( + engine.data.sprites.length, + ); + Sprite weaponSprite = engine.data.sprites[spriteIndex]; + + int weaponWidth = (width * 0.5).toInt(); + int weaponHeight = (viewHeight * 0.8).toInt(); + + int startX = (width ~/ 2) - (weaponWidth ~/ 2); + int startY = + viewHeight - weaponHeight + (engine.player.weaponAnimOffset ~/ 4); + + for (int dy = 0; dy < weaponHeight; dy++) { + for (int dx = 0; dx < weaponWidth; dx++) { + int texX = (dx * 64 ~/ weaponWidth).clamp(0, 63); + int texY = (dy * 64 ~/ weaponHeight).clamp(0, 63); + + int colorByte = weaponSprite.pixels[texX * 64 + texY]; + if (colorByte != 255) { + int drawX = startX + dx; + int drawY = startY + dy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < viewHeight) { + _buffer.pixels[drawY * width + drawX] = + ColorPalette.vga32Bit[colorByte]; + } + } + } + } + } + + @override + void drawHud(WolfEngine engine) { + int statusBarIndex = engine.data.vgaImages.indexWhere( + (img) => img.width == 320 && img.height == 40, + ); + if (statusBarIndex == -1) return; + + // 1. Draw Background + _blitVgaImage(engine.data.vgaImages[statusBarIndex], 0, 160); + + // 2. Draw Stats (100% mathematically accurate right-aligned coordinates) + _drawNumber(1, 32, 176, engine.data.vgaImages); // Floor + _drawNumber(engine.player.score, 96, 176, engine.data.vgaImages); // Score + _drawNumber(3, 120, 176, engine.data.vgaImages); // Lives + _drawNumber( + engine.player.health, + 192, + 176, + engine.data.vgaImages, + ); // Health + _drawNumber(engine.player.ammo, 232, 176, engine.data.vgaImages); // Ammo + + // 3. Draw BJ's Face & Current Weapon + _drawFace(engine); + _drawWeaponIcon(engine); + } + + @override + FrameBuffer finalizeFrame() { + // If the player took damage, overlay a red tint across the 3D view + if (_engine.player.damageFlash > 0) { + _applyDamageFlash(); + } + return _buffer; // Return the fully painted pixel array + } + + // =========================================================================== + // PRIVATE HELPER METHODS + // =========================================================================== + + /// Maps the planar VGA image data directly to 32-bit pixels. + /// (Assuming a 1:1 scale, which is standard for the 320x200 software renderer). + void _blitVgaImage(VgaImage image, int startX, int startY) { + int planeWidth = image.width ~/ 4; + int planeSize = planeWidth * image.height; + + for (int dy = 0; dy < image.height; dy++) { + for (int dx = 0; dx < image.width; dx++) { + int drawX = startX + dx; + int drawY = startY + dy; + + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + int srcX = dx.clamp(0, image.width - 1); + int srcY = dy.clamp(0, image.height - 1); + + int plane = srcX % 4; + int sx = srcX ~/ 4; + int index = (plane * planeSize) + (srcY * planeWidth) + sx; + + int colorByte = image.pixels[index]; + if (colorByte != 255) { + _buffer.pixels[drawY * width + drawX] = + ColorPalette.vga32Bit[colorByte]; + } + } + } + } + } + + void _drawNumber( + int value, + int rightAlignX, + int startY, + List vgaImages, + ) { + const int zeroIndex = 96; + String numStr = value.toString(); + int currentX = rightAlignX - (numStr.length * 8); + + for (int i = 0; i < numStr.length; i++) { + int digit = int.parse(numStr[i]); + if (zeroIndex + digit < vgaImages.length) { + _blitVgaImage(vgaImages[zeroIndex + digit], currentX, startY); + } + currentX += 8; + } + } + + void _drawFace(WolfEngine engine) { + int health = engine.player.health; + int faceIndex; + + if (health <= 0) { + faceIndex = 127; // Dead face + } else { + int healthTier = ((100 - health) ~/ 16).clamp(0, 6); + faceIndex = 106 + (healthTier * 3); + } + + if (faceIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[faceIndex], 136, 164); + } + } + + void _drawWeaponIcon(WolfEngine engine) { + int weaponIndex = 89; // Default to Pistol + + if (engine.player.hasChainGun) { + weaponIndex = 91; + } else if (engine.player.hasMachineGun) { + weaponIndex = 90; + } + + if (weaponIndex < engine.data.vgaImages.length) { + _blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164); + } + } + + /// Tints the top 80% of the screen red based on player.damageFlash intensity + void _applyDamageFlash() { + // Grab the intensity (0.0 to 1.0) + double intensity = _engine.player.damageFlash; + + // Calculate how much to boost red and drop green/blue + int redBoost = (150 * intensity).toInt(); + double colorDrop = 1.0 - (0.5 * intensity); + + for (int y = 0; y < viewHeight; y++) { + for (int x = 0; x < width; x++) { + int index = y * width + x; + int color = _buffer.pixels[index]; + + int r = color & 0xFF; + int g = (color >> 8) & 0xFF; + int b = (color >> 16) & 0xFF; + + r = (r + redBoost).clamp(0, 255); + g = (g * colorDrop).toInt(); + b = (b * colorDrop).toInt(); + + _buffer.pixels[index] = (0xFF000000) | (b << 16) | (g << 8) | r; + } + } + } +} diff --git a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart index 47aadd2..396b499 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_engine.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_engine.dart @@ -9,10 +9,4 @@ export 'src/engine/input/engine_input.dart'; export 'src/engine/managers/door_manager.dart'; export 'src/engine/managers/pushwall_manager.dart'; export 'src/engine/player/player.dart'; -export 'src/engine/rasterizer/ascii_rasterizer.dart' - show AsciiRasterizer, ColoredChar; -export 'src/engine/rasterizer/cli_rasterizer.dart'; -export 'src/engine/rasterizer/rasterizer.dart'; -export 'src/engine/rasterizer/sixel_rasterizer.dart'; -export 'src/engine/rasterizer/software_rasterizer.dart'; export 'src/engine/wolf_3d_engine_base.dart'; diff --git a/packages/wolf_3d_dart/lib/wolf_3d_rasterizer.dart b/packages/wolf_3d_dart/lib/wolf_3d_rasterizer.dart new file mode 100644 index 0000000..654e592 --- /dev/null +++ b/packages/wolf_3d_dart/lib/wolf_3d_rasterizer.dart @@ -0,0 +1,7 @@ +library; + +export 'src/rasterizer/ascii_rasterizer.dart' show AsciiRasterizer, ColoredChar; +export 'src/rasterizer/cli_rasterizer.dart'; +export 'src/rasterizer/rasterizer.dart'; +export 'src/rasterizer/sixel_rasterizer.dart'; +export 'src/rasterizer/software_rasterizer.dart'; diff --git a/packages/wolf_3d_dart/test/rasterizer/pushwall_rasterizer_test.dart b/packages/wolf_3d_dart/test/rasterizer/pushwall_rasterizer_test.dart new file mode 100644 index 0000000..ef70fc3 --- /dev/null +++ b/packages/wolf_3d_dart/test/rasterizer/pushwall_rasterizer_test.dart @@ -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_rasterizer.dart'; + +void main() { + group('Pushwall rasterization', () { + 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 = SoftwareRasterizer().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))); +} diff --git a/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart b/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart index 2a90eb8..0adbc66 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:wolf_3d_dart/wolf_3d_engine.dart'; +import 'package:wolf_3d_dart/wolf_3d_rasterizer.dart'; import 'package:wolf_3d_renderer/base_renderer.dart'; class WolfAsciiRenderer extends BaseWolfRenderer { diff --git a/packages/wolf_3d_renderer/lib/wolf_3d_flutter_renderer.dart b/packages/wolf_3d_renderer/lib/wolf_3d_flutter_renderer.dart index b90a76e..9eb5990 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_flutter_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_flutter_renderer.dart @@ -2,7 +2,7 @@ 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_engine.dart'; +import 'package:wolf_3d_dart/wolf_3d_rasterizer.dart'; import 'package:wolf_3d_renderer/base_renderer.dart'; import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart';