use crate::{Dialogue, Ending, Font, Item, Palette, Room, Sprite, TextDirection, Tile, Variable, transform_line_endings, segments_from_string, from_base36, new_unique_id}; use loe::TransformMode; use std::str::FromStr; /// in very early versions of Bitsy, room tiles were defined as single alphanumeric characters - /// so there was a maximum of 36 unique tiles. later versions are comma-separated. /// RoomFormat is implemented here so we can save in the original format. #[derive(Debug, Eq, PartialEq, Copy, Clone)] pub enum RoomFormat {Contiguous, CommaSeparated} #[derive(Debug)] pub struct InvalidRoomFormat; impl RoomFormat { fn from(str: &str) -> Result { match str { "0" => Ok(RoomFormat::Contiguous), "1" => Ok(RoomFormat::CommaSeparated), _ => Err(InvalidRoomFormat), } } 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} #[derive(Debug)] pub struct InvalidRoomType; impl RoomType { fn from(string: &str) -> Result { match string { "ROOM" => Ok(RoomType::Room), "SET" => Ok(RoomType::Set), _ => Err(InvalidRoomType), } } } 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, } #[derive(Debug)] pub struct InvalidVersion; impl Version { fn from(str: &str) -> Result { let parts: Vec<&str> = str.split(".").collect(); if parts.len() == 2 { Ok(Version { major: parts[0].parse().unwrap(), minor: parts[1].parse().unwrap(), }) } else { Err (InvalidVersion) } } } #[derive(Debug)] pub struct SpriteNotFound; #[derive(Clone, 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 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) } #[derive(Debug)] pub struct GameHasNoAvatar; // todo no tiles? no rooms? no palettes? 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("\"\"\"") // multi-line game name || ( ! 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 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 ", ""); let segment = Version::from(&segment); if segment.is_ok() { version = Some(segment.unwrap()); } } else if segment.starts_with("! ROOM_FORMAT") { let segment = segment.replace("! ROOM_FORMAT ", ""); room_format = Some( RoomFormat::from(&segment).unwrap_or(RoomFormat::CommaSeparated) ); } 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 ") { let sprite = Sprite::from(segment); if sprite.is_ok() { sprites.push(sprite.unwrap()); } } 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 ") { let ending = Ending::from_str(&segment); if ending.is_ok() { endings.push(ending.unwrap()); } } else if segment.starts_with("VAR ") { variables.push(Variable::from(segment)); } else if segment.starts_with("FONT ") { font_data = Some(segment); } } // todo check if SPR A (avatar) exists Ok( Game { name, version, room_format, room_type, font, custom_font, text_direction, palettes, rooms, tiles, sprites, items, dialogues, endings, variables, font_data, line_endings_crlf, } ) } /// todo refactor this into "get T by ID", taking a Vec and an ID name? #[inline] pub fn get_sprite_by_id(&self, id: String) -> Result<&Sprite, SpriteNotFound> { let index = self.sprites.iter().position( |sprite| sprite.id == id ); if index.is_some() { Ok(&self.sprites[index.unwrap()]) } else { Err(SpriteNotFound) } } pub fn get_avatar(&self) -> Result<&Sprite, SpriteNotFound> { self.get_sprite_by_id("A".to_string()) } pub fn merge(game: Game) { // ignore title, version, room format, room type, font, text direction // maybe we need hashmaps of old->new IDs, for each type of ID? // we need functions for "add sprite" etc. that return the newly generated valid ID // for each new palette: // check if a matching palette exists // if yes, replace any room palette IDs with the extant palette ID // check if palette ID clashes with an existing palette ID // check if palette name clashes and if so, give a new name ("[name] 2" or something) // for each tile: // check if a matching tile exists // if yes, replace room tile IDs with that of the extant tile // if no, give a new unique tile ID and change room tile IDs to this // ignore avatar (maybe convert to normal sprite instead?) // for each item // for each dialogue item // for each ending // for each variable // for each room // give room a new unique ID // check room name - if duplicate, append "2" or something? // convert tile/item IDs to new ones // add room to game // for each sprite // } } 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 { segments.push(sprite.to_string()); } for item in &self.items { segments.push(item.to_string()); } for dialogue in &self.dialogues { // this replacement is silly but see segments_from_string() for explanation segments.push(dialogue.to_string().replace("\"\"\"\n\"\"\"", "")); } 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.clone()).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) -> String { // don't allow `0` - this is a reserved ID // todo new_sprite_id will need to avoid A too let mut ids = self.tile_ids(); ids.push("0".to_string()); new_unique_id(ids) } /// adds a tile safely and returns the new tile ID #[inline] pub fn add_tile(&mut self, mut tile: Tile) -> String { let new_id = self.new_tile_id(); tile.id = new_id.clone(); 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!["a".to_string()]); } #[test] fn test_new_tile_id() { // default tile has an id of 10 ("a"), and 0 is reserved assert_eq!(crate::mock::game_default().new_tile_id(), "1".to_string()); // for a game with a gap in the tile IDs, check the gap is used let mut game = crate::mock::game_default(); let mut tiles: Vec = Vec::new(); // 0 is reserved; upper bound is non-inclusive for n in 1..10 { if n != 4 { let mut new_tile = crate::mock::tile_default(); new_tile.id = format!("{}", n).to_string(); tiles.push(new_tile); } } game.tiles = tiles; assert_eq!(game.new_tile_id(), "4".to_string()); // fill in the space created above, then test that tile IDs get sorted let mut new_tile = crate::mock::tile_default(); new_tile.id = "4".to_string(); game.tiles.push(new_tile); assert_eq!(game.new_tile_id(), "a".to_string()); } #[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, "1".to_string()); assert_eq!(game.tiles.len(), 2); let new_id = game.add_tile(crate::mock::tile_default()); assert_eq!(new_id, "2".to_string()); 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")) } }