// ▄████████▄ + ███ + ▄█████████ ███ + // ███▀ ▀███ + + ███ ███▀ + ███ + + // ███ + ███ ███ ███ █████████ ███ ███ ███ ███ // ███ +███ ███ ███ ███ ███▐██████ ███ ███ ███ // ███ + ███ ███+ ███ +███ ███ + ███ ███ + ███ // ███▄ ▄███ ███▄ ███ ███ + ███ + ███ ███▄ ███ // ▀████████▀ + ▀███████ ███▄ ███▄ ▀████ ▀███████ // + + + ███ // + ▀████████████████████████████████████████████████████▀ // // This module manages variables, settings, as well as evaluating // "if"-conditions in chats. use crate::prelude::*; use bevy::prelude::*; use bevy::window::WindowMode; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::env; use std::fs; use toml_edit::DocumentMut; pub const SCOPE_SEPARATOR: &str = "$"; pub const TOKEN_EQUALS: &str = "=="; pub const TOKEN_EQUALS_NOT: &str = "!="; pub const TOKEN_GREATER_THAN: &str = ">"; pub const TOKEN_LESS_THAN: &str = "<"; pub const TOKEN_GREATER_EQUALS: &str = ">="; pub const TOKEN_LESS_EQUALS: &str = "<="; pub const TOKEN_NEGATE: &str = "~"; pub const DEFAULT_CHAT_SPEED: f32 = 10.0; pub const DEFAULT_CONFIG_TOML: &str = include_str!("data/outfly.toml"); #[derive(Resource)] pub struct Settings { pub dev_mode: bool, pub god_mode: bool, pub version: String, pub alive: bool, pub mute_sfx: bool, pub noise_cancellation_mode: usize, pub noise_cancellation_modes: Vec<(String, f32)>, pub radio_mode: usize, pub radio_modes: Vec, // see also: settings.is_radio_playing() pub volume_sfx: f32, pub volume_music: f32, pub mouse_sensitivity: f32, pub fov: f32, pub fov_highspeed: f32, pub zoom_fov: f32, pub zoom_sensitivity_factor: f32, pub font_size_hud: f32, pub font_size_fps: f32, pub font_size_conversations: f32, pub font_size_choices: f32, pub font_size_console: f32, pub font_size_speedometer: f32, pub font_size_deathtext: f32, pub font_size_deathsubtext: f32, pub font_size_deathpoem: f32, pub font_size_death_achievements: f32, pub font_size_achievement: f32, pub font_size_achievement_header: f32, pub font_size_keybindings: f32, pub font_size_version: f32, pub hud_color: Color, pub hud_color_fps: Color, pub hud_color_console: Color, pub hud_color_console_warn: Color, pub hud_color_console_system: Color, pub hud_color_console_achievement: Color, pub hud_color_alert: Color, pub hud_color_subtitles: Color, pub hud_color_choices: Color, pub hud_color_speedometer: Color, pub hud_color_deathpoem: Color, pub hud_color_achievement: Color, pub hud_color_achievement_header: Color, pub hud_color_achievement_accomplished: Color, pub hud_color_death: Color, pub hud_color_death_achievements: Color, pub hud_color_keybindings: Color, pub hud_color_version: Color, pub chat_speed: f32, pub ar_avatar: usize, pub flashlight_active: bool, pub reactor_state: usize, pub hud_active: bool, pub map_active: bool, pub deathscreen_active: bool, pub menu_active: bool, pub death_cause: String, pub is_zooming: bool, pub third_person: bool, pub rotation_stabilizer_active: bool, pub cruise_control_active: bool, pub shadows_sun: bool, pub shadows_pointlights: bool, pub shadowmap_resolution: usize, pub large_moons: bool, pub key_selectobject: MouseButton, pub key_zoom: MouseButton, pub key_map: KeyCode, pub key_map_zoom_out: KeyCode, pub key_map_zoom_in: KeyCode, //pub key_map_zoom_out_wheel: MouseButton, //pub key_map_zoom_in_wheel: MouseButton, pub key_togglehud: KeyCode, pub key_menu: KeyCode, pub key_fullscreen: KeyCode, pub key_help: KeyCode, pub key_forward: KeyCode, pub key_back: KeyCode, pub key_left: KeyCode, pub key_right: KeyCode, pub key_up: KeyCode, pub key_down: KeyCode, pub key_run: KeyCode, pub key_stop: KeyCode, pub key_interact: KeyCode, pub key_vehicle: KeyCode, pub key_camera: KeyCode, pub key_flashlight: KeyCode, pub key_cruise_control: KeyCode, pub key_rotate: KeyCode, pub key_rotation_stabilizer: KeyCode, pub key_mouseup: KeyCode, pub key_mousedown: KeyCode, pub key_mouseleft: KeyCode, pub key_mouseright: KeyCode, pub key_rotateleft: KeyCode, pub key_rotateright: KeyCode, pub key_reply1: KeyCode, pub key_reply2: KeyCode, pub key_reply3: KeyCode, pub key_reply4: KeyCode, pub key_reply5: KeyCode, pub key_reply6: KeyCode, pub key_reply7: KeyCode, pub key_reply8: KeyCode, pub key_reply9: KeyCode, pub key_reply10: KeyCode, pub key_next_chat_line: KeyCode, pub key_cheat_god_mode: KeyCode, pub key_cheat_stop: KeyCode, pub key_cheat_speed: KeyCode, pub key_cheat_speed_backward: KeyCode, pub key_cheat_teleport: KeyCode, pub key_cheat_pizza: KeyCode, pub key_cheat_farview1: KeyCode, pub key_cheat_farview2: KeyCode, pub key_cheat_adrenaline_zero: KeyCode, pub key_cheat_adrenaline_mid: KeyCode, pub key_cheat_adrenaline_max: KeyCode, pub key_cheat_die: KeyCode, } impl Default for Settings { fn default() -> Self { let dev_mode = cfg!(feature = "dev_mode") && env::var("CARGO").is_ok(); let version = if let Some(version) = option_env!("CARGO_PKG_VERSION") { version.to_string() } else { "".to_string() }; Settings { dev_mode, god_mode: false, version, alive: true, mute_sfx: false, noise_cancellation_mode: 0, noise_cancellation_modes: vec![ ("Off".to_string(), 1.0), ("33%".to_string(), 0.66), ("66%".to_string(), 0.33), ("100%".to_string(), 0.0), ], radio_mode: 1, radio_modes: vec![ // see also: settings.is_radio_playing() "Off".to_string(), "Space Wave Radio".to_string(), "Amplify outside recordings".to_string(), ], volume_sfx: 1.0, volume_music: 1.0, mouse_sensitivity: 0.4, fov: 50.0, fov_highspeed: 25.0, zoom_fov: 15.0, zoom_sensitivity_factor: 0.25, font_size_hud: 24.0, font_size_fps: 14.0, font_size_conversations: 32.0, font_size_choices: 28.0, font_size_console: 20.0, font_size_speedometer: 34.0, font_size_deathtext: 64.0, font_size_deathsubtext: 32.0, font_size_deathpoem: 18.0, font_size_death_achievements: 24.0, font_size_achievement: 24.0, font_size_achievement_header: 32.0, font_size_keybindings: 20.0, font_size_version: 20.0, hud_color: Srgba::hex(COLOR_PRIMARY).unwrap().into(), hud_color_fps: Srgba::hex("#181818").unwrap().into(), hud_color_console: Srgba::hex(COLOR_PRIMARY).unwrap().into(), hud_color_console_achievement: Srgba::hex(COLOR_SUCCESS).unwrap().into(), hud_color_console_warn: Srgba::hex(COLOR_WARNING).unwrap().into(), hud_color_console_system: Srgba::hex(COLOR_SECONDARY).unwrap().into(), hud_color_alert: Srgba::hex(COLOR_SECONDARY).unwrap().into(), hud_color_subtitles: Srgba::hex(COLOR_SECONDARY).unwrap().into(), hud_color_choices: Srgba::hex(COLOR_BODY).unwrap().into(), hud_color_speedometer: Srgba::hex(COLOR_PRIMARY).unwrap().into(), hud_color_deathpoem: Srgba::hex("#CC2200").unwrap().into(), hud_color_achievement: Srgba::hex(COLOR_DIM).unwrap().into(), hud_color_achievement_accomplished: Srgba::hex(COLOR_SUCCESS).unwrap().into(), hud_color_achievement_header: Srgba::hex(COLOR_PRIMARY).unwrap().into(), hud_color_death: Srgba::hex(COLOR_SECONDARY).unwrap().into(), hud_color_death_achievements: Srgba::hex(COLOR_SECONDARY).unwrap().into(), hud_color_keybindings: Srgba::hex(COLOR_DIM).unwrap().into(), hud_color_version: Srgba::hex(COLOR_PRIMARY).unwrap().into(), chat_speed: DEFAULT_CHAT_SPEED, ar_avatar: 0, flashlight_active: false, reactor_state: 1, hud_active: true, map_active: false, deathscreen_active: false, menu_active: false, death_cause: "Unknown".to_string(), is_zooming: false, third_person: true, rotation_stabilizer_active: true, cruise_control_active: false, shadows_sun: true, shadows_pointlights: false, shadowmap_resolution: 2048, large_moons: false, key_selectobject: MouseButton::Left, key_zoom: MouseButton::Right, key_map: KeyCode::KeyM, key_map_zoom_out: KeyCode::ShiftLeft, key_map_zoom_in: KeyCode::ControlLeft, //key_map_zoom_out_wheel: KeyCode::Shift, //key_map_zoom_in_wheel: KeyCode::Shift, key_togglehud: KeyCode::Tab, key_menu: KeyCode::Escape, key_fullscreen: KeyCode::F11, key_help: KeyCode::F1, key_forward: KeyCode::KeyW, key_back: KeyCode::KeyS, key_left: KeyCode::KeyA, key_right: KeyCode::KeyD, key_up: KeyCode::ShiftLeft, key_down: KeyCode::ControlLeft, key_run: KeyCode::KeyR, key_stop: KeyCode::Space, key_interact: KeyCode::KeyE, key_vehicle: KeyCode::KeyQ, key_camera: KeyCode::KeyC, key_flashlight: KeyCode::KeyF, key_cruise_control: KeyCode::KeyT, key_rotate: KeyCode::KeyR, key_rotation_stabilizer: KeyCode::KeyY, key_mouseup: KeyCode::KeyI, key_mousedown: KeyCode::KeyK, key_mouseleft: KeyCode::KeyJ, key_mouseright: KeyCode::KeyL, key_rotateleft: KeyCode::KeyU, key_rotateright: KeyCode::KeyO, key_reply1: KeyCode::Digit1, key_reply2: KeyCode::Digit2, key_reply3: KeyCode::Digit3, key_reply4: KeyCode::Digit4, key_reply5: KeyCode::Digit5, key_reply6: KeyCode::Digit6, key_reply7: KeyCode::Digit7, key_reply8: KeyCode::Digit8, key_reply9: KeyCode::Digit9, key_reply10: KeyCode::Digit0, key_next_chat_line: KeyCode::Backquote, key_cheat_god_mode: KeyCode::KeyG, key_cheat_stop: KeyCode::KeyZ, key_cheat_speed: KeyCode::KeyV, key_cheat_speed_backward: KeyCode::KeyB, key_cheat_teleport: KeyCode::KeyX, key_cheat_pizza: KeyCode::F9, key_cheat_farview1: KeyCode::F10, key_cheat_farview2: KeyCode::F12, key_cheat_adrenaline_zero: KeyCode::F5, key_cheat_adrenaline_mid: KeyCode::F6, key_cheat_adrenaline_max: KeyCode::F8, key_cheat_die: KeyCode::F4, } } } impl Settings { #[allow(dead_code)] pub fn reset(&mut self) { println!("Resetting settings!"); *self = Self::default(); } pub fn reset_player_settings(&mut self) { println!("Resetting player settings!"); let default = Self::default(); self.rotation_stabilizer_active = default.rotation_stabilizer_active; self.is_zooming = default.is_zooming; self.flashlight_active = default.flashlight_active; self.cruise_control_active = default.cruise_control_active; self.map_active = default.map_active; } pub fn get_reply_keys(&self) -> [KeyCode; 10] { return [ self.key_reply1, self.key_reply2, self.key_reply3, self.key_reply4, self.key_reply5, self.key_reply6, self.key_reply7, self.key_reply8, self.key_reply9, self.key_reply10, ]; } pub fn in_control(&self) -> bool { return self.alive && !self.menu_active; } pub fn is_radio_playing(&self, sfx: audio::Sfx) -> Option { let radio = self.radio_mode; match sfx { audio::Sfx::BGMTakeoff => Some(radio == 1), audio::Sfx::BGMActualJupiterRecording => Some(radio == 2), _ => None, } } pub fn set_noise_cancellation_mode(&mut self, value: usize) { let value = if value >= self.noise_cancellation_modes.len() { warn!("Attempting to set too large noise cancellation mode: {value}"); 0 } else { value }; self.noise_cancellation_mode = value; self.volume_sfx = if let Some(noisecancel) = self .noise_cancellation_modes .get(self.noise_cancellation_mode) { noisecancel.1 } else { self.noise_cancellation_modes[0].1 }; self.mute_sfx = value >= 3; } } #[derive(Resource, Default, Debug)] pub struct AchievementTracker { pub repair_suit: bool, pub drink_a_pizza: bool, pub in_jupiters_shadow: bool, pub find_earth: bool, pub ride_every_vehicle: bool, pub vehicles_ridden: HashSet, pub all_vehicles: HashSet, pub talk_to_everyone: bool, pub people_talked_to: HashSet, pub all_people: HashSet, } impl AchievementTracker { pub fn to_bool_vec(&self) -> Vec { vec![ self.repair_suit, self.drink_a_pizza, self.ride_every_vehicle, self.talk_to_everyone, self.find_earth, self.in_jupiters_shadow, ] } pub fn achieve_all(&mut self) { self.repair_suit = true; self.drink_a_pizza = true; self.ride_every_vehicle = true; self.talk_to_everyone = true; self.find_earth = true; self.in_jupiters_shadow = true; } pub fn to_textsections(&self) -> Vec { fn collectible(current: usize, total: usize) -> String { if current < total { format!(" ({}/{})", current, total) } else { "".to_string() } } let ride = collectible(self.vehicles_ridden.len(), self.all_vehicles.len()); let talk = collectible(self.people_talked_to.len(), self.all_people.len()); vec![ "Repair Your Suit\n".to_string(), "Enjoy A Pizza\n".to_string(), format!("Ride Every Vehicle{ride}\n"), format!("Talk To Everyone{talk}\n"), "Find Earth\n".to_string(), "Enter Jupiter's Shadow\n".to_string(), ] } pub fn to_overview(&self) -> Vec<(bool, String)> { vec![ (self.repair_suit, "repair your suit".into()), (self.drink_a_pizza, "enjoy a pizza".into()), (self.ride_every_vehicle, "ride every vehicle".into()), (self.talk_to_everyone, "talk to everyone".into()), (self.find_earth, "find Earth".into()), (self.in_jupiters_shadow, "enter Jupiter's shadow".into()), ] } pub fn to_summary(&self) -> String { let list = self.to_overview(); let count = list.iter().filter(|(achieved, _)| *achieved).count(); if count == 0 { return "".to_string(); } let mut summary = "\n\n\nYou managed to ".to_string(); for (i, (_, text)) in list.iter().filter(|(achieved, _)| *achieved).enumerate() { summary += text.as_str(); if i + 2 == count { summary += ", and "; } else if i + 1 == count { summary += " before you perished."; if count == list.len() { summary += "\nA truly astounding achievement, a glimmer in the void, before it all fades, into nothingness."; } } else { summary += ", "; } } summary } } // Used for settings that are preserved across restarts #[derive(Resource, Serialize, Deserialize, Debug, Default)] #[serde(default)] pub struct Preferences { pub fullscreen_mode: String, pub fullscreen_on: bool, pub render_mode: String, pub augmented_reality: bool, pub radio_station: usize, pub noise_cancellation_mode: usize, pub third_person: bool, pub shadows_sun: bool, pub avatar: usize, #[serde(default = "Preferences::default_light_amp")] pub light_amp: usize, // 0-3 #[serde(default = "Preferences::default_flashlight_power")] pub flashlight_power: usize, // 0-2 pub thruster_boost: usize, // 0-2 #[serde(skip)] pub source_file: Option, } impl Preferences { pub fn default_light_amp() -> usize { 1 } pub fn default_flashlight_power() -> usize { 2 } pub fn get_fullscreen_mode(&self) -> WindowMode { match self.fullscreen_mode.as_str() { "legacy" => WindowMode::Fullscreen, "sized" => WindowMode::SizedFullscreen, _ => WindowMode::BorderlessFullscreen, } } pub fn get_window_mode(&self) -> WindowMode { match self.fullscreen_on { true => self.get_fullscreen_mode(), false => WindowMode::Windowed, } } pub fn render_mode_is_gl(&self) -> bool { return self.render_mode == "gl"; } pub fn save(&self) { if let Some(path) = get_prefs_path() { match toml_edit::ser::to_document::(self) { Ok(doc) => match fs::write(path.clone(), doc.to_string()) { Ok(_) => { info!("Saved preferences to {path}."); } Err(error) => { error!("Error while writing preferences: {:?}", error); } }, Err(error) => { error!("Error while writing preferences: {:?}", error); } } } } } fn file_is_readable(file_path: &str) -> bool { fs::metadata(file_path) .map(|metadata| metadata.is_file()) .unwrap_or(false) } fn path_is_directory(file_path: &str) -> bool { fs::metadata(file_path) .map(|metadata| metadata.is_dir()) .unwrap_or(false) } fn get_prefs_path() -> Option { let test = CONF_FILE; if file_is_readable(test) { return Some(test.to_string()); } if let Some(mut conf) = dirs::config_dir() { conf.push("OutFly"); if !conf.exists() { match fs::create_dir_all(&conf) { Ok(_) => {} Err(error) => { eprintln!("Failed creating configuration directory: {error}"); } } } if let Some(test) = conf.to_str() { if !path_is_directory(test) { eprintln!("Failed creating configuration directory"); return None; } } conf.push(CONF_FILE); if !conf.exists() { match fs::write(&conf, DEFAULT_CONFIG_TOML.to_string()) { Ok(_) => {} Err(error) => { eprintln!("Failed creating configuration file: {error}"); } } } if let Some(test) = conf.to_str() { if file_is_readable(test) { return Some(test.to_string()); } } } return None; } pub fn load_prefs() -> Preferences { let (toml, path) = match get_prefs_path() { Some(path) => { let toml = fs::read_to_string(&path); match toml { Ok(toml) => (toml, Some(path)), Err(error) => { eprintln!("Error: Failed to open preferences file '{path}': {error}"); return Preferences::default(); } } } None => { println!("Found no preference file, using default preferences."); (DEFAULT_CONFIG_TOML.to_string(), None) } }; match toml.parse::() { Ok(doc) => match toml_edit::de::from_document::(doc) { Ok(mut prefs) => { if let Some(path) = &path { println!("Loaded preference file from {path}"); } else { println!("Loaded preferences from internal defaults"); } prefs.source_file = path; prefs.flashlight_power = prefs.flashlight_power.clamp(0, 3); prefs.light_amp = prefs.light_amp.clamp(0, 3); prefs.thruster_boost = prefs.thruster_boost.clamp(0, 2); prefs } Err(error) => { eprintln!("Error: Failed to read preference line: {error}"); Preferences::default() } }, Err(error) => { eprintln!("Error: Failed to open preferences: {error}"); Preferences::default() } } } #[derive(Resource, Debug)] pub struct GameVars { pub db: HashMap, } impl Default for GameVars { fn default() -> Self { Self { db: HashMap::new() } } } impl GameVars { pub fn reset(&mut self) { self.db.clear(); } #[allow(dead_code)] pub fn get(&self, key: &str) -> Option { if let Some(value) = self.db.get(key) { return Some(value.clone()); } return None; } #[allow(dead_code)] pub fn getf(&self, key: &str) -> Option { if let Some(value) = self.db.get(key) { if let Ok(float) = value.parse::() { return Some(float); } } return None; } pub fn getb(&self, key: &str) -> bool { if let Some(value) = self.db.get(key) { return Self::evaluate_str_as_bool(value); } return false; } pub fn evaluate_str_as_bool(string: &str) -> bool { return string != "0"; } // This method ensures that the variable name contains a scope separator, // and if a scope is missing, it prefixes the fallback scope. // Should NOT be used on non-variable values, like plain strings. // // See test_normalize_varname() for examples. pub fn normalize_varname(fallback_scope: &str, key: &str) -> String { let parts: Vec<&str> = key.split(SCOPE_SEPARATOR).collect(); let key: String = if parts.len() == 1 { // we got a key like "foo", turn it into "$foo" fallback_scope.to_string() + SCOPE_SEPARATOR + key } else if parts.len() > 1 { // we got a key with at least one "$" // extract anything before the last "$": let scope_part: String = parts[0..parts.len() - 1].join(SCOPE_SEPARATOR); if scope_part.is_empty() { // we got a key like "$foo", just prefix the fallback scope fallback_scope.to_string() + key } else { // we got a key like "Ke$ha$foo" or "$$foo" (which is the convention for // global variables), leave the scope intact key.to_string() } } else { // we got an empty string. this is bad, but handle gracefully fallback_scope.to_string() + SCOPE_SEPARATOR }; return key.to_lowercase(); } pub fn set_in_scope(&mut self, fallback_scope: &str, key: &str, value: String) { let key = Self::normalize_varname(fallback_scope, key); self.db.insert(key, value); } pub fn evaluate_condition(&self, condition: &str, scope: &str) -> bool { let parts: Vec<&str> = condition.split(" ").collect(); if parts.len() == 0 { // Got an empty string, this is always false. return false; } else if parts.len() == 1 { // Got something like "if $somevar:". // Check whether the variable evaluates to true. let part = parts[0]; let (part, negate) = if part.starts_with(TOKEN_NEGATE) { (&part[1..], true) } else { (part, false) }; if part.contains(SCOPE_SEPARATOR) { let part = Self::normalize_varname(scope, part); let value_bool = self.getb(part.as_str()); return value_bool ^ negate; } else { return Self::evaluate_str_as_bool(part) ^ negate; } } else if parts.len() == 2 { // Got something like "if $something somethingelse" // Check whether the two are identical. let mut left: String = parts[0].to_string(); if left.contains(SCOPE_SEPARATOR) { let key = Self::normalize_varname(scope, left.as_str()); let value = self.get(key.as_str()); left = if let Some(value) = value { value } else { warn!("Couldn't find variable `{key}` on left hand side of a condition"); "".to_string() }; } let mut right: String = parts[1].to_string(); if right.contains(SCOPE_SEPARATOR) { let key = Self::normalize_varname(scope, right.as_str()); let value = self.get(key.as_str()); right = if let Some(value) = value { value } else { warn!("Couldn't find variable `{key}` on right hand side of a condition"); "".to_string() }; } return left == right; } else { // Got something like "if $something != somethingelse bla bla" let mut left: String = parts[0].to_string(); if left.contains(SCOPE_SEPARATOR) { let key = Self::normalize_varname(scope, left.as_str()); let value = self.get(key.as_str()); left = if let Some(value) = value { value } else { warn!("Couldn't find variable `{key}` on left hand side of a condition"); "".to_string() }; } let mut right: String = parts[2..parts.len()].join(" ").to_string(); if right.contains(SCOPE_SEPARATOR) { let key = Self::normalize_varname(scope, right.as_str()); let value = self.get(key.as_str()); right = if let Some(value) = value { value } else { warn!("Couldn't find variable `{key}` on right hand side of a condition"); "".to_string() }; } let floats = (left.parse::(), right.parse::()); let operator: &str = parts[1]; match operator { TOKEN_EQUALS => { if let (Ok(left), Ok(right)) = floats { return left == right; } return left == right; } TOKEN_EQUALS_NOT => { if let (Ok(left), Ok(right)) = floats { return left != right; } return left != right; } TOKEN_GREATER_THAN => { if let (Ok(left), Ok(right)) = floats { return left > right; } return false; } TOKEN_GREATER_EQUALS => { if let (Ok(left), Ok(right)) = floats { return left >= right; } return false; } TOKEN_LESS_THAN => { if let (Ok(left), Ok(right)) = floats { return left < right; } return false; } TOKEN_LESS_EQUALS => { if let (Ok(left), Ok(right)) = floats { return left <= right; } return false; } _ => { error!("Unknown operator '{operator}' in if-condition!"); return false; } } } } } #[test] fn test_normalize_varname() { assert_eq!(GameVars::normalize_varname("Clippy", ""), "clippy$"); assert_eq!(GameVars::normalize_varname("Clippy", "foo"), "clippy$foo"); assert_eq!(GameVars::normalize_varname("Clippy", "FOO"), "clippy$foo"); assert_eq!(GameVars::normalize_varname("Clippy", "$foo"), "clippy$foo"); assert_eq!(GameVars::normalize_varname("Clippy", "$$foo"), "$$foo"); assert_eq!( GameVars::normalize_varname("Clippy", "PizzaClippy$foo"), "pizzaclippy$foo" ); assert_eq!( GameVars::normalize_varname("Clippy", "$foo$foo$foo$foo"), "$foo$foo$foo$foo" ); } #[derive(Resource, Default)] pub struct CommandLineOptions { pub window_mode_fullscreen: WindowMode, pub window_mode_initial: WindowMode, pub use_gl: bool, }