use crate::{Dialogue, Ending, Font, Item, Palette, Room, Sprite, TextDirection, Tile, ToBase36, Variable, transform_line_endings, segments_from_string, is_string_numeric}; use std::error::Error; use loe::TransformMode; /// in very early versions of Bitsy, room tiles were defined as single characters /// so, only 36 tiles total. later versions are comma-separated #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum RoomFormat {Contiguous, CommaSeparated} impl RoomFormat { fn from(str: &str) -> Result { match str { "0" => Ok(RoomFormat::Contiguous), "1" => Ok(RoomFormat::CommaSeparated), _ => panic!(format!("Invalid room format: {}", str)), } } fn to_string(&self) -> String { match &self { RoomFormat::Contiguous => "0", RoomFormat::CommaSeparated => "1", }.to_string() } } /// in very early versions of Bitsy, a room was called a "set" #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum RoomType {Room, Set} impl From<&str> for RoomType { fn from(string: &str) -> RoomType { match string { "ROOM" => RoomType::Room, "SET" => RoomType::Set, _ => panic!("Unrecognised room type"), } } } impl ToString for RoomType { fn to_string(&self) -> String { match &self { RoomType::Set => "SET", RoomType::Room => "ROOM", }.to_string() } } #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub struct Version { pub major: u8, pub minor: u8, } impl Version { fn from(str: &str) -> Version { let parts: Vec<&str> = str.split(".").collect(); assert_eq!(parts.len(), 2); Version { major: parts[0].parse().unwrap(), minor: parts[1].parse().unwrap(), } } } #[derive(Debug, PartialEq)] pub struct Game { pub name: String, pub version: Option, pub room_format: Option, pub(crate) room_type: RoomType, pub font: Font, pub custom_font: Option, // used if font is Font::Custom pub text_direction: TextDirection, pub palettes: Vec, pub rooms: Vec, pub tiles: Vec, pub avatar: Sprite, pub sprites: Vec, pub items: Vec, pub dialogues: Vec, pub endings: Vec, pub variables: Vec, pub font_data: Option, // todo make this an actual struct for parsing pub(crate) line_endings_crlf: bool, // otherwise lf (unix/mac) } impl Game { pub fn from(string: String) -> Result { let line_endings_crlf = string.contains("\r\n"); let mut string = string; if line_endings_crlf { string = transform_line_endings(string, TransformMode::LF) } let string = string.trim_start_matches("\n").to_string(); let mut segments = segments_from_string(string); let mut name = "".to_string(); if ! segments[0].starts_with("# BITSY VERSION ") && ! segments[0].starts_with("! ROOM_FORMAT ") && ! segments[0].starts_with("PAL ") && ! segments[0].starts_with("DEFAULT_FONT ") && ! segments[0].starts_with("TEXT_DIRECTION ") { name = segments[0].to_string(); segments = segments[1..].to_owned(); } let segments = segments; let name = name; let mut dialogues: Vec = Vec::new(); let mut endings: Vec = Vec::new(); let mut variables: Vec = Vec::new(); let mut font_data: Option = None; let mut version = None; let mut room_format = None; let mut room_type = RoomType::Room; let mut font = Font::AsciiSmall; let mut custom_font = None; let mut text_direction = TextDirection::LeftToRight; let mut palettes: Vec = Vec::new(); let mut rooms: Vec = Vec::new(); let mut tiles: Vec = Vec::new(); let mut avatar: Option = None; // unwrap this later let mut sprites: Vec = Vec::new(); let mut items: Vec = Vec::new(); for segment in segments { if segment.starts_with("# BITSY VERSION") { let segment = segment.replace("# BITSY VERSION ", ""); version = Some(Version::from(&segment)); } else if segment.starts_with("! ROOM_FORMAT") { let segment = segment.replace("! ROOM_FORMAT ", ""); room_format = Some(RoomFormat::from(&segment).unwrap()); } else if segment.starts_with("DEFAULT_FONT") { let segment = segment.replace("DEFAULT_FONT ", ""); font = Font::from(&segment); if font == Font::Custom { custom_font = Some(segment.to_string()); } } else if segment.trim() == "TEXT_DIRECTION RTL".to_string() { text_direction = TextDirection::RightToLeft; } else if segment.starts_with("PAL ") { palettes.push(Palette::from(segment)); } else if segment.starts_with("ROOM ") || segment.starts_with("SET ") { if segment.starts_with("SET ") { room_type = RoomType::Set; } rooms.push(Room::from(segment)); } else if segment.starts_with("TIL ") { tiles.push(Tile::from(segment)); } else if segment.starts_with("SPR A") { avatar = Some(Sprite::from(segment)); } else if segment.starts_with("SPR ") { sprites.push(Sprite::from(segment)); } else if segment.starts_with("ITM ") { items.push(Item::from(segment)); } else if segment.starts_with("DLG ") { dialogues.push(Dialogue::from(segment)); } else if segment.starts_with("END ") { endings.push(Ending::from(segment)); } else if segment.starts_with("VAR ") { variables.push(Variable::from(segment)); } else if segment.starts_with("FONT ") { font_data = Some(segment); } } assert!(avatar.is_some()); let avatar = avatar.unwrap(); Ok( Game { name, version, room_format, room_type, font, custom_font, text_direction, palettes, rooms, tiles, avatar, sprites, items, dialogues, endings, variables, font_data, line_endings_crlf, } ) } } impl ToString for Game { #[inline] fn to_string(&self) -> String { let mut segments: Vec = Vec::new(); // todo refactor for palette in &self.palettes { segments.push(palette.to_string()); } for room in &self.rooms { segments.push(room.to_string(self.room_format(), self.room_type)); } for tile in &self.tiles { segments.push(tile.to_string()); } for sprite in &self.sprites { if is_string_numeric(sprite.id.to_base36()) { segments.push(sprite.to_string()); } } segments.push(self.avatar.to_string().replace("SPR a", "SPR A")); for sprite in &self.sprites { if !is_string_numeric(sprite.id.to_base36()) { segments.push(sprite.to_string()); } } for item in &self.items { segments.push(item.to_string()); } for dialogue in &self.dialogues { segments.push(dialogue.to_string()); } for ending in &self.endings { segments.push(ending.to_string()); } for variable in &self.variables { segments.push(variable.to_string()); } if self.font_data.is_some() { segments.push(self.font_data.to_owned().unwrap()) } transform_line_endings( format!( "{}{}{}{}{}\n\n{}\n\n", &self.name, &self.version_line(), &self.room_format_line(), &self.font_line(), &self.text_direction_line(), segments.join("\n\n"), ), if self.line_endings_crlf {TransformMode::CRLF} else {TransformMode::LF} ) } } impl Game { #[inline] pub fn tile_ids(&self) -> Vec { self.tiles.iter().map(|tile| tile.id).collect() } /// first available tile ID. /// e.g. if current tile IDs are [0, 2, 3] the result will be `1` /// if current tile IDs are [0, 1, 2] the result will be `3` #[inline] pub fn new_tile_id(&self) -> u64 { let mut new_id = 0; let mut ids = self.tile_ids(); ids.sort(); for id in ids { if new_id == id { new_id += 1; } else { return new_id; } } new_id + 1 } /// adds a tile safely and returns the new tile ID #[inline] pub fn add_tile(&mut self, mut tile: Tile) -> u64 { let new_id = self.new_tile_id(); tile.id = new_id; self.tiles.push(tile); new_id } #[inline] fn version_line(&self) -> String { if self.version.is_some() { format!( "\n\n# BITSY VERSION {}.{}", self.version.as_ref().unwrap().major, self.version.as_ref().unwrap().minor ) } else { "".to_string() } } #[inline] fn room_format_line(&self) -> String { if self.room_format.is_some() { format!("\n\n! ROOM_FORMAT {}", self.room_format.unwrap().to_string()) } else { "".to_string() } } #[inline] fn font_line(&self) -> String { if self.font == Font::AsciiSmall { "".to_string() } else { if self.font == Font::Custom { format!("\n\nDEFAULT_FONT {}", self.custom_font.as_ref().unwrap()) } else { format!("\n\nDEFAULT_FONT {}", self.font.to_string().unwrap()) } } } #[inline] fn text_direction_line(&self) -> &str { if self.text_direction == TextDirection::RightToLeft { "\n\nTEXT_DIRECTION RTL" } else { "" } } /// older bitsy games do not specify a version, but we can infer 1.0 #[inline] pub fn version(&self) -> Version { self.version.unwrap_or(Version { major: 1, minor: 0 }) } /// older bitsy games do not specify a room format, but we can infer 0 #[inline] pub fn room_format(&self) -> RoomFormat { self.room_format.unwrap_or(RoomFormat::Contiguous) } } #[cfg(test)] mod test { use crate::game::{Version, Game}; use crate::text::{TextDirection, Font}; use crate::tile::Tile; #[test] fn test_game_from_string() { let output = Game::from(include_str!["test-resources/default.bitsy"].to_string()).unwrap(); let expected = crate::mock::game_default(); assert_eq!(output, expected); } #[test] fn test_game_to_string() { let output = crate::mock::game_default().to_string(); let expected = include_str!["test-resources/default.bitsy"].to_string(); assert_eq!(output, expected); } #[test] fn test_tile_ids() { assert_eq!(crate::mock::game_default().tile_ids(), vec![10]); } #[test] fn test_new_tile_id() { // default tile has an id of 10 ("a"), so 0 is available assert_eq!(crate::mock::game_default().new_tile_id(), 0); let mut game = crate::mock::game_default(); let mut tiles: Vec = Vec::new(); for n in 0..9 { if n != 4 { let mut new_tile = crate::mock::tile_default(); new_tile.id = n; tiles.push(new_tile); } } game.tiles = tiles; assert_eq!(game.new_tile_id(), 4); // fill in the space created above, and test that tile IDs get sorted let mut new_tile = crate::mock::tile_default(); new_tile.id = 4; game.tiles.push(new_tile); assert_eq!(game.new_tile_id(), 10); } #[test] fn test_add_tile() { let mut game = crate::mock::game_default(); let new_id = game.add_tile(crate::mock::tile_default()); assert_eq!(new_id, 0); assert_eq!(game.tiles.len(), 2); let new_id = game.add_tile(crate::mock::tile_default()); assert_eq!(new_id, 1); assert_eq!(game.tiles.len(), 3); } #[test] fn test_arabic() { let game = Game::from(include_str!("test-resources/arabic.bitsy").to_string()).unwrap(); assert_eq!(game.font, Font::Arabic); assert_eq!(game.text_direction, TextDirection::RightToLeft); } #[test] fn test_version_formatting() { let mut game = crate::mock::game_default(); game.version = Some(Version { major: 5, minor: 0 }); assert!(game.to_string().contains("# BITSY VERSION 5.0")) } }