Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/PR_DRIVING_GAME.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
93 changes: 93 additions & 0 deletions src/game/draw.rs
Original file line number Diff line number Diff line change
@@ -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<Span>> = 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<Line> = 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);
}
}
25 changes: 25 additions & 0 deletions src/game/entities.rs
Original file line number Diff line number Diff line change
@@ -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
}
32 changes: 32 additions & 0 deletions src/game/input.rs
Original file line number Diff line number Diff line change
@@ -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<GameCmd> {
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,
}
}
7 changes: 7 additions & 0 deletions src/game/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions src/game/scene.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GameScene {
Menu,
Running,
Paused,
GameOver,
}
32 changes: 32 additions & 0 deletions src/game/spawn.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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);
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 e = Enemy {
x,
y: 1.0, // start below HUD row
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);
}
92 changes: 92 additions & 0 deletions src/game/state.rs
Original file line number Diff line number Diff line change
@@ -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<Enemy>,
pub projectiles: Vec<Projectile>,
pub score: u64,
pub lives: u8,
pub invuln_until: Option<Instant>,
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);
}
}
Loading