zart 1.9.2 copy "zart: ^1.9.2" to clipboard
zart: ^1.9.2 copied to clipboard

This is a Dart implementation of the Infocom Z-Machine.

example/main.dart

import 'dart:io';
import 'dart:async';

import 'package:zart/src/logging.dart' show log;
import 'package:logging/logging.dart' show Level;
import 'package:zart/zart.dart' hide getPreamble;
import 'package:zart/src/config/configuration_manager.dart';
import 'package:zart/src/ui/settings_screen.dart';
import 'package:zart/src/ui/terminal_display.dart';

/// A full-screen terminal-based console player for Z-Machine.
/// Uses dart_console for cross-platform support.
void main(List<String> args) async {
  log.level = Level.INFO;

  // Initialize Configuration
  final config = ConfigurationManager();
  config.load();

  // final debugFile = File('debug.txt');
  // debugFile.writeAsStringSync(''); // Clear file
  // log.onRecord.listen((record) {
  //   debugFile.writeAsStringSync(
  //     '${record.level.name}: ${record.message}\n',
  //     mode: FileMode.append,
  //   );
  // });

  if (args.isEmpty) {
    stdout.writeln('Usage: zart <game>');
    exit(1);
  }

  final filename = args.first;
  final f = File(filename);

  if (!f.existsSync()) {
    stdout.writeln('Error: Game file not found at "$filename"');
    stdout.writeln('Current Directory: ${Directory.current.path}');
    exit(1);
  }

  var isGameRunning = false;
  final terminal = TerminalDisplay();
  terminal.config = config;
  terminal.applySavedSettings();
  terminal.onOpenSettings = () =>
      SettingsScreen(terminal, config).show(isGameStarted: isGameRunning);

  try {
    final bytes = f.readAsBytesSync();
    final gameData = Blorb.getZData(bytes);

    if (gameData == null) {
      stdout.writeln('Unable to load game.');
      exit(1);
    }

    // Set IoProvider before loading
    final provider = TerminalProvider(terminal, filename);
    Z.io = provider as IoProvider;
    // Map autosave trigger to provider flag
    terminal.onAutosave = () {
      provider.isQuickSaveMode = true;
    };
    // Map autorestore trigger
    terminal.onRestore = () {
      provider.isAutorestoreMode = true;
    };

    Z.load(gameData);
  } catch (fe) {
    stdout.writeln("Exception occurred while trying to load game: $fe");
    exit(1);
  }

  // Disable debugging for clean display
  Debugger.enableDebug = false;
  Debugger.enableVerbose = false;
  Debugger.enableTrace = false;
  Debugger.enableStackTrace = false;

  // Handle Ctrl+C to properly exit full-screen mode
  ProcessSignal.sigint.watch().listen((_) {
    try {
      terminal.exitFullScreen();
      stdout.writeln('Interrupted.');
      exit(0);
    } catch (e, stack) {
      terminal.exitFullScreen();
      stdout.writeln('Error: $e');
      stdout.writeln('Stack Trace: $stack');
      rethrow;
    }
  });

  try {
    // Enter full-screen mode
    terminal.enterFullScreen();

    // Jump straight into game
    isGameRunning = true;
    terminal.enableStatusBar = true; // Show status bar in game

    // Command queue for chained commands (e.g., "get up.take all.north")
    final commandQueue = <String>[];

    // Pump API: run until input needed, then get input and continue
    var state = await Z.runUntilInput();

    while (state != ZMachineRunState.quit) {
      switch (state) {
        case ZMachineRunState.needsLineInput:
          if (commandQueue.isEmpty) {
            terminal.render();
            final line = await terminal.readLine();
            terminal.appendToWindow0('\n');
            // Split by '.' to support chained commands
            final commands = line
                .split('.')
                .map((c) => c.trim())
                .where((c) => c.isNotEmpty)
                .toList();
            if (commands.isEmpty) {
              state = await Z.submitLineInput('');
            } else {
              commandQueue.addAll(commands);
              state = await Z.submitLineInput(commandQueue.removeAt(0));
            }
          } else {
            final cmd = commandQueue.removeAt(0);
            terminal.appendToWindow0('$cmd\n');
            state = await Z.submitLineInput(cmd);
          }
          break;
        case ZMachineRunState.needsCharInput:
          terminal.render();
          final char = await terminal.readChar();
          if (char.isNotEmpty) {
            state = await Z.submitCharInput(char);
          }
          break;
        case ZMachineRunState.quit:
        case ZMachineRunState.error:
        case ZMachineRunState.running:
          break;
      }
    }

    terminal.appendToWindow0('\n[Press any key to exit]');
    terminal.render();
    await terminal.readChar();
  } on GameException catch (e) {
    terminal.exitFullScreen();
    log.severe('A game error occurred: $e');
    exit(1);
  } catch (err, stack) {
    terminal.exitFullScreen();
    stdout.writeln('A system error occurred: $err');
    stdout.writeln('Stack Trace:\n$stack');
    exit(1);
  } finally {
    terminal.exitFullScreen();
    exit(0);
  }
}

