1use crate::controls::speed::Speed;
2use crate::graphics::graphic_block::Position;
3use crate::graphics::menus::retro_parameter_table::generic_logic::{
4 ActionParameter, CellValue, RowData,
5};
6use clap::Parser;
7use clap::{ArgAction, CommandFactory};
8use serde::{Deserialize, Serialize};
9use std::fs::File;
10use std::io;
11use std::io::{Read, Write};
12use std::iter::Iterator;
13use std::ops::RangeInclusive;
14use std::path::Path;
15use toml::Table;
16use unicode_segmentation::UnicodeSegmentation;
17pub const INI_POSITION: Position = Position { x: 50, y: 5 };
19pub const ONLY_FOR_CLI_PARAMETERS: [&str; 2] = ["load", "no-"];
21#[allow(clippy::needless_raw_string_hashes)]
23const PARAMS_HEADER: &str = r#"
24# Snake Game Configuration
25# ---------------------------
26# classic_mode: true for classic rules (walls kill, no wrapping)
27# uncaps_fps: disables frame limiting (true = no limit)
28# life: starting lives
29# nb_of_fruits: number of fruits available in the game at once
30# body_symbol: character for the snake's body
31# head_symbol: character for the snake's head
32# snake_length: initial length of the snake
33# speed: speed of the snake (Slow, Normal, Fast, Crazy)
34"#;
35
36macro_rules! define_args_withs {
38 (
39 $( $field_name:ident: $min:expr => $max:expr ),* $(,)?
40 ) => {
41 $(const $field_name: &str = stringify!($field_name);)*
43 #[must_use] pub fn get_parameter_range(param_name: &str) -> Option<std::ops::RangeInclusive<u16>> {
45 let idiomatic =param_name.to_string().replace("-","_").to_uppercase();
46 match idiomatic.as_str() {
47 $(
48 stringify!($field_name) => Some($min..=$max)
49 ,
50 )*
51 _ => None,
52 }
53 }
54 #[must_use] fn get_parameter_range_parser(param_name: &str) -> clap::builder::RangedI64ValueParser<u16> {
56 match param_name {
57 $(
58 stringify!($field_name) =>
59 clap::value_parser!(u16).range($min as i64..=$max as i64)
60 ,
61 )*
62 _ => clap::value_parser!(u16).range(1_i64..=99_i64),
63 }
64 }
65};
66}
67define_args_withs! {
70SNAKE_LENGTH: 2 => 999,
71LIFE: 1 => 99,
72NB_OF_FRUITS : 1 => 999,
73PRESETS: 1 => 7,
74}
75const MAX_EMOJI_BY_LINE_COUNT: u16 = 19;
76pub const DISPLAYABLE_EMOJI: [&str; 38] = [
79 "๐", "๐", "๐ฅ", "๐พ", "๐ข", "๐ฆ", "๐ชฝ", "๐ฅ", "๐ฃ", "๐ฆ ", "๐ฆด", "๐ฃ", "๐ฅ", "๐ฅฎ", "๐ช", "๐ฉ",
80 "๐ง", "๐ด", "๐งจ", "๐ฆ", "๐", "๐", "๐ค ", "๐คก", "๐ฅณ", "๐ฅธ", "๐บ", "๐น", "๐พ", "๐ผ", "๐", "๐",
81 "๐ฆ", "๐ณ", "๐", "โ๏ธ", "๐ฝ", "@",
82];
83#[derive(Parser, Serialize, Deserialize, Debug, Clone)]
85#[serde(default)]
86#[command(
87 author,
88 version,
89 long_version = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"),
90 env!("CARGO_PKG_DESCRIPTION"),
91 "\nRepository: ", env!("CARGO_PKG_REPOSITORY"),
92 "\nBuilt with Rust ", env!("CARGO_PKG_RUST_VERSION")),
93 about = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"),
94 "\nSnake Game in terminal with CLI arguments.\nQuick custom run: cargo run -- -z ๐พ -b ๐ชฝ -l 10 "),
95 long_about = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"), "\n",
96 env!("CARGO_PKG_DESCRIPTION"), " where you can configure the velocity, \
97 snake appearance, and more using command-line arguments.\nExample for asian vibes: rsnake -z ๐ผ -b ๐ฅ")
98)]
99#[derive(Default)]
100#[allow(clippy::struct_excessive_bools)]
101pub struct GameOptions {
102 #[arg(
107 short,
108 long,
109 value_enum, default_value_t = Speed::Normal,
110 help = "Sets the movement speed of the snake."
111 )]
112 pub speed: Speed,
113
114 #[arg(
119 short = 'z',
120 long,
121 default_value = DISPLAYABLE_EMOJI[34],
122 help = format!("Symbol used to represent the snake's head.\nHint:{}"
123 ,GameOptions::emojis_with_news_line()),
124 long_help = format!("Symbol used to represent the snake's head.\nHint:{},\
125 \n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
126 value_parser = |s: &str| -> Result<String, String>{
127 if s.graphemes(true).count() != 1 {
128 return Err(String::from("Head symbol must be exactly one grapheme / character"));
129 }
130 Ok(s.to_string())
131 }
132 )]
133 pub head_symbol: String,
134
135 #[arg(
141 short,
142 long,
143 default_value = DISPLAYABLE_EMOJI[35],
144 help = format!("Symbol used to represent the snake's body/trail.\
145 \nHint:{}",GameOptions::emojis_with_news_line()),
146 long_help = format!("Symbol used to represent the snake's body/trail.\
147 \nHint:{}\n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
148 value_parser = |s: &str| -> Result<String, String>{
149 if s.graphemes(true).count() != 1 {
150 return Err(String::from("Head symbol must be exactly one grapheme / character"));
151 }
152 Ok(s.to_string())
153 }
154 )]
155 pub body_symbol: String,
156
157 #[arg(
159 short = 'n',
160 long, default_value_t = 10,
162 value_parser = get_parameter_range_parser(SNAKE_LENGTH),
163 help = format!("Defines the initial length of the snake {}",pretty(get_parameter_range(SNAKE_LENGTH).unwrap()))
164 )]
165 #[serde(alias = "SNAKE_LENGTH")]
166 pub snake_length: u16,
167
168 #[arg(
170 short,
171 long,
172 default_value_t = 3,
173 value_parser = get_parameter_range_parser(LIFE),
174 help = format!("Defines the initial number of lives for the player {}",pretty(get_parameter_range(LIFE).unwrap()))
175 )]
176 #[serde(alias = "LIFE")]
177 pub life: u16,
178
179 #[arg(
181 short = 'f',
182 long,
183 default_value_t = 5,
184 value_parser = get_parameter_range_parser(NB_OF_FRUITS),
185 help = format!("Defines the number of fruits available at once {}",pretty(get_parameter_range(NB_OF_FRUITS).unwrap()))
186 )]
187 #[serde(alias = "nb_of_fruit", alias = "NB_OF_FRUITS")]
188 pub nb_of_fruits: u16,
189 #[arg(
197 long = "caps-fps",
198 overrides_with = "caps_fps",
199 help = "Set to caps FPS limit (max 60 FPS) [default] "
200 )]
201 #[serde(skip, default = "default_false")]
202 no_caps_fps: bool,
203 #[arg(
204 long = "no-caps-fps",
205 default_value_t = true,
206 action = ArgAction::SetFalse,
207 )]
208 pub caps_fps: bool,
209 #[arg(
211 long,
212 default_value_t = false,
213 overrides_with = "no_classic_mode",
214 help = "Classic mode: classic logic with only growing snake no cut-size-fruit \nNo-classic [default] with a more modern and challenging logic with cut-size-fruits "
215 )]
216 pub classic_mode: bool,
217 #[arg(long)]
218 #[serde(skip, default = "default_true")]
219 no_classic_mode: bool,
220 #[arg(
222 long,
223 default_missing_value = None,
224 value_parser = get_parameter_range_parser(PRESETS),
225 help = format!("Load game parameters PRESETS configuration from {}.\
226 Files are searched in the same folder as the executable.\
227 Save from edit menu to get the template or get one from best configuration example on the repository.\
228 Override cli arguments.",pretty(get_parameter_range(PRESETS).unwrap()))
229 )]
230 pub load: Option<u16>,
231}
232
233impl GameOptions {
234 #[must_use]
236 pub fn initial_position() -> Position {
237 INI_POSITION
238 }
239 #[must_use]
240 pub fn emojis_with_news_line() -> String {
241 DISPLAYABLE_EMOJI
242 .iter()
243 .enumerate()
244 .map(|(i, e)| {
245 if i == MAX_EMOJI_BY_LINE_COUNT as usize {
246 "\n".to_string() + e
247 } else {
248 (*e).to_string()
249 }
250 })
251 .collect::<String>()
252 }
253 pub fn emojis_iterator() -> impl Iterator<Item = String> {
254 DISPLAYABLE_EMOJI.iter().map(ToString::to_string)
255 }
256
257 #[must_use]
261 pub fn to_structured_toml(&self) -> Table {
262 let toml_string =
263 toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
264 toml_string.parse::<Table>().expect("invalid doc")
265 }
266
267 pub fn validate(&mut self) {
272 clamp_to(&mut self.snake_length, get_parameter_range(SNAKE_LENGTH));
273 clamp_to(&mut self.life, get_parameter_range(LIFE));
274 clamp_to(&mut self.nb_of_fruits, get_parameter_range(NB_OF_FRUITS));
275 if let Some(preset) = &mut self.load {
276 clamp_to(preset, get_parameter_range(PRESETS));
277 }
279 if self.head_symbol.graphemes(true).count() != 1 {
282 self.head_symbol = DISPLAYABLE_EMOJI[34].to_string();
283 }
284 if self.body_symbol.graphemes(true).count() != 1 {
285 self.body_symbol = DISPLAYABLE_EMOJI[35].to_string();
286 }
287 }
288
289 pub fn load_from_toml_preset(preset: u16) -> io::Result<Self> {
295 let path = format!("snake_preset_{preset}.toml");
296 let mut params = Self::load_from_toml(path)?;
297 params.load = Some(preset);
298 Ok(params)
299 }
300 fn load_from_toml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
310 let mut file = File::open(path)?;
311 let mut contents = String::new();
312 file.read_to_string(&mut contents)?;
313 let mut params: Self =
314 toml::from_str(&contents).expect("Failed to deserialize GameParameters from TOML");
315 params.validate();
316 Ok(params)
317 }
318 pub fn save_to_toml_preset(&mut self, preset: u16) -> io::Result<()> {
324 let path = format!("snake_preset_{preset}.toml");
325 self.save_to_toml(path)
326 }
327 fn save_to_toml<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
337 let toml_string =
338 toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
339 let full_output = format!("{PARAMS_HEADER}\n{toml_string}");
340 let mut file = File::create(path)?;
341 file.write_all(full_output.as_bytes())?;
342 Ok(())
343 }
344}
345
346fn clamp_to(value: &mut u16, range: Option<RangeInclusive<u16>>) {
347 if let Some(range) = range {
348 let (min, max) = range.into_inner();
349 *value = (*value).clamp(min, max);
350 }
351}
352fn pretty(r: RangeInclusive<u16>) -> String {
353 format!("[{}-{}]", r.start(), r.end()).to_string()
354}
355fn default_true() -> bool {
357 true
358}
359fn default_false() -> bool {
360 false
361}
362
363impl ActionParameter for GameOptions {
364 fn apply_and_save(&mut self, rows: &[RowData], current_preset: Option<u16>) {
365 let command = GameOptions::command();
366 let prog_name = command.get_name().to_string();
367 let mut new_args = vec![prog_name];
368 for row in rows {
369 for cell in &row.cells {
370 if let CellValue::Options {
371 option_name,
372 values,
373 index,
374 ..
375 } = cell
376 {
377 let value = &values[*index];
378 match value.parse::<bool>() {
379 Ok(bv) => {
380 let bv_name: String = if bv {
385 option_name.clone()
386 } else {
387 option_name.replace("--", "--no-")
388 };
389 new_args.push(bv_name);
390 }
391 Err(_) => {
392 new_args.extend([option_name.clone(), value.clone()]);
394 }
395 }
396 }
397 }
398 }
399 self.update_from(new_args);
405 self.load = current_preset;
406 if let Some(preset) = current_preset {
408 let _ = self.save_to_toml_preset(preset);
409 }
410 }
411}
412
413#[cfg(test)]
414#[allow(clippy::field_reassign_with_default)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn test_validates() {
420 let mut options = GameOptions::default();
421 options.snake_length = 1000;
423 options.life = 100;
424 options.nb_of_fruits = 1000;
425 options.load = Some(8);
426 options.validate();
427 assert_eq!(options.snake_length, 999);
428 assert_eq!(options.life, 99);
429 assert_eq!(options.nb_of_fruits, 999);
430 assert_eq!(options.load, Some(7));
431
432 options.snake_length = 1;
434 options.life = 0;
435 options.nb_of_fruits = 0;
436 options.load = Some(0);
437 options.validate();
438 assert_eq!(options.snake_length, 2);
439 assert_eq!(options.life, 1);
440 assert_eq!(options.nb_of_fruits, 1);
441 assert_eq!(options.load, Some(1));
442 }
443
444 #[test]
445 fn test_validate_symbols() {
446 let mut options = GameOptions::default();
447 options.head_symbol = "invalid".to_string();
448 options.body_symbol = String::new();
449 options.validate();
450 assert_eq!(options.head_symbol.graphemes(true).count(), 1);
451 assert_eq!(options.body_symbol.graphemes(true).count(), 1);
452 }
453
454 #[test]
455 fn test_save_load_preset() {
456 let mut options = GameOptions::default();
457 options.snake_length = 42;
458 let preset_idx = 7;
459 let filename = format!("snake_preset_{preset_idx}.toml");
460 options.save_to_toml_preset(preset_idx).unwrap();
462
463 let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
465 assert_eq!(loaded.snake_length, 42);
466 assert_eq!(loaded.load, Some(preset_idx));
467
468 let _ = std::fs::remove_file(filename);
470 }
471
472 #[test]
473 fn test_load_invalid_toml_clamped() {
474 let preset_idx = 6;
475 let filename = format!("snake_preset_{preset_idx}.toml");
476 let content = "SNAKE_LENGTH = 2000\nLIFE = 0\n";
477 std::fs::write(&filename, content).unwrap();
478
479 let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
480 assert_eq!(loaded.snake_length, 999);
481 assert_eq!(loaded.life, 1);
482
483 let _ = std::fs::remove_file(filename);
484 }
485}