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::time::{SystemTime, UNIX_EPOCH};
11
12const GRID_W: u16 = 40;
13const GRID_H: u16 = 22;
14const INVADER_ROWS: u16 = 4;
15const INVADER_COLS: u16 = 8;
16const INVADER_SPACING_X: u16 = 4;
17const INVADER_SPACING_Y: u16 = 2;
18const PLAYER_Y: u16 = GRID_H - 2;
19const INVADER_MOVE_RATE: u64 = 12;
20const BULLET_RATE: u64 = 2;
21
22#[derive(Clone, Copy)]
23struct Bullet {
24 x: u16,
25 y: i16,
26 direction: i16, }
28
29pub struct SpaceGame {
30 player_x: u16,
31 invaders: Vec<(u16, u16, bool)>, bullets: Vec<Bullet>,
33 pub score: u32,
34 high_score: u32,
35 alive: bool,
36 pub game_over: bool,
37 invader_dir: i16, invader_drop: bool,
39 tick_count: u64,
40 shoot_cooldown: u64,
41 enemy_shoot_timer: u64,
42 rng_state: u64,
43 music_child: Option<std::process::Child>,
44 victory: bool,
45}
46
47impl Default for SpaceGame {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53impl SpaceGame {
54 pub fn new() -> Self {
55 let seed = SystemTime::now()
56 .duration_since(UNIX_EPOCH)
57 .unwrap_or_default()
58 .as_nanos() as u64;
59 let mut game = Self {
60 player_x: GRID_W / 2,
61 invaders: Vec::new(),
62 bullets: Vec::new(),
63 score: 0,
64 high_score: 0,
65 alive: true,
66 game_over: false,
67 invader_dir: 1,
68 invader_drop: false,
69 tick_count: 0,
70 shoot_cooldown: 0,
71 enemy_shoot_timer: 0,
72 rng_state: seed,
73 music_child: None,
74 victory: false,
75 };
76 game.spawn_invaders();
77 game.start_music();
78 game
79 }
80
81 fn spawn_invaders(&mut self) {
82 self.invaders.clear();
83 let start_x = (GRID_W - INVADER_COLS * INVADER_SPACING_X) / 2;
84 for row in 0..INVADER_ROWS {
85 for col in 0..INVADER_COLS {
86 self.invaders.push((
87 start_x + col * INVADER_SPACING_X,
88 2 + row * INVADER_SPACING_Y,
89 true,
90 ));
91 }
92 }
93 }
94
95 fn next_rand(&mut self) -> u64 {
96 self.rng_state ^= self.rng_state << 13;
97 self.rng_state ^= self.rng_state >> 7;
98 self.rng_state ^= self.rng_state << 17;
99 self.rng_state
100 }
101
102 pub fn tick(&mut self) {
103 if !self.alive || self.game_over {
104 return;
105 }
106 self.tick_count += 1;
107
108 if self.tick_count.is_multiple_of(BULLET_RATE) {
110 let mut hits: Vec<usize> = Vec::new();
111 for (bi, bullet) in self.bullets.iter_mut().enumerate() {
112 bullet.y += bullet.direction;
113 if bullet.y < 0 || bullet.y >= GRID_H as i16 {
114 hits.push(bi);
115 continue;
116 }
117 if bullet.direction == -1 {
119 for inv in self.invaders.iter_mut() {
120 if inv.2 && bullet.x.abs_diff(inv.0) <= 1 && bullet.y == inv.1 as i16 {
121 inv.2 = false;
122 hits.push(bi);
123 self.score += 10;
124 break;
125 }
126 }
127 }
128 if bullet.direction == 1
130 && bullet.y == PLAYER_Y as i16
131 && bullet.x.abs_diff(self.player_x) <= 1
132 {
133 self.die();
134 return;
135 }
136 }
137 hits.sort_unstable();
138 hits.dedup();
139 for i in hits.into_iter().rev() {
140 if i < self.bullets.len() {
141 self.bullets.remove(i);
142 }
143 }
144 }
145
146 if self.tick_count.is_multiple_of(INVADER_MOVE_RATE) {
148 if self.invader_drop {
149 for inv in self.invaders.iter_mut() {
150 if inv.2 {
151 inv.1 += 1;
152 }
153 }
154 self.invader_drop = false;
155 for inv in &self.invaders {
157 if inv.2 && inv.1 >= PLAYER_Y {
158 self.die();
159 return;
160 }
161 }
162 } else {
163 let mut should_drop = false;
164 for inv in self.invaders.iter_mut() {
165 if !inv.2 {
166 continue;
167 }
168 let new_x = inv.0 as i16 + self.invader_dir;
169 if new_x < 0 || new_x >= GRID_W as i16 {
170 should_drop = true;
171 break;
172 }
173 }
174 if should_drop {
175 self.invader_dir = -self.invader_dir;
176 self.invader_drop = true;
177 } else {
178 for inv in self.invaders.iter_mut() {
179 if inv.2 {
180 inv.0 = (inv.0 as i16 + self.invader_dir) as u16;
181 }
182 }
183 }
184 }
185 }
186
187 self.enemy_shoot_timer += 1;
189 if self.enemy_shoot_timer >= 20 {
190 self.enemy_shoot_timer = 0;
191 let alive_count = self.invaders.iter().filter(|i| i.2).count();
192 if alive_count > 0 {
193 let idx = self.next_rand() as usize % alive_count;
194 let inv = self.invaders.iter().filter(|i| i.2).nth(idx).copied();
195 if let Some((x, y, _)) = inv {
196 self.bullets.push(Bullet {
197 x,
198 y: y as i16 + 1,
199 direction: 1,
200 });
201 }
202 }
203 }
204
205 if self.shoot_cooldown > 0 {
206 self.shoot_cooldown -= 1;
207 }
208
209 if self.invaders.iter().all(|i| !i.2) {
211 self.victory = true;
212 self.game_over = true;
213 self.high_score = self.high_score.max(self.score);
214 self.stop_music();
215 let score = self.score;
216 std::thread::spawn(move || {
217 if cfg!(target_os = "macos") {
218 let _ = std::process::Command::new("say")
219 .args(["-v", "Trinoids", &format!("Victory. Score {}.", score)])
220 .stdout(std::process::Stdio::null())
221 .stderr(std::process::Stdio::null())
222 .spawn();
223 }
224 });
225 }
226 }
227
228 fn die(&mut self) {
229 self.alive = false;
230 self.game_over = true;
231 self.high_score = self.high_score.max(self.score);
232 self.stop_music();
233 let score = self.score;
234 std::thread::spawn(move || {
235 if cfg!(target_os = "macos") {
236 let _ = std::process::Command::new("say")
237 .args(["-v", "Trinoids", &format!("Game over. Score {}.", score)])
238 .stdout(std::process::Stdio::null())
239 .stderr(std::process::Stdio::null())
240 .spawn();
241 }
242 });
243 }
244
245 pub fn handle_input(&mut self, key: KeyCode) -> bool {
246 match key {
247 KeyCode::Esc | KeyCode::Char('q') => {
248 self.stop_music();
249 return true;
250 }
251 KeyCode::Left | KeyCode::Char('a') if self.player_x > 1 => {
252 self.player_x -= 1;
253 }
254 KeyCode::Right | KeyCode::Char('d') if self.player_x < GRID_W - 2 => {
255 self.player_x += 1;
256 }
257 KeyCode::Char(' ') | KeyCode::Up if self.alive && self.shoot_cooldown == 0 => {
258 self.bullets.push(Bullet {
259 x: self.player_x,
260 y: PLAYER_Y as i16 - 1,
261 direction: -1,
262 });
263 self.shoot_cooldown = 4;
264 }
265 KeyCode::Enter if self.game_over => {
266 let hs = self.high_score.max(self.score);
267 *self = SpaceGame::new();
268 self.high_score = hs;
269 }
270 _ => {}
271 }
272 false
273 }
274
275 fn start_music(&mut self) {
276 if !cfg!(target_os = "macos") {
277 return;
278 }
279 std::thread::spawn(|| {
280 let _ = std::process::Command::new("say")
281 .args(["-v", "Trinoids", "Space invaders activated"])
282 .stdout(std::process::Stdio::null())
283 .stderr(std::process::Stdio::null())
284 .spawn();
285 });
286 if let Ok(child) = std::process::Command::new("bash")
287 .args(["-c", "while true; do afplay -r 1.5 /System/Library/Sounds/Pop.aiff 2>/dev/null; sleep 0.3; done"])
288 .stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { self.music_child = Some(child) }
289 }
290
291 fn stop_music(&mut self) {
292 if let Some(ref mut child) = self.music_child {
293 let _ = child.kill();
294 let _ = child.wait();
295 }
296 self.music_child = None;
297 }
298
299 pub fn draw(&self, f: &mut Frame, area: Rect) {
300 f.render_widget(Clear, area);
301 let block = Block::default()
302 .borders(Borders::ALL)
303 .title(format!(
304 " SPACE INVADERS | Score: {} | High: {} ",
305 self.score, self.high_score
306 ))
307 .title_style(
308 Style::default()
309 .fg(Color::Cyan)
310 .add_modifier(Modifier::BOLD),
311 )
312 .border_style(Style::default().fg(Color::Cyan));
313 let inner = block.inner(area);
314 f.render_widget(block, area);
315
316 let cell_w = 2u16;
317 let visible_w = (inner.width / cell_w).min(GRID_W);
318 let visible_h = inner.height.min(GRID_H);
319 let mut lines: Vec<Line> = Vec::new();
320
321 for y in 0..visible_h {
322 let mut spans: Vec<Span> = Vec::new();
323 for x in 0..visible_w {
324 if y == PLAYER_Y && x.abs_diff(self.player_x) <= 1 {
326 if x == self.player_x {
327 spans.push(Span::styled(
328 "/\\",
329 Style::default()
330 .fg(Color::Green)
331 .add_modifier(Modifier::BOLD),
332 ));
333 } else if x == self.player_x.wrapping_sub(1) {
334 spans.push(Span::styled("[=", Style::default().fg(Color::Green)));
335 } else {
336 spans.push(Span::styled("=]", Style::default().fg(Color::Green)));
337 }
338 continue;
339 }
340 let mut drawn = false;
342 for inv in &self.invaders {
343 if inv.2 && inv.0 == x && inv.1 == y {
344 let row = self.invaders.iter().position(|i| i == inv).unwrap_or(0)
345 / INVADER_COLS as usize;
346 let color = match row {
347 0 => Color::Red,
348 1 => Color::Magenta,
349 2 => Color::Yellow,
350 _ => Color::LightRed,
351 };
352 spans.push(Span::styled("<>", Style::default().fg(color)));
353 drawn = true;
354 break;
355 }
356 }
357 if drawn {
358 continue;
359 }
360 let mut bullet_drawn = false;
362 for bullet in &self.bullets {
363 if bullet.x == x && bullet.y == y as i16 {
364 let (ch, color) = if bullet.direction == -1 {
365 ("||", Color::White)
366 } else {
367 ("::", Color::Red)
368 };
369 spans.push(Span::styled(ch, Style::default().fg(color)));
370 bullet_drawn = true;
371 break;
372 }
373 }
374 if bullet_drawn {
375 continue;
376 }
377 spans.push(Span::raw(" "));
379 }
380 lines.push(Line::from(spans));
381 }
382 f.render_widget(Paragraph::new(lines), inner);
383
384 if self.game_over {
386 let title = if self.victory {
387 "VICTORY!"
388 } else {
389 "GAME OVER"
390 };
391 let title_color = if self.victory {
392 Color::Green
393 } else {
394 Color::Red
395 };
396 let ow = 34u16;
397 let oh = 7u16;
398 let ox = area.x + area.width.saturating_sub(ow) / 2;
399 let oy = area.y + area.height.saturating_sub(oh) / 2;
400 let oa = Rect::new(ox, oy, ow, oh);
401 f.render_widget(Clear, oa);
402 let go = Paragraph::new(vec![
403 Line::from(""),
404 Line::from(Span::styled(
405 format!(" == {} ==", title),
406 Style::default()
407 .fg(title_color)
408 .add_modifier(Modifier::BOLD),
409 )),
410 Line::from(Span::styled(
411 format!(" Score: {:<7}", self.score),
412 Style::default().fg(Color::Yellow),
413 )),
414 Line::from(Span::styled(
415 format!(" High: {:<7}", self.high_score),
416 Style::default().fg(Color::Green),
417 )),
418 Line::from(Span::styled(
419 " ═══════════════════",
420 Style::default().fg(title_color),
421 )),
422 Line::from(Span::styled(
423 " Enter=Restart Esc=Exit",
424 Style::default().fg(Color::DarkGray),
425 )),
426 ])
427 .block(
428 Block::default()
429 .borders(Borders::ALL)
430 .border_style(Style::default().fg(title_color)),
431 );
432 f.render_widget(go, oa);
433 }
434 }
435}
436
437impl Drop for SpaceGame {
438 fn drop(&mut self) {
439 self.stop_music();
440 }
441}