class TerminalProvider implements IoProvider {
  final TerminalDisplay terminal;
  final String gameName;
  bool isQuickSaveMode = false;
  bool isAutorestoreMode = false;
  TerminalProvider(this.terminal, this.gameName);

  @override
  int getFlags1() {
    // Flag 1 = Color available (bit 0)
    // Flag 4 = Bold available (bit 2)
    // Flag 5 = Italic available (bit 3)
    // Flag 6 = Fixed-width font available (bit 4)
    // Flag 8 = Timed input available (bit 7)
    return 1 | 4 | 8 | 16 | 128; // Color, Bold, Italic, Fixed, Timed input
    // Note: Timed input isn't fully implemented in run loop yet but we claim it.
  }

  // Method mapping implementation...
  @override
  Future<dynamic> command(Map<String, dynamic> commandMessage) async {
    final cmd = commandMessage['command'] as IoCommands;
    switch (cmd) {
      case IoCommands.print:
        final window = commandMessage['window'] as int;
        final buffer = commandMessage['buffer'] as String?;
        if (buffer != null) {
          if (window == 1) {
            terminal.writeToWindow1(buffer);
          } else {
            terminal.appendToWindow0(buffer);
          }
        }
        break;
      case IoCommands.splitWindow:
        final lines = commandMessage['lines'] as int;
        terminal.splitWindow(lines);
        break;
      case IoCommands.setWindow:
        // Current window is implicit in print command usage in Z-Machine
        // But we track it in IoProvider? No, ScreenModel manages where text goes?
        // Z-Machine ops: `set_window`.
        // The interpreter passes `window` arg only to `print`.
        // We're good.
        break;
      case IoCommands.clearScreen:
        final window = commandMessage['window_id'] as int;
        if (window == -1 || window == -2) {
          terminal.clearAll();
        } else if (window == 0) {
          terminal.clearWindow0();
        } else if (window == 1) {
          terminal.clearWindow1();
        }
        break;
      case IoCommands.setCursor:
        final line = commandMessage['line'] as int;
        final col = commandMessage['column'] as int;
        terminal.setCursor(line, col);
        break;
      case IoCommands.getCursor:
        return terminal.getCursor();
      case IoCommands.setTextStyle:
        final style = commandMessage['style'] as int;
        terminal.setStyle(style);
        break;
      case IoCommands.setColour:
        final fg = commandMessage['foreground'] as int;
        final bg = commandMessage['background'] as int;
        terminal.setColors(fg, bg);
        break;
      case IoCommands.eraseLine:
        // Erase line in current window?
        // Z-machine standard: erase to end of line.
        // We'll leave unimplemented for now.
        break;
      case IoCommands.status:
        // V3 Status Line
        final room = commandMessage['room_name'] as String;
        final score1 = commandMessage['score_one'] as String;
        final score2 = commandMessage['score_two'] as String;
        final isTime = (commandMessage['game_type'] as String) == 'TIME';

        // Format: "Room Name" (left) ... "Score: A Moves: B" (right)
        final rightText = isTime
            ? 'Time: $score1:$score2'
            : 'Score: $score1 Moves: $score2';

        // Ensure window 1 has at least 1 line
        if (terminal.screen.window1Height < 1) {
          terminal.splitWindow(1); // Force 1 line for status
        }

        // We want to construct a single line of text with padding
        // But writeToWindow1 writes sequentially.
        // And we want INVERSE VIDEO.

        // Enable White on Grey + Bold
        terminal.setStyle(2); // 2=Bold
        terminal.setColors(9, 10); // White on Grey

        // Move to top-left of Window 1
        terminal.setCursor(1, 1);

        // 1. Write Room Name
        terminal.writeToWindow1(' $room');

        // 2. Calculate padding
        final width = terminal.cols;
        final leftLen = room.length + 1; // +1 for leading space
        final rightLen =
            rightText.length + 1; // +1 for trailing space? or just visual?
        final pad = width - leftLen - rightLen;

        if (pad > 0) {
          terminal.writeToWindow1(' ' * pad);
        }

        // 3. Write Score/Moves
        terminal.writeToWindow1('$rightText ');

        // Reset style
        // Reset style
        terminal.setStyle(0);
        terminal.setColors(1, 1); // Reset to defaults
        break;
      case IoCommands.save:
        final fileData = commandMessage['file_data'] as List<int>;

        String filename;
        if (isQuickSaveMode) {
          // QuickSave logic
          // Use format "quick_save_{game_name}.sav"
          // Robustly handle path separators (both / and \) to get just the filename
          String base = gameName.split(RegExp(r'[/\\]')).last;
          if (base.contains('.')) {
            base = base.substring(0, base.lastIndexOf('.'));
          }

          filename = 'quick_save_$base.sav';

          final f = File(filename);
          f.writeAsBytesSync(fileData);

          // Show transient message
          terminal.showTempMessage('Game saved...');

          // Reset flag
          isQuickSaveMode = false;
          return true;
        }

        terminal.appendToWindow0('\nEnter filename to save: ');
        terminal.render();
        filename = await terminal.readLine();
        terminal.appendToWindow0('$filename\n');

        if (filename.isEmpty) return false;

        if (!filename.toLowerCase().endsWith('.sav')) {
          filename += '.sav';
        }

        try {
          final f = File(filename);
          f.writeAsBytesSync(fileData);
          terminal.appendToWindow0('Saved to "$filename".\n');
          return true;
        } catch (e) {
          terminal.appendToWindow0('Save failed: $e\n');
          return false;
        }
      case IoCommands.restore:
        String filename;

        if (isAutorestoreMode) {
          // Robustly handle path separators (both / and \) to get just the filename
          String base = gameName.split(RegExp(r'[/\\]')).last;
          if (base.contains('.')) {
            base = base.substring(0, base.lastIndexOf('.'));
          }

          filename = 'quick_save_$base.sav';

          final f = File(filename);
          if (!f.existsSync()) {
            terminal.showTempMessage(
              'QuickSave File Not Found! Cannot Restore',
              seconds: 3,
            );
            isAutorestoreMode = false;
            return null;
          }

          final data = f.readAsBytesSync();
          // We send success message only after we know we are returning data.
          // Note: The Z-Machine might take a moment to process, but from UI perspective 'Restoring...' is valid.
          // User asked for "Game restored." message after bytes sent.
          // Since we return 'data' here, the Z-Machine uses it *immediately*.
          // So "Game restored..." is appropriate here.
          terminal.showTempMessage('Game restored...', seconds: 3);

          isAutorestoreMode = false;
          return data;
        }

        terminal.appendToWindow0('\nEnter filename to restore: ');
        terminal.render();
        filename = await terminal.readLine();
        terminal.appendToWindow0('$filename\n');

        if (filename.isEmpty) return null;

        if (!filename.toLowerCase().endsWith('.sav')) {
          filename += '.sav';
        }

        try {
          final f = File(filename);
          if (!f.existsSync()) {
            terminal.appendToWindow0('File not found: "$filename"\n');
            return null;
          }
          final data = f.readAsBytesSync();
          terminal.appendToWindow0('Restored from "$filename".\n');
          return data;
        } catch (e) {
          terminal.appendToWindow0('Restore failed: $e\n');
          return null;
        }

      default:
        break;
    }
  }
}
2
likes
0
points
1.07k
downloads

Publisher

unverified uploader

Weekly Downloads

This is a Dart implementation of the Infocom Z-Machine.

Homepage
Repository (GitHub)
View/report issues

Topics

#zmachine #interactive-fiction #zork

License

unknown (license)

Dependencies

dart_console, logging

More

Packages that depend on zart