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 ignore_case = true
112 )]
113 pub speed: Speed,
114
115 #[arg(
120 short = 'z',
121 long,
122 default_value = DISPLAYABLE_EMOJI[34],
123 help = format!("Symbol used to represent the snake's head.\nHint:{}"
124 ,GameOptions::emojis_with_news_line()),
125 long_help = format!("Symbol used to represent the snake's head.\nHint:{},\
126 \n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
127 value_parser = |s: &str| -> Result<String, String>{
128 if s.graphemes(true).count() != 1 {
129 return Err(String::from("Head symbol must be exactly one grapheme / character"));
130 }
131 Ok(s.to_string())
132 }
133 )]
134 pub head_symbol: String,
135
136 #[arg(
142 short,
143 long,
144 default_value = DISPLAYABLE_EMOJI[35],
145 help = format!("Symbol used to represent the snake's body/trail.\
146 \nHint:{}",GameOptions::emojis_with_news_line()),
147 long_help = format!("Symbol used to represent the snake's body/trail.\
148 \nHint:{}\n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
149 value_parser = |s: &str| -> Result<String, String>{
150 if s.graphemes(true).count() != 1 {
151 return Err(String::from("Head symbol must be exactly one grapheme / character"));
152 }
153 Ok(s.to_string())
154 }
155 )]
156 pub body_symbol: String,
157
158 #[arg(
160 short = 'n',
161 long, default_value_t = 10,
163 value_parser = get_parameter_range_parser(SNAKE_LENGTH),
164 help = format!("Defines the initial length of the snake {}",pretty(get_parameter_range(SNAKE_LENGTH).unwrap()))
165 )]
166 #[serde(alias = "SNAKE_LENGTH")]
167 pub snake_length: u16,
168
169 #[arg(
171 short,
172 long,
173 default_value_t = 3,
174 value_parser = get_parameter_range_parser(LIFE),
175 help = format!("Defines the initial number of lives for the player {}",pretty(get_parameter_range(LIFE).unwrap()))
176 )]
177 #[serde(alias = "LIFE")]
178 pub life: u16,
179
180 #[arg(
182 short = 'f',
183 long,
184 default_value_t = 5,
185 value_parser = get_parameter_range_parser(NB_OF_FRUITS),
186 help = format!("Defines the number of fruits available at once {}",pretty(get_parameter_range(NB_OF_FRUITS).unwrap()))
187 )]
188 #[serde(alias = "nb_of_fruit", alias = "NB_OF_FRUITS")]
189 pub nb_of_fruits: u16,
190 #[arg(
198 long = "caps-fps",
199 overrides_with = "caps_fps",
200 help = "Set to caps FPS limit (max 60 FPS) [default] "
201 )]
202 #[serde(skip, default = "default_false")]
203 no_caps_fps: bool,
204 #[arg(
205 long = "no-caps-fps",
206 default_value_t = true,
207 action = ArgAction::SetFalse,
208 )]
209 pub caps_fps: bool,
210 #[arg(
212 long,
213 default_value_t = false,
214 overrides_with = "no_classic_mode",
215 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 "
216 )]
217 pub classic_mode: bool,
218 #[arg(long)]
219 #[serde(skip, default = "default_true")]
220 no_classic_mode: bool,
221 #[arg(
223 long,
224 default_missing_value = None,
225 value_parser = get_parameter_range_parser(PRESETS),
226 help = format!("Load game parameters PRESETS configuration from {}.\
227 Files are searched in the same folder as the executable.\
228 Save from edit menu to get the template or get one from best configuration example on the repository.\
229 Override cli arguments.",pretty(get_parameter_range(PRESETS).unwrap()))
230 )]
231 #[serde(skip)]
232 pub load: Option<u16>,
233}
234
235impl GameOptions {
236 #[must_use]
238 pub fn initial_position() -> Position {
239 INI_POSITION
240 }
241 #[must_use]
242 pub fn emojis_with_news_line() -> String {
243 DISPLAYABLE_EMOJI
244 .iter()
245 .enumerate()
246 .map(|(i, e)| {
247 if i == MAX_EMOJI_BY_LINE_COUNT as usize {
248 "\n".to_string() + e
249 } else {
250 (*e).to_string()
251 }
252 })
253 .collect::<String>()
254 }
255 pub fn emojis_iterator() -> impl Iterator<Item = String> {
256 DISPLAYABLE_EMOJI.iter().map(ToString::to_string)
257 }
258
259 #[must_use]
263 pub fn to_structured_toml(&self) -> Table {
264 let toml_string =
265 toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
266 toml_string.parse::<Table>().expect("invalid doc")
267 }
268
269 pub fn validate(&mut self) {
274 clamp_to(&mut self.snake_length, get_parameter_range(SNAKE_LENGTH));
275 clamp_to(&mut self.life, get_parameter_range(LIFE));
276 clamp_to(&mut self.nb_of_fruits, get_parameter_range(NB_OF_FRUITS));
277 if let Some(preset) = &mut self.load {
278 clamp_to(preset, get_parameter_range(PRESETS));
279 }
281 if self.head_symbol.graphemes(true).count() != 1 {
284 self.head_symbol = DISPLAYABLE_EMOJI[34].to_string();
285 }
286 if self.body_symbol.graphemes(true).count() != 1 {
287 self.body_symbol = DISPLAYABLE_EMOJI[35].to_string();
288 }
289 }
290
291 pub fn load_from_toml_preset(preset: u16) -> io::Result<Self> {
297 let path = format!("snake_preset_{preset}.toml");
298 let mut params = Self::load_from_toml(path)?;
299 params.load = Some(preset);
300 Ok(params)
301 }
302 fn load_from_toml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
312 let mut file = File::open(path)?;
313 let mut contents = String::new();
314 file.read_to_string(&mut contents)?;
315 let mut params: Self =
316 toml::from_str(&contents).expect("Failed to deserialize GameParameters from TOML");
317 params.validate();
318 Ok(params)
319 }
320 pub fn save_to_toml_preset(&mut self, preset: u16) -> io::Result<()> {
326 let path = format!("snake_preset_{preset}.toml");
327 self.save_to_toml(path)
328 }
329 fn save_to_toml<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
339 let toml_string =
340 toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
341 let full_output = format!("{PARAMS_HEADER}\n{toml_string}");
342 let mut file = File::create(path)?;
343 file.write_all(full_output.as_bytes())?;
344 Ok(())
345 }
346}
347
348fn clamp_to(value: &mut u16, range: Option<RangeInclusive<u16>>) {
349 if let Some(range) = range {
350 let (min, max) = range.into_inner();
351 *value = (*value).clamp(min, max);
352 }
353}
354fn pretty(r: RangeInclusive<u16>) -> String {
355 format!("[{}-{}]", r.start(), r.end()).to_string()
356}
357fn default_true() -> bool {
359 true
360}
361fn default_false() -> bool {
362 false
363}
364
365impl ActionParameter for GameOptions {
366 fn apply_and_save(&mut self, rows: &[RowData], current_preset: Option<u16>) {
367 let command = GameOptions::command();
368 let prog_name = command.get_name().to_string();
369 let mut new_args = vec![prog_name];
370 for row in rows {
371 for cell in &row.cells {
372 if let CellValue::Options {
373 option_name,
374 values,
375 index,
376 ..
377 } = cell
378 {
379 let value = &values[*index];
380 match value.parse::<bool>() {
381 Ok(bv) => {
382 let bv_name: String = if bv {
387 option_name.clone()
388 } else {
389 option_name.replace("--", "--no-")
390 };
391 new_args.push(bv_name);
392 }
393 Err(_) => {
394 new_args.extend([option_name.clone(), value.clone()]);
396 }
397 }
398 }
399 }
400 }
401 self.update_from(new_args);
407 self.load = current_preset;
408 if let Some(preset) = current_preset {
410 let _ = self.save_to_toml_preset(preset);
411 }
412 }
413}
414
415#[cfg(test)]
416#[allow(clippy::field_reassign_with_default)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn test_validates() {
422 let mut options = GameOptions::default();
423 options.snake_length = 1000;
425 options.life = 100;
426 options.nb_of_fruits = 1000;
427 options.load = Some(8);
428 options.validate();
429 assert_eq!(options.snake_length, 999);
430 assert_eq!(options.life, 99);
431 assert_eq!(options.nb_of_fruits, 999);
432 assert_eq!(options.load, Some(7));
433
434 options.snake_length = 1;
436 options.life = 0;
437 options.nb_of_fruits = 0;
438 options.load = Some(0);
439 options.validate();
440 assert_eq!(options.snake_length, 2);
441 assert_eq!(options.life, 1);
442 assert_eq!(options.nb_of_fruits, 1);
443 assert_eq!(options.load, Some(1));
444 }
445
446 #[test]
447 fn test_validate_symbols() {
448 let mut options = GameOptions::default();
449 options.head_symbol = "invalid".to_string();
450 options.body_symbol = String::new();
451 options.validate();
452 assert_eq!(options.head_symbol.graphemes(true).count(), 1);
453 assert_eq!(options.body_symbol.graphemes(true).count(), 1);
454 }
455
456 #[test]
457 fn test_save_load_preset() {
458 let mut options = GameOptions::default();
459 options.snake_length = 42;
460 let preset_idx = 7;
461 let filename = format!("snake_preset_{preset_idx}.toml");
462 options.save_to_toml_preset(preset_idx).unwrap();
464
465 let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
467 assert_eq!(loaded.snake_length, 42);
468 assert_eq!(loaded.load, Some(preset_idx));
469
470 let _ = std::fs::remove_file(filename);
472 }
473
474 #[test]
475 fn test_load_invalid_toml_clamped() {
476 let preset_idx = 6;
477 let filename = format!("snake_preset_{preset_idx}.toml");
478 let content = "SNAKE_LENGTH = 2000\nLIFE = 0\n";
479 std::fs::write(&filename, content).unwrap();
480
481 let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
482 assert_eq!(loaded.snake_length, 999);
483 assert_eq!(loaded.life, 1);
484
485 let _ = std::fs::remove_file(filename);
486 }
487}