diff --git a/apps/wolf_3d_cli/bin/main.dart b/apps/wolf_3d_cli/bin/main.dart index c502eb9..d5f9a90 100644 --- a/apps/wolf_3d_cli/bin/main.dart +++ b/apps/wolf_3d_cli/bin/main.dart @@ -46,7 +46,6 @@ void main() async { final engine = WolfEngine( data: availableGames.values.first, - difficulty: Difficulty.medium, startingEpisode: 0, frameBuffer: FrameBuffer( stdout.terminalColumns, diff --git a/apps/wolf_3d_cli/lib/cli_game_loop.dart b/apps/wolf_3d_cli/lib/cli_game_loop.dart index fd74720..09d46a8 100644 --- a/apps/wolf_3d_cli/lib/cli_game_loop.dart +++ b/apps/wolf_3d_cli/lib/cli_game_loop.dart @@ -106,7 +106,8 @@ class CliGameLoop { } void _handleInput(List bytes) { - if (bytes.contains(113) || bytes.contains(27)) { + // Keep q and Ctrl+C as hard exits; ESC is now menu-back input. + if (bytes.contains(113) || bytes.contains(3)) { stop(); onExit(0); return; diff --git a/apps/wolf_3d_gui/lib/screens/difficulty_screen.dart b/apps/wolf_3d_gui/lib/screens/difficulty_screen.dart deleted file mode 100644 index c9dfe89..0000000 --- a/apps/wolf_3d_gui/lib/screens/difficulty_screen.dart +++ /dev/null @@ -1,104 +0,0 @@ -/// Difficulty picker shown after the player chooses an episode. -library; - -import 'package:flutter/material.dart'; -import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; -import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; -import 'package:wolf_3d_gui/screens/game_screen.dart'; - -/// Starts a new game session using the active game and episode from [Wolf3d]. -class DifficultyScreen extends StatefulWidget { - /// Shared application facade carrying the active game, input, and audio. - final Wolf3d wolf3d; - - /// Creates the difficulty-selection screen for [wolf3d]. - const DifficultyScreen({ - super.key, - required this.wolf3d, - }); - - @override - State createState() => _DifficultyScreenState(); -} - -class _DifficultyScreenState extends State { - bool get isShareware => - widget.wolf3d.activeGame.version == GameVersion.shareware; - - @override - void dispose() { - widget.wolf3d.audio.stopMusic(); - super.dispose(); - } - - /// Replaces the menu flow with an active [GameScreen] using [difficulty]. - void _startGame(Difficulty difficulty, {bool showGallery = false}) { - widget.wolf3d.audio.stopMusic(); - - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (context) => GameScreen( - data: widget.wolf3d.activeGame, - difficulty: difficulty, - startingEpisode: widget.wolf3d.activeEpisode, - audio: widget.wolf3d.audio, - input: widget.wolf3d.input, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.black, - floatingActionButton: FloatingActionButton( - backgroundColor: Colors.red[900], - onPressed: () => _startGame(Difficulty.medium, showGallery: true), - child: const Icon(Icons.bug_report, color: Colors.white), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - 'HOW TOUGH ARE YOU?', - style: TextStyle( - color: Colors.red, - fontSize: 32, - fontWeight: FontWeight.bold, - fontFamily: 'Courier', - ), - ), - const SizedBox(height: 40), - ListView.builder( - shrinkWrap: true, - itemCount: Difficulty.values.length, - itemBuilder: (context, index) { - final Difficulty difficulty = Difficulty.values[index]; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueGrey[900], - foregroundColor: Colors.white, - minimumSize: const Size(300, 50), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - ), - onPressed: () => _startGame(difficulty), - child: Text( - difficulty.title, - style: const TextStyle(fontSize: 18), - ), - ), - ); - }, - ), - ], - ), - ), - ); - } -} diff --git a/apps/wolf_3d_gui/lib/screens/episode_screen.dart b/apps/wolf_3d_gui/lib/screens/episode_screen.dart index 1361652..2309315 100644 --- a/apps/wolf_3d_gui/lib/screens/episode_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/episode_screen.dart @@ -4,7 +4,7 @@ library; import 'package:flutter/material.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; -import 'package:wolf_3d_gui/screens/difficulty_screen.dart'; +import 'package:wolf_3d_gui/screens/game_screen.dart'; import 'package:wolf_3d_gui/screens/sprite_gallery.dart'; import 'package:wolf_3d_gui/screens/vga_gallery.dart'; @@ -27,12 +27,13 @@ class _EpisodeScreenState extends State { widget.wolf3d.audio.playMenuMusic(); } - /// Persists the chosen episode in [Wolf3d] and advances to difficulty select. + /// Persists the chosen episode and lets the engine present difficulty select. void _selectEpisode(int index) { widget.wolf3d.setActiveEpisode(index); + widget.wolf3d.clearActiveDifficulty(); Navigator.of(context).push( MaterialPageRoute( - builder: (context) => DifficultyScreen(wolf3d: widget.wolf3d), + builder: (context) => GameScreen(wolf3d: widget.wolf3d), ), ); } diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index bd39271..2b6358a 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -3,36 +3,19 @@ library; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; -import 'package:wolf_3d_flutter/wolf_3d_input_flutter.dart'; +import 'package:wolf_3d_flutter/wolf_3d_flutter.dart'; import 'package:wolf_3d_renderer/wolf_3d_ascii_renderer.dart'; import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart'; -/// Owns a [WolfEngine] instance and exposes renderer/input integrations to Flutter. +/// Launches a [WolfEngine] via [Wolf3d] and exposes renderer/input integrations. class GameScreen extends StatefulWidget { - /// Fully parsed game data for the selected version. - final WolfensteinData data; + /// Shared application facade owning the engine, audio, and input. + final Wolf3d wolf3d; - /// Difficulty applied when creating the engine session. - final Difficulty difficulty; - - /// Episode index used as the starting world. - final int startingEpisode; - - /// Shared audio backend reused across menu and gameplay screens. - final EngineAudio audio; - - /// Flutter input adapter that translates widget events into engine input. - final Wolf3dFlutterInput input; - - /// Creates a gameplay screen with the supplied game session configuration. + /// Creates a gameplay screen driven by [wolf3d]. const GameScreen({ - required this.data, - required this.difficulty, - required this.startingEpisode, - required this.audio, - required this.input, + required this.wolf3d, super.key, }); @@ -47,90 +30,147 @@ class _GameScreenState extends State { @override void initState() { super.initState(); - _engine = WolfEngine( - data: widget.data, - difficulty: widget.difficulty, - startingEpisode: widget.startingEpisode, - frameBuffer: FrameBuffer(320, 200), - audio: widget.audio, - input: widget.input, + _engine = widget.wolf3d.launchEngine( onGameWon: () => Navigator.of(context).pop(), ); - _engine.init(); } @override Widget build(BuildContext context) { - return Scaffold( - body: Listener( - onPointerDown: widget.input.onPointerDown, - onPointerUp: widget.input.onPointerUp, - onPointerMove: widget.input.onPointerMove, - onPointerHover: widget.input.onPointerMove, - child: Stack( - children: [ - // Keep both renderers behind the same engine so mode switching does - // not reset level state or audio playback. - _useAsciiMode - ? WolfAsciiRenderer(engine: _engine) - : WolfFlutterRenderer(engine: _engine), + return WillPopScope( + onWillPop: () async { + if (_engine.isDifficultySelectionPending) { + widget.wolf3d.input.queueBackAction(); + return false; + } + return true; + }, + child: Scaffold( + body: LayoutBuilder( + builder: (context, constraints) { + final viewportRect = _menuViewportRect( + Size(constraints.maxWidth, constraints.maxHeight), + ); - if (!_engine.isInitialized) - Container( - color: Colors.black, - child: const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(color: Colors.teal), - SizedBox(height: 20), - Text( - "GET PSYCHED!", - style: TextStyle( - color: Colors.teal, - fontFamily: 'monospace', + return Listener( + onPointerDown: (event) { + widget.wolf3d.input.onPointerDown(event); + if (_engine.isDifficultySelectionPending && + viewportRect.width > 0 && + viewportRect.height > 0 && + viewportRect.contains(event.localPosition)) { + final normalizedX = + (event.localPosition.dx - viewportRect.left) / + viewportRect.width; + final normalizedY = + (event.localPosition.dy - viewportRect.top) / + viewportRect.height; + widget.wolf3d.input.queueMenuTap( + x: normalizedX, + y: normalizedY, + ); + } + }, + onPointerUp: widget.wolf3d.input.onPointerUp, + onPointerMove: widget.wolf3d.input.onPointerMove, + onPointerHover: widget.wolf3d.input.onPointerMove, + child: Stack( + children: [ + // Keep both renderers behind the same engine so mode switching does + // not reset level state or audio playback. + _useAsciiMode + ? WolfAsciiRenderer(engine: _engine) + : WolfFlutterRenderer(engine: _engine), + + if (!_engine.isInitialized) + Container( + color: Colors.black, + child: const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(color: Colors.teal), + SizedBox(height: 20), + Text( + "GET PSYCHED!", + style: TextStyle( + color: Colors.teal, + fontFamily: 'monospace', + ), + ), + ], ), ), - ], + ), + + // Tab toggles the renderer implementation for quick visual debugging. + Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.tab) { + setState(() => _useAsciiMode = !_useAsciiMode); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: const SizedBox.shrink(), ), - ), - ), - // Tab toggles the renderer implementation for quick visual debugging. - Focus( - autofocus: true, - onKeyEvent: (node, event) { - if (event is KeyDownEvent && - event.logicalKey == LogicalKeyboardKey.tab) { - setState(() => _useAsciiMode = !_useAsciiMode); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: const SizedBox.shrink(), - ), + // A second full-screen overlay keeps the presentation simple while + // the engine is still warming up or decoding the first frame. + if (!_engine.isInitialized) + Container( + color: Colors.black, + child: const Center( + child: CircularProgressIndicator(color: Colors.teal), + ), + ), - // A second full-screen overlay keeps the presentation simple while - // the engine is still warming up or decoding the first frame. - if (!_engine.isInitialized) - Container( - color: Colors.black, - child: const Center( - child: CircularProgressIndicator(color: Colors.teal), - ), + Positioned( + top: 16, + right: 16, + child: Text( + 'TAB: Swap Renderer', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + ), + ), + ), + ], ), - - Positioned( - top: 16, - right: 16, - child: Text( - 'TAB: Swap Renderer', - style: TextStyle(color: Colors.white.withValues(alpha: 0.5)), - ), - ), - ], + ); + }, ), ), ); } + + Rect _menuViewportRect(Size availableSize) { + if (availableSize.width <= 0 || availableSize.height <= 0) { + return Rect.zero; + } + + const double aspect = 4 / 3; + final double outerPadding = _useAsciiMode ? 0.0 : 16.0; + final double maxWidth = (availableSize.width - (outerPadding * 2)).clamp( + 1.0, + double.infinity, + ); + final double maxHeight = (availableSize.height - (outerPadding * 2)).clamp( + 1.0, + double.infinity, + ); + + double viewportWidth = maxWidth; + double viewportHeight = viewportWidth / aspect; + if (viewportHeight > maxHeight) { + viewportHeight = maxHeight; + viewportWidth = viewportHeight * aspect; + } + + final double left = (availableSize.width - viewportWidth) / 2; + final double top = (availableSize.height - viewportHeight) / 2; + return Rect.fromLTWH(left, top, viewportWidth, viewportHeight); + } } diff --git a/apps/wolf_3d_gui/lib/screens/wolf_menu_shell.dart b/apps/wolf_3d_gui/lib/screens/wolf_menu_shell.dart new file mode 100644 index 0000000..90af24f --- /dev/null +++ b/apps/wolf_3d_gui/lib/screens/wolf_menu_shell.dart @@ -0,0 +1,95 @@ +/// Shared shell for Wolf3D-style menu screens. +library; + +import 'package:flutter/material.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_renderer/wolf_3d_asset_painter.dart'; + +/// Provides a common menu layout with panel framing and optional bottom art. +class WolfMenuShell extends StatelessWidget { + /// Full-screen background color behind the panel. + final Color backgroundColor; + + /// Solid panel fill used for the menu content area. + final Color panelColor; + + /// Optional heading shown above the panel (text or image). + final Widget? header; + + /// Primary menu content rendered inside the panel. + final Widget panelChild; + + /// Optional centered VGA image anchored near the bottom of the screen. + final VgaImage? bottomSprite; + + /// Width of the menu panel. + final double panelWidth; + + /// Padding applied around [panelChild] inside the panel. + final EdgeInsets panelPadding; + + /// Scale factor for [bottomSprite]. + final double bottomSpriteScale; + + /// Distance from the bottom edge for [bottomSprite]. + final double bottomOffset; + + /// Vertical spacing between [header] and the panel. + final double headerSpacing; + + const WolfMenuShell({ + super.key, + required this.backgroundColor, + required this.panelColor, + required this.panelChild, + this.header, + this.bottomSprite, + this.panelWidth = 520, + this.panelPadding = const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + this.bottomSpriteScale = 3, + this.bottomOffset = 20, + this.headerSpacing = 14, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill( + child: ColoredBox(color: backgroundColor), + ), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ?header, + if (header != null) SizedBox(height: headerSpacing), + Container( + width: panelWidth, + padding: panelPadding, + color: panelColor, + child: panelChild, + ), + ], + ), + ), + if (bottomSprite != null) + Positioned( + left: 0, + right: 0, + bottom: bottomOffset, + child: Center( + child: SizedBox( + width: bottomSprite!.width * bottomSpriteScale, + height: bottomSprite!.height * bottomSpriteScale, + child: WolfAssetPainter.vga(bottomSprite), + ), + ), + ), + ], + ); + } +} diff --git a/packages/wolf_3d_dart/lib/src/engine/input/engine_input.dart b/packages/wolf_3d_dart/lib/src/engine/input/engine_input.dart index 64bbe85..e960474 100644 --- a/packages/wolf_3d_dart/lib/src/engine/input/engine_input.dart +++ b/packages/wolf_3d_dart/lib/src/engine/input/engine_input.dart @@ -8,6 +8,9 @@ class EngineInput { final bool isTurningRight; final bool isFiring; final bool isInteracting; + final bool isBack; + final double? menuTapX; + final double? menuTapY; final WeaponType? requestedWeapon; const EngineInput({ @@ -17,6 +20,9 @@ class EngineInput { this.isTurningRight = false, this.isFiring = false, this.isInteracting = false, + this.isBack = false, + this.menuTapX, + this.menuTapY, this.requestedWeapon, }); } 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 deleted file mode 100644 index 7b80c4c..0000000 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/ascii_rasterizer.dart +++ /dev/null @@ -1,837 +0,0 @@ -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'; - -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/engine/rasterizer/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart deleted file mode 100644 index 9633081..0000000 --- a/packages/wolf_3d_dart/lib/src/engine/rasterizer/sixel_rasterizer.dart +++ /dev/null @@ -1,423 +0,0 @@ -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/engine/rasterizer/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/engine/rasterizer/software_rasterizer.dart index 847b7f6..5442f6b 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 @@ -3,8 +3,39 @@ 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'; +import 'package:wolf_3d_dart/wolf_3d_menu.dart'; class SoftwareRasterizer extends Rasterizer { + static const Map> _menuFont = { + 'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], + 'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], + 'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], + 'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], + 'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], + 'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], + 'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], + 'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + 'I': ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], + 'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], + 'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], + 'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], + 'N': ['10001', '10001', '11001', '10101', '10011', '10001', '10001'], + 'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], + 'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + 'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], + 'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], + 'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + 'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], + 'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'], + 'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], + '?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'], + '!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], + ',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], + '.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], + "'": ['00100', '00100', '00100', '00000', '00000', '00000', '00000'], + ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], + }; + late FrameBuffer _buffer; late WolfEngine _engine; @@ -145,10 +176,126 @@ class SoftwareRasterizer extends Rasterizer { _drawWeaponIcon(engine); } + @override + void drawMenu(WolfEngine engine) { + final int bgColor = ColorPalette.vga32Bit[153]; + final int panelColor = ColorPalette.vga32Bit[157]; + final int headingColor = ColorPalette.vga32Bit[119]; + final int selectedTextColor = ColorPalette.vga32Bit[19]; + final int unselectedTextColor = ColorPalette.vga32Bit[23]; + + for (int i = 0; i < _buffer.pixels.length; i++) { + _buffer.pixels[i] = bgColor; + } + + const panelX = 28; + const panelY = 70; + const panelW = 264; + const panelH = 82; + + for (int y = panelY; y < panelY + panelH; y++) { + if (y < 0 || y >= height) continue; + final rowStart = y * width; + for (int x = panelX; x < panelX + panelW; x++) { + if (x >= 0 && x < width) { + _buffer.pixels[rowStart + x] = panelColor; + } + } + } + + final art = WolfClassicMenuArt(engine.data); + _drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingColor, scale: 2); + + final bottom = art.pic(15); + if (bottom != null) { + final x = (width - bottom.width) ~/ 2; + final y = height - bottom.height - 8; + _blitVgaImage(bottom, x, y); + } + + final face = art.difficultyOption( + Difficulty.values[engine.menuSelectedDifficultyIndex], + ); + if (face != null) { + _blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22); + } + + final cursor = art.pic(engine.isMenuCursorAltFrame ? 9 : 8); + const rowYStart = panelY + 16; + const rowStep = 15; + const textX = panelX + 42; + const labels = [ + 'CAN I PLAY, DADDY?', + "DON'T HURT ME.", + "BRING 'EM ON!", + 'I AM DEATH INCARNATE!', + ]; + + for (int i = 0; i < Difficulty.values.length; i++) { + final y = rowYStart + (i * rowStep); + final isSelected = i == engine.menuSelectedDifficultyIndex; + + if (isSelected && cursor != null) { + _blitVgaImage(cursor, panelX + 10, y - 2); + } + + _drawMenuText( + labels[i], + textX, + y, + isSelected ? selectedTextColor : unselectedTextColor, + ); + } + } + + void _drawMenuText( + String text, + int startX, + int startY, + int color, { + int scale = 1, + }) { + int x = startX; + for (final rune in text.runes) { + final char = String.fromCharCode(rune).toUpperCase(); + final pattern = _menuFont[char] ?? _menuFont[' ']!; + + for (int row = 0; row < pattern.length; row++) { + final bits = pattern[row]; + for (int col = 0; col < bits.length; col++) { + if (bits[col] != '1') continue; + for (int sy = 0; sy < scale; sy++) { + for (int sx = 0; sx < scale; sx++) { + final drawX = x + (col * scale) + sx; + final drawY = startY + (row * scale) + sy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + _buffer.pixels[drawY * width + drawX] = color; + } + } + } + } + } + + x += (6 * scale); + } + } + + void _drawMenuTextCentered( + String text, + int y, + int color, { + int scale = 1, + }) { + final textWidth = text.length * 6 * scale; + final x = ((width - textWidth) ~/ 2).clamp(0, width - 1); + _drawMenuText(text, x, y, color, scale: scale); + } + @override FrameBuffer finalizeFrame() { // If the player took damage, overlay a red tint across the 3D view - if (_engine.player.damageFlash > 0) { + if (!_engine.isDifficultySelectionPending && + _engine.player.damageFlash > 0) { _applyDamageFlash(); } return _buffer; // Return the fully painted pixel array 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 55f0b5e..fa652b5 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 @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:wolf_3d_dart/src/menu/menu_manager.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_entities.dart'; @@ -13,11 +14,13 @@ import 'package:wolf_3d_dart/wolf_3d_input.dart'; class WolfEngine { WolfEngine({ required this.data, - required this.difficulty, required this.startingEpisode, required this.onGameWon, required this.input, required this.frameBuffer, + this.difficulty, + this.menuBackgroundRgb = 0x890000, + this.menuPanelRgb = 0x590002, EngineAudio? audio, }) : audio = audio ?? CliSilentAudio(), doorManager = DoorManager( @@ -33,8 +36,26 @@ class WolfEngine { /// The static game data (textures, sounds, maps) parsed from original files. final WolfensteinData data; + /// Desired menu background color in 24-bit RGB. + final int menuBackgroundRgb; + + /// Desired menu panel color in 24-bit RGB. + final int menuPanelRgb; + /// The active difficulty level, affecting enemy spawning and behavior. - final Difficulty difficulty; + Difficulty? difficulty; + + /// Whether the engine is waiting on player difficulty selection. + bool get isDifficultySelectionPending => difficulty == null; + + /// Menu state owner for difficulty-selection navigation and edge detection. + final MenuManager menuManager = MenuManager(); + + /// Cursor index used by renderer-side difficulty menus. + int get menuSelectedDifficultyIndex => menuManager.selectedDifficultyIndex; + + /// Cursor blink phase used by renderer-side difficulty menus. + bool get isMenuCursorAltFrame => menuManager.isCursorAltFrame(_timeAliveMs); /// The episode index where the game session begins. final int startingEpisode; @@ -62,7 +83,11 @@ class WolfEngine { // --- World State --- /// The player's current position, stats, and inventory. - late Player player; + /// + /// This starts with a safe placeholder so menu-mode rendering/input can + /// access player fields (for example damage flash state) before a map is + /// loaded. `_loadLevel()` replaces it with the true map spawn. + Player player = Player(x: 1.5, y: 1.5, angle: 0.0); /// The mutable 64x64 grid representing the current world. /// This grid is modified in real-time by doors and pushwalls. @@ -91,7 +116,13 @@ class WolfEngine { audio.activeGame = data; _currentEpisodeIndex = startingEpisode; _currentLevelIndex = 0; - _loadLevel(); + + menuManager.beginDifficultySelection(initialDifficulty: difficulty); + + if (!isDifficultySelectionPending) { + _loadLevel(); + } + isInitialized = true; } @@ -119,6 +150,12 @@ class WolfEngine { // 1. Process User Input input.update(); final currentInput = input.currentInput; + + if (isDifficultySelectionPending) { + _tickDifficultyMenu(currentInput); + return; + } + final inputResult = _processInputs(delta, currentInput); // 2. Update Environment @@ -150,6 +187,19 @@ class WolfEngine { ); } + void _tickDifficultyMenu(EngineInput input) { + final menuResult = menuManager.updateDifficultySelection(input); + if (menuResult.goBack) { + onGameWon(); + return; + } + + if (menuResult.selected != null) { + difficulty = menuResult.selected; + _loadLevel(); + } + } + /// Wipes the current world state and builds a new floor from map data. void _loadLevel() { entities.clear(); @@ -182,7 +232,7 @@ class WolfEngine { objId, x + 0.5, y + 0.5, - difficulty, + difficulty!, data.sprites.length, isSharewareMode: data.version == GameVersion.shareware, ); @@ -401,7 +451,7 @@ class WolfEngine { MapObject.ammoClip, entity.x, entity.y, - difficulty, + difficulty!, data.sprites.length, ); if (droppedAmmo != null) itemsToAdd.add(droppedAmmo); 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 cfefebe..32b0869 100644 --- a/packages/wolf_3d_dart/lib/src/input/cli_input.dart +++ b/packages/wolf_3d_dart/lib/src/input/cli_input.dart @@ -14,10 +14,32 @@ class CliInput extends Wolf3dInput { bool _pRight = false; bool _pFire = false; bool _pInteract = false; + bool _pBack = false; WeaponType? _pWeapon; /// Queues a raw terminal key sequence for the next engine frame. void handleKey(List bytes) { + // Escape sequences for arrow keys (CSI A/B/C/D) in raw terminal mode. + if (bytes.length >= 3 && bytes[0] == 27 && bytes[1] == 91) { + if (bytes[2] == 65) _pForward = true; // Up + if (bytes[2] == 66) _pBackward = true; // Down + if (bytes[2] == 67) _pRight = true; // Right + if (bytes[2] == 68) _pLeft = true; // Left + return; + } + + // Bare Escape key is a menu back action. + if (bytes.length == 1 && bytes[0] == 27) { + _pBack = true; + return; + } + + // Enter maps to menu select/confirm. + if (bytes.length == 1 && (bytes[0] == 13 || bytes[0] == 10)) { + _pInteract = true; + return; + } + String char = String.fromCharCodes(bytes).toLowerCase(); if (char == 'w') _pForward = true; @@ -45,10 +67,12 @@ class CliInput extends Wolf3dInput { isTurningRight = _pRight; isFiring = _pFire; isInteracting = _pInteract; + isBack = _pBack; requestedWeapon = _pWeapon; // Reset the pending buffer so each keypress behaves like a frame impulse. _pForward = _pBackward = _pLeft = _pRight = _pFire = _pInteract = false; + _pBack = false; _pWeapon = null; } } diff --git a/packages/wolf_3d_dart/lib/src/input/wolf_3d_input.dart b/packages/wolf_3d_dart/lib/src/input/wolf_3d_input.dart index 9dcc90e..79ea9e2 100644 --- a/packages/wolf_3d_dart/lib/src/input/wolf_3d_input.dart +++ b/packages/wolf_3d_dart/lib/src/input/wolf_3d_input.dart @@ -7,6 +7,9 @@ abstract class Wolf3dInput { bool isTurningLeft = false; bool isTurningRight = false; bool isInteracting = false; + bool isBack = false; + double? menuTapX; + double? menuTapY; bool isFiring = false; WeaponType? requestedWeapon; @@ -22,6 +25,9 @@ abstract class Wolf3dInput { isTurningRight: isTurningRight, isFiring: isFiring, isInteracting: isInteracting, + isBack: isBack, + menuTapX: menuTapX, + menuTapY: menuTapY, requestedWeapon: requestedWeapon, ); } @@ -33,6 +39,7 @@ enum WolfInputAction { turnRight, fire, interact, + back, weapon1, weapon2, weapon3, diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart new file mode 100644 index 0000000..602f04b --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -0,0 +1,94 @@ +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'package:wolf_3d_dart/wolf_3d_engine.dart'; + +/// Handles menu-only input state such as selection movement and edge triggers. +class MenuManager { + int _selectedDifficultyIndex = 0; + + bool _prevUp = false; + bool _prevDown = false; + bool _prevConfirm = false; + bool _prevBack = false; + + /// Current selected difficulty row index. + int get selectedDifficultyIndex => _selectedDifficultyIndex; + + /// Resets menu navigation state for a new difficulty selection flow. + void beginDifficultySelection({Difficulty? initialDifficulty}) { + _selectedDifficultyIndex = initialDifficulty == null + ? 0 + : Difficulty.values + .indexOf(initialDifficulty) + .clamp( + 0, + Difficulty.values.length - 1, + ); + + _prevUp = false; + _prevDown = false; + _prevConfirm = false; + _prevBack = false; + } + + /// Returns a menu action snapshot for this frame. + ({Difficulty? selected, bool goBack}) updateDifficultySelection( + EngineInput input, + ) { + final upNow = input.isMovingForward; + final downNow = input.isMovingBackward; + final confirmNow = input.isInteracting || input.isFiring; + final backNow = input.isBack; + + if (upNow && !_prevUp) { + _selectedDifficultyIndex = + (_selectedDifficultyIndex - 1 + Difficulty.values.length) % + Difficulty.values.length; + } + + if (downNow && !_prevDown) { + _selectedDifficultyIndex = + (_selectedDifficultyIndex + 1) % Difficulty.values.length; + } + + // Pointer/touch selection for hosts that provide menu tap coordinates. + if (input.menuTapX != null && input.menuTapY != null) { + final x320 = (input.menuTapX!.clamp(0.0, 1.0) * 320).toDouble(); + final y200 = (input.menuTapY!.clamp(0.0, 1.0) * 200).toDouble(); + + const panelX = 28.0; + const panelY = 70.0; + const panelW = 264.0; + const panelH = 82.0; + const rowYStart = 86.0; + const rowStep = 15.0; + + if (x320 >= panelX && + x320 <= panelX + panelW && + y200 >= panelY && + y200 <= panelY + panelH) { + final index = ((y200 - rowYStart + (rowStep / 2)) / rowStep).floor(); + if (index >= 0 && index < Difficulty.values.length) { + _selectedDifficultyIndex = index; + return (selected: Difficulty.values[index], goBack: false); + } + } + } + + Difficulty? selected; + if (confirmNow && !_prevConfirm) { + selected = Difficulty.values[_selectedDifficultyIndex]; + } + + final bool goBack = backNow && !_prevBack; + + _prevUp = upNow; + _prevDown = downNow; + _prevConfirm = confirmNow; + _prevBack = backNow; + + return (selected: selected, goBack: goBack); + } + + /// Whether to show the alternate cursor frame at [elapsedMs]. + bool isCursorAltFrame(int elapsedMs) => ((elapsedMs ~/ 220) % 2) == 1; +} diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart index b14cd71..748978b 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/ascii_rasterizer.dart @@ -3,6 +3,7 @@ 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 'package:wolf_3d_dart/wolf_3d_menu.dart'; import 'cli_rasterizer.dart'; @@ -324,6 +325,123 @@ class AsciiRasterizer extends CliRasterizer { } } + @override + void drawMenu(WolfEngine engine) { + final int bgColor = _rgbToRawColor(engine.menuBackgroundRgb); + final int panelColor = _rgbToRawColor(engine.menuPanelRgb); + final int headingColor = ColorPalette.vga32Bit[119]; + final int selectedTextColor = ColorPalette.vga32Bit[19]; + final int unselectedTextColor = ColorPalette.vga32Bit[23]; + + if (isTerminal) { + _fillTerminalRect(0, 0, width, _terminalPixelHeight, bgColor); + } else { + _fillRect(0, 0, width, height, activeTheme.solid, bgColor); + } + + _fillRect320(28, 70, 264, 82, panelColor); + + const heading = 'HOW TOUGH ARE YOU?'; + final headingY = ((48 / 200) * height).toInt().clamp(0, height - 1); + final headingX = ((width - heading.length) ~/ 2).clamp(0, width - 1); + _writeString(headingX, headingY, heading, headingColor, bgColor); + + final art = WolfClassicMenuArt(engine.data); + + final face = art.difficultyOption( + Difficulty.values[engine.menuSelectedDifficultyIndex], + ); + if (face != null) { + _blitVgaImageAscii(face, 28 + 264 - face.width - 10, 92); + } + + final cursor = art.pic(engine.isMenuCursorAltFrame ? 9 : 8); + const rowYStart = 86; + const rowStep = 15; + const labels = [ + 'CAN I PLAY, DADDY?', + "DON'T HURT ME.", + "BRING 'EM ON!", + 'I AM DEATH INCARNATE!', + ]; + + for (int i = 0; i < Difficulty.values.length; i++) { + final y = rowYStart + (i * rowStep); + final isSelected = i == engine.menuSelectedDifficultyIndex; + + if (isSelected && cursor != null) { + _blitVgaImageAscii(cursor, 38, y - 2); + } + + final textY = ((y / 200) * height).toInt().clamp(0, height - 1); + final textX = ((70 / 320) * width).toInt().clamp(0, width - 1); + _writeString( + textX, + textY, + labels[i], + isSelected ? selectedTextColor : unselectedTextColor, + panelColor, + ); + } + + final int hintKeyColor = _rgbToRawColor(0xFF5555); + final int hintLabelColor = _rgbToRawColor(0x900303); + final int hintBackground = _rgbToRawColor(0x000000); + + _fillRect320(0, 176, 320, 24, hintBackground); + + final hintY = ((186 / 200) * height).toInt().clamp(0, height - 1); + int hintX = ((24 / 320) * width).toInt().clamp(0, width - 1); + + _writeString( + hintX, + hintY, + '^/v', + hintKeyColor, + hintBackground, + ); + hintX += 4; + _writeString( + hintX, + hintY, + ' MOVE ', + hintLabelColor, + hintBackground, + ); + hintX += 7; + _writeString( + hintX, + hintY, + 'RET', + hintKeyColor, + hintBackground, + ); + hintX += 4; + _writeString( + hintX, + hintY, + ' SELECT ', + hintLabelColor, + hintBackground, + ); + hintX += 9; + _writeString( + hintX, + hintY, + 'ESC', + hintKeyColor, + hintBackground, + ); + hintX += 4; + _writeString( + hintX, + hintY, + ' BACK', + hintLabelColor, + hintBackground, + ); + } + void _drawSimpleHud(WolfEngine engine) { final int hudWidth = isTerminal ? projectionWidth : width; final int hudRows = height - viewHeight; @@ -635,7 +753,8 @@ class AsciiRasterizer extends CliRasterizer { @override dynamic finalizeFrame() { - if (_engine.player.damageFlash > 0.0) { + if (!_engine.isDifficultySelectionPending && + _engine.player.damageFlash > 0.0) { if (isTerminal) { _applyDamageFlashToScene(); } else { @@ -710,6 +829,29 @@ class AsciiRasterizer extends CliRasterizer { } } + void _fillRect320( + int startX320, + int startY200, + int w320, + int h200, + int color, + ) { + final double scaleX = (isTerminal ? projectionWidth : width) / 320.0; + final double scaleY = (isTerminal ? _terminalPixelHeight : height) / 200.0; + + final int startX = + (isTerminal ? projectionOffsetX : 0) + (startX320 * scaleX).toInt(); + final int startY = (startY200 * scaleY).toInt(); + final int w = math.max(1, (w320 * scaleX).toInt()); + final int h = math.max(1, (h200 * scaleY).toInt()); + + if (isTerminal) { + _fillTerminalRect(startX, startY, w, h, color); + } else { + _fillRect(startX, startY, w, h, activeTheme.solid, color); + } + } + // --- DAMAGE FLASH --- void _applyDamageFlash() { for (int y = 0; y < viewHeight; y++) { @@ -835,4 +977,12 @@ class AsciiRasterizer extends CliRasterizer { return buffer; } + + int _rgbToRawColor(int rgb) { + final int r = (rgb >> 16) & 0xFF; + final int g = (rgb >> 8) & 0xFF; + final int b = rgb & 0xFF; + // ColoredChar values use the same raw pixel packing as the framebuffer. + return (0xFF000000) | (b << 16) | (g << 8) | r; + } } diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart index b72093e..6f166d1 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizer.dart @@ -50,6 +50,11 @@ abstract class Rasterizer { // 1. Setup the frame (clear screen, draw floor/ceiling) prepareFrame(engine); + if (engine.isDifficultySelectionPending) { + drawMenu(engine); + return finalizeFrame(); + } + // 2. Do the heavy math for Raycasting Walls _castWalls(engine); @@ -103,6 +108,11 @@ abstract class Rasterizer { /// Return the finished frame (e.g., the FrameBuffer itself, or an ASCII list). T finalizeFrame(); + /// Draws a non-world menu frame when the engine is awaiting configuration. + /// + /// Default implementation is a no-op for renderers that don't support menus. + void drawMenu(WolfEngine engine) {} + // =========================================================================== // SHARED LIGHTING MATH // =========================================================================== diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/cli_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/cli_rasterizer.dart deleted file mode 100644 index 564e2f1..0000000 --- a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/cli_rasterizer.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:wolf_3d_dart/src/rasterizer/rasterizer.dart'; -import 'package:wolf_3d_dart/wolf_3d_engine.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/rasterizer/rasterizers/sixel_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/sixel_rasterizer.dart deleted file mode 100644 index 9633081..0000000 --- a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/sixel_rasterizer.dart +++ /dev/null @@ -1,423 +0,0 @@ -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 deleted file mode 100644 index 847b7f6..0000000 --- a/packages/wolf_3d_dart/lib/src/rasterizer/rasterizers/software_rasterizer.dart +++ /dev/null @@ -1,265 +0,0 @@ -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 index f1739e4..9691a9b 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/sixel_rasterizer.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; +import 'package:wolf_3d_dart/wolf_3d_menu.dart'; import 'cli_rasterizer.dart'; @@ -17,6 +18,36 @@ import 'cli_rasterizer.dart'; /// preserving a 4:3 presentation while falling back to size warnings when the /// terminal is too small. class SixelRasterizer extends CliRasterizer { + static const Map> _menuFont = { + 'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], + 'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], + 'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], + 'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], + 'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], + 'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], + 'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], + 'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + 'I': ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], + 'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], + 'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], + 'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], + 'N': ['10001', '10001', '11001', '10101', '10011', '10001', '10001'], + 'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], + 'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + 'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], + 'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], + 'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + 'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], + 'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'], + 'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], + '?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'], + '!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], + ',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], + '.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], + "'": ['00100', '00100', '00100', '00000', '00000', '00000', '00000'], + ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], + }; + static const double _targetAspectRatio = 4 / 3; static const int _defaultLineHeightPx = 18; static const double _defaultCellWidthToHeight = 0.55; @@ -326,6 +357,112 @@ class SixelRasterizer extends CliRasterizer { _drawWeaponIcon(engine); } + @override + void drawMenu(WolfEngine engine) { + final int bgColor = _rgbToPaletteIndex(engine.menuBackgroundRgb); + final int panelColor = _rgbToPaletteIndex(engine.menuPanelRgb); + const int headingIndex = 119; + const int selectedTextIndex = 19; + const int unselectedTextIndex = 23; + + for (int i = 0; i < _screen.length; i++) { + _screen[i] = bgColor; + } + + _fillRect320(28, 70, 264, 82, panelColor); + + final art = WolfClassicMenuArt(engine.data); + _drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingIndex, scale: 2); + + final bottom = art.pic(15); + if (bottom != null) { + _blitVgaImage(bottom, (320 - bottom.width) ~/ 2, 200 - bottom.height - 8); + } + + final face = art.difficultyOption( + Difficulty.values[engine.menuSelectedDifficultyIndex], + ); + if (face != null) { + _blitVgaImage(face, 28 + 264 - face.width - 10, 92); + } + + final cursor = art.pic(engine.isMenuCursorAltFrame ? 9 : 8); + const rowYStart = 86; + const rowStep = 15; + const textX = 70; + const labels = [ + 'CAN I PLAY, DADDY?', + "DON'T HURT ME.", + "BRING 'EM ON!", + 'I AM DEATH INCARNATE!', + ]; + for (int i = 0; i < Difficulty.values.length; i++) { + final y = rowYStart + (i * rowStep); + final isSelected = i == engine.menuSelectedDifficultyIndex; + + if (isSelected && cursor != null) { + _blitVgaImage(cursor, 38, y - 2); + } + + _drawMenuText( + labels[i], + textX, + y, + isSelected ? selectedTextIndex : unselectedTextIndex, + ); + } + } + + void _drawMenuText( + String text, + int startX, + int startY, + int colorIndex, { + int scale = 1, + }) { + final double scaleX = width / 320.0; + final double scaleY = height / 200.0; + + int x320 = startX; + for (final rune in text.runes) { + final char = String.fromCharCode(rune).toUpperCase(); + final pattern = _menuFont[char] ?? _menuFont[' ']!; + + for (int row = 0; row < pattern.length; row++) { + final bits = pattern[row]; + for (int col = 0; col < bits.length; col++) { + if (bits[col] != '1') continue; + + for (int sy = 0; sy < scale; sy++) { + for (int sx = 0; sx < scale; sx++) { + final int px320 = x320 + (col * scale) + sx; + final int py200 = startY + (row * scale) + sy; + + final int drawX = (px320 * scaleX).toInt(); + final int drawY = (py200 * scaleY).toInt(); + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + _screen[drawY * width + drawX] = colorIndex; + } + } + } + } + } + + x320 += (6 * scale); + } + } + + void _drawMenuTextCentered( + String text, + int y, + int colorIndex, { + int scale = 1, + }) { + final int textWidth = text.length * 6 * scale; + final int x = ((320 - textWidth) ~/ 2).clamp(0, 319); + _drawMenuText(text, x, y, colorIndex, scale: scale); + } + @override String finalizeFrame() { if (!isSixelSupported) { @@ -395,7 +532,9 @@ class SixelRasterizer extends CliRasterizer { StringBuffer sb = StringBuffer(); sb.write('\x1bPq'); - double damageIntensity = _engine.player.damageFlash; + double damageIntensity = _engine.isDifficultySelectionPending + ? 0.0 + : _engine.player.damageFlash; int redBoost = (150 * damageIntensity).toInt(); double colorDrop = 1.0 - (0.5 * damageIntensity); @@ -530,6 +669,26 @@ class SixelRasterizer extends CliRasterizer { } } + void _fillRect320(int startX, int startY, int w, int h, int colorIndex) { + 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, (w * scaleX).toInt()); + final int destHeight = math.max(1, (h * scaleY).toInt()); + + for (int dy = 0; dy < destHeight; dy++) { + for (int dx = 0; dx < destWidth; dx++) { + final int drawX = destStartX + dx; + final int drawY = destStartY + dy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + _screen[drawY * width + drawX] = colorIndex; + } + } + } + } + void _drawNumber( int value, int rightAlignX, @@ -571,4 +730,32 @@ class SixelRasterizer extends CliRasterizer { _blitVgaImage(engine.data.vgaImages[weaponIndex], 256, 164); } } + + int _rgbToPaletteIndex(int rgb) { + final int targetR = (rgb >> 16) & 0xFF; + final int targetG = (rgb >> 8) & 0xFF; + final int targetB = rgb & 0xFF; + + int bestIndex = 0; + int bestDistance = 1 << 30; + + for (int i = 0; i < 256; i++) { + final int color = ColorPalette.vga32Bit[i]; + final int r = color & 0xFF; + final int g = (color >> 8) & 0xFF; + final int b = (color >> 16) & 0xFF; + + final int dr = targetR - r; + final int dg = targetG - g; + final int db = targetB - b; + final int dist = (dr * dr) + (dg * dg) + (db * db); + + if (dist < bestDistance) { + bestDistance = dist; + bestIndex = i; + } + } + + return bestIndex; + } } diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart index 847b7f6..b698877 100644 --- a/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart +++ b/packages/wolf_3d_dart/lib/src/rasterizer/software_rasterizer.dart @@ -3,8 +3,39 @@ 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'; +import 'package:wolf_3d_dart/wolf_3d_menu.dart'; class SoftwareRasterizer extends Rasterizer { + static const Map> _menuFont = { + 'A': ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], + 'B': ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], + 'C': ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], + 'D': ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], + 'E': ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], + 'F': ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], + 'G': ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], + 'H': ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + 'I': ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], + 'K': ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], + 'L': ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], + 'M': ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], + 'N': ['10001', '10001', '11001', '10101', '10011', '10001', '10001'], + 'O': ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], + 'P': ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + 'R': ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], + 'S': ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], + 'T': ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + 'U': ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], + 'W': ['10001', '10001', '10001', '10101', '10101', '11011', '10001'], + 'Y': ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], + '?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'], + '!': ['00100', '00100', '00100', '00100', '00100', '00000', '00100'], + ',': ['00000', '00000', '00000', '00000', '00110', '00100', '01000'], + '.': ['00000', '00000', '00000', '00000', '00000', '00110', '00110'], + "'": ['00100', '00100', '00100', '00000', '00000', '00000', '00000'], + ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], + }; + late FrameBuffer _buffer; late WolfEngine _engine; @@ -145,10 +176,135 @@ class SoftwareRasterizer extends Rasterizer { _drawWeaponIcon(engine); } + @override + void drawMenu(WolfEngine engine) { + final int bgColor = _rgbToFrameColor(engine.menuBackgroundRgb); + final int panelColor = _rgbToFrameColor(engine.menuPanelRgb); + final int headingColor = ColorPalette.vga32Bit[119]; + final int selectedTextColor = ColorPalette.vga32Bit[19]; + final int unselectedTextColor = ColorPalette.vga32Bit[23]; + + for (int i = 0; i < _buffer.pixels.length; i++) { + _buffer.pixels[i] = bgColor; + } + + const panelX = 28; + const panelY = 70; + const panelW = 264; + const panelH = 82; + + for (int y = panelY; y < panelY + panelH; y++) { + if (y < 0 || y >= height) continue; + final rowStart = y * width; + for (int x = panelX; x < panelX + panelW; x++) { + if (x >= 0 && x < width) { + _buffer.pixels[rowStart + x] = panelColor; + } + } + } + + final art = WolfClassicMenuArt(engine.data); + _drawMenuTextCentered('HOW TOUGH ARE YOU?', 48, headingColor, scale: 2); + + final bottom = art.pic(15); + if (bottom != null) { + final x = (width - bottom.width) ~/ 2; + final y = height - bottom.height - 8; + _blitVgaImage(bottom, x, y); + } + + final face = art.difficultyOption( + Difficulty.values[engine.menuSelectedDifficultyIndex], + ); + if (face != null) { + _blitVgaImage(face, panelX + panelW - face.width - 10, panelY + 22); + } + + final cursor = art.pic(engine.isMenuCursorAltFrame ? 9 : 8); + const rowYStart = panelY + 16; + const rowStep = 15; + const textX = panelX + 42; + const labels = [ + 'CAN I PLAY, DADDY?', + "DON'T HURT ME.", + "BRING 'EM ON!", + 'I AM DEATH INCARNATE!', + ]; + + for (int i = 0; i < Difficulty.values.length; i++) { + final y = rowYStart + (i * rowStep); + final isSelected = i == engine.menuSelectedDifficultyIndex; + + if (isSelected && cursor != null) { + _blitVgaImage(cursor, panelX + 10, y - 2); + } + + _drawMenuText( + labels[i], + textX, + y, + isSelected ? selectedTextColor : unselectedTextColor, + ); + } + } + + int _rgbToFrameColor(int rgb) { + final int r = (rgb >> 16) & 0xFF; + final int g = (rgb >> 8) & 0xFF; + final int b = rgb & 0xFF; + // Framebuffer expects bytes in RGBA order; this packed int produces that + // layout on little-endian platforms. + return (0xFF000000) | (b << 16) | (g << 8) | r; + } + + void _drawMenuText( + String text, + int startX, + int startY, + int color, { + int scale = 1, + }) { + int x = startX; + for (final rune in text.runes) { + final char = String.fromCharCode(rune).toUpperCase(); + final pattern = _menuFont[char] ?? _menuFont[' ']!; + + for (int row = 0; row < pattern.length; row++) { + final bits = pattern[row]; + for (int col = 0; col < bits.length; col++) { + if (bits[col] != '1') continue; + for (int sy = 0; sy < scale; sy++) { + for (int sx = 0; sx < scale; sx++) { + final drawX = x + (col * scale) + sx; + final drawY = startY + (row * scale) + sy; + if (drawX >= 0 && drawX < width && drawY >= 0 && drawY < height) { + _buffer.pixels[drawY * width + drawX] = color; + } + } + } + } + } + + x += (6 * scale); + } + } + + void _drawMenuTextCentered( + String text, + int y, + int color, { + int scale = 1, + }) { + final textWidth = text.length * 6 * scale; + final x = ((width - textWidth) ~/ 2).clamp(0, width - 1); + _drawMenuText(text, x, y, color, scale: scale); + } + @override FrameBuffer finalizeFrame() { // If the player took damage, overlay a red tint across the 3D view - if (_engine.player.damageFlash > 0) { + if (!_engine.isDifficultySelectionPending && + _engine.player.damageFlash > 0) { _applyDamageFlash(); } return _buffer; // Return the fully painted pixel array diff --git a/packages/wolf_3d_dart/lib/src/rasterizer/src/rasterizer.dart b/packages/wolf_3d_dart/lib/src/rasterizer/src/rasterizer.dart deleted file mode 100644 index b72093e..0000000 --- a/packages/wolf_3d_dart/lib/src/rasterizer/src/rasterizer.dart +++ /dev/null @@ -1,473 +0,0 @@ -import 'dart:math' as math; - -import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; -import 'package:wolf_3d_dart/wolf_3d_engine.dart'; -import 'package:wolf_3d_dart/wolf_3d_entities.dart'; - -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 deleted file mode 100644 index 9633081..0000000 --- a/packages/wolf_3d_dart/lib/src/rasterizer/src/sixel_rasterizer.dart +++ /dev/null @@ -1,423 +0,0 @@ -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 deleted file mode 100644 index 847b7f6..0000000 --- a/packages/wolf_3d_dart/lib/src/rasterizer/src/software_rasterizer.dart +++ /dev/null @@ -1,265 +0,0 @@ -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_menu.dart b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart new file mode 100644 index 0000000..47a7ec9 --- /dev/null +++ b/packages/wolf_3d_dart/lib/wolf_3d_menu.dart @@ -0,0 +1,167 @@ +/// Shared menu helpers for Wolf3D hosts. +library; + +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +/// Known VGA picture indexes used by the original Wolf3D control-panel menus. +/// +/// Values below are picture-table indexes (not raw chunk ids). +/// For example, `C_CONTROLPIC` is chunk 26 in `GFXV_WL6.H`, so its picture +/// index is `26 - STARTPICS(3) = 23`. +abstract class WolfMenuPic { + static const int hBj = 0; // H_BJPIC + static const int hTopWindow = 3; // H_TOPWINDOWPIC + static const int cOptions = 7; // C_OPTIONSPIC + static const int cCursor1 = 8; // C_CURSOR1PIC + static const int cCursor2 = 9; // C_CURSOR2PIC + static const int cNotSelected = 10; // C_NOTSELECTEDPIC + static const int cSelected = 11; // C_SELECTEDPIC + static const int cBabyMode = 16; // C_BABYMODEPIC + static const int cEasy = 17; // C_EASYPIC + static const int cNormal = 18; // C_NORMALPIC + static const int cHard = 19; // C_HARDPIC + static const int cControl = 23; // C_CONTROLPIC + static const int cEpisode1 = 27; // C_EPISODE1PIC + static const int cEpisode2 = 28; // C_EPISODE2PIC + static const int cEpisode3 = 29; // C_EPISODE3PIC + static const int cEpisode4 = 30; // C_EPISODE4PIC + static const int cEpisode5 = 31; // C_EPISODE5PIC + static const int cEpisode6 = 32; // C_EPISODE6PIC + static const int statusBar = 83; // STATUSBARPIC + static const int title = 84; // TITLEPIC + static const int pg13 = 85; // PG13PIC + static const int credits = 86; // CREDITSPIC + static const int highScores = 87; // HIGHSCORESPIC + + static const List episodePics = [ + cEpisode1, + cEpisode2, + cEpisode3, + cEpisode4, + cEpisode5, + cEpisode6, + ]; +} + +/// Structured accessors for classic Wolf3D menu art. +class WolfClassicMenuArt { + final WolfensteinData data; + + WolfClassicMenuArt(this.data); + + int? _resolvedIndexOffset; + + VgaImage? get controlBackground { + final preferred = mappedPic(WolfMenuPic.cControl); + if (_looksLikeMenuBackdrop(preferred)) { + return preferred; + } + + // Older data layouts may shift/control-panel art around nearby indices. + for (int delta = -4; delta <= 4; delta++) { + final candidate = mappedPic(WolfMenuPic.cControl + delta); + if (_looksLikeMenuBackdrop(candidate)) { + return candidate; + } + } + + return preferred; + } + + VgaImage? get title => mappedPic(WolfMenuPic.title); + + VgaImage? get heading => mappedPic(WolfMenuPic.hTopWindow); + + VgaImage? get selectedMarker => mappedPic(WolfMenuPic.cSelected); + + VgaImage? get unselectedMarker => mappedPic(WolfMenuPic.cNotSelected); + + VgaImage? get optionsLabel => mappedPic(WolfMenuPic.cOptions); + + VgaImage? get credits => mappedPic(WolfMenuPic.credits); + + VgaImage? episodeOption(int episodeIndex) { + if (episodeIndex < 0 || episodeIndex >= WolfMenuPic.episodePics.length) { + return null; + } + return mappedPic(WolfMenuPic.episodePics[episodeIndex]); + } + + VgaImage? difficultyOption(Difficulty difficulty) { + switch (difficulty) { + case Difficulty.baby: + return mappedPic(WolfMenuPic.cBabyMode); + case Difficulty.easy: + return mappedPic(WolfMenuPic.cEasy); + case Difficulty.medium: + return mappedPic(WolfMenuPic.cNormal); + case Difficulty.hard: + return mappedPic(WolfMenuPic.cHard); + } + } + + /// Returns [index] after applying a detected version/layout offset. + VgaImage? mappedPic(int index) { + return pic(index + _indexOffset); + } + + int get _indexOffset { + if (_resolvedIndexOffset != null) { + return _resolvedIndexOffset!; + } + + // Retail and shareware generally place STATUSBAR/TITLE/PG13/CREDITS as a + // contiguous block. If files are from a different release, infer a shift. + for (int i = 0; i < data.vgaImages.length - 3; i++) { + final status = data.vgaImages[i]; + if (!_looksLikeStatusBar(status)) { + continue; + } + + final title = data.vgaImages[i + 1]; + final pg13 = data.vgaImages[i + 2]; + final credits = data.vgaImages[i + 3]; + if (_looksLikeFullScreen(title) && + _looksLikeFullScreen(pg13) && + _looksLikeFullScreen(credits)) { + _resolvedIndexOffset = i - WolfMenuPic.statusBar; + return _resolvedIndexOffset!; + } + } + + _resolvedIndexOffset = 0; + return 0; + } + + bool _looksLikeStatusBar(VgaImage image) { + return image.width >= 280 && image.height >= 24 && image.height <= 64; + } + + bool _looksLikeFullScreen(VgaImage image) { + return image.width >= 280 && image.height >= 140; + } + + bool _looksLikeMenuBackdrop(VgaImage? image) { + if (image == null) { + return false; + } + return image.width >= 180 && image.height >= 100; + } + + VgaImage? pic(int index) { + if (index < 0 || index >= data.vgaImages.length) { + return null; + } + final image = data.vgaImages[index]; + + // Ignore known gameplay HUD art in menu composition. + if (index == WolfMenuPic.statusBar + _indexOffset) { + return null; + } + if (image.width <= 0 || image.height <= 0) { + return null; + } + + return image; + } +} diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart index f516bc1..cb8f506 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_flutter.dart @@ -21,6 +21,12 @@ class Wolf3d { /// Shared engine audio backend used by menus and gameplay sessions. final EngineAudio audio = WolfAudio(); + /// Engine menu background color as 24-bit RGB. + int menuBackgroundRgb = 0x890000; + + /// Engine menu panel color as 24-bit RGB. + int menuPanelRgb = 0x590002; + /// Shared Flutter input adapter reused by gameplay screens. final Wolf3dFlutterInput input = Wolf3dFlutterInput(); @@ -41,6 +47,54 @@ class Wolf3d { /// Index of the episode currently selected in the UI flow. int get activeEpisode => _activeEpisode; + Difficulty? _activeDifficulty; + + /// The difficulty applied when [launchEngine] creates a new session. + Difficulty? get activeDifficulty => _activeDifficulty; + + /// Stores [difficulty] so the next [launchEngine] call uses it. + void setActiveDifficulty(Difficulty difficulty) { + _activeDifficulty = difficulty; + } + + /// Clears any previously selected difficulty so the engine can prompt for one. + void clearActiveDifficulty() { + _activeDifficulty = null; + } + + WolfEngine? _engine; + + /// The most recently launched engine. + /// + /// Throws a [StateError] until [launchEngine] has been called. + WolfEngine get engine { + if (_engine == null) { + throw StateError('No engine launched. Call launchEngine() first.'); + } + return _engine!; + } + + /// Creates and initializes a [WolfEngine] for the current session config. + /// + /// Uses [activeGame], [activeEpisode], and [activeDifficulty]. Stores the + /// engine so it can be retrieved via [engine]. [onGameWon] is invoked when + /// the player completes the final level of the episode. + WolfEngine launchEngine({required void Function() onGameWon}) { + _engine = WolfEngine( + data: activeGame, + difficulty: _activeDifficulty, + startingEpisode: _activeEpisode, + frameBuffer: FrameBuffer(320, 200), + menuBackgroundRgb: menuBackgroundRgb, + menuPanelRgb: menuPanelRgb, + audio: audio, + input: input, + onGameWon: onGameWon, + ); + _engine!.init(); + return _engine!; + } + /// Sets the active episode for the current [activeGame]. void setActiveEpisode(int episodeIndex) { if (_activeGame == null) { diff --git a/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart b/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart index 8a6b7f1..1c5874e 100644 --- a/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart +++ b/packages/wolf_3d_flutter/lib/wolf_3d_input_flutter.dart @@ -37,7 +37,12 @@ class Wolf3dFlutterInput extends Wolf3dInput { LogicalKeyboardKey.controlLeft, LogicalKeyboardKey.controlRight, }, - WolfInputAction.interact: {LogicalKeyboardKey.space}, + WolfInputAction.interact: { + LogicalKeyboardKey.space, + LogicalKeyboardKey.enter, + LogicalKeyboardKey.numpadEnter, + }, + WolfInputAction.back: {LogicalKeyboardKey.escape}, WolfInputAction.weapon1: {LogicalKeyboardKey.digit1}, WolfInputAction.weapon2: {LogicalKeyboardKey.digit2}, WolfInputAction.weapon3: {LogicalKeyboardKey.digit3}, @@ -52,6 +57,9 @@ class Wolf3dFlutterInput extends Wolf3dInput { double _mouseDeltaX = 0.0; double _mouseDeltaY = 0.0; bool _previousMouseRightDown = false; + bool _queuedBack = false; + double? _queuedMenuTapX; + double? _queuedMenuTapY; // Mouse-look is optional so touch or keyboard-only hosts can keep the same // adapter without incurring accidental pointer-driven movement. @@ -105,6 +113,17 @@ class Wolf3dFlutterInput extends Wolf3dInput { } } + /// Queues a one-frame back action, typically from system back gestures. + void queueBackAction() { + _queuedBack = true; + } + + /// Queues a one-frame menu tap with normalized coordinates [0..1]. + void queueMenuTap({required double x, required double y}) { + _queuedMenuTapX = x.clamp(0.0, 1.0); + _queuedMenuTapY = y.clamp(0.0, 1.0); + } + /// Returns whether any bound key for [action] is currently pressed. bool _isActive(WolfInputAction action, Set pressedKeys) { return bindings[action]!.any((key) => pressedKeys.contains(key)); @@ -146,6 +165,11 @@ class Wolf3dFlutterInput extends Wolf3dInput { _isNewlyPressed(WolfInputAction.interact, newlyPressedKeys) || (mouseLookEnabled && isMouseRightDown && !_previousMouseRightDown); + isBack = + _isNewlyPressed(WolfInputAction.back, newlyPressedKeys) || _queuedBack; + menuTapX = _queuedMenuTapX; + menuTapY = _queuedMenuTapY; + // Left click or Ctrl to fire isFiring = _isActive(WolfInputAction.fire, pressedKeys) || @@ -169,5 +193,8 @@ class Wolf3dFlutterInput extends Wolf3dInput { // weapon switching only fire once per physical key press. _previousKeys = Set.from(pressedKeys); _previousMouseRightDown = isMouseRightDown; + _queuedBack = false; + _queuedMenuTapX = null; + _queuedMenuTapY = null; } } 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 cf63cc0..b240b7a 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart @@ -36,7 +36,9 @@ class _WolfAsciiRendererState extends BaseWolfRendererState { } @override - Color get scaffoldColor => const Color.fromARGB(255, 4, 64, 64); + Color get scaffoldColor => widget.engine.isDifficultySelectionPending + ? _colorFromRgb(widget.engine.menuBackgroundRgb) + : const Color.fromARGB(255, 4, 64, 64); @override void performRender() { @@ -51,6 +53,10 @@ class _WolfAsciiRendererState extends BaseWolfRendererState { ? const SizedBox.shrink() : AsciiFrameWidget(frameData: _asciiFrame); } + + Color _colorFromRgb(int rgb) { + return Color(0xFF000000 | (rgb & 0x00FFFFFF)); + } } /// Paints a pre-rasterized ASCII frame using grouped text spans per color run. 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 d1905cc..5336cf3 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_flutter_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_flutter_renderer.dart @@ -41,7 +41,9 @@ class _WolfFlutterRendererState } @override - Color get scaffoldColor => const Color.fromARGB(255, 4, 64, 64); + Color get scaffoldColor => widget.engine.isDifficultySelectionPending + ? _colorFromRgb(widget.engine.menuBackgroundRgb) + : const Color.fromARGB(255, 4, 64, 64); @override void performRender() { @@ -85,4 +87,8 @@ class _WolfFlutterRendererState ), ); } + + Color _colorFromRgb(int rgb) { + return Color(0xFF000000 | (rgb & 0x00FFFFFF)); + } }