/// Terminal game loop that ties engine ticks, raw input, and CLI rendering together. library; import 'dart:async'; import 'dart:io'; import 'package:wolf_3d_dart/wolf_3d_engine.dart'; import 'package:wolf_3d_dart/wolf_3d_input.dart'; import 'package:wolf_3d_dart/wolf_3d_rasterizer.dart'; /// Runs the Wolf3D engine inside a terminal using CLI-specific rasterizers. /// /// The loop owns raw-stdin handling, renderer switching, terminal size checks, /// and frame pacing. It expects [engine.input] to be a [CliInput] instance so /// raw key bytes can be queued directly into the engine input adapter. class CliGameLoop { CliGameLoop({ required this.engine, required this.onExit, }) : input = engine.input is CliInput ? engine.input as CliInput : throw ArgumentError.value( engine.input, 'engine.input', 'CliGameLoop requires a CliInput instance.', ), primaryRasterizer = AsciiRasterizer( mode: AsciiRasterizerMode.terminalAnsi, ), secondaryRasterizer = SixelRasterizer() { _rasterizer = primaryRasterizer; } final WolfEngine engine; final CliRasterizer primaryRasterizer; final CliRasterizer secondaryRasterizer; final CliInput input; final void Function(int code) onExit; final Stopwatch _stopwatch = Stopwatch(); final Stream> _stdinStream = stdin.asBroadcastStream(); late CliRasterizer _rasterizer; StreamSubscription>? _stdinSubscription; Timer? _timer; bool _isRunning = false; Duration _lastTick = Duration.zero; /// Starts terminal probing, enters raw input mode, and begins the frame timer. Future start() async { if (_isRunning) { 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.lineMode = false; } catch (_) { // Keep running without raw mode when stdin is not mutable. } } stdout.write('\x1b[?25l\x1b[2J'); _stdinSubscription = _stdinStream.listen(_handleInput); _stopwatch.start(); _timer = Timer.periodic(const Duration(milliseconds: 33), _tick); _isRunning = true; } /// Stops the timer, unsubscribes from stdin, and restores terminal settings. void stop() { if (!_isRunning) { return; } _timer?.cancel(); _timer = null; _stdinSubscription?.cancel(); _stdinSubscription = null; if (_stopwatch.isRunning) { _stopwatch.stop(); } if (stdin.hasTerminal) { try { stdin.echoMode = true; stdin.lineMode = true; } catch (_) { // Ignore cleanup failures if stdin is no longer a mutable TTY. } } if (stdout.hasTerminal) { stdout.write('\x1b[0m\x1b[?25h'); } _isRunning = false; } void _handleInput(List bytes) { // Keep q and Ctrl+C as hard exits; ESC is now menu-back input. if (bytes.contains(113) || bytes.contains(3)) { stop(); onExit(0); return; } if (bytes.contains(9)) { // Tab swaps between rasterizers so renderer debugging stays available // without restarting the process. _rasterizer = identical(_rasterizer, secondaryRasterizer) ? primaryRasterizer : secondaryRasterizer; stdout.write('\x1b[2J\x1b[H'); return; } input.handleKey(bytes); } void _tick(Timer timer) { if (!_isRunning) { return; } if (stdout.hasTerminal) { final int cols = stdout.terminalColumns; final int rows = stdout.terminalLines; if (!_rasterizer.prepareTerminalFrame( engine, columns: cols, rows: rows, )) { // Size warnings are rendered instead of running the simulation so the // game does not keep advancing while the user resizes the terminal. stdout.write('\x1b[2J\x1b[H'); stdout.write( _rasterizer.buildTerminalSizeWarning(columns: cols, rows: rows), ); _lastTick = _stopwatch.elapsed; return; } } final Duration currentTick = _stopwatch.elapsed; final Duration elapsed = currentTick - _lastTick; _lastTick = currentTick; stdout.write('\x1b[H'); engine.tick(elapsed); stdout.write(_rasterizer.render(engine)); } }