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};
9use 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}