diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index bdf13dc5a9c6..55cdcd882eef 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -686,6 +686,7 @@ pub enum MiniGameKind { SubwaySurfer, Snake, FlappyBird, + Pacman, } /// Collection of settings that are specific to the TUI. diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 319f90ab3339..5e67fc00c132 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3150,6 +3150,7 @@ impl App { codex_core::config::types::MiniGameKind::SubwaySurfer => "subway_surfer", codex_core::config::types::MiniGameKind::Snake => "snake", codex_core::config::types::MiniGameKind::FlappyBird => "flappy_bird", + codex_core::config::types::MiniGameKind::Pacman => "pacman", }; let edit = ConfigEdit::SetPath { segments: vec!["tui".to_string(), "mini_game".to_string()], diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 878f3f1c67e0..61a18c3168b4 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -5674,6 +5674,16 @@ impl ChatWidget { dismiss_on_select: true, ..Default::default() }, + SelectionItem { + name: "Pacman".to_string(), + description: Some("Munch pellets and dodge ghosts through the maze".to_string()), + is_current: current == MiniGameKind::Pacman, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::UpdateMiniGameKind(MiniGameKind::Pacman)); + })], + dismiss_on_select: true, + ..Default::default() + }, SelectionItem { name: "Quit Games".to_string(), description: Some("Close the game and return to the input bar".to_string()), diff --git a/codex-rs/tui/src/games/mod.rs b/codex-rs/tui/src/games/mod.rs index 470ccb55b729..e8754437eb89 100644 --- a/codex-rs/tui/src/games/mod.rs +++ b/codex-rs/tui/src/games/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod connect4; pub(crate) mod flappy_bird; +pub(crate) mod pacman; pub(crate) mod snake; pub(crate) mod subway_surfer; pub(crate) mod tetris; @@ -60,6 +61,7 @@ impl GameOverlay { } MiniGameKind::Snake => Box::new(snake::SnakeGame::new(frame_requester)), MiniGameKind::FlappyBird => Box::new(flappy_bird::FlappyBirdGame::new(frame_requester)), + MiniGameKind::Pacman => Box::new(pacman::PacmanGame::new(frame_requester)), }; Self { game, diff --git a/codex-rs/tui/src/games/pacman.rs b/codex-rs/tui/src/games/pacman.rs new file mode 100644 index 000000000000..e7cad1687598 --- /dev/null +++ b/codex-rs/tui/src/games/pacman.rs @@ -0,0 +1,694 @@ +use std::time::Duration; +use std::time::Instant; + +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; + +use super::GameWidget; +use crate::tui::FrameRequester; + +const PAC_TICK_MS: u64 = 140; +const GHOST_TICK_MS: u64 = 180; +const POWER_DURATION_TICKS: u32 = 40; +const CELL_W: u16 = 2; + +const MAZE_H: usize = 13; +const MAZE_W: usize = 21; + +// Legend: # wall, . pellet, o power pellet, P pacman spawn, G ghost spawn, space empty. +const MAZE: [&str; MAZE_H] = [ + "#####################", + "#o.................o#", + "#.###.###.#.###.###.#", + "#...................#", + "#.###.#.#####.#.###.#", + "#.....#.......#.....#", + "#.###.#.G.G.G.#.###.#", + "#.....#.......#.....#", + "#.###.#.#####.#.###.#", + "#...................#", + "#.###.###.#.###.###.#", + "#o........P........o#", + "#####################", +]; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Dir { + Up, + Down, + Left, + Right, + None, +} + +impl Dir { + fn delta(self) -> (i32, i32) { + match self { + Dir::Up => (-1, 0), + Dir::Down => (1, 0), + Dir::Left => (0, -1), + Dir::Right => (0, 1), + Dir::None => (0, 0), + } + } + + fn opposite(self) -> Self { + match self { + Dir::Up => Dir::Down, + Dir::Down => Dir::Up, + Dir::Left => Dir::Right, + Dir::Right => Dir::Left, + Dir::None => Dir::None, + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tile { + Wall, + Pellet, + Power, + Empty, +} + +#[derive(Clone, Copy)] +struct Ghost { + r: i32, + c: i32, + home_r: i32, + home_c: i32, + dir: Dir, + color: Color, + scared: bool, + eaten: bool, +} + +pub(crate) struct PacmanGame { + tiles: Vec>, + pac_r: i32, + pac_c: i32, + pac_start_r: i32, + pac_start_c: i32, + dir: Dir, + queued_dir: Dir, + ghosts: Vec, + score: u32, + pellets_remaining: u32, + power_ticks_remaining: u32, + game_over: bool, + won: bool, + frame_requester: FrameRequester, + last_pac_tick: Instant, + last_ghost_tick: Instant, + rng_state: u32, + animation_frame: u32, +} + +impl PacmanGame { + pub fn new(frame_requester: FrameRequester) -> Self { + let mut game = Self { + tiles: Vec::new(), + pac_r: 0, + pac_c: 0, + pac_start_r: 0, + pac_start_c: 0, + dir: Dir::None, + queued_dir: Dir::None, + ghosts: Vec::new(), + score: 0, + pellets_remaining: 0, + power_ticks_remaining: 0, + game_over: false, + won: false, + frame_requester, + last_pac_tick: Instant::now(), + last_ghost_tick: Instant::now(), + rng_state: 0xC0DE_BABE, + animation_frame: 0, + }; + game.load_maze(); + game + } + + fn load_maze(&mut self) { + let ghost_colors = [ + Color::Red, + Color::LightMagenta, + Color::Cyan, + Color::LightYellow, + ]; + self.tiles = vec![vec![Tile::Empty; MAZE_W]; MAZE_H]; + self.ghosts.clear(); + self.pellets_remaining = 0; + let mut ghost_idx = 0usize; + for (r, line) in MAZE.iter().enumerate() { + for (c, ch) in line.chars().enumerate() { + if c >= MAZE_W { + break; + } + match ch { + '#' => self.tiles[r][c] = Tile::Wall, + '.' => { + self.tiles[r][c] = Tile::Pellet; + self.pellets_remaining += 1; + } + 'o' => { + self.tiles[r][c] = Tile::Power; + self.pellets_remaining += 1; + } + 'P' => { + self.tiles[r][c] = Tile::Empty; + self.pac_start_r = r as i32; + self.pac_start_c = c as i32; + } + 'G' => { + self.tiles[r][c] = Tile::Pellet; + self.pellets_remaining += 1; + let color = ghost_colors[ghost_idx % ghost_colors.len()]; + self.ghosts.push(Ghost { + r: r as i32, + c: c as i32, + home_r: r as i32, + home_c: c as i32, + dir: Dir::Left, + color, + scared: false, + eaten: false, + }); + ghost_idx += 1; + } + _ => self.tiles[r][c] = Tile::Empty, + } + } + } + self.pac_r = self.pac_start_r; + self.pac_c = self.pac_start_c; + self.dir = Dir::None; + self.queued_dir = Dir::None; + } + + fn is_wall(&self, r: i32, c: i32) -> bool { + if r < 0 || r >= MAZE_H as i32 || c < 0 || c >= MAZE_W as i32 { + return true; + } + matches!(self.tiles[r as usize][c as usize], Tile::Wall) + } + + fn pseudo_rand(&mut self) -> u32 { + self.rng_state = self + .rng_state + .wrapping_mul(1103515245) + .wrapping_add(12345); + (self.rng_state >> 16) & 0x7FFF + } + + fn advance_pac(&mut self) { + if self.queued_dir != Dir::None { + let (dr, dc) = self.queued_dir.delta(); + if !self.is_wall(self.pac_r + dr, self.pac_c + dc) { + self.dir = self.queued_dir; + self.queued_dir = Dir::None; + } + } + + let (dr, dc) = self.dir.delta(); + if (dr, dc) == (0, 0) { + return; + } + let nr = self.pac_r + dr; + let nc = self.pac_c + dc; + if self.is_wall(nr, nc) { + return; + } + self.pac_r = nr; + self.pac_c = nc; + + let tile = self.tiles[self.pac_r as usize][self.pac_c as usize]; + match tile { + Tile::Pellet => { + self.tiles[self.pac_r as usize][self.pac_c as usize] = Tile::Empty; + self.score += 10; + self.pellets_remaining = self.pellets_remaining.saturating_sub(1); + } + Tile::Power => { + self.tiles[self.pac_r as usize][self.pac_c as usize] = Tile::Empty; + self.score += 50; + self.pellets_remaining = self.pellets_remaining.saturating_sub(1); + self.power_ticks_remaining = POWER_DURATION_TICKS; + for g in self.ghosts.iter_mut() { + if !g.eaten { + g.scared = true; + g.dir = g.dir.opposite(); + } + } + } + _ => {} + } + + if self.pellets_remaining == 0 { + self.won = true; + self.game_over = true; + } + } + + fn advance_ghosts(&mut self) { + let pac_r = self.pac_r; + let pac_c = self.pac_c; + let ghost_count = self.ghosts.len(); + + for i in 0..ghost_count { + let (r, c, dir, scared, eaten, home_r, home_c) = { + let g = &self.ghosts[i]; + (g.r, g.c, g.dir, g.scared, g.eaten, g.home_r, g.home_c) + }; + + if eaten { + self.ghosts[i].r = home_r; + self.ghosts[i].c = home_c; + self.ghosts[i].eaten = false; + self.ghosts[i].scared = false; + self.ghosts[i].dir = Dir::Left; + continue; + } + + let candidates = [Dir::Up, Dir::Down, Dir::Left, Dir::Right]; + let mut valid: Vec = candidates + .iter() + .copied() + .filter(|&d| { + if dir != Dir::None && d == dir.opposite() { + return false; + } + let (dr, dc) = d.delta(); + !self.is_wall(r + dr, c + dc) + }) + .collect(); + + if valid.is_empty() { + valid = candidates + .iter() + .copied() + .filter(|&d| { + let (dr, dc) = d.delta(); + !self.is_wall(r + dr, c + dc) + }) + .collect(); + } + + let chosen = if valid.is_empty() { + dir + } else if scared { + let rand = self.pseudo_rand(); + if rand.is_multiple_of(5) { + valid[(rand as usize) % valid.len()] + } else { + let mut best = valid[0]; + let mut best_dist = i32::MIN; + for d in &valid { + let (dr, dc) = d.delta(); + let nr = r + dr; + let nc = c + dc; + let dist = (nr - pac_r).abs() + (nc - pac_c).abs(); + if dist > best_dist { + best_dist = dist; + best = *d; + } + } + best + } + } else { + let rand = self.pseudo_rand(); + if rand % 10 < 3 { + valid[(rand as usize) % valid.len()] + } else { + let mut best = valid[0]; + let mut best_dist = i32::MAX; + for d in &valid { + let (dr, dc) = d.delta(); + let nr = r + dr; + let nc = c + dc; + let dist = (nr - pac_r).abs() + (nc - pac_c).abs(); + if dist < best_dist { + best_dist = dist; + best = *d; + } + } + best + } + }; + + let (dr, dc) = chosen.delta(); + self.ghosts[i].r = r + dr; + self.ghosts[i].c = c + dc; + self.ghosts[i].dir = chosen; + } + } + + fn check_collision(&mut self) { + for g in self.ghosts.iter_mut() { + if g.eaten { + continue; + } + if g.r == self.pac_r && g.c == self.pac_c { + if g.scared { + g.eaten = true; + g.scared = false; + self.score += 200; + } else { + self.game_over = true; + self.won = false; + return; + } + } + } + } +} + +impl GameWidget for PacmanGame { + fn handle_key_event(&mut self, key_event: KeyEvent) -> bool { + if key_event.kind != KeyEventKind::Press { + return false; + } + + if self.game_over { + if matches!(key_event.code, KeyCode::Enter | KeyCode::Char(' ')) { + self.reset(); + return true; + } + return false; + } + + let new_dir = match key_event.code { + KeyCode::Up | KeyCode::Char('w') | KeyCode::Char('W') => Some(Dir::Up), + KeyCode::Down | KeyCode::Char('s') | KeyCode::Char('S') => Some(Dir::Down), + KeyCode::Left | KeyCode::Char('a') | KeyCode::Char('A') => Some(Dir::Left), + KeyCode::Right | KeyCode::Char('d') | KeyCode::Char('D') => Some(Dir::Right), + _ => None, + }; + + if let Some(d) = new_dir { + self.queued_dir = d; + return true; + } + + false + } + + fn tick(&mut self) { + if self.game_over { + return; + } + + let now = Instant::now(); + let mut moved = false; + + if now.duration_since(self.last_pac_tick) >= Duration::from_millis(PAC_TICK_MS) { + self.last_pac_tick = now; + self.advance_pac(); + self.check_collision(); + moved = true; + } + + if !self.game_over + && now.duration_since(self.last_ghost_tick) >= Duration::from_millis(GHOST_TICK_MS) + { + self.last_ghost_tick = now; + if self.power_ticks_remaining > 0 { + self.power_ticks_remaining -= 1; + if self.power_ticks_remaining == 0 { + for g in self.ghosts.iter_mut() { + g.scared = false; + } + } + } + self.advance_ghosts(); + self.check_collision(); + moved = true; + } + + if moved { + self.animation_frame = self.animation_frame.wrapping_add(1); + } + + let next_delay = PAC_TICK_MS.min(GHOST_TICK_MS); + self.frame_requester + .schedule_frame_in(Duration::from_millis(next_delay)); + } + + fn is_game_over(&self) -> bool { + self.game_over + } + + fn reset(&mut self) { + self.score = 0; + self.power_ticks_remaining = 0; + self.game_over = false; + self.won = false; + self.last_pac_tick = Instant::now(); + self.last_ghost_tick = Instant::now(); + self.animation_frame = 0; + self.load_maze(); + } + + fn render_game(&self, area: Rect, buf: &mut Buffer) { + if area.height < 6 || area.width < 20 { + return; + } + + let dim = Style::default().fg(Color::DarkGray); + let border_style = Style::default().fg(Color::Gray); + let white = Style::default().fg(Color::White); + let wall_style = Style::default().fg(Color::Blue); + let pellet_style = Style::default().fg(Color::Gray); + let power_style = Style::default().fg(Color::LightYellow); + let pac_style = Style::default().fg(Color::Yellow); + let score_label = Style::default().fg(Color::Gray); + + let board_w = MAZE_W as u16 * CELL_W + 2; + let bx = area.x + area.width.saturating_sub(board_w) / 2; + + let mut y = area.y; + let y_max = area.y + area.height; + + // Header: controls (left) and score (right) + if y < y_max { + buf.set_string(area.x + 1, y, "Arrow keys / WASD to move", dim); + let score_text = format!("{}", self.score); + let label = "Score "; + let sx = area.x + + area + .width + .saturating_sub((label.len() + score_text.len()) as u16 + 1); + buf.set_string(sx, y, label, score_label); + buf.set_string(sx + label.len() as u16, y, &score_text, white); + y += 1; + } + + // Top border + if y < y_max { + let mut border = String::new(); + border.push('\u{250c}'); + for _ in 0..MAZE_W as u16 * CELL_W { + border.push('\u{2500}'); + } + border.push('\u{2510}'); + buf.set_string(bx, y, &border, border_style); + y += 1; + } + + // Maze rows + for row in 0..MAZE_H { + if y >= y_max { + break; + } + buf.set_string(bx, y, "\u{2502}", border_style); + let mut x = bx + 1; + + for col in 0..MAZE_W { + let is_pac = self.pac_r == row as i32 && self.pac_c == col as i32; + let ghost_here = self + .ghosts + .iter() + .find(|g| g.r == row as i32 && g.c == col as i32); + + if is_pac { + let glyph = match self.dir { + Dir::Right | Dir::None => "\u{25D0} ", + Dir::Left => "\u{25D1} ", + Dir::Up => "\u{25D2} ", + Dir::Down => "\u{25D3} ", + }; + buf.set_string(x, y, glyph, pac_style); + } else if let Some(g) = ghost_here { + if g.eaten { + buf.set_string(x, y, "\u{00B7}\u{00B7}", dim); + } else if g.scared { + // Flash near end of power duration. + let flashing = self.power_ticks_remaining <= 10 + && self.power_ticks_remaining.is_multiple_of(2); + let color = if flashing { Color::White } else { Color::Blue }; + buf.set_string(x, y, "\u{15E3} ", Style::default().fg(color)); + } else { + buf.set_string(x, y, "\u{15E3} ", Style::default().fg(g.color)); + } + } else { + match self.tiles[row][col] { + Tile::Wall => buf.set_string(x, y, "\u{2588}\u{2588}", wall_style), + Tile::Pellet => buf.set_string(x, y, " \u{00B7}", pellet_style), + Tile::Power => { + let visible = self.animation_frame % 8 < 6; + if visible { + buf.set_string(x, y, " \u{25CF}", power_style); + } else { + buf.set_string(x, y, " ", dim); + } + } + Tile::Empty => buf.set_string(x, y, " ", dim), + } + } + x += CELL_W; + } + + buf.set_string(x, y, "\u{2502}", border_style); + y += 1; + } + + // Bottom border + if y < y_max { + let mut border = String::new(); + border.push('\u{2514}'); + for _ in 0..MAZE_W as u16 * CELL_W { + border.push('\u{2500}'); + } + border.push('\u{2518}'); + buf.set_string(bx, y, &border, border_style); + y += 1; + } + + // Status + if self.game_over && y < y_max { + let (label, color) = if self.won { + ("You Win! ", Color::LightGreen) + } else { + ("Game Over! ", Color::Red) + }; + buf.set_string(area.x + 1, y, label, Style::default().fg(color)); + let sc = format!("Score: {} ", self.score); + let px = area.x + 1 + label.len() as u16; + buf.set_string(px, y, &sc, white); + buf.set_string(px + sc.len() as u16, y, "Press Enter to retry.", dim); + } + } + + fn game_desired_height(&self, _width: u16) -> u16 { + // 1 header + 1 top border + MAZE_H rows + 1 bottom border + 1 status + (MAZE_H as u16) + 4 + } + + fn title(&self) -> &str { + " Pacman " + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pacman_spawns_with_pellets() { + let game = PacmanGame::new(FrameRequester::test_dummy()); + assert!(game.pellets_remaining > 0); + assert_eq!(game.score, 0); + assert!(!game.game_over); + } + + #[test] + fn pacman_eats_pellet_when_moving() { + let mut game = PacmanGame::new(FrameRequester::test_dummy()); + // Find a direction from spawn that has a pellet. + let dirs = [Dir::Up, Dir::Down, Dir::Left, Dir::Right]; + let mut chose = None; + for d in dirs { + let (dr, dc) = d.delta(); + let nr = game.pac_r + dr; + let nc = game.pac_c + dc; + if !game.is_wall(nr, nc) + && matches!(game.tiles[nr as usize][nc as usize], Tile::Pellet) + { + chose = Some(d); + break; + } + } + let d = chose.expect("pacman spawn should have an adjacent pellet"); + game.dir = d; + let pellets_before = game.pellets_remaining; + + game.advance_pac(); + + assert_eq!(game.score, 10); + assert_eq!(game.pellets_remaining, pellets_before - 1); + } + + #[test] + fn pacman_blocked_by_wall() { + let mut game = PacmanGame::new(FrameRequester::test_dummy()); + // Force pacman into a corner scenario and verify wall blocks it. + game.pac_r = 1; + game.pac_c = 1; + game.dir = Dir::Up; + let before = (game.pac_r, game.pac_c); + + game.advance_pac(); + + assert_eq!((game.pac_r, game.pac_c), before); + } + + #[test] + fn ghost_collision_ends_game() { + let mut game = PacmanGame::new(FrameRequester::test_dummy()); + assert!(!game.ghosts.is_empty()); + let g0r = game.ghosts[0].r; + let g0c = game.ghosts[0].c; + game.pac_r = g0r; + game.pac_c = g0c; + + game.check_collision(); + + assert!(game.game_over); + assert!(!game.won); + } + + #[test] + fn power_pellet_scares_ghosts() { + let mut game = PacmanGame::new(FrameRequester::test_dummy()); + // Move pac onto a power pellet directly. + let mut found = None; + for r in 0..MAZE_H { + for c in 0..MAZE_W { + if matches!(game.tiles[r][c], Tile::Power) { + found = Some((r as i32, c as i32)); + } + } + } + let (pr, pc) = found.expect("maze has power pellets"); + // Place pac one step away and step onto it. + game.pac_r = pr; + game.pac_c = pc - 1; + if game.is_wall(game.pac_r, game.pac_c) { + game.pac_c = pc + 1; + game.dir = Dir::Left; + } else { + game.dir = Dir::Right; + } + + game.advance_pac(); + + assert!(game.power_ticks_remaining > 0); + assert!(game.ghosts.iter().all(|g| g.scared)); + } +}