1use crate::controls::speed::Speed;
2use crate::game_logic::logger::log_configuration::LogLevel;
3use crate::graphics::graphic_block::Position;
4use crate::graphics::menus::retro_parameter_table::generic_logic::{
5 ActionParameter, CellValue, RowData,
6};
7use clap::Parser;
8use clap::{ArgAction, CommandFactory};
9use serde::{Deserialize, Serialize};
10use std::fs::File;
11use std::io;
12use std::io::{Read, Write};
13use std::iter::Iterator;
14use std::ops::RangeInclusive;
15use std::path::Path;
16use toml::Table;
17use unicode_segmentation::UnicodeSegmentation;
18pub const INI_POSITION: Position = Position { x: 50, y: 5 };
20pub const ONLY_FOR_CLI_PARAMETERS: [&str; 2] = ["load", "no-"];
22#[allow(clippy::needless_raw_string_hashes)]
24const PARAMS_HEADER: &str = r#"
25# Snake Game Configuration
26# ---------------------------
27# negative_size_fruits: allow fruits that can shrink the snake
28# fruit_timer: enable fruit lifetime countdown and auto-replacement
29# fruit_duration_seconds: base lifetime of a fruit before multipliers are applied
30# caps_fps: Enable frame limiting (default to true, false = no limit)
31# life: starting lives
32# nb_of_fruits: number of fruits available in the game at once
33# body_symbol: character for the snake's body
34# head_symbol: character for the snake's head
35# snake_length: initial length of the snake
36# speed: speed of the snake (Slow, Normal, Fast, Crazy)
37# log_level: logging level (off, error, warn, info, debug, trace)
38"#;
39
40macro_rules! define_args_withs {
42 (
43 $( $field_name:ident: $min:expr => $max:expr ),* $(,)?
44 ) => {
45 $(const $field_name: &str = stringify!($field_name);)*
47 #[must_use] pub fn get_parameter_range(param_name: &str) -> Option<std::ops::RangeInclusive<u16>> {
49 let idiomatic =param_name.to_string().replace("-","_").to_uppercase();
50 match idiomatic.as_str() {
51 $(
52 stringify!($field_name) => Some($min..=$max)
53 ,
54 )*
55 _ => None,
56 }
57 }
58 #[must_use] fn get_parameter_range_parser(param_name: &str) -> clap::builder::RangedI64ValueParser<u16> {
60 match param_name {
61 $(
62 stringify!($field_name) =>
63 clap::value_parser!(u16).range($min as i64..=$max as i64)
64 ,
65 )*
66 _ => clap::value_parser!(u16).range(1_i64..=99_i64),
67 }
68 }
69};
70}
71define_args_withs! {
74SNAKE_LENGTH: 2 => 999,
75LIFE: 1 => 99,
76NB_OF_FRUITS : 1 => 999,
77FRUIT_DURATION_SECONDS: 1 => 60,
78PRESETS: 1 => 7,
79}
80const MAX_EMOJI_BY_LINE_COUNT: u16 = 19;
81pub const DISPLAYABLE_EMOJI: [&str; 38] = [
84 "๐", "๐", "๐ฅ", "๐พ", "๐ข", "๐ฆ", "๐ชฝ", "๐ฅ", "๐ฃ", "๐ฆ ", "๐ฆด", "๐ฃ", "๐ฅ", "๐ฅฎ", "๐ช", "๐ฉ",
85 "๐ง", "๐ด", "๐งจ", "๐ฆ", "๐", "๐", "๐ค ", "๐คก", "๐ฅณ", "๐ฅธ", "๐บ", "๐น", "๐พ", "๐ผ", "๐", "๐",
86 "๐ฆ", "๐ณ", "๐", "โ๏ธ", "๐ฝ", "@",
87];
88#[derive(Parser, Serialize, Deserialize, Debug, Clone, Default)]
90#[serde(default)]
91#[command(
92 author,
93 version,
94 long_version = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"),
95 env!("CARGO_PKG_DESCRIPTION"),
96 "\nRepository: ", env!("CARGO_PKG_REPOSITORY"),
97 "\nBuilt with Rust ", env!("CARGO_PKG_RUST_VERSION")),
98 about = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"),
99 "\nSnake Game in terminal with CLI arguments.\nQuick custom run: cargo run -- -z ๐พ -b ๐ชฝ -l 10 "),
100 long_about = concat!("v", env!("CARGO_PKG_VERSION"), " by ", env!("CARGO_PKG_AUTHORS"), "\n",
101 env!("CARGO_PKG_DESCRIPTION"), " where you can configure the velocity, \
102 snake appearance, and more using command-line arguments.\nExample for asian vibes: rsnake -z ๐ผ -b ๐ฅ")
103)]
104#[allow(clippy::struct_excessive_bools)]
105pub struct GameOptions {
106 #[arg(
111 short,
112 long,
113 value_enum, default_value_t = Speed::Normal,
114 help = "Sets the movement speed of the snake.",
115 ignore_case = true
116 )]
117 pub speed: Speed,
118
119 #[arg(
124 short = 'z',
125 long,
126 default_value = DISPLAYABLE_EMOJI[34],
127 help = format!("Symbol used to represent the snake's head.\nHint:{}"
128 ,GameOptions::emojis_with_news_line()),
129 long_help = format!("Symbol used to represent the snake's head.\nHint:{},\
130 \n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
131 value_parser = |s: &str| -> Result<String, String>{
132 if s.graphemes(true).count() != 1 {
133 return Err(String::from("Head symbol must be exactly one grapheme / character"));
134 }
135 Ok(s.to_string())
136 }
137 )]
138 pub head_symbol: String,
139
140 #[arg(
146 short,
147 long,
148 default_value = DISPLAYABLE_EMOJI[35],
149 help = format!("Symbol used to represent the snake's body/trail.\
150 \nHint:{}",GameOptions::emojis_with_news_line()),
151 long_help = format!("Symbol used to represent the snake's body/trail.\
152 \nHint:{}\n/!\\ emoji displaying on multiple chars could be badly rendered/unplayable",GameOptions::emojis_with_news_line()),
153 value_parser = |s: &str| -> Result<String, String>{
154 if s.graphemes(true).count() != 1 {
155 return Err(String::from("Head symbol must be exactly one grapheme / character"));
156 }
157 Ok(s.to_string())
158 }
159 )]
160 pub body_symbol: String,
161
162 #[arg(
164 short = 'n',
165 long, default_value_t = 10,
167 value_parser = get_parameter_range_parser(SNAKE_LENGTH),
168 help = format!("Defines the initial length of the snake {}",pretty(get_parameter_range(SNAKE_LENGTH).unwrap()))
169 )]
170 #[serde(alias = "SNAKE_LENGTH")]
171 pub snake_length: u16,
172
173 #[arg(
175 short,
176 long,
177 default_value_t = 3,
178 value_parser = get_parameter_range_parser(LIFE),
179 help = format!("Defines the initial number of lives for the player {}",pretty(get_parameter_range(LIFE).unwrap()))
180 )]
181 #[serde(alias = "LIFE")]
182 pub life: u16,
183
184 #[arg(
186 short = 'f',
187 long,
188 default_value_t = 5,
189 value_parser = get_parameter_range_parser(NB_OF_FRUITS),
190 help = format!("Defines the number of fruits available at once {}",pretty(get_parameter_range(NB_OF_FRUITS).unwrap()))
191 )]
192 #[serde(alias = "nb_of_fruit", alias = "NB_OF_FRUITS")]
193 pub nb_of_fruits: u16,
194
195 #[arg(
197 long = "fruit-timer",
198 overrides_with = "fruit_timer",
199 help = "Enable the fruit lifetime countdown and automatic replacement [default]"
200 )]
201 #[serde(skip, default = "default_false")]
202 no_fruit_timer: bool,
203 #[arg(
204 long = "no-fruit-timer",
205 default_value_t = true,
206 action = ArgAction::SetFalse,
207 )]
208 pub fruit_timer: bool,
209
210 #[arg(
212 long,
213 default_value_t = 20,
214 value_parser = get_parameter_range_parser(FRUIT_DURATION_SECONDS),
215 help = format!(
216 "Defines the base lifetime used to derive fruit durations {}",
217 pretty(get_parameter_range(FRUIT_DURATION_SECONDS).unwrap())
218 )
219 )]
220 pub fruit_duration_seconds: u16,
221
222 #[arg(
224 long = "negative-size-fruits",
225 overrides_with = "negative_size_fruits",
226 help = "Allow fruits that can shrink the snake [default]"
227 )]
228 #[serde(skip, default = "default_false")]
229 no_negative_size_fruits: bool,
230 #[arg(
231 long = "no-negative-size-fruits",
232 default_value_t = true,
233 action = ArgAction::SetFalse,
234 )]
235 pub negative_size_fruits: bool,
236
237 #[arg(
239 long,
240 value_enum,
241 default_value_t = LogLevel::Off,
242 help = "Sets the logging level (off, error, warn, info, debug, trace).",
243 ignore_case = true
244 )]
245 pub log_level: LogLevel,
246 #[arg(
254 long = "caps-fps",
255 overrides_with = "caps_fps",
256 help = "Set to caps FPS limit (max 60 FPS) [default] "
257 )]
258 #[serde(skip, default = "default_false")]
259 no_caps_fps: bool,
260 #[arg(
261 long = "no-caps-fps",
262 default_value_t = true,
263 action = ArgAction::SetFalse,
264 )]
265 pub caps_fps: bool,
266 #[arg(
268 long,
269 default_missing_value = None,
270 value_parser = get_parameter_range_parser(PRESETS),
271 help = format!("Load game parameters PRESETS configuration from {}.\
272 Files are searched in the same folder as the executable.\
273 Save from edit menu to get the template or get one from best configuration example on the repository.\
274 Override cli arguments.",pretty(get_parameter_range(PRESETS).unwrap()))
275 )]
276 #[serde(skip)]
277 pub load: Option<u16>,
278}
279
280impl GameOptions {
281 #[must_use]
283 pub fn initial_position() -> Position {
284 INI_POSITION
285 }
286 #[must_use]
287 pub fn emojis_with_news_line() -> String {
288 DISPLAYABLE_EMOJI
289 .iter()
290 .enumerate()
291 .map(|(i, e)| {
292 if i == MAX_EMOJI_BY_LINE_COUNT as usize {
293 "\n".to_string() + e
294 } else {
295 (*e).to_string()
296 }
297 })
298 .collect::<String>()
299 }
300 pub fn emojis_iterator() -> impl Iterator<Item = String> {
301 DISPLAYABLE_EMOJI.iter().map(ToString::to_string)
302 }
303
304 #[must_use]
308 pub fn to_structured_toml(&self) -> Table {
309 let toml_string =
310 toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
311 toml_string.parse::<Table>().expect("invalid doc")
312 }
313
314 pub fn validate(&mut self) {
319 clamp_to(&mut self.snake_length, get_parameter_range(SNAKE_LENGTH));
320 clamp_to(&mut self.life, get_parameter_range(LIFE));
321 clamp_to(&mut self.nb_of_fruits, get_parameter_range(NB_OF_FRUITS));
322 clamp_to(
323 &mut self.fruit_duration_seconds,
324 get_parameter_range(FRUIT_DURATION_SECONDS),
325 );
326 if let Some(preset) = &mut self.load {
327 clamp_to(preset, get_parameter_range(PRESETS));
328 }
330 if self.head_symbol.graphemes(true).count() != 1 {
333 self.head_symbol = DISPLAYABLE_EMOJI[34].to_string();
334 }
335 if self.body_symbol.graphemes(true).count() != 1 {
336 self.body_symbol = DISPLAYABLE_EMOJI[35].to_string();
337 }
338 }
339
340 pub fn load_from_toml_preset(preset: u16) -> io::Result<Self> {
346 let path = format!("snake_preset_{preset}.toml");
347 let mut params = Self::load_from_toml(path)?;
348 params.load = Some(preset);
349 Ok(params)
350 }
351 fn load_from_toml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
361 let mut file = File::open(path)?;
362 let mut contents = String::new();
363 file.read_to_string(&mut contents)?;
364 let mut params: Self =
365 toml::from_str(&contents).expect("Failed to deserialize GameParameters from TOML");
366 params.validate();
367 Ok(params)
368 }
369 pub fn save_to_toml_preset(&mut self, preset: u16) -> io::Result<()> {
375 let path = format!("snake_preset_{preset}.toml");
376 self.save_to_toml(path)
377 }
378 fn save_to_toml<P: AsRef<Path>>(&mut self, path: P) -> io::Result<()> {
388 let toml_string =
389 toml::to_string_pretty(self).expect("Failed to serialize GameParameters to TOML");
390 let full_output = format!("{PARAMS_HEADER}\n{toml_string}");
391 let mut file = File::create(path)?;
392 file.write_all(full_output.as_bytes())?;
393 Ok(())
394 }
395}
396
397fn clamp_to(value: &mut u16, range: Option<RangeInclusive<u16>>) {
398 if let Some(range) = range {
399 let (min, max) = range.into_inner();
400 *value = (*value).clamp(min, max);
401 }
402}
403fn pretty(r: RangeInclusive<u16>) -> String {
404 format!("[{}-{}]", r.start(), r.end()).to_string()
405}
406fn default_false() -> bool {
408 false
409}
410
411impl ActionParameter for GameOptions {
412 fn apply_and_save(&mut self, rows: &[RowData], current_preset: Option<u16>) {
413 let command = GameOptions::command();
414 let prog_name = command.get_name().to_string();
415 let mut new_args = vec![prog_name];
416 for row in rows {
417 for cell in &row.cells {
418 if let CellValue::Options {
419 option_name,
420 values,
421 index,
422 ..
423 } = cell
424 {
425 let value = &values[*index];
426 match value.parse::<bool>() {
427 Ok(bv) => {
428 let bv_name: String = if bv {
433 option_name.clone()
434 } else {
435 option_name.replace("--", "--no-")
436 };
437 new_args.push(bv_name);
438 }
439 Err(_) => {
440 new_args.extend([option_name.clone(), value.clone()]);
442 }
443 }
444 }
445 }
446 }
447 self.update_from(new_args);
453 self.load = current_preset;
454 if let Some(preset) = current_preset {
456 let _ = self.save_to_toml_preset(preset);
457 }
458 }
459}
460
461#[cfg(test)]
462#[allow(clippy::field_reassign_with_default)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn test_validates() {
468 let mut options = GameOptions::default();
469 options.snake_length = 1000;
471 options.life = 100;
472 options.nb_of_fruits = 1000;
473 options.load = Some(8);
474 options.validate();
475 assert_eq!(options.snake_length, 999);
476 assert_eq!(options.life, 99);
477 assert_eq!(options.nb_of_fruits, 999);
478 assert_eq!(options.load, Some(7));
479
480 options.snake_length = 1;
482 options.life = 0;
483 options.nb_of_fruits = 0;
484 options.load = Some(0);
485 options.validate();
486 assert_eq!(options.snake_length, 2);
487 assert_eq!(options.life, 1);
488 assert_eq!(options.nb_of_fruits, 1);
489 assert_eq!(options.load, Some(1));
490 }
491
492 #[test]
493 fn test_validate_symbols() {
494 let mut options = GameOptions::default();
495 options.head_symbol = "invalid".to_string();
496 options.body_symbol = String::new();
497 options.validate();
498 assert_eq!(options.head_symbol.graphemes(true).count(), 1);
499 assert_eq!(options.body_symbol.graphemes(true).count(), 1);
500 }
501
502 #[test]
503 fn test_save_load_preset() {
504 let mut options = GameOptions::default();
505 options.snake_length = 42;
506 let preset_idx = 7;
507 let filename = format!("snake_preset_{preset_idx}.toml");
508 options.save_to_toml_preset(preset_idx).unwrap();
510
511 let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
513 assert_eq!(loaded.snake_length, 42);
514 assert_eq!(loaded.load, Some(preset_idx));
515
516 let _ = std::fs::remove_file(filename);
518 }
519
520 #[test]
521 fn test_load_invalid_toml_clamped() {
522 let preset_idx = 6;
523 let filename = format!("snake_preset_{preset_idx}.toml");
524 let content = "SNAKE_LENGTH = 2000\nLIFE = 0\n";
525 std::fs::write(&filename, content).unwrap();
526
527 let loaded = GameOptions::load_from_toml_preset(preset_idx).unwrap();
528 assert_eq!(loaded.snake_length, 999);
529 assert_eq!(loaded.life, 1);
530
531 let _ = std::fs::remove_file(filename);
532 }
533}