diff --git a/apps/wolf_3d_cli/lib/cli_game_loop.dart b/apps/wolf_3d_cli/lib/cli_game_loop.dart index bb8b733..34ace6a 100644 --- a/apps/wolf_3d_cli/lib/cli_game_loop.dart +++ b/apps/wolf_3d_cli/lib/cli_game_loop.dart @@ -125,9 +125,36 @@ class CliGameLoop { return; } + if (bytes.contains(116) || bytes.contains(84)) { + _cycleAsciiTheme(); + return; + } + input.handleKey(bytes); } + void _cycleAsciiTheme() { + final List asciiRenderers = [ + if (primaryRenderer is AsciiRenderer) primaryRenderer as AsciiRenderer, + if (secondaryRenderer is AsciiRenderer) + secondaryRenderer as AsciiRenderer, + ]; + if (asciiRenderers.isEmpty) { + return; + } + + final AsciiTheme nextTheme = AsciiThemes.nextOf( + asciiRenderers.first.activeTheme, + ); + for (final renderer in asciiRenderers) { + renderer.activeTheme = nextTheme; + } + + if (stdout.hasTerminal) { + stdout.write('\x1b[2J\x1b[H'); + } + } + void _tick(Timer timer) { if (!_isRunning) { return; diff --git a/apps/wolf_3d_gui/lib/screens/game_screen.dart b/apps/wolf_3d_gui/lib/screens/game_screen.dart index 4744c2d..5fb6bbd 100644 --- a/apps/wolf_3d_gui/lib/screens/game_screen.dart +++ b/apps/wolf_3d_gui/lib/screens/game_screen.dart @@ -4,6 +4,7 @@ library; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; +import 'package:wolf_3d_dart/wolf_3d_renderer.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'; @@ -33,6 +34,7 @@ class GameScreen extends StatefulWidget { class _GameScreenState extends State { late final WolfEngine _engine; _RendererMode _rendererMode = _RendererMode.software; + AsciiTheme _asciiTheme = AsciiThemes.blocks; @override void initState() { @@ -104,7 +106,7 @@ class _GameScreenState extends State { top: 16, right: 16, child: Text( - 'TAB: ${_modeLabel(_rendererMode)} `: FPS ${_engine.showFpsCounter ? 'On' : 'Off'}', + 'TAB: ${_modeLabel(_rendererMode)} T: ${_asciiTheme.name} `: FPS ${_engine.showFpsCounter ? 'On' : 'Off'}', style: TextStyle( color: Colors.white.withValues(alpha: 0.5), ), @@ -131,6 +133,7 @@ class _GameScreenState extends State { case _RendererMode.ascii: return WolfAsciiRenderer( engine: _engine, + theme: _asciiTheme, onKeyEvent: _handleRendererKeyEvent, ); case _RendererMode.glsl: @@ -155,6 +158,13 @@ class _GameScreenState extends State { if (event.logicalKey == LogicalKeyboardKey.backquote || event.character == '`') { setState(_toggleFpsCounter); + return; + } + + if (event.logicalKey == LogicalKeyboardKey.keyT || + event.character == 't' || + event.character == 'T') { + setState(_cycleAsciiTheme); } } @@ -185,6 +195,10 @@ class _GameScreenState extends State { _engine.showFpsCounter = !_engine.showFpsCounter; } + void _cycleAsciiTheme() { + _asciiTheme = AsciiThemes.nextOf(_asciiTheme); + } + String _modeLabel(_RendererMode mode) { switch (mode) { case _RendererMode.software: diff --git a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart index 27fcb8a..13ea43d 100644 --- a/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart +++ b/packages/wolf_3d_dart/lib/src/rendering/ascii_renderer.dart @@ -1,3 +1,4 @@ +import 'dart:developer' show log; import 'dart:math' as math; import 'package:arcane_helper_utils/arcane_helper_utils.dart'; @@ -35,9 +36,9 @@ class AsciiTheme { /// A collection of pre-defined character sets abstract class AsciiThemes { static const AsciiTheme blocks = AsciiTheme('Blocks', "█▓▒░ "); - static const AsciiTheme classic = AsciiTheme('Classic', "@%#*+=-:. "); + static const AsciiTheme quadrant = AsciiTheme('Quadrant', "▛▜▟▙▚▞▖ "); - static const List values = [blocks, classic]; + static const List values = [blocks, quadrant]; static AsciiTheme nextOf(AsciiTheme current) { final int currentIndex = values.indexOf(current); @@ -111,6 +112,26 @@ class AsciiRenderer extends CliRendererBackend { late List> _screen; late List> _scenePixels; + String? _lastLoggedThemeName; + + static const List _quadrantByMask = [ + ' ', + '▘', + '▝', + '▀', + '▖', + '▌', + '▞', + '▛', + '▗', + '▚', + '▐', + '▜', + '▄', + '▙', + '▟', + '█', + ]; @override final double aspectMultiplier; @@ -148,6 +169,10 @@ class AsciiRenderer extends CliRendererBackend { int get _terminalPixelHeight => _usesTerminalLayout ? height * 2 : height; + int get _terminalSceneWidth => width; + + bool get _usesQuadrantCompose => activeTheme == AsciiThemes.quadrant; + int get _viewportRightX => projectionOffsetX + projectionWidth; int get _terminalBackdropColor => @@ -157,6 +182,7 @@ class AsciiRenderer extends CliRendererBackend { @override /// Initializes the character grid before running the shared render pipeline. dynamic render(WolfEngine engine) { + _logThemeIfChanged(); _screen = List.generate( engine.frameBuffer.height, (_) => List.filled( @@ -1224,7 +1250,10 @@ class AsciiRenderer extends CliRendererBackend { for (int dx = 0; dx < w; dx++) { int x = startX + dx; int y = startY + dy; - if (x >= 0 && x < width && y >= 0 && y < _terminalPixelHeight) { + if (x >= 0 && + x < _terminalSceneWidth && + y >= 0 && + y < _terminalPixelHeight) { _scenePixels[y][x] = color; } } @@ -1309,8 +1338,13 @@ class AsciiRenderer extends CliRendererBackend { 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]; + final int leftX = x; + final int rightX = _usesQuadrantCompose + ? math.min(x + 1, _terminalSceneWidth - 1) + : leftX; + + int topColor = _scenePixels[topY][leftX]; + int bottomColor = _scenePixels[bottomY][leftX]; ColoredChar overlay = _screen[y][x]; if (overlay.char != ' ') { @@ -1322,7 +1356,12 @@ class AsciiRenderer extends CliRendererBackend { _screen[y][x] = ColoredChar( overlay.char, overlay.rawColor, - bottomColor, + _usesQuadrantCompose + ? _blendPackedColors( + _scenePixels[bottomY][leftX], + _scenePixels[bottomY][rightX], + ) + : bottomColor, ); } continue; @@ -1331,13 +1370,109 @@ class AsciiRenderer extends CliRendererBackend { // Pack two scene rows into one cell for both terminal and Flutter grid // modes. Overlay characters above keep a null background in Flutter // mode, so this does not introduce text background artifacts. - _screen[y][x] = topColor == bottomColor - ? ColoredChar('█', topColor) - : ColoredChar('▀', topColor, bottomColor); + if (_usesQuadrantCompose) { + final (char, fgColor, bgColor) = _composeQuadrantCell( + _scenePixels[topY][leftX], + _scenePixels[topY][rightX], + _scenePixels[bottomY][leftX], + _scenePixels[bottomY][rightX], + ); + _screen[y][x] = bgColor == null + ? ColoredChar(char, fgColor) + : ColoredChar(char, fgColor, bgColor); + continue; + } + + if (topColor == bottomColor) { + _screen[y][x] = ColoredChar('█', topColor); + continue; + } + + _screen[y][x] = ColoredChar('▀', topColor, bottomColor); } } } + (String char, int fgColor, int? bgColor) _composeQuadrantCell( + int topLeft, + int topRight, + int bottomLeft, + int bottomRight, + ) { + final List samples = [topLeft, topRight, bottomLeft, bottomRight]; + int bgColor = samples.first; + int bgCount = 0; + for (final candidate in samples) { + final int count = samples.where((value) => value == candidate).length; + if (count > bgCount) { + bgColor = candidate; + bgCount = count; + } + } + + int fgColor = bgColor; + double fgDistance = -1.0; + for (final candidate in samples) { + final double distance = _packedColorDistance(candidate, bgColor); + if (distance > fgDistance) { + fgDistance = distance; + fgColor = candidate; + } + } + + if (fgColor == bgColor) { + return ('█', fgColor, null); + } + + int mask = 0; + if (_packedColorDistance(topLeft, fgColor) <= + _packedColorDistance(topLeft, bgColor)) { + mask |= 1; + } + if (_packedColorDistance(topRight, fgColor) <= + _packedColorDistance(topRight, bgColor)) { + mask |= 2; + } + if (_packedColorDistance(bottomLeft, fgColor) <= + _packedColorDistance(bottomLeft, bgColor)) { + mask |= 4; + } + if (_packedColorDistance(bottomRight, fgColor) <= + _packedColorDistance(bottomRight, bgColor)) { + mask |= 8; + } + + if (mask == 0) { + return (' ', bgColor, null); + } + if (mask == 15) { + return ('█', fgColor, null); + } + return (_quadrantByMask[mask], fgColor, bgColor); + } + + void _logThemeIfChanged() { + if (_lastLoggedThemeName == activeTheme.name) { + return; + } + _lastLoggedThemeName = activeTheme.name; + log('ASCII renderer theme: ${activeTheme.name}', name: 'AsciiRenderer'); + } + + int _blendPackedColors(int a, int b) { + final int r = (((a & 0xFF) + (b & 0xFF)) / 2).round(); + final int g = ((((a >> 8) & 0xFF) + ((b >> 8) & 0xFF)) / 2).round(); + final int blue = ((((a >> 16) & 0xFF) + ((b >> 16) & 0xFF)) / 2).round(); + return 0xFF000000 | (blue << 16) | (g << 8) | r; + } + + double _packedColorDistance(int a, int b) { + final int dr = (a & 0xFF) - (b & 0xFF); + final int dg = ((a >> 8) & 0xFF) - ((b >> 8) & 0xFF); + final int db = ((a >> 16) & 0xFF) - ((b >> 16) & 0xFF); + return (dr * dr + dg * dg + db * db).toDouble(); + } + /// Converts the current frame to a single printable ANSI string StringBuffer toAnsiString() { StringBuffer buffer = StringBuffer(); diff --git a/packages/wolf_3d_dart/lib/wolf_3d_renderer.dart b/packages/wolf_3d_dart/lib/wolf_3d_renderer.dart index cf3f581..1ec8a60 100644 --- a/packages/wolf_3d_dart/lib/wolf_3d_renderer.dart +++ b/packages/wolf_3d_dart/lib/wolf_3d_renderer.dart @@ -3,7 +3,7 @@ library; export 'src/raycasting/projection.dart'; export 'src/raycasting/raycaster.dart'; export 'src/rendering/ascii_renderer.dart' - show AsciiRenderer, AsciiRendererMode, ColoredChar; + show AsciiRenderer, AsciiRendererMode, AsciiTheme, AsciiThemes, ColoredChar; export 'src/rendering/cli_renderer_backend.dart'; export 'src/rendering/renderer_backend.dart'; export 'src/rendering/sixel_renderer.dart'; diff --git a/packages/wolf_3d_dart/test/rendering/ascii_themes_test.dart b/packages/wolf_3d_dart/test/rendering/ascii_themes_test.dart new file mode 100644 index 0000000..90b34ec --- /dev/null +++ b/packages/wolf_3d_dart/test/rendering/ascii_themes_test.dart @@ -0,0 +1,20 @@ +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/wolf_3d_renderer.dart'; + +void main() { + group('AsciiThemes', () { + test('contains quadrant as a selectable style', () { + expect(AsciiThemes.values, contains(AsciiThemes.quadrant)); + }); + + test('cycles through blocks and quadrant', () { + expect(AsciiThemes.nextOf(AsciiThemes.blocks), AsciiThemes.quadrant); + expect(AsciiThemes.nextOf(AsciiThemes.quadrant), AsciiThemes.blocks); + }); + + test('defaults to first style when unknown style is provided', () { + const custom = AsciiTheme('Custom', 'Xx '); + expect(AsciiThemes.nextOf(custom), AsciiThemes.blocks); + }); + }); +} 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 06e3cb6..9094616 100644 --- a/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart +++ b/packages/wolf_3d_renderer/lib/wolf_3d_ascii_renderer.dart @@ -7,9 +7,12 @@ import 'package:wolf_3d_renderer/base_renderer.dart'; /// Displays the game using a text-mode approximation of the original renderer. class WolfAsciiRenderer extends BaseWolfRenderer { + final AsciiTheme theme; + /// Creates an ASCII renderer bound to [engine]. const WolfAsciiRenderer({ required super.engine, + this.theme = AsciiThemes.blocks, super.onKeyEvent, super.key, }); @@ -30,6 +33,7 @@ class _WolfAsciiRendererState extends BaseWolfRendererState { @override void initState() { super.initState(); + _asciiRenderer.activeTheme = widget.theme; // ASCII output uses a reduced logical framebuffer because glyph rendering // expands the final view significantly once laid out in Flutter text. if (widget.engine.frameBuffer.width != _renderWidth || @@ -38,6 +42,14 @@ class _WolfAsciiRendererState extends BaseWolfRendererState { } } + @override + void didUpdateWidget(covariant WolfAsciiRenderer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.theme != widget.theme) { + _asciiRenderer.activeTheme = widget.theme; + } + } + @override Color get scaffoldColor => widget.engine.difficulty == null ? _colorFromRgb(widget.engine.menuBackgroundRgb)