Refactor game loop to await start and enhance Sixel rasterizer terminal support detection

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-18 02:43:39 +01:00
parent 7fe9a8bc40
commit 28938f7301
3 changed files with 187 additions and 27 deletions

View File

@@ -56,5 +56,5 @@ void main() async {
onExit: stopAndExit,
);
gameLoop.start();
await gameLoop.start();
}

View File

@@ -9,7 +9,13 @@ class CliGameLoop {
CliGameLoop({
required this.engine,
required this.onExit,
}) : input = CliInput(),
}) : input = engine.input is CliInput
? engine.input as CliInput
: throw ArgumentError.value(
engine.input,
'engine.input',
'CliGameLoop requires a CliInput instance.',
),
primaryRasterizer = SixelRasterizer(),
secondaryRasterizer = AsciiRasterizer(isTerminal: true) {
_rasterizer = primaryRasterizer;
@@ -22,23 +28,37 @@ class CliGameLoop {
final void Function(int code) onExit;
final Stopwatch _stopwatch = Stopwatch();
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
late CliRasterizer _rasterizer;
StreamSubscription<List<int>>? _stdinSubscription;
Timer? _timer;
bool _isRunning = false;
Duration _lastTick = Duration.zero;
void start() {
Future<void> start() async {
if (_isRunning) {
return;
}
stdin.echoMode = false;
stdin.lineMode = false;
if (primaryRasterizer is SixelRasterizer) {
final sixel = primaryRasterizer as SixelRasterizer;
sixel.isSixelSupported = await SixelRasterizer.checkTerminalSixelSupport(
inputStream: _stdinStream,
);
}
if (stdin.hasTerminal) {
try {
stdin.echoMode = false;
stdin.lineMode = false;
} catch (_) {
// Keep running without raw mode when stdin is not mutable.
}
}
stdout.write('\x1b[?25l\x1b[2J');
_stdinSubscription = stdin.listen(_handleInput);
_stdinSubscription = _stdinStream.listen(_handleInput);
_stopwatch.start();
_timer = Timer.periodic(const Duration(milliseconds: 33), _tick);
_isRunning = true;
@@ -59,8 +79,12 @@ class CliGameLoop {
}
if (stdin.hasTerminal) {
stdin.echoMode = true;
stdin.lineMode = true;
try {
stdin.echoMode = true;
stdin.lineMode = true;
} catch (_) {
// Ignore cleanup failures if stdin is no longer a mutable TTY.
}
}
if (stdout.hasTerminal) {

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'dart:typed_data';
@@ -24,6 +26,105 @@ class SixelRasterizer extends CliRasterizer<String> {
int _outputHeight = 1;
bool _needsBackgroundClear = true;
/// Flag to determine if Sixel should actually be encoded and rendered.
/// This should be updated by calling [checkTerminalSixelSupport] before the game loop.
bool isSixelSupported = true;
// ===========================================================================
// TERMINAL AUTODETECT
// ===========================================================================
/// Asynchronously checks if the current terminal emulator supports Sixel graphics.
/// Ports the device attribute query logic from lsix.
static Future<bool> checkTerminalSixelSupport({
Stream<List<int>>? inputStream,
}) async {
// Match lsix behavior: allow explicit user override for misreporting terminals.
if (Platform.environment.containsKey('LSIX_FORCE_SIXEL_SUPPORT')) {
return true;
}
// YAFT is vt102 compatible and cannot respond to the vt220 sequence, but supports Sixel.
final term = Platform.environment['TERM'] ?? '';
if (term.startsWith('yaft')) {
return true;
}
if (!stdin.hasTerminal) return false;
bool terminalModesChanged = false;
bool originalLineMode = true;
bool originalEchoMode = true;
try {
// Some runtimes report hasTerminal=true but still reject mode mutation
// with EBADF (bad file descriptor). Treat that as unsupported.
originalLineMode = stdin.lineMode;
originalEchoMode = stdin.echoMode;
// Don't show escape sequences the terminal doesn't understand[cite: 24].
stdin.lineMode = false;
stdin.echoMode = false;
terminalModesChanged = true;
// Send Device Attributes query[cite: 25].
stdout.write('\x1b[c');
await stdout.flush();
final responseBytes = <int>[];
final completer = Completer<bool>();
final sub = (inputStream ?? stdin).listen((List<int> data) {
for (var byte in data) {
responseBytes.add(byte);
// Wait for the 'c' terminating character[cite: 25].
if (byte == 99) {
if (!completer.isCompleted) completer.complete(true);
}
}
});
try {
// Wait up to 1 second for the terminal to respond[cite: 25].
await completer.future.timeout(const Duration(seconds: 1));
} catch (_) {
// Timeout occurred
} finally {
sub.cancel();
}
final response = String.fromCharCodes(responseBytes);
// Some terminals include full CSI sequences like "\x1b[?62;4;6c".
// Keep only numeric attribute codes to mirror lsix's shell splitting behavior.
final attributeCodes = RegExp(
r'\d+',
).allMatches(response).map((m) => m.group(0)).whereType<String>().toSet();
// Split by ';' or '?' or 'c' to parse attributes[cite: 25].
final parts = response.split(RegExp(r'[;?c]'));
// Code "4" indicates Sixel support[cite: 26].
return parts.contains('4') || attributeCodes.contains('4');
} catch (e) {
return false;
} finally {
// Restore standard terminal settings
if (terminalModesChanged) {
try {
stdin.lineMode = originalLineMode;
stdin.echoMode = originalEchoMode;
} catch (_) {
// Ignore restoration failures to avoid crashing shutdown paths.
}
}
}
}
// ===========================================================================
// RENDERING ENGINE
// ===========================================================================
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
final int previousOffsetColumns = _offsetColumns;
final int previousOffsetRows = _offsetRows;
@@ -91,6 +192,7 @@ class SixelRasterizer extends CliRasterizer<String> {
_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;
@@ -129,9 +231,6 @@ class SixelRasterizer extends CliRasterizer<String> {
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;
}
}
@@ -215,6 +314,10 @@ class SixelRasterizer extends CliRasterizer<String> {
@override
String finalizeFrame() {
if (!isSixelSupported) {
return _renderNotSupportedMessage();
}
final String clearPrefix = _needsBackgroundClear
? '$_terminalTealBackground\x1b[2J\x1b[0m'
: '';
@@ -222,18 +325,62 @@ class SixelRasterizer extends CliRasterizer<String> {
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
}
// ===========================================================================
// UI FALLBACK
// ===========================================================================
String _renderNotSupportedMessage() {
int cols = 80;
int rows = 24;
try {
if (stdout.hasTerminal) {
cols = stdout.terminalColumns;
rows = stdout.terminalLines;
}
} catch (_) {}
const String msg1 = "Terminal does not support Sixel.";
const String msg2 = "Press TAB to switch renderers.";
final int boxWidth = math.max(msg1.length, msg2.length) + 6;
const int boxHeight = 5;
final int startX = math.max(1, (cols - boxWidth) ~/ 2);
final int startY = math.max(1, (rows - boxHeight) ~/ 2);
final StringBuffer sb = StringBuffer();
if (_needsBackgroundClear) {
sb.write('\x1b[0m\x1b[2J');
_needsBackgroundClear = false;
}
String center(String text) {
final int padLeft = (boxWidth - 2 - text.length) ~/ 2;
final int padRight = boxWidth - 2 - text.length - padLeft;
return (' ' * padLeft) + text + (' ' * padRight);
}
// Draw centered box
sb.write('\x1b[$startY;${startX}H┌${'' * (boxWidth - 2)}');
sb.write('\x1b[${startY + 1};${startX}H│${center(msg1)}');
sb.write('\x1b[${startY + 2};${startX}H│${center("")}');
sb.write('\x1b[${startY + 3};${startX}H│${center(msg2)}');
sb.write('\x1b[${startY + 4};${startX}H└${'' * (boxWidth - 2)}');
// Park the cursor at the bottom out of the way
sb.write('\x1b[$rows;1H');
return sb.toString();
}
// ===========================================================================
// 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);
@@ -250,7 +397,6 @@ class SixelRasterizer extends CliRasterizer<String> {
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;
@@ -258,11 +404,9 @@ class SixelRasterizer extends CliRasterizer<String> {
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<int, Uint8List> 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;
@@ -272,28 +416,22 @@ class SixelRasterizer extends CliRasterizer<String> {
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) {
@@ -312,7 +450,6 @@ class SixelRasterizer extends CliRasterizer<String> {
}
}
// End Sixel sequence
sb.write('\x1b\\');
return sb.toString();
}
@@ -335,7 +472,6 @@ class SixelRasterizer extends CliRasterizer<String> {
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
String char = String.fromCharCode(value + 63);
// Sixel RLE format: !<count><char> (only worth it if count > 3)
if (runLength > 3) {
sb.write('!$runLength$char');
} else {
@@ -344,7 +480,7 @@ class SixelRasterizer extends CliRasterizer<String> {
}
// ===========================================================================
// PRIVATE HUD HELPERS (Adapted for 8-bit index buffer)
// PRIVATE HUD HELPERS
// ===========================================================================
void _blitVgaImage(VgaImage image, int startX, int startY) {