From 4dcfef82c88a6d08c0c6a55b9243e611b2ff4324 Mon Sep 17 00:00:00 2001 From: Dat Nguyen <64458422+datnguyen1@users.noreply.github.com> Date: Sun, 10 Aug 2025 23:25:18 -0500 Subject: [PATCH 1/4] Add Driving Game mode: 3-lane shooter with fixed-timestep, input, draw, and integration; update README --- README.md | 20 ++++ src/main.rs | 1 + src/screen/controls_block.rs | 6 +- src/screen/main_loop.rs | 180 ++++++++++++++++++++++++++++++----- 4 files changed, 182 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9838475..f718083 100644 --- a/README.md +++ b/README.md @@ -205,3 +205,23 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file --- *Made with ❤️ and 🎵 in Rust* + +### Driving Game Mode + +A simple Ratatui-based arcade mini-game that runs alongside the jukebox. + +- Controls: + - W/S: move up/down + - A/D: move left/right between 3 lanes + - Space: fire projectile straight up + - P: pause/resume + - Esc or Q: exit back to the jukebox UI + - R: restart when Game Over + - M: toggle music while gaming (default: on) + +- HUD: shows Score, Lives, and Speed (top row inside the game area). +- Enemies spawn at the top and move downward; hit them with projectiles for points. +- Collisions with enemies reduce lives and grant brief invulnerability. +- Game uses a fixed timestep (≈30 Hz) and runs independently of audio playback. + +Requirements: 80x24 terminal or larger recommended. Works without any track loaded. diff --git a/src/main.rs b/src/main.rs index b81de8d..3bc20a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use crate::screen::main_loop::run_app; mod jukebox_state; mod canvas_state; mod screen; +mod game; // ADD #[derive(Parser)] #[command(name = "jukebox-cli")] diff --git a/src/screen/controls_block.rs b/src/screen/controls_block.rs index 3cf0f0e..c6d6e29 100644 --- a/src/screen/controls_block.rs +++ b/src/screen/controls_block.rs @@ -23,10 +23,14 @@ pub fn render_controls_block(f: &mut Frame, area: Rect) { Span::styled("+/-", Style::default().fg(Color::Magenta)), Span::raw(" - Volume"), ]), + Line::from(vec![ + Span::styled("g", Style::default().fg(Color::Yellow)), + Span::raw(" - Start Driving Game"), + ]), ]; let controls_paragraph = Paragraph::new(controls) .block(Block::default().title("Controls").borders(Borders::ALL)); f.render_widget(controls_paragraph, area); -} +} \ No newline at end of file diff --git a/src/screen/main_loop.rs b/src/screen/main_loop.rs index bf4f4a8..760f792 100644 --- a/src/screen/main_loop.rs +++ b/src/screen/main_loop.rs @@ -1,5 +1,6 @@ use std::io; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use crossterm::event; use ratatui::{Terminal, prelude::Backend}; @@ -13,8 +14,14 @@ use crate::{ block_utils::{make_horizontal_chunks, make_vertical_chunks}, jukebox_side::render_jukebox_matrix, }, + game, }; +enum AppMode { + Jukebox, + DrivingGame, +} + pub fn run_app( terminal: &mut Terminal, music_path: Option, @@ -25,7 +32,136 @@ pub fn run_app( let music_path = music_path.unwrap_or_else(|| Path::new("example_music").to_path_buf()); let mut jukebox_state = jukebox_state::JukeboxState::new(&music_path); let mut canvas_state = canvas_state::CanvasState::new(); + + let mut mode = AppMode::Jukebox; + let mut game_state: Option = None; + let mut music_during_game = true; + + let fixed_step = Duration::from_millis(33); // ~30 FPS + let mut accumulator = Duration::ZERO; + let mut last = Instant::now(); + loop { + let now = Instant::now(); + let frame_dt = now.saturating_duration_since(last); + last = now; + accumulator += frame_dt; + + // Drain input events quickly + if event::poll(Duration::from_millis(1))? { + match event::read()? { + event::Event::Key(key) if key.kind == event::KeyEventKind::Press => { + match mode { + AppMode::Jukebox => { + match key.code { + event::KeyCode::Char('q') => break, + event::KeyCode::Char('p') | event::KeyCode::Enter => jukebox_state.play(), + event::KeyCode::Char('s') => jukebox_state.pause(), + event::KeyCode::Char('+') => jukebox_state.add_volume(10), + event::KeyCode::Char('-') => jukebox_state.sub_volume(10), + event::KeyCode::Down => jukebox_state.move_selection(1), + event::KeyCode::Up => jukebox_state.move_selection(-1), + event::KeyCode::Char('g') => { + // start game + let size = terminal.size()?; + // Place game in the jukebox chunk area; compute same layout to get game rect + // For simplicity, seed with current nanos; stable enough + let seed = now.elapsed().as_nanos() as u64 ^ now.elapsed().as_micros() as u64 ^ 0x9E3779B97F4A7C15; + // We'll initialize with current overall size; draw function will be passed precise area each frame + game_state = Some(game::state::GameState::new(size, seed)); + mode = AppMode::DrivingGame; + } + _ => {} + } + } + AppMode::DrivingGame => { + if let Some(cmd) = game::input::map_key(key) { + if let Some(gs) = &mut game_state { + use game::input::GameCmd::*; + use game::scene::GameScene; + match cmd { + MoveLeft => { + if gs.player.lane > 0 { gs.player.lane -= 1; } + gs.player.x = gs.lane_x(gs.player.lane); + } + MoveRight => { + if gs.player.lane < 2 { gs.player.lane += 1; } + gs.player.x = gs.lane_x(gs.player.lane); + } + MoveUp => { + gs.player.y = gs.player.y.saturating_sub(1).max(1); + } + MoveDown => { + gs.player.y = (gs.player.y + 1).min(gs.height.saturating_sub(2)); + } + Fire => { + let now = Instant::now(); + if now >= gs.player.fire_cooldown_until { + gs.player.fire_cooldown_until = now + Duration::from_millis(150); + gs.projectiles.push(game::entities::Projectile { + x: gs.player.x, + y: gs.player.y.saturating_sub(1) as f32, + speed: 40.0, + }); + } + } + Pause => { + gs.scene = match gs.scene { + GameScene::Running => GameScene::Paused, + GameScene::Paused => GameScene::Running, + s => s, + }; + } + Restart => { + let size = terminal.size()?; + let seed = now.elapsed().as_nanos() as u64 ^ 0xA2B79C3D; + *gs = game::state::GameState::new(size, seed); + } + ToggleMusic => { + music_during_game = !music_during_game; + if !music_during_game { + jukebox_state.pause(); + } + } + Exit => { + mode = AppMode::Jukebox; + game_state = None; + } + } + } else { + // no state -> exit to jukebox + mode = AppMode::Jukebox; + } + } + } + } + } + event::Event::Resize(_, _) => { + if let Some(gs) = &mut game_state { + let size = terminal.size()?; + gs.resize(size); + } + } + _ => {} + } + } + + // Update audio progression (independent) + jukebox_state.handle_song_end(); + + // Update game with fixed timestep + if let (AppMode::DrivingGame, Some(gs)) = (&mode, &mut game_state) { + while accumulator >= fixed_step { + game::update::update(gs, fixed_step); + accumulator -= fixed_step; + } + } else { + // In jukebox mode we can throttle accumulator + if accumulator >= Duration::from_millis(100) { + accumulator = Duration::ZERO; + } + } + terminal.draw(|f| { let size = f.area(); @@ -33,38 +169,34 @@ pub fn run_app( let top_chunks = make_horizontal_chunks(vertical_chunks[0], &[70, 30]); - let jukebox_chunk = top_chunks[0]; // Show jukebox matrix + let jukebox_chunk = top_chunks[0]; // main left area let controls_info_chunk = make_horizontal_chunks(vertical_chunks[1], &[50, 50]); - let controls_chunk = controls_info_chunk[0]; // Show controls - let info_chunk = controls_info_chunk[1]; // Show info block - let song_chunk = top_chunks[1]; // Show playlist side + let controls_chunk = controls_info_chunk[0]; // controls + let info_chunk = controls_info_chunk[1]; // info + let song_chunk = top_chunks[1]; // playlist side render_info_block(f, info_chunk, &jukebox_state); render_playlist_side(f, song_chunk, &jukebox_state); - render_jukebox_matrix(f, jukebox_chunk, &mut canvas_state, &jukebox_state); - render_controls_block(f, controls_chunk); - })?; - // Check if the song has ended - jukebox_state.handle_song_end(); - - if event::poll(std::time::Duration::from_millis(100))? { - if let event::Event::Key(key) = event::read()? { - if key.kind == event::KeyEventKind::Press { - match key.code { - event::KeyCode::Char('q') => break, - event::KeyCode::Char('p') => jukebox_state.play(), - event::KeyCode::Char('s') => jukebox_state.pause(), - event::KeyCode::Char('+') => jukebox_state.add_volume(10), - event::KeyCode::Char('-') => jukebox_state.sub_volume(10), - event::KeyCode::Down => jukebox_state.move_selection(1), - event::KeyCode::Up => jukebox_state.move_selection(-1), - event::KeyCode::Enter => jukebox_state.play(), - _ => {} + match mode { + AppMode::Jukebox => { + canvas_state.update_is_playing(jukebox_state.is_playing()); + canvas_state.update_notes(jukebox_chunk.width, jukebox_chunk.height, jukebox_state.is_playing()); + render_jukebox_matrix(f, jukebox_chunk, &mut canvas_state, &jukebox_state); + } + AppMode::DrivingGame => { + if let Some(gs) = &mut game_state { + // Ensure state matches this specific area + if gs.width != jukebox_chunk.width || gs.height != jukebox_chunk.height { + gs.resize(jukebox_chunk); + } + game::draw::draw(f, gs, jukebox_chunk); } } } - } + + render_controls_block(f, controls_chunk); + })?; } Ok(()) } From 8a2c360c51cb2fee44a0b39d128c77e44893e10e Mon Sep 17 00:00:00 2001 From: Dat Nguyen <64458422+datnguyen1@users.noreply.github.com> Date: Sun, 10 Aug 2025 23:29:31 -0500 Subject: [PATCH 2/4] Add Driving Game mode module scaffolding --- src/game/draw.rs | 93 ++++++++++++++++++++++++++++++++++++++++ src/game/input.rs | 32 ++++++++++++++ src/game/mod.rs | 7 +++ src/game/scene.rs | 7 +++ src/game/spawn.rs | 33 +++++++++++++++ src/game/state.rs | 92 ++++++++++++++++++++++++++++++++++++++++ src/game/update.rs | 103 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 367 insertions(+) create mode 100644 src/game/draw.rs create mode 100644 src/game/input.rs create mode 100644 src/game/mod.rs create mode 100644 src/game/scene.rs create mode 100644 src/game/spawn.rs create mode 100644 src/game/state.rs create mode 100644 src/game/update.rs diff --git a/src/game/draw.rs b/src/game/draw.rs new file mode 100644 index 0000000..229d86a --- /dev/null +++ b/src/game/draw.rs @@ -0,0 +1,93 @@ +use ratatui::{ + prelude::*, + widgets::{Paragraph, Block, Borders}, + style::{Style, Color}, + text::{Span, Line}, +}; +use crate::game::state::GameState; +use crate::game::scene::GameScene; + +pub fn draw(f: &mut Frame, state: &GameState, area: Rect) { + let w = area.width as usize; + let h = area.height as usize; + if w == 0 || h == 0 { + return; + } + + let mut rows: Vec> = vec![vec![Span::raw(" "); w]; h]; + + let paused = state.scene == GameScene::Paused; + let speed = (state.base_speed + state.elapsed.as_secs_f32() * 0.02); + let hud = format!( + "Score: {} Lives: {} Speed: {:.1} {}", + state.score, + state.lives, + speed, + if paused { "[Paused]" } else { "" } + ); + let hud_spans = Line::from(hud); + + let left_border_x = 0usize; + let right_border_x = w.saturating_sub(1); + let inner_left = 1usize; + let inner_width = w.saturating_sub(2).max(1); + let div1_x = inner_left + inner_width / 3; + let div2_x = inner_left + (inner_width * 2) / 3; + + let border_style = Style::default().fg(Color::DarkGray); + let divider_style = Style::default().fg(Color::Gray); + + for y in 1..h { + rows[y][left_border_x] = Span::styled("|", border_style); + rows[y][right_border_x] = Span::styled("|", border_style); + if div1_x < w { rows[y][div1_x] = Span::styled(":", divider_style); } + if div2_x < w { rows[y][div2_x] = Span::styled(":", divider_style); } + } + + let enemy_style = Style::default().fg(Color::Red); + for e in &state.enemies { + let ex = e.x as usize; + let ey = e.y.round() as isize; + if ey >= 1 && (ey as usize) < h && ex < w { + rows[ey as usize][ex] = Span::styled("V", enemy_style); + } + } + + let proj_style = Style::default().fg(Color::Yellow); + for p in &state.projectiles { + let px = p.x as usize; + let py = p.y.round() as isize; + if py >= 1 && (py as usize) < h && px < w { + rows[py as usize][px] = Span::styled("^", proj_style); + } + } + + let player_style = Style::default().fg(Color::Green); + let px = state.player.x as usize; + let py = state.player.y as usize; + if py < h && px < w { + rows[py][px] = Span::styled("A", player_style); + } + + let mut lines: Vec = Vec::with_capacity(h); + lines.push(hud_spans); + for y in 1..h { + lines.push(Line::from(std::mem::take(&mut rows[y]))); + } + + let para = Paragraph::new(lines) + .block(Block::default().borders(Borders::NONE)); + f.render_widget(para, area); + + if state.scene == GameScene::GameOver { + let msg = format!("GAME OVER Score: {} [r]=Restart [Esc/q]=Exit", state.score); + let msg_line = Line::from(Span::styled(msg, Style::default().fg(Color::White).bg(Color::DarkGray))); + let overlay = Paragraph::new(msg_line).block(Block::default().borders(Borders::ALL).title("Driving")); + let ow = (area.width / 2).max(20); + let oh = 3; + let ox = area.x + (area.width.saturating_sub(ow)) / 2; + let oy = area.y + (area.height.saturating_sub(oh)) / 2; + let orect = Rect::new(ox, oy, ow, oh); + f.render_widget(overlay, orect); + } +} \ No newline at end of file diff --git a/src/game/input.rs b/src/game/input.rs new file mode 100644 index 0000000..c15b9b0 --- /dev/null +++ b/src/game/input.rs @@ -0,0 +1,32 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GameCmd { + MoveLeft, + MoveRight, + MoveUp, + MoveDown, + Fire, + Pause, + Exit, + Restart, + ToggleMusic, +} + +pub fn map_key(ev: KeyEvent) -> Option { + if ev.kind != KeyEventKind::Press { + return None; + } + match ev.code { + KeyCode::Char('a') | KeyCode::Left => Some(GameCmd::MoveLeft), + KeyCode::Char('d') | KeyCode::Right => Some(GameCmd::MoveRight), + KeyCode::Char('w') | KeyCode::Up => Some(GameCmd::MoveUp), + KeyCode::Char('s') | KeyCode::Down => Some(GameCmd::MoveDown), + KeyCode::Char(' ') => Some(GameCmd::Fire), + KeyCode::Char('p') => Some(GameCmd::Pause), + KeyCode::Esc | KeyCode::Char('q') => Some(GameCmd::Exit), + KeyCode::Char('r') => Some(GameCmd::Restart), + KeyCode::Char('m') => Some(GameCmd::ToggleMusic), + _ => None, + } +} \ No newline at end of file diff --git a/src/game/mod.rs b/src/game/mod.rs new file mode 100644 index 0000000..efd225f --- /dev/null +++ b/src/game/mod.rs @@ -0,0 +1,7 @@ +pub mod scene; +pub mod entities; +pub mod state; +pub mod input; +pub mod spawn; +pub mod update; +pub mod draw; diff --git a/src/game/scene.rs b/src/game/scene.rs new file mode 100644 index 0000000..bb0f0bf --- /dev/null +++ b/src/game/scene.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GameScene { + Menu, + Running, + Paused, + GameOver, +} \ No newline at end of file diff --git a/src/game/spawn.rs b/src/game/spawn.rs new file mode 100644 index 0000000..1bf4714 --- /dev/null +++ b/src/game/spawn.rs @@ -0,0 +1,33 @@ +use std::time::Instant; +use rand::Rng; +use crate::game::state::GameState; +use crate::game::entities::Enemy; + +pub fn maybe_spawn(state: &mut GameState, now: Instant) { + let since = now.saturating_duration_since(state.last_spawn); + if since < state.spawn_cooldown { + return; + } + state.last_spawn = now; + + let elapsed_s = state.elapsed.as_secs_f32(); + let p = (0.20 + elapsed_s * 0.01).min(0.80); + let rng = &mut state.rng; + if rng.random_bool(p as f64) { + let lane = rng.random_range(0..3u8); + let x = state.lane_x(lane); + let speed_jitter = rng.random_range(0.0..=2.0); + let e = Enemy { + x, + y: 1.0, + speed: state.base_speed + speed_jitter, + w: 1, + h: 1, + }; + state.enemies.push(e); + } + + let ms = state.spawn_cooldown.as_millis() as i64; + let new_ms = (ms - 2).max(150) as u64; + state.spawn_cooldown = std::time::Duration::from_millis(new_ms); +} \ No newline at end of file diff --git a/src/game/state.rs b/src/game/state.rs new file mode 100644 index 0000000..ec012d4 --- /dev/null +++ b/src/game/state.rs @@ -0,0 +1,92 @@ +use std::time::{Duration, Instant}; +use ratatui::prelude::Rect; +use rand::{rngs::StdRng, SeedableRng}; +use crate::game::scene::GameScene; +use crate::game::entities::{Player, Enemy, Projectile}; + +pub struct GameState { + pub width: u16, + pub height: u16, + pub scene: GameScene, + pub player: Player, + pub enemies: Vec, + pub projectiles: Vec, + pub score: u64, + pub lives: u8, + pub invuln_until: Option, + pub rng: StdRng, + pub last_spawn: Instant, + pub spawn_cooldown: Duration, + pub base_speed: f32, + pub elapsed: Duration, +} + +impl GameState { + pub fn new(area: Rect, seed: u64) -> Self { + let width = area.width.max(10); + let height = area.height.max(10); + let mut s = Self { + width, + height, + scene: GameScene::Running, + player: Player { + lane: 1, + x: 0, + y: height.saturating_sub(3).max(1), + fire_cooldown_until: Instant::now(), + }, + enemies: Vec::new(), + projectiles: Vec::new(), + score: 0, + lives: 3, + invuln_until: None, + rng: StdRng::seed_from_u64(seed), + last_spawn: Instant::now(), + spawn_cooldown: Duration::from_millis(600), + base_speed: 10.0, + elapsed: Duration::ZERO, + }; + s.player.x = s.lane_x(s.player.lane); + s + } + + pub fn lane_x(&self, lane: u8) -> u16 { + let lane = lane.min(2); + if self.width <= 2 { + return 0; + } + let inner_left = 1u16; + let inner_width = self.width - 2; + let inner_w = inner_width as u32; + let center = inner_left as u32 + (inner_w * (2 * lane as u32 + 1)) / (2 * 3); + center.min((self.width - 1) as u32) as u16 + } + + pub fn clamp_player(&mut self) { + self.player.y = self.player.y.clamp(1, self.height.saturating_sub(2)); + self.player.x = self.lane_x(self.player.lane); + } + + pub fn resize(&mut self, area: Rect) { + self.width = area.width.max(10); + self.height = area.height.max(10); + self.player.y = self.player.y.min(self.height.saturating_sub(2)).max(1); + self.player.x = self.lane_x(self.player.lane); + self.enemies.retain(|e| (e.y as i32) >= 1 && (e.y as u16) < self.height.saturating_sub(1)); + self.projectiles.retain(|p| (p.y as i32) >= 1 && (p.y as u16) < self.height.saturating_sub(1)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn lane_centers_monotonic() { + let s = GameState::new(Rect::new(0,0, 80, 24), 42); + let x0 = s.lane_x(0); + let x1 = s.lane_x(1); + let x2 = s.lane_x(2); + assert!(x0 < x1 && x1 < x2); + assert!(x0 > 0 && x2 < s.width-1); + } +} \ No newline at end of file diff --git a/src/game/update.rs b/src/game/update.rs new file mode 100644 index 0000000..37a5995 --- /dev/null +++ b/src/game/update.rs @@ -0,0 +1,103 @@ +use std::time::{Duration, Instant}; +use crate::game::state::GameState; +use crate::game::scene::GameScene; +use crate::game::spawn::maybe_spawn; + +fn aabb_intersect(ax: u16, ay: u16, aw: u16, ah: u16, bx: u16, by: u16, bw: u16, bh: u16) -> bool { + let ax2 = ax.saturating_add(aw.saturating_sub(1)); + let ay2 = ay.saturating_add(ah.saturating_sub(1)); + let bx2 = bx.saturating_add(bw.saturating_sub(1)); + let by2 = by.saturating_add(bh.saturating_sub(1)); + !(ax2 < bx || bx2 < ax || ay2 < by || by2 < ay) +} + +pub fn update(state: &mut GameState, dt: Duration) { + if state.scene != GameScene::Running { + return; + } + + state.elapsed += dt; + let dt_s = dt.as_secs_f32(); + let speed_scale = 1.0 + (state.elapsed.as_secs_f32() * 0.02); + + // Move enemies downward + for e in &mut state.enemies { + e.y += e.speed * speed_scale * dt_s; + } + + // Move projectiles upward + for p in &mut state.projectiles { + p.y -= p.speed * dt_s; + } + + // Cleanup off-screen + let h = state.height; + state.enemies.retain(|e| (e.y as i32) >= 1 && (e.y as u16) < h.saturating_sub(1)); + state.projectiles.retain(|p| (p.y as i32) >= 1); + + // Projectile vs Enemy collisions (simple same-cell overlap) + let mut to_remove_proj = vec![false; state.projectiles.len()]; + let mut to_remove_enemy = vec![false; state.enemies.len()]; + for (pi, p) in state.projectiles.iter().enumerate() { + let px = p.x; + let py = p.y.round().clamp(1.0, (h.saturating_sub(2)) as f32) as u16; + for (ei, e) in state.enemies.iter().enumerate() { + let ex = e.x; + let ey = e.y.round().clamp(1.0, (h.saturating_sub(2)) as f32) as u16; + if aabb_intersect(px, py, 1, 1, ex, ey, e.w, e.h) { + to_remove_proj[pi] = true; + to_remove_enemy[ei] = true; + // score + // Protect against potential overflow (u64 is large, but be safe) + state.score = state.score.saturating_add(100); + break; + } + } + } + // Remove flagged + if to_remove_enemy.iter().any(|&b| b) { + let mut i = 0; + state.enemies.retain(|_| { + let keep = !to_remove_enemy[i]; + i += 1; + keep + }); + } + if to_remove_proj.iter().any(|&b| b) { + let mut i = 0; + state.projectiles.retain(|_| { + let keep = !to_remove_proj[i]; + i += 1; + keep + }); + } + + // Player vs Enemy collisions + let now = Instant::now(); + let invuln = state.invuln_until; + let player_x = state.player.x; + let player_y = state.player.y; + if invuln.map_or(true, |t| now >= t) { + let mut hit_index: Option = None; + for (i, e) in state.enemies.iter().enumerate() { + let ex = e.x; + let ey = e.y.round().clamp(1.0, (h.saturating_sub(2)) as f32) as u16; + if aabb_intersect(player_x, player_y, 1, 1, ex, ey, e.w, e.h) { + hit_index = Some(i); + break; + } + } + if let Some(i) = hit_index { + // remove enemy and apply damage + state.enemies.swap_remove(i); + state.lives = state.lives.saturating_sub(1); + state.invuln_until = Some(now + Duration::from_millis(1000)); + if state.lives == 0 { + state.scene = GameScene::GameOver; + } + } + } + + // Spawning + maybe_spawn(state, now); +} \ No newline at end of file From 2244c2f93813a7f1324e504948ef0c131f41473f Mon Sep 17 00:00:00 2001 From: Dat Nguyen <64458422+datnguyen1@users.noreply.github.com> Date: Sun, 10 Aug 2025 23:39:16 -0500 Subject: [PATCH 3/4] Driving Game mode: integrate fixed-timestep, input routing, draw; add game module; README section; spawner/rust 0.29 fixes --- src/game/draw.rs | 4 ++-- src/game/entities.rs | 25 +++++++++++++++++++++++++ src/game/spawn.rs | 9 ++++----- src/screen/main_loop.rs | 23 ++++++++++++----------- 4 files changed, 43 insertions(+), 18 deletions(-) create mode 100644 src/game/entities.rs diff --git a/src/game/draw.rs b/src/game/draw.rs index 229d86a..4ed54f0 100644 --- a/src/game/draw.rs +++ b/src/game/draw.rs @@ -7,7 +7,7 @@ use ratatui::{ use crate::game::state::GameState; use crate::game::scene::GameScene; -pub fn draw(f: &mut Frame, state: &GameState, area: Rect) { +pub fn draw(f: &mut Frame, state: &GameState, area: Rect) { let w = area.width as usize; let h = area.height as usize; if w == 0 || h == 0 { @@ -17,7 +17,7 @@ pub fn draw(f: &mut Frame, state: &GameState, area: Rect) { let mut rows: Vec> = vec![vec![Span::raw(" "); w]; h]; let paused = state.scene == GameScene::Paused; - let speed = (state.base_speed + state.elapsed.as_secs_f32() * 0.02); + let speed = state.base_speed + state.elapsed.as_secs_f32() * 0.02; let hud = format!( "Score: {} Lives: {} Speed: {:.1} {}", state.score, diff --git a/src/game/entities.rs b/src/game/entities.rs new file mode 100644 index 0000000..52761a4 --- /dev/null +++ b/src/game/entities.rs @@ -0,0 +1,25 @@ +use std::time::Instant; + +#[derive(Debug, Clone)] +pub struct Player { + pub lane: u8, // 0..=2 + pub x: u16, // column (relative to game area) + pub y: u16, // row (relative to game area) + pub fire_cooldown_until: Instant, +} + +#[derive(Debug, Clone)] +pub struct Enemy { + pub x: u16, + pub y: f32, + pub speed: f32, // rows per second + pub w: u16, + pub h: u16, +} + +#[derive(Debug, Clone)] +pub struct Projectile { + pub x: u16, + pub y: f32, + pub speed: f32, // rows per second upward +} \ No newline at end of file diff --git a/src/game/spawn.rs b/src/game/spawn.rs index 1bf4714..ad6825c 100644 --- a/src/game/spawn.rs +++ b/src/game/spawn.rs @@ -12,14 +12,13 @@ pub fn maybe_spawn(state: &mut GameState, now: Instant) { let elapsed_s = state.elapsed.as_secs_f32(); let p = (0.20 + elapsed_s * 0.01).min(0.80); - let rng = &mut state.rng; - if rng.random_bool(p as f64) { - let lane = rng.random_range(0..3u8); + if state.rng.random_bool(p as f64) { + let lane = state.rng.random_range(0..3u8); + let speed_jitter = state.rng.random_range(0.0..=2.0); let x = state.lane_x(lane); - let speed_jitter = rng.random_range(0.0..=2.0); let e = Enemy { x, - y: 1.0, + y: 1.0, // start below HUD row speed: state.base_speed + speed_jitter, w: 1, h: 1, diff --git a/src/screen/main_loop.rs b/src/screen/main_loop.rs index 760f792..921c07c 100644 --- a/src/screen/main_loop.rs +++ b/src/screen/main_loop.rs @@ -63,12 +63,12 @@ pub fn run_app( event::KeyCode::Up => jukebox_state.move_selection(-1), event::KeyCode::Char('g') => { // start game - let size = terminal.size()?; - // Place game in the jukebox chunk area; compute same layout to get game rect - // For simplicity, seed with current nanos; stable enough - let seed = now.elapsed().as_nanos() as u64 ^ now.elapsed().as_micros() as u64 ^ 0x9E3779B97F4A7C15; - // We'll initialize with current overall size; draw function will be passed precise area each frame - game_state = Some(game::state::GameState::new(size, seed)); + let sz = terminal.size()?; + let rect = ratatui::layout::Rect::new(0, 0, sz.width, sz.height); + let seed = now.elapsed().as_nanos() as u64 + ^ now.elapsed().as_micros() as u64 + ^ 0x9E3779B97F4A7C15; + game_state = Some(game::state::GameState::new(rect, seed)); mode = AppMode::DrivingGame; } _ => {} @@ -113,9 +113,10 @@ pub fn run_app( }; } Restart => { - let size = terminal.size()?; + let sz = terminal.size()?; + let rect = ratatui::layout::Rect::new(0, 0, sz.width, sz.height); let seed = now.elapsed().as_nanos() as u64 ^ 0xA2B79C3D; - *gs = game::state::GameState::new(size, seed); + *gs = game::state::GameState::new(rect, seed); } ToggleMusic => { music_during_game = !music_during_game; @@ -136,10 +137,10 @@ pub fn run_app( } } } - event::Event::Resize(_, _) => { + event::Event::Resize(w, h) => { if let Some(gs) = &mut game_state { - let size = terminal.size()?; - gs.resize(size); + let rect = ratatui::layout::Rect::new(0, 0, w, h); + gs.resize(rect); } } _ => {} From 46d52dda5607b17641edf46e2983bc27f58413ee Mon Sep 17 00:00:00 2001 From: Dat Nguyen <64458422+datnguyen1@users.noreply.github.com> Date: Sun, 10 Aug 2025 23:41:01 -0500 Subject: [PATCH 4/4] docs: add PR body for Driving Game mode --- .github/PR_DRIVING_GAME.md | 56 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/PR_DRIVING_GAME.md diff --git a/.github/PR_DRIVING_GAME.md b/.github/PR_DRIVING_GAME.md new file mode 100644 index 0000000..391329c --- /dev/null +++ b/.github/PR_DRIVING_GAME.md @@ -0,0 +1,56 @@ +## Overview +Adds a new Driving Game mode: a 3‑lane top‑down mini‑game rendered in Ratatui that runs independently of audio playback. The player drives a car across three lanes, dodges incoming cars, and fires projectiles straight up to destroy them. Game logic runs on a fixed timestep; audio playback (Rodio/Symphonia) remains non‑blocking and continues in the background by default. + +## Controls +- W/S: move up/down +- A/D: move left/right between 3 lanes +- Space: fire projectile upward +- P: pause/resume +- Esc or Q: exit game back to main UI +- R: restart on Game Over +- M: toggle music during game (default: on) + +## Mechanics +- Player starts with 3 lives; collision with an enemy reduces life and triggers ~1s invulnerability frames. +- Enemies spawn at the top with a probability that increases over time; downward speed slightly accelerates. +- Projectiles destroy the first enemy hit in their lane; +100 score each. +- Enemies that reach the bottom are removed (no penalty beyond missed score). +- Game over when lives reach 0; overlay appears with score and restart/exit options. + +## Architecture +- New module tree under `src/game/`: + - `scene.rs`: `GameScene` enum (Running, Paused, GameOver) + - `entities.rs`: `Player`, `Enemy`, `Projectile` + - `state.rs`: `GameState` (lanes, player, enemies, projectiles, score, lives, rng, timers) + - `input.rs`: key mapping to `GameCmd` + - `spawn.rs`: enemy spawn logic and difficulty curve + - `update.rs`: per‑tick movement, collisions, cleanup + - `draw.rs`: rendering of borders, lanes, cars, projectiles, HUD +- Integration: + - `AppMode::DrivingGame` in `src/screen/main_loop.rs` + - Fixed timestep loop (~33ms) with accumulator; render each tick + - Input routed to game mapping when in game mode; jukebox controls untouched otherwise + - Audio playback independent and non‑blocking; optional in‑game toggle (M) + +## Acceptance Criteria +- New menu/control entry to start “Driving Game” (`g`) starts gameplay without crashing +- Left/right borders and three vertical lanes rendered; player moves with WASD within bounds +- Enemies spawn and move down; collisions reduce lives with invulnerability frames +- Space fires projectiles that destroy enemies and award score +- Pause works; Esc/Q returns to main UI; Game Over screen shows score and allows restart +- Works in at least 80x24; degrades gracefully on smaller sizes +- No regressions to existing MP3 playback; game runs even without a loaded track + +## Demo +- Screenshot available as `screenshot.png` in repo. +- (Optional) A short GIF can be added in a follow‑up. + +## README +See the new section: [Driving Game Mode](README.md#driving-game-mode). + +## Notes +- Render uses ASCII with safe ANSI colors; avoids Unicode assumptions +- Basic test included for lane math monotonicity in `state.rs` + +## Commits +Key commits in this branch include module scaffolding, integration, and fixed‑timestep/input/draw wiring. See commit history in this PR for details.