From a9af6859f3577219976b6426158b09ff631301c1 Mon Sep 17 00:00:00 2001 From: Chris Mounce Date: Tue, 20 Jan 2026 18:30:54 -0800 Subject: [PATCH] Add initial text format for .ZZT/.BRD --- src/zzt/elements.rs | 228 ++++++++++++++++++++++++++++++++++++++++++++ src/zzt/mod.rs | 3 + src/zzt/text.rs | 216 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 447 insertions(+) create mode 100644 src/zzt/elements.rs create mode 100644 src/zzt/text.rs diff --git a/src/zzt/elements.rs b/src/zzt/elements.rs new file mode 100644 index 0000000..4553f9a --- /dev/null +++ b/src/zzt/elements.rs @@ -0,0 +1,228 @@ +/// ZZT element types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum Element { + Empty = 0, + BoardEdge = 1, + Messenger = 2, + Monitor = 3, + Player = 4, + Ammo = 5, + Torch = 6, + Gem = 7, + Key = 8, + Door = 9, + Scroll = 10, + Passage = 11, + Duplicator = 12, + Bomb = 13, + Energizer = 14, + Star = 15, + Clockwise = 16, + Counter = 17, + Bullet = 18, + Water = 19, + Forest = 20, + Solid = 21, + Normal = 22, + Breakable = 23, + Boulder = 24, + SliderNs = 25, + SliderEw = 26, + Fake = 27, + Invisible = 28, + BlinkWall = 29, + Transporter = 30, + Line = 31, + Ricochet = 32, + BlinkRayH = 33, + Bear = 34, + Ruffian = 35, + Object = 36, + Slime = 37, + Shark = 38, + SpinningGun = 39, + Pusher = 40, + Lion = 41, + Tiger = 42, + BlinkRayV = 43, + Head = 44, + Segment = 45, + // 46 is unused + TextBlue = 47, + TextGreen = 48, + TextCyan = 49, + TextRed = 50, + TextPurple = 51, + TextBrown = 52, + TextBlack = 53, +} + +impl Element { + /// Try to convert a u8 to an Element. + pub fn from_u8(id: u8) -> Option { + match id { + 0 => Some(Element::Empty), + 1 => Some(Element::BoardEdge), + 2 => Some(Element::Messenger), + 3 => Some(Element::Monitor), + 4 => Some(Element::Player), + 5 => Some(Element::Ammo), + 6 => Some(Element::Torch), + 7 => Some(Element::Gem), + 8 => Some(Element::Key), + 9 => Some(Element::Door), + 10 => Some(Element::Scroll), + 11 => Some(Element::Passage), + 12 => Some(Element::Duplicator), + 13 => Some(Element::Bomb), + 14 => Some(Element::Energizer), + 15 => Some(Element::Star), + 16 => Some(Element::Clockwise), + 17 => Some(Element::Counter), + 18 => Some(Element::Bullet), + 19 => Some(Element::Water), + 20 => Some(Element::Forest), + 21 => Some(Element::Solid), + 22 => Some(Element::Normal), + 23 => Some(Element::Breakable), + 24 => Some(Element::Boulder), + 25 => Some(Element::SliderNs), + 26 => Some(Element::SliderEw), + 27 => Some(Element::Fake), + 28 => Some(Element::Invisible), + 29 => Some(Element::BlinkWall), + 30 => Some(Element::Transporter), + 31 => Some(Element::Line), + 32 => Some(Element::Ricochet), + 33 => Some(Element::BlinkRayH), + 34 => Some(Element::Bear), + 35 => Some(Element::Ruffian), + 36 => Some(Element::Object), + 37 => Some(Element::Slime), + 38 => Some(Element::Shark), + 39 => Some(Element::SpinningGun), + 40 => Some(Element::Pusher), + 41 => Some(Element::Lion), + 42 => Some(Element::Tiger), + 43 => Some(Element::BlinkRayV), + 44 => Some(Element::Head), + 45 => Some(Element::Segment), + 47 => Some(Element::TextBlue), + 48 => Some(Element::TextGreen), + 49 => Some(Element::TextCyan), + 50 => Some(Element::TextRed), + 51 => Some(Element::TextPurple), + 52 => Some(Element::TextBrown), + 53 => Some(Element::TextBlack), + _ => None, + } + } + + /// Get the human-readable name for this element. + pub fn name(self) -> &'static str { + match self { + Element::Empty => "empty", + Element::BoardEdge => "board_edge", + Element::Messenger => "messenger", + Element::Monitor => "monitor", + Element::Player => "player", + Element::Ammo => "ammo", + Element::Torch => "torch", + Element::Gem => "gem", + Element::Key => "key", + Element::Door => "door", + Element::Scroll => "scroll", + Element::Passage => "passage", + Element::Duplicator => "duplicator", + Element::Bomb => "bomb", + Element::Energizer => "energizer", + Element::Star => "star", + Element::Clockwise => "clockwise", + Element::Counter => "counter", + Element::Bullet => "bullet", + Element::Water => "water", + Element::Forest => "forest", + Element::Solid => "solid", + Element::Normal => "normal", + Element::Breakable => "breakable", + Element::Boulder => "boulder", + Element::SliderNs => "sliderns", + Element::SliderEw => "sliderew", + Element::Fake => "fake", + Element::Invisible => "invisible", + Element::BlinkWall => "blinkwall", + Element::Transporter => "transporter", + Element::Line => "line", + Element::Ricochet => "ricochet", + Element::BlinkRayH => "blink_ray_h", + Element::Bear => "bear", + Element::Ruffian => "ruffian", + Element::Object => "object", + Element::Slime => "slime", + Element::Shark => "shark", + Element::SpinningGun => "spinninggun", + Element::Pusher => "pusher", + Element::Lion => "lion", + Element::Tiger => "tiger", + Element::BlinkRayV => "blink_ray_v", + Element::Head => "head", + Element::Segment => "segment", + Element::TextBlue => "text_blue", + Element::TextGreen => "text_green", + Element::TextCyan => "text_cyan", + Element::TextRed => "text_red", + Element::TextPurple => "text_purple", + Element::TextBrown => "text_brown", + Element::TextBlack => "text_black", + } + } + + /// Get the alias for parameter 1 based on element type. + pub fn p1_alias(self) -> Option<&'static str> { + match self { + Element::BlinkWall => Some("start_time"), + Element::Bear => Some("sensitivity"), + Element::Ruffian => Some("intelligence"), + Element::Object => Some("char"), + Element::Shark => Some("intelligence"), + Element::SpinningGun => Some("intelligence"), + Element::Lion => Some("intelligence"), + Element::Tiger => Some("intelligence"), + Element::Head => Some("intelligence"), + _ => None, + } + } + + /// Get the alias for parameter 2 based on element type. + pub fn p2_alias(self) -> Option<&'static str> { + match self { + Element::Duplicator => Some("rate"), + Element::BlinkWall => Some("period"), + Element::Ruffian => Some("resting_time"), + Element::Slime => Some("speed"), + Element::SpinningGun => Some("firing_rate"), + Element::Tiger => Some("firing_rate"), + Element::Head => Some("deviance"), + _ => None, + } + } + + /// Get the alias for parameter 3 based on element type. + pub fn p3_alias(self) -> Option<&'static str> { + match self { + Element::Passage => Some("destination"), + Element::SpinningGun => Some("firing_type"), + Element::Tiger => Some("firing_type"), + _ => None, + } + } +} + +/// Get the human-readable name for an element ID, or describe unknown elements. +pub fn element_name(id: u8) -> String { + match Element::from_u8(id) { + Some(e) => e.name().to_string(), + None => format!("unknown ({})", id), + } +} diff --git a/src/zzt/mod.rs b/src/zzt/mod.rs index 84952b2..95b3268 100644 --- a/src/zzt/mod.rs +++ b/src/zzt/mod.rs @@ -1,7 +1,10 @@ +mod elements; mod encoding; mod error; mod parse; +mod text; pub use encoding::{decode_multiline, decode_oneline, encode_multiline, encode_oneline}; pub use error::ParseError; pub use parse::{Board, Program, Stat, Tile, World}; +pub use text::{board_to_text, world_to_text}; diff --git a/src/zzt/text.rs b/src/zzt/text.rs new file mode 100644 index 0000000..aa42cc6 --- /dev/null +++ b/src/zzt/text.rs @@ -0,0 +1,216 @@ +use std::fmt::Write; + +use super::elements::{Element, element_name}; +use super::parse::{Board, Program, Stat, Tile, World}; + +/// Write key = value if value != default. +macro_rules! kv { + ($out:expr, $key:expr, $val:expr, $default:expr) => { + if $val != $default { + writeln!($out, "{} = {}", $key, $val).unwrap(); + } + }; +} + +/// Write key = true if value is true. +macro_rules! kv_bool { + ($out:expr, $key:expr, $val:expr) => { + if $val { + writeln!($out, "{} = true", $key).unwrap(); + } + }; +} + +/// Write key = "value" if value != default. +macro_rules! kv_str { + ($out:expr, $key:expr, $val:expr, $default:expr) => { + if $val != $default { + writeln!($out, "{} = {:?}", $key, $val).unwrap(); + } + }; +} + +/// Convert a World to its text representation. +pub fn world_to_text(world: &World) -> String { + let mut output = String::new(); + write_world_header(&mut output, world); + + for (i, board) in world.boards.iter().enumerate() { + output.push('\n'); + write_board(&mut output, i, board); + } + + output +} + +/// Convert a standalone Board to its text representation. +pub fn board_to_text(board: &Board) -> String { + let mut output = String::new(); + write_board(&mut output, 0, board); + output +} + +fn write_world_header(output: &mut String, world: &World) { + output.push_str("[world]\n"); + writeln!(output, "name = {:?}", world.name).unwrap(); + + kv!(output, "health", world.health, 100); + kv!(output, "ammo", world.ammo, 0); + kv!(output, "gems", world.gems, 0); + kv!(output, "torches", world.torches, 0); + kv!(output, "score", world.score, 0); + + let key_str = keys_to_string(&world.keys); + if !key_str.is_empty() { + writeln!(output, "keys = {:?}", key_str).unwrap(); + } + + kv!(output, "starting_board", world.starting_board, 0); + kv_bool!(output, "saved_game", world.locked); + + // Print flags until we've printed all the non-empty ones. + // If there are empty ones in between, they will be printed, e.g. ["foo", "", "bar"], + // but the empty ones at the end will be omitted. + let last_non_empty = world.flags.iter().rposition(|f| !f.is_empty()); + if let Some(last_idx) = last_non_empty { + let flags = &world.flags[..=last_idx]; + write!(output, "flags = [").unwrap(); + for (i, flag) in flags.iter().enumerate() { + if i > 0 { + output.push_str(", "); + } + write!(output, "{:?}", flag).unwrap(); + } + output.push_str("]\n"); + } + + kv!(output, "torch_cycles", world.torch_cycles, 0); + kv!(output, "energizer_cycles", world.energizer_cycles, 0); + kv!(output, "time", world.time, 0); + kv!(output, "time_ticks", world.time_ticks, 0); +} + +fn keys_to_string(keys: &[bool; 7]) -> String { + let key_chars = ['b', 'g', 'c', 'r', 'p', 'y', 'w']; + keys.iter() + .zip(key_chars.iter()) + .filter(|(has_key, _)| **has_key) + .map(|(_, c)| *c) + .collect() +} + +fn write_board(output: &mut String, index: usize, board: &Board) { + writeln!(output, "[board {}]", index).unwrap(); + writeln!(output, "title = {:?}", board.name).unwrap(); + output.push('\n'); + + write_terrain(output, &board.tiles); + output.push('\n'); + + kv!(output, "shots", board.max_shots, 0); + kv_bool!(output, "dark", board.is_dark); + kv!(output, "exit_n", board.exit_north, 0); + kv!(output, "exit_s", board.exit_south, 0); + kv!(output, "exit_e", board.exit_east, 0); + kv!(output, "exit_w", board.exit_west, 0); + kv_bool!(output, "reenter", board.restart_on_zap); + kv!(output, "time_limit", board.time_limit, 0); + kv!(output, "enter_x", board.enter_x, 0); + kv!(output, "enter_y", board.enter_y, 0); + kv_str!(output, "message", &board.message, ""); + + for (i, stat) in board.stats.iter().enumerate() { + let element = get_element_at(board, stat.x, stat.y); + output.push('\n'); + write_stat(output, i, stat, element); + } +} + +fn write_terrain(output: &mut String, tiles: &[Tile]) { + // Elements: 60x25 grid, element bytes as 2-digit hex + for row in 0..25 { + for col in 0..60 { + let tile = &tiles[row * 60 + col]; + write!(output, "{:02x}", tile.element).unwrap(); + } + output.push('\n'); + } + + output.push('\n'); + + // Colors: 60x25 grid, color bytes as 2-digit hex + for row in 0..25 { + for col in 0..60 { + let tile = &tiles[row * 60 + col]; + write!(output, "{:02x}", tile.color).unwrap(); + } + output.push('\n'); + } +} + +fn get_element_at(board: &Board, x: u8, y: u8) -> Option { + if x == 0 || y == 0 || x > 60 || y > 25 { + return None; + } + let index = ((y as usize - 1) * 60) + (x as usize - 1); + board.tiles.get(index).map(|t| t.element) +} + +fn write_stat(output: &mut String, index: usize, stat: &Stat, element: Option) { + // Stat header with element type comment + let element_comment = match element { + Some(id) => element_name(id), + None => "off-board".to_string(), + }; + writeln!(output, "[stat {}] # {}", index, element_comment).unwrap(); + + writeln!(output, "x = {}", stat.x).unwrap(); + writeln!(output, "y = {}", stat.y).unwrap(); + + kv!(output, "cycle", stat.cycle, 0); + kv!(output, "x_step", stat.x_step, 0); + kv!(output, "y_step", stat.y_step, 0); + + if stat.under.element != 0 || stat.under.color != 0 { + writeln!( + output, + "under = ({}, {})", + stat.under.element, stat.under.color + ) + .unwrap(); + } + + kv!(output, "follower", stat.follower, -1); + kv!(output, "leader", stat.leader, -1); + kv!(output, "instruction_pointer", stat.instruction_pointer, 0); + + // Parameters with element-specific aliases + let elem = element.and_then(Element::from_u8); + write_param(output, stat.p1, "p1", elem.and_then(|e| e.p1_alias())); + write_param(output, stat.p2, "p2", elem.and_then(|e| e.p2_alias())); + write_param(output, stat.p3, "p3", elem.and_then(|e| e.p3_alias())); + + // Program/code + match &stat.program { + Program::Own(code) if !code.is_empty() => { + output.push_str("code = \"\"\"\n"); + output.push_str(code); + if !code.ends_with('\n') { + output.push('\n'); + } + output.push_str("\"\"\"\n"); + } + Program::Bound(idx) => { + writeln!(output, "bind = {}", idx).unwrap(); + } + _ => {} + } +} + +fn write_param(output: &mut String, value: u8, generic_name: &str, alias: Option<&str>) { + if value == 0 { + return; + } + let name = alias.unwrap_or(generic_name); + writeln!(output, "{} = {}", name, value).unwrap(); +}