Skip to main content

battlecommand_forge/
space.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: Space Invaders — type /space in chat!
10use 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, // -1 = up (player), +1 = down (enemy)
27}
28
29pub struct SpaceGame {
30    player_x: u16,
31    invaders: Vec<(u16, u16, bool)>, // x, y, alive
32    bullets: Vec<Bullet>,
33    pub score: u32,
34    high_score: u32,
35    alive: bool,
36    pub game_over: bool,
37    invader_dir: i16, // 1 = right, -1 = left
38    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        // Move bullets
109        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                // Player bullet hits invader
118                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                // Enemy bullet hits player
129                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        // Move invaders
147        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                // Check if invaders reached player
156                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        // Enemy shooting
188        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        // Victory check
210        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                // Player
325                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                // Invaders
341                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                // Bullets
361                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                // Empty
378                spans.push(Span::raw("  "));
379            }
380            lines.push(Line::from(spans));
381        }
382        f.render_widget(Paragraph::new(lines), inner);
383
384        // Game over / victory overlay
385        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}