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

@@ -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<AsciiTheme> values = [blocks, classic];
static const List<AsciiTheme> values = [blocks, quadrant];
static AsciiTheme nextOf(AsciiTheme current) {
final int currentIndex = values.indexOf(current);
@@ -111,6 +112,26 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
late List<List<ColoredChar>> _screen;
late List<List<int>> _scenePixels;
String? _lastLoggedThemeName;
static const List<String> _quadrantByMask = <String>[
' ',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
];
@override
final double aspectMultiplier;
@@ -148,6 +169,10 @@ class AsciiRenderer extends CliRendererBackend<dynamic> {
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<dynamic> {
@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<dynamic> {
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<dynamic> {
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<dynamic> {
_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<dynamic> {
// 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<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
StringBuffer toAnsiString() {
StringBuffer buffer = StringBuffer();

View File

@@ -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';

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.
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<WolfAsciiRenderer> {
@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<WolfAsciiRenderer> {
}
}
@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)