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:
@@ -56,5 +56,5 @@ void main() async {
|
||||
onExit: stopAndExit,
|
||||
);
|
||||
|
||||
gameLoop.start();
|
||||
await gameLoop.start();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user