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:
@@ -125,9 +125,36 @@ class CliGameLoop {
|
||||
return;
|
||||
}
|
||||
|
||||
if (bytes.contains(116) || bytes.contains(84)) {
|
||||
_cycleAsciiTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!_isRunning) {
|
||||
return;
|
||||
|
||||
@@ -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<GameScreen> {
|
||||
late final WolfEngine _engine;
|
||||
_RendererMode _rendererMode = _RendererMode.software;
|
||||
AsciiTheme _asciiTheme = AsciiThemes.blocks;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -104,7 +106,7 @@ class _GameScreenState extends State<GameScreen> {
|
||||
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<GameScreen> {
|
||||
case _RendererMode.ascii:
|
||||
return WolfAsciiRenderer(
|
||||
engine: _engine,
|
||||
theme: _asciiTheme,
|
||||
onKeyEvent: _handleRendererKeyEvent,
|
||||
);
|
||||
case _RendererMode.glsl:
|
||||
@@ -155,6 +158,13 @@ class _GameScreenState extends State<GameScreen> {
|
||||
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<GameScreen> {
|
||||
_engine.showFpsCounter = !_engine.showFpsCounter;
|
||||
}
|
||||
|
||||
void _cycleAsciiTheme() {
|
||||
_asciiTheme = AsciiThemes.nextOf(_asciiTheme);
|
||||
}
|
||||
|
||||
String _modeLabel(_RendererMode mode) {
|
||||
switch (mode) {
|
||||
case _RendererMode.software:
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
20
packages/wolf_3d_dart/test/rendering/ascii_themes_test.dart
Normal file
20
packages/wolf_3d_dart/test/rendering/ascii_themes_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user