Skip to main content

battlecommand_forge/
snake.rs

1use crossterm::event::KeyCode;
2use ratatui::{
3    layout::Rect,
4    style::{Color, Modifier, Style},
5    text::{Line, Span},
6    widgets::{Block, Borders, Clear, Paragraph},
7    Frame,
8};
9/// Easter egg: Retro snake game — type /snake in chat!
10/// Ported from battleclaw-v2. macOS audio (say + afplay).
11use std::collections::VecDeque;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14const GRID_W: u16 = 40;
15const GRID_H: u16 = 20;
16const INITIAL_SPEED: u64 = 3;
17
18#[derive(Debug, Clone, Copy, PartialEq)]
19enum Direction {
20    Up,
21    Down,
22    Left,
23    Right,
24}
25
26pub struct SnakeGame {
27    snake: VecDeque<(u16, u16)>,
28    direction: Direction,
29    next_direction: Direction,
30    food: (u16, u16),
31    pub score: u32,
32    high_score: u32,
33    alive: bool,
34    pub game_over: bool,
35    width: u16,
36    height: u16,
37    tick_count: u64,
38    speed: u64,
39    music_child: Option<std::process::Child>,
40    rng_state: u64,
41}
42
43impl Default for SnakeGame {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl SnakeGame {
50    pub fn new() -> Self {
51        let mid_x = GRID_W / 2;
52        let mid_y = GRID_H / 2;
53        let mut snake = VecDeque::new();
54        snake.push_back((mid_x, mid_y));
55        snake.push_back((mid_x - 1, mid_y));
56        snake.push_back((mid_x - 2, mid_y));
57
58        let seed = SystemTime::now()
59            .duration_since(UNIX_EPOCH)
60            .unwrap_or_default()
61            .as_nanos() as u64;
62        let mut game = Self {
63            snake,
64            direction: Direction::Right,
65            next_direction: Direction::Right,
66            food: (0, 0),
67            score: 0,
68            high_score: 0,
69            alive: true,
70            game_over: false,
71            width: GRID_W,
72            height: GRID_H,
73            tick_count: 0,
74            speed: INITIAL_SPEED,
75            music_child: None,
76            rng_state: seed,
77        };
78        game.spawn_food();
79        game.start_music();
80        game
81    }
82
83    fn next_rand(&mut self) -> u64 {
84        self.rng_state ^= self.rng_state << 13;
85        self.rng_state ^= self.rng_state >> 7;
86        self.rng_state ^= self.rng_state << 17;
87        self.rng_state
88    }
89
90    fn spawn_food(&mut self) {
91        for _ in 0..200 {
92            let r = self.next_rand();
93            let x = (r % self.width as u64) as u16;
94            let y = ((r >> 16) % self.height as u64) as u16;
95            if !self.snake.contains(&(x, y)) {
96                self.food = (x, y);
97                return;
98            }
99        }
100    }
101
102    pub fn tick(&mut self) {
103        if !self.alive || self.game_over {
104            return;
105        }
106        self.tick_count += 1;
107        if !self.tick_count.is_multiple_of(self.speed) {
108            return;
109        }
110
111        self.direction = self.next_direction;
112        let (hx, hy) = *self.snake.front().unwrap();
113        let new_head = match self.direction {
114            Direction::Up => {
115                if hy == 0 {
116                    self.die();
117                    return;
118                }
119                (hx, hy - 1)
120            }
121            Direction::Down => (hx, hy + 1),
122            Direction::Left => {
123                if hx == 0 {
124                    self.die();
125                    return;
126                }
127                (hx - 1, hy)
128            }
129            Direction::Right => (hx + 1, hy),
130        };
131
132        if new_head.0 >= self.width || new_head.1 >= self.height {
133            self.die();
134            return;
135        }
136        if self.snake.contains(&new_head) {
137            self.die();
138            return;
139        }
140
141        self.snake.push_front(new_head);
142        if new_head == self.food {
143            self.score += 10;
144            if self.score.is_multiple_of(50) && self.speed > 1 {
145                self.speed -= 1;
146            }
147            self.spawn_food();
148        } else {
149            self.snake.pop_back();
150        }
151    }
152
153    fn die(&mut self) {
154        self.alive = false;
155        self.game_over = true;
156        self.high_score = self.high_score.max(self.score);
157        self.stop_music();
158        let score = self.score;
159        std::thread::spawn(move || {
160            if cfg!(target_os = "macos") {
161                let _ = std::process::Command::new("say")
162                    .args(["-v", "Trinoids", &format!("Game over. Score {}.", score)])
163                    .stdout(std::process::Stdio::null())
164                    .stderr(std::process::Stdio::null())
165                    .spawn();
166            }
167        });
168    }
169
170    pub fn handle_input(&mut self, key: KeyCode) -> bool {
171        match key {
172            KeyCode::Esc | KeyCode::Char('q') => {
173                self.stop_music();
174                return true;
175            }
176            KeyCode::Up | KeyCode::Char('w') if self.direction != Direction::Down => {
177                self.next_direction = Direction::Up;
178            }
179            KeyCode::Down | KeyCode::Char('s') if self.direction != Direction::Up => {
180                self.next_direction = Direction::Down;
181            }
182            KeyCode::Left | KeyCode::Char('a') if self.direction != Direction::Right => {
183                self.next_direction = Direction::Left;
184            }
185            KeyCode::Right | KeyCode::Char('d') if self.direction != Direction::Left => {
186                self.next_direction = Direction::Right;
187            }
188            KeyCode::Enter if self.game_over => {
189                let hs = self.high_score.max(self.score);
190                *self = SnakeGame::new();
191                self.high_score = hs;
192            }
193            _ => {}
194        }
195        false
196    }
197
198    fn start_music(&mut self) {
199        if !cfg!(target_os = "macos") {
200            return;
201        }
202        std::thread::spawn(|| {
203            let _ = std::process::Command::new("say")
204                .args(["-v", "Trinoids", "Snake mode activated"])
205                .stdout(std::process::Stdio::null())
206                .stderr(std::process::Stdio::null())
207                .spawn();
208        });
209        if let Ok(child) = std::process::Command::new("bash")
210            .args(["-c", "while true; do afplay -r 2.0 /System/Library/Sounds/Tink.aiff 2>/dev/null; sleep 0.12; done"])
211            .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { self.music_child = Some(child) }
212    }
213
214    fn stop_music(&mut self) {
215        if let Some(ref mut child) = self.music_child {
216            let _ = child.kill();
217            let _ = child.wait();
218        }
219        self.music_child = None;
220    }
221
222    pub fn draw(&self, f: &mut Frame, area: Rect) {
223        f.render_widget(Clear, area);
224        let block = Block::default()
225            .borders(Borders::ALL)
226            .title(format!(
227                " SNAKE | Score: {} | High: {} | Speed: {} ",
228                self.score,
229                self.high_score,
230                INITIAL_SPEED + 1 - self.speed
231            ))
232            .title_style(
233                Style::default()
234                    .fg(Color::Green)
235                    .add_modifier(Modifier::BOLD),
236            )
237            .border_style(Style::default().fg(Color::Green));
238        let inner = block.inner(area);
239        f.render_widget(block, area);
240
241        let cell_w = 2u16;
242        let visible_w = (inner.width / cell_w).min(self.width);
243        let visible_h = inner.height.min(self.height);
244        let mut lines: Vec<Line> = Vec::new();
245
246        for y in 0..visible_h {
247            let mut spans: Vec<Span> = Vec::new();
248            for x in 0..visible_w {
249                let pos = (x, y);
250                if Some(&pos) == self.snake.front() {
251                    spans.push(Span::styled("██", Style::default().fg(Color::LightGreen)));
252                } else if self.snake.contains(&pos) {
253                    spans.push(Span::styled("██", Style::default().fg(Color::Green)));
254                } else if pos == self.food {
255                    spans.push(Span::styled("██", Style::default().fg(Color::Red)));
256                } else {
257                    spans.push(Span::raw("  "));
258                }
259            }
260            lines.push(Line::from(spans));
261        }
262        f.render_widget(Paragraph::new(lines), inner);
263
264        if self.game_over {
265            let ow = 34u16;
266            let oh = 7u16;
267            let ox = area.x + area.width.saturating_sub(ow) / 2;
268            let oy = area.y + area.height.saturating_sub(oh) / 2;
269            let oa = Rect::new(ox, oy, ow, oh);
270            f.render_widget(Clear, oa);
271            let go = Paragraph::new(vec![
272                Line::from(""),
273                Line::from(Span::styled(
274                    "  ╔══ GAME OVER ══╗",
275                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
276                )),
277                Line::from(Span::styled(
278                    format!("  ║  Score: {:<7}║", self.score),
279                    Style::default().fg(Color::Yellow),
280                )),
281                Line::from(Span::styled(
282                    format!("  ║  High:  {:<7}║", self.high_score),
283                    Style::default().fg(Color::Green),
284                )),
285                Line::from(Span::styled(
286                    "  ╚═══════════════╝",
287                    Style::default().fg(Color::Red),
288                )),
289                Line::from(Span::styled(
290                    "  Enter=Restart Esc=Exit",
291                    Style::default().fg(Color::DarkGray),
292                )),
293            ])
294            .block(
295                Block::default()
296                    .borders(Borders::ALL)
297                    .border_style(Style::default().fg(Color::Red)),
298            );
299            f.render_widget(go, oa);
300        }
301    }
302}
303
304impl Drop for SnakeGame {
305    fn drop(&mut self) {
306        self.stop_music();
307    }
308}