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,
|
onExit: stopAndExit,
|
||||||
);
|
);
|
||||||
|
|
||||||
gameLoop.start();
|
await gameLoop.start();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ class CliGameLoop {
|
|||||||
CliGameLoop({
|
CliGameLoop({
|
||||||
required this.engine,
|
required this.engine,
|
||||||
required this.onExit,
|
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(),
|
primaryRasterizer = SixelRasterizer(),
|
||||||
secondaryRasterizer = AsciiRasterizer(isTerminal: true) {
|
secondaryRasterizer = AsciiRasterizer(isTerminal: true) {
|
||||||
_rasterizer = primaryRasterizer;
|
_rasterizer = primaryRasterizer;
|
||||||
@@ -22,23 +28,37 @@ class CliGameLoop {
|
|||||||
final void Function(int code) onExit;
|
final void Function(int code) onExit;
|
||||||
|
|
||||||
final Stopwatch _stopwatch = Stopwatch();
|
final Stopwatch _stopwatch = Stopwatch();
|
||||||
|
final Stream<List<int>> _stdinStream = stdin.asBroadcastStream();
|
||||||
late CliRasterizer _rasterizer;
|
late CliRasterizer _rasterizer;
|
||||||
StreamSubscription<List<int>>? _stdinSubscription;
|
StreamSubscription<List<int>>? _stdinSubscription;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
bool _isRunning = false;
|
bool _isRunning = false;
|
||||||
Duration _lastTick = Duration.zero;
|
Duration _lastTick = Duration.zero;
|
||||||
|
|
||||||
void start() {
|
Future<void> start() async {
|
||||||
if (_isRunning) {
|
if (_isRunning) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (primaryRasterizer is SixelRasterizer) {
|
||||||
|
final sixel = primaryRasterizer as SixelRasterizer;
|
||||||
|
sixel.isSixelSupported = await SixelRasterizer.checkTerminalSixelSupport(
|
||||||
|
inputStream: _stdinStream,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stdin.hasTerminal) {
|
||||||
|
try {
|
||||||
stdin.echoMode = false;
|
stdin.echoMode = false;
|
||||||
stdin.lineMode = false;
|
stdin.lineMode = false;
|
||||||
|
} catch (_) {
|
||||||
|
// Keep running without raw mode when stdin is not mutable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stdout.write('\x1b[?25l\x1b[2J');
|
stdout.write('\x1b[?25l\x1b[2J');
|
||||||
|
|
||||||
_stdinSubscription = stdin.listen(_handleInput);
|
_stdinSubscription = _stdinStream.listen(_handleInput);
|
||||||
_stopwatch.start();
|
_stopwatch.start();
|
||||||
_timer = Timer.periodic(const Duration(milliseconds: 33), _tick);
|
_timer = Timer.periodic(const Duration(milliseconds: 33), _tick);
|
||||||
_isRunning = true;
|
_isRunning = true;
|
||||||
@@ -59,8 +79,12 @@ class CliGameLoop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stdin.hasTerminal) {
|
if (stdin.hasTerminal) {
|
||||||
|
try {
|
||||||
stdin.echoMode = true;
|
stdin.echoMode = true;
|
||||||
stdin.lineMode = true;
|
stdin.lineMode = true;
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore cleanup failures if stdin is no longer a mutable TTY.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stdout.hasTerminal) {
|
if (stdout.hasTerminal) {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
@@ -24,6 +26,105 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
int _outputHeight = 1;
|
int _outputHeight = 1;
|
||||||
bool _needsBackgroundClear = true;
|
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) {
|
FrameBuffer _createScaledBuffer(FrameBuffer terminalBuffer) {
|
||||||
final int previousOffsetColumns = _offsetColumns;
|
final int previousOffsetColumns = _offsetColumns;
|
||||||
final int previousOffsetRows = _offsetRows;
|
final int previousOffsetRows = _offsetRows;
|
||||||
@@ -91,6 +192,7 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
_engine = engine;
|
_engine = engine;
|
||||||
final FrameBuffer originalBuffer = engine.frameBuffer;
|
final FrameBuffer originalBuffer = engine.frameBuffer;
|
||||||
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
|
final FrameBuffer scaledBuffer = _createScaledBuffer(originalBuffer);
|
||||||
|
|
||||||
// We only need 8-bit indices for the 256 VGA colors
|
// We only need 8-bit indices for the 256 VGA colors
|
||||||
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
|
_screen = Uint8List(scaledBuffer.width * scaledBuffer.height);
|
||||||
engine.frameBuffer = scaledBuffer;
|
engine.frameBuffer = scaledBuffer;
|
||||||
@@ -129,9 +231,6 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
int texY = (relativeY * 64).toInt().clamp(0, 63);
|
||||||
|
|
||||||
int colorByte = texture.pixels[texX * 64 + texY];
|
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;
|
_screen[y * width + x] = colorByte;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +314,10 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String finalizeFrame() {
|
String finalizeFrame() {
|
||||||
|
if (!isSixelSupported) {
|
||||||
|
return _renderNotSupportedMessage();
|
||||||
|
}
|
||||||
|
|
||||||
final String clearPrefix = _needsBackgroundClear
|
final String clearPrefix = _needsBackgroundClear
|
||||||
? '$_terminalTealBackground\x1b[2J\x1b[0m'
|
? '$_terminalTealBackground\x1b[2J\x1b[0m'
|
||||||
: '';
|
: '';
|
||||||
@@ -222,18 +325,62 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
return '$clearPrefix\x1b[${_offsetRows + 1};${_offsetColumns + 1}H${toSixelString()}';
|
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
|
// SIXEL ENCODER
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
||||||
/// Converts the 8-bit index buffer into a standard Sixel sequence
|
|
||||||
String toSixelString() {
|
String toSixelString() {
|
||||||
StringBuffer sb = StringBuffer();
|
StringBuffer sb = StringBuffer();
|
||||||
|
|
||||||
// Start Sixel sequence (q = Sixel format)
|
|
||||||
sb.write('\x1bPq');
|
sb.write('\x1bPq');
|
||||||
|
|
||||||
// 1. Define the Palette (and apply damage flash directly to the palette!)
|
|
||||||
double damageIntensity = _engine.player.damageFlash;
|
double damageIntensity = _engine.player.damageFlash;
|
||||||
int redBoost = (150 * damageIntensity).toInt();
|
int redBoost = (150 * damageIntensity).toInt();
|
||||||
double colorDrop = 1.0 - (0.5 * damageIntensity);
|
double colorDrop = 1.0 - (0.5 * damageIntensity);
|
||||||
@@ -250,7 +397,6 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
b = (b * colorDrop).toInt().clamp(0, 255);
|
b = (b * colorDrop).toInt().clamp(0, 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sixel RGB ranges from 0 to 100
|
|
||||||
int sixelR = (r * 100) ~/ 255;
|
int sixelR = (r * 100) ~/ 255;
|
||||||
int sixelG = (g * 100) ~/ 255;
|
int sixelG = (g * 100) ~/ 255;
|
||||||
int sixelB = (b * 100) ~/ 255;
|
int sixelB = (b * 100) ~/ 255;
|
||||||
@@ -258,11 +404,9 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
|
sb.write('#$i;2;$sixelR;$sixelG;$sixelB');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Encode scaled image in 6-pixel vertical bands.
|
|
||||||
for (int band = 0; band < _outputHeight; band += 6) {
|
for (int band = 0; band < _outputHeight; band += 6) {
|
||||||
Map<int, Uint8List> colorMap = {};
|
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 x = 0; x < _outputWidth; x++) {
|
||||||
for (int yOffset = 0; yOffset < 6; yOffset++) {
|
for (int yOffset = 0; yOffset < 6; yOffset++) {
|
||||||
int y = band + yOffset;
|
int y = band + yOffset;
|
||||||
@@ -272,28 +416,22 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
if (!colorMap.containsKey(colorIdx)) {
|
if (!colorMap.containsKey(colorIdx)) {
|
||||||
colorMap[colorIdx] = Uint8List(_outputWidth);
|
colorMap[colorIdx] = Uint8List(_outputWidth);
|
||||||
}
|
}
|
||||||
// Set the bit corresponding to the vertical position (0-5)
|
|
||||||
colorMap[colorIdx]![x] |= (1 << yOffset);
|
colorMap[colorIdx]![x] |= (1 << yOffset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the encoded Sixel characters for each color present in the band
|
|
||||||
bool firstColor = true;
|
bool firstColor = true;
|
||||||
for (var entry in colorMap.entries) {
|
for (var entry in colorMap.entries) {
|
||||||
if (!firstColor) {
|
if (!firstColor) {
|
||||||
// Carriage return to overlay colors on the same band
|
|
||||||
sb.write('\$');
|
sb.write('\$');
|
||||||
}
|
}
|
||||||
firstColor = false;
|
firstColor = false;
|
||||||
|
|
||||||
// Select color index
|
|
||||||
sb.write('#${entry.key}');
|
sb.write('#${entry.key}');
|
||||||
|
|
||||||
Uint8List cols = entry.value;
|
Uint8List cols = entry.value;
|
||||||
int currentVal = -1;
|
int currentVal = -1;
|
||||||
int runLength = 0;
|
int runLength = 0;
|
||||||
|
|
||||||
// Run-Length Encoding (RLE) loop
|
|
||||||
for (int x = 0; x < _outputWidth; x++) {
|
for (int x = 0; x < _outputWidth; x++) {
|
||||||
int val = cols[x];
|
int val = cols[x];
|
||||||
if (val == currentVal) {
|
if (val == currentVal) {
|
||||||
@@ -312,7 +450,6 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// End Sixel sequence
|
|
||||||
sb.write('\x1b\\');
|
sb.write('\x1b\\');
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
@@ -335,7 +472,6 @@ class SixelRasterizer extends CliRasterizer<String> {
|
|||||||
|
|
||||||
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
|
void _writeSixelRle(StringBuffer sb, int value, int runLength) {
|
||||||
String char = String.fromCharCode(value + 63);
|
String char = String.fromCharCode(value + 63);
|
||||||
// Sixel RLE format: !<count><char> (only worth it if count > 3)
|
|
||||||
if (runLength > 3) {
|
if (runLength > 3) {
|
||||||
sb.write('!$runLength$char');
|
sb.write('!$runLength$char');
|
||||||
} else {
|
} 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) {
|
void _blitVgaImage(VgaImage image, int startX, int startY) {
|
||||||
|
|||||||
Reference in New Issue
Block a user