feat: Implement ASCII theme cycling and add quadrant theme support

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-19 23:29:00 +01:00
parent aab79b5c50
commit add8bcfde1
6 changed files with 219 additions and 11 deletions

View File

@@ -125,9 +125,36 @@ class CliGameLoop {
return; return;
} }
if (bytes.contains(116) || bytes.contains(84)) {
_cycleAsciiTheme();
return;
}
input.handleKey(bytes); input.handleKey(bytes);
} }
void _cycleAsciiTheme() {
final List<AsciiRenderer> asciiRenderers = <AsciiRenderer>[
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) { void _tick(Timer timer) {
if (!_isRunning) { if (!_isRunning) {
return; return;

View File

@@ -4,6 +4,7 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/wolf_3d_renderer.dart';
import 'package:wolf_3d_flutter/wolf_3d_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_ascii_renderer.dart';
import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart'; import 'package:wolf_3d_renderer/wolf_3d_flutter_renderer.dart';
@@ -33,6 +34,7 @@ class GameScreen extends StatefulWidget {
class _GameScreenState extends State<GameScreen> { class _GameScreenState extends State<GameScreen> {
late final WolfEngine _engine; late final WolfEngine _engine;
_RendererMode _rendererMode = _RendererMode.software; _RendererMode _rendererMode = _RendererMode.software;
AsciiTheme _asciiTheme = AsciiThemes.blocks;
@override @override
void initState() { void initState() {
@@ -104,7 +106,7 @@ class _GameScreenState extends State<GameScreen> {
top: 16, top: 16,
right: 16, right: 16,
child: Text( child: Text(
'TAB: ${_modeLabel(_rendererMode)} `: FPS ${_engine.showFpsCounter ? 'On' : 'Off'}', 'TAB: ${_modeLabel(_rendererMode)} T: ${_asciiTheme.name} `: FPS ${_engine.showFpsCounter ? 'On' : 'Off'}',
style: TextStyle( style: TextStyle(
color: Colors.white.withValues(alpha: 0.5), color: Colors.white.withValues(alpha: 0.5),
), ),
@@ -131,6 +133,7 @@ class _GameScreenState extends State<GameScreen> {
case _RendererMode.ascii: case _RendererMode.ascii:
return WolfAsciiRenderer( return WolfAsciiRenderer(
engine: _engine, engine: _engine,
theme: _asciiTheme,
onKeyEvent: _handleRendererKeyEvent, onKeyEvent: _handleRendererKeyEvent,
); );
case _RendererMode.glsl: case _RendererMode.glsl:
@@ -155,6 +158,13 @@ class _GameScreenState extends State<GameScreen> {
if (event.logicalKey == LogicalKeyboardKey.backquote || if (event.logicalKey == LogicalKeyboardKey.backquote ||
event.character == '`') { event.character == '`') {
setState(_toggleFpsCounter); setState(_toggleFpsCounter);
return;
}
if (event.logicalKey == LogicalKeyboardKey.keyT ||
event.character == 't' ||
event.character == 'T') {
setState(_cycleAsciiTheme);
} }
} }
@@ -185,6 +195,10 @@ class _GameScreenState extends State<GameScreen> {
_engine.showFpsCounter = !_engine.showFpsCounter; _engine.showFpsCounter = !_engine.showFpsCounter;
} }
void _cycleAsciiTheme() {
_asciiTheme = AsciiThemes.nextOf(_asciiTheme);
}
String _modeLabel(_RendererMode mode) { String _modeLabel(_RendererMode mode) {
switch (mode) { switch (mode) {
case _RendererMode.software: case _RendererMode.software:

View File

@@ -1,3 +1,4 @@
import 'dart:developer' show log;
import 'dart:math' as math; import 'dart:math' as math;
import 'package:arcane_helper_utils/arcane_helper_utils.dart'; import 'package:arcane_helper_utils/arcane_helper_utils.dart';
@@ -35,9 +36,9 @@ class AsciiTheme {
/// A collection of pre-defined character sets /// A collection of pre-defined character sets
abstract class AsciiThemes { abstract class AsciiThemes {
static const AsciiTheme blocks = AsciiTheme('Blocks', "█▓▒░ "); static const AsciiTheme blocks = AsciiTheme('Blocks', "█▓▒░ ");
static const AsciiTheme classic = AsciiTheme('Classic', "@%#*+=-:. "); static const AsciiTheme quadrant = AsciiTheme('Quadrant', "▛▜▟▙▚▞▖ ");
static const List<AsciiTheme> values = [blocks, classic]; static const List<AsciiTheme> values = [blocks, quadrant];
static AsciiTheme nextOf(AsciiTheme current) { static AsciiTheme nextOf(AsciiTheme current) {
final int currentIndex = values.indexOf(current); final int currentIndex = values.indexOf(current);
@@ -111,6 +112,26 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
late List<List<ColoredChar>> _screen; late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels; late List<List<int>> _scenePixels;
String? _lastLoggedThemeName;
static const List<String> _quadrantByMask = <String>[
' ',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
];
@override @override
final double aspectMultiplier; final double aspectMultiplier;
@@ -148,6 +169,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
int get _terminalPixelHeight => _usesTerminalLayout ? height * 2 : height; int get _terminalPixelHeight => _usesTerminalLayout ? height * 2 : height;
int get _terminalSceneWidth => width;
bool get _usesQuadrantCompose => activeTheme == AsciiThemes.quadrant;
int get _viewportRightX => projectionOffsetX + projectionWidth; int get _viewportRightX => projectionOffsetX + projectionWidth;
int get _terminalBackdropColor => int get _terminalBackdropColor =>
@@ -157,6 +182,7 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
@override @override
/// Initializes the character grid before running the shared render pipeline. /// Initializes the character grid before running the shared render pipeline.
dynamic render(WolfEngine engine) { dynamic render(WolfEngine engine) {
_logThemeIfChanged();
_screen = List.generate( _screen = List.generate(
engine.frameBuffer.height, engine.frameBuffer.height,
(_) => List.filled( (_) => List.filled(
@@ -1224,7 +1250,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
for (int dx = 0; dx < w; dx++) { for (int dx = 0; dx < w; dx++) {
int x = startX + dx; int x = startX + dx;
int y = startY + dy; 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; _scenePixels[y][x] = color;
} }
} }
@@ -1309,8 +1338,13 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
int topY = y * 2; int topY = y * 2;
int bottomY = math.min(topY + 1, _terminalPixelHeight - 1); int bottomY = math.min(topY + 1, _terminalPixelHeight - 1);
for (int x = 0; x < width; x++) { for (int x = 0; x < width; x++) {
int topColor = _scenePixels[topY][x]; final int leftX = x;
int bottomColor = _scenePixels[bottomY][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]; ColoredChar overlay = _screen[y][x];
if (overlay.char != ' ') { if (overlay.char != ' ') {
@@ -1322,7 +1356,12 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
_screen[y][x] = ColoredChar( _screen[y][x] = ColoredChar(
overlay.char, overlay.char,
overlay.rawColor, overlay.rawColor,
bottomColor, _usesQuadrantCompose
? _blendPackedColors(
_scenePixels[bottomY][leftX],
_scenePixels[bottomY][rightX],
)
: bottomColor,
); );
} }
continue; continue;
@@ -1331,13 +1370,109 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
// Pack two scene rows into one cell for both terminal and Flutter grid // Pack two scene rows into one cell for both terminal and Flutter grid
// modes. Overlay characters above keep a null background in Flutter // modes. Overlay characters above keep a null background in Flutter
// mode, so this does not introduce text background artifacts. // mode, so this does not introduce text background artifacts.
_screen[y][x] = topColor == bottomColor if (_usesQuadrantCompose) {
? ColoredChar('', topColor) final (char, fgColor, bgColor) = _composeQuadrantCell(
: ColoredChar('', topColor, bottomColor); _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<int> samples = <int>[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 /// Converts the current frame to a single printable ANSI string
StringBuffer toAnsiString() { StringBuffer toAnsiString() {
StringBuffer buffer = StringBuffer(); StringBuffer buffer = StringBuffer();

View File

@@ -3,7 +3,7 @@ library;
export 'src/raycasting/projection.dart'; export 'src/raycasting/projection.dart';
export 'src/raycasting/raycaster.dart'; export 'src/raycasting/raycaster.dart';
export 'src/rendering/ascii_renderer.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/cli_renderer_backend.dart';
export 'src/rendering/renderer_backend.dart'; export 'src/rendering/renderer_backend.dart';
export 'src/rendering/sixel_renderer.dart'; export 'src/rendering/sixel_renderer.dart';

View File

@@ -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);
});
});
}

View File

@@ -7,9 +7,12 @@ import 'package:wolf_3d_renderer/base_renderer.dart';
/// Displays the game using a text-mode approximation of the original renderer. /// Displays the game using a text-mode approximation of the original renderer.
class WolfAsciiRenderer extends BaseWolfRenderer { class WolfAsciiRenderer extends BaseWolfRenderer {
final AsciiTheme theme;
/// Creates an ASCII renderer bound to [engine]. /// Creates an ASCII renderer bound to [engine].
const WolfAsciiRenderer({ const WolfAsciiRenderer({
required super.engine, required super.engine,
this.theme = AsciiThemes.blocks,
super.onKeyEvent, super.onKeyEvent,
super.key, super.key,
}); });
@@ -30,6 +33,7 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_asciiRenderer.activeTheme = widget.theme;
// ASCII output uses a reduced logical framebuffer because glyph rendering // ASCII output uses a reduced logical framebuffer because glyph rendering
// expands the final view significantly once laid out in Flutter text. // expands the final view significantly once laid out in Flutter text.
if (widget.engine.frameBuffer.width != _renderWidth || if (widget.engine.frameBuffer.width != _renderWidth ||
@@ -38,6 +42,14 @@ class _WolfAsciiRendererState extends BaseWolfRendererState<WolfAsciiRenderer> {
} }
} }
@override
void didUpdateWidget(covariant WolfAsciiRenderer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.theme != widget.theme) {
_asciiRenderer.activeTheme = widget.theme;
}
}
@override @override
Color get scaffoldColor => widget.engine.difficulty == null Color get scaffoldColor => widget.engine.difficulty == null
? _colorFromRgb(widget.engine.menuBackgroundRgb) ? _colorFromRgb(widget.engine.menuBackgroundRgb)