diff --git a/crates/apps/snake/Cargo.toml b/crates/apps/snake/Cargo.toml index e547623..f362f5a 100644 --- a/crates/apps/snake/Cargo.toml +++ b/crates/apps/snake/Cargo.toml @@ -4,3 +4,5 @@ version = "0.1.0" edition = "2024" [dependencies] +types = { workspace = true } +userspace = { workspace = true } diff --git a/crates/apps/snake/src/game.rs b/crates/apps/snake/src/game.rs new file mode 100644 index 0000000..788d3f6 --- /dev/null +++ b/crates/apps/snake/src/game.rs @@ -0,0 +1,159 @@ +pub const GRID_WIDTH: u16 = 20; +pub const GRID_HEIGHT: u16 = 15; +const MAX_SNAKE_LEN: usize = 128; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Position { + pub x: u16, + pub y: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Up, + Down, + Left, + Right, +} + +impl Direction { + pub fn opposite(&self) -> Direction { + match self { + Direction::Up => Direction::Down, + Direction::Down => Direction::Up, + Direction::Left => Direction::Right, + Direction::Right => Direction::Left, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GameState { + Running, + GameOver, +} + +pub struct Snake { + body: [Position; MAX_SNAKE_LEN], + len: usize, + direction: Direction, + next_direction: Direction, + food: Position, + state: GameState, + score: u16, + rng_state: u32, +} + +impl Snake { + pub fn new() -> Self { + let start = Position { x: GRID_WIDTH / 2, y: GRID_HEIGHT / 2 }; + let mut body = [Position { x: 0, y: 0 }; MAX_SNAKE_LEN]; + body[0] = start; + body[1] = Position { x: start.x - 1, y: start.y }; + body[2] = Position { x: start.x - 2, y: start.y }; + + let mut game = Self { + body, + len: 3, + direction: Direction::Right, + next_direction: Direction::Right, + food: Position { x: 5, y: 5 }, + state: GameState::Running, + score: 0, + rng_state: 42, + }; + game.place_food(); + game + } + + pub fn set_direction(&mut self, dir: Direction) { + if dir != self.direction.opposite() { + self.next_direction = dir; + } + } + + pub fn tick(&mut self) { + if self.state != GameState::Running { + return; + } + + self.direction = self.next_direction; + + let head = self.body[0]; + let new_head = match self.direction { + Direction::Up => { + if head.y == 0 { self.state = GameState::GameOver; return; } + Position { x: head.x, y: head.y - 1 } + } + Direction::Down => { + if head.y >= GRID_HEIGHT - 1 { self.state = GameState::GameOver; return; } + Position { x: head.x, y: head.y + 1 } + } + Direction::Left => { + if head.x == 0 { self.state = GameState::GameOver; return; } + Position { x: head.x - 1, y: head.y } + } + Direction::Right => { + if head.x >= GRID_WIDTH - 1 { self.state = GameState::GameOver; return; } + Position { x: head.x + 1, y: head.y } + } + }; + + if self.hits_self(new_head) { + self.state = GameState::GameOver; + return; + } + + let ate_food = new_head == self.food; + + if !ate_food { + for i in (1..self.len).rev() { + self.body[i] = self.body[i - 1]; + } + } else { + if self.len < MAX_SNAKE_LEN { + for i in (1..self.len + 1).rev() { + self.body[i] = self.body[i - 1]; + } + self.len += 1; + } + self.score += 1; + self.place_food(); + } + + self.body[0] = new_head; + } + + fn hits_self(&self, pos: Position) -> bool { + for i in 0..self.len { + if self.body[i] == pos { + return true; + } + } + false + } + + fn place_food(&mut self) { + loop { + self.rng_state ^= self.rng_state << 13; + self.rng_state ^= self.rng_state >> 17; + self.rng_state ^= self.rng_state << 5; + + let x = (self.rng_state % GRID_WIDTH as u32) as u16; + let y = ((self.rng_state / GRID_WIDTH as u32) % GRID_HEIGHT as u32) as u16; + let pos = Position { x, y }; + + if !self.hits_self(pos) { + self.food = pos; + return; + } + } + } + + pub fn head(&self) -> Position { self.body[0] } + pub fn body(&self) -> &[Position] { &self.body[..self.len] } + pub fn food(&self) -> Position { self.food } + pub fn state(&self) -> GameState { self.state } + pub fn score(&self) -> u16 { self.score } + pub fn direction(&self) -> Direction { self.direction } +} diff --git a/crates/apps/snake/src/lib.rs b/crates/apps/snake/src/lib.rs new file mode 100644 index 0000000..bc6b8ef --- /dev/null +++ b/crates/apps/snake/src/lib.rs @@ -0,0 +1,8 @@ +#![no_std] + +pub mod game; +pub mod renderer; + +#[cfg(test)] +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/apps/snake/src/main.rs b/crates/apps/snake/src/main.rs index e7a11a9..da766c6 100644 --- a/crates/apps/snake/src/main.rs +++ b/crates/apps/snake/src/main.rs @@ -1,3 +1,100 @@ -fn main() { - println!("Hello, world!"); +#![no_std] +#![no_main] + +use snake::game::{Direction, GameState, Snake}; +use snake::renderer::{render, RenderCommand}; + +const DISPLAY_EP: usize = 0; +const INPUT_EP: usize = 1; +const TICK_DELAY: usize = 2_000_000; +const MAX_RENDER_COMMANDS: usize = 192; + +#[cfg(not(test))] +use core::arch::global_asm; + +#[cfg(not(test))] +global_asm!( + r#" +.section .text.entry +.globl _start +_start: + call main + j _start +"# +); + +#[cfg(not(test))] +#[unsafe(no_mangle)] +extern "C" fn main() -> ! { + run_game(); +} + +fn run_game() -> ! { + let mut snake = Snake::new(); + let mut commands = [RenderCommand { x: 0, y: 0, w: 0, h: 0, color: 0 }; MAX_RENDER_COMMANDS]; + + loop { + handle_input(&mut snake); + + snake.tick(); + + let count = render(&snake, &mut commands); + send_render_commands(&commands, count); + + if snake.state() == GameState::GameOver { + userspace::syscall::exit(); + } + + wait_tick(); + } +} + +fn handle_input(snake: &mut Snake) { + // PollEvent request: tag=0 + let _ = userspace::syscall::send(INPUT_EP, 0, 0, 0, 0); + let (tag, kind, _data, _) = userspace::syscall::receive(INPUT_EP); + + // InputResponse::Event(KeyDown(key)): tag=1, key_kind in word 1 + if tag == 1 { + match kind { + 0 => snake.set_direction(Direction::Up), + 1 => snake.set_direction(Direction::Down), + 2 => snake.set_direction(Direction::Left), + 3 => snake.set_direction(Direction::Right), + _ => {} + } + } +} + +fn send_render_commands(commands: &[RenderCommand], count: usize) { + // Clear: DisplayRequest tag=1 + let _ = userspace::syscall::send(DISPLAY_EP, 1, 0, 0, 0); + let _ = userspace::syscall::receive(DISPLAY_EP); + + for i in 0..count { + let cmd = &commands[i]; + // DrawRect: DisplayRequest tag=0, xy packed, wh packed, color + let xy = cmd.x as usize | ((cmd.y as usize) << 16); + let wh = cmd.w as usize | ((cmd.h as usize) << 16); + let _ = userspace::syscall::send(DISPLAY_EP, 0, xy, wh, cmd.color as usize); + let _ = userspace::syscall::receive(DISPLAY_EP); + } + + // Flush: DisplayRequest tag=2 + let _ = userspace::syscall::send(DISPLAY_EP, 2, 0, 0, 0); + let _ = userspace::syscall::receive(DISPLAY_EP); +} + +fn wait_tick() { + let start = userspace::syscall::get_time(); + let deadline = start + TICK_DELAY; + while userspace::syscall::get_time() < deadline { + userspace::syscall::yield_now(); + } +} + +#[cfg(not(test))] +#[panic_handler] +fn panic(_: &core::panic::PanicInfo) -> ! { + userspace::syscall::exit(); } diff --git a/crates/apps/snake/src/renderer.rs b/crates/apps/snake/src/renderer.rs new file mode 100644 index 0000000..c102b66 --- /dev/null +++ b/crates/apps/snake/src/renderer.rs @@ -0,0 +1,74 @@ +use crate::game::{Snake, Position, GRID_WIDTH, GRID_HEIGHT}; + +const CELL_SIZE: u16 = 16; +const BG_COLOR: u32 = 0x1A1A2E; +const SNAKE_COLOR: u32 = 0x00FF41; +const HEAD_COLOR: u32 = 0x00CC33; +const FOOD_COLOR: u32 = 0xFF0040; +const BORDER_COLOR: u32 = 0x333355; + +#[derive(Clone, Copy)] +pub struct RenderCommand { + pub x: u16, + pub y: u16, + pub w: u16, + pub h: u16, + pub color: u32, +} + +pub fn render(snake: &Snake, commands: &mut [RenderCommand]) -> usize { + let mut count = 0; + + // Background + if count < commands.len() { + commands[count] = RenderCommand { + x: 0, y: 0, + w: GRID_WIDTH * CELL_SIZE, + h: GRID_HEIGHT * CELL_SIZE, + color: BG_COLOR, + }; + count += 1; + } + + // Border + if count < commands.len() { + commands[count] = RenderCommand { + x: 0, y: 0, + w: GRID_WIDTH * CELL_SIZE, + h: 1, + color: BORDER_COLOR, + }; + count += 1; + } + + // Food + let food = snake.food(); + if count < commands.len() { + commands[count] = cell_command(food, FOOD_COLOR); + count += 1; + } + + // Snake body + let body = snake.body(); + for (i, &pos) in body.iter().enumerate() { + if count >= commands.len() { break; } + let color = if i == 0 { HEAD_COLOR } else { SNAKE_COLOR }; + commands[count] = cell_command(pos, color); + count += 1; + } + + count +} + +fn cell_command(pos: Position, color: u32) -> RenderCommand { + RenderCommand { + x: pos.x * CELL_SIZE + 1, + y: pos.y * CELL_SIZE + 1, + w: CELL_SIZE - 2, + h: CELL_SIZE - 2, + color, + } +} + +pub fn grid_pixel_width() -> u16 { GRID_WIDTH * CELL_SIZE } +pub fn grid_pixel_height() -> u16 { GRID_HEIGHT * CELL_SIZE } diff --git a/crates/apps/snake/src/tests/game.rs b/crates/apps/snake/src/tests/game.rs new file mode 100644 index 0000000..f37174b --- /dev/null +++ b/crates/apps/snake/src/tests/game.rs @@ -0,0 +1,82 @@ +use crate::game::*; + +#[test] +fn new_game_is_running() { + let game = Snake::new(); + assert_eq!(game.state(), GameState::Running); +} + +#[test] +fn initial_snake_has_three_segments() { + let game = Snake::new(); + assert_eq!(game.body().len(), 3); +} + +#[test] +fn initial_score_is_zero() { + let game = Snake::new(); + assert_eq!(game.score(), 0); +} + +#[test] +fn tick_moves_head_right() { + let mut game = Snake::new(); + let initial_head = game.head(); + game.tick(); + assert_eq!(game.head().x, initial_head.x + 1); + assert_eq!(game.head().y, initial_head.y); +} + +#[test] +fn set_direction_changes_movement() { + let mut game = Snake::new(); + game.set_direction(Direction::Down); + game.tick(); + let head = game.head(); + assert_eq!(head.y, GRID_HEIGHT / 2 + 1); +} + +#[test] +fn cannot_reverse_direction() { + let mut game = Snake::new(); + // moving right, try to go left — should be ignored + game.set_direction(Direction::Left); + game.tick(); + assert_eq!(game.direction(), Direction::Right); +} + +#[test] +fn wall_collision_ends_game() { + let mut game = Snake::new(); + // Move right until hitting the wall + for _ in 0..GRID_WIDTH { + if game.state() != GameState::Running { break; } + game.tick(); + } + assert_eq!(game.state(), GameState::GameOver); +} + +#[test] +fn food_exists_on_grid() { + let game = Snake::new(); + let food = game.food(); + assert!(food.x < GRID_WIDTH); + assert!(food.y < GRID_HEIGHT); +} + +#[test] +fn body_length_preserved_without_food() { + let mut game = Snake::new(); + let initial_len = game.body().len(); + game.tick(); + assert_eq!(game.body().len(), initial_len); +} + +#[test] +fn direction_up_moves_correctly() { + let mut game = Snake::new(); + game.set_direction(Direction::Up); + let y_before = game.head().y; + game.tick(); + assert_eq!(game.head().y, y_before - 1); +} diff --git a/crates/apps/snake/src/tests/mod.rs b/crates/apps/snake/src/tests/mod.rs new file mode 100644 index 0000000..0e00e40 --- /dev/null +++ b/crates/apps/snake/src/tests/mod.rs @@ -0,0 +1,2 @@ +mod game; +mod renderer; diff --git a/crates/apps/snake/src/tests/renderer.rs b/crates/apps/snake/src/tests/renderer.rs new file mode 100644 index 0000000..2f118f7 --- /dev/null +++ b/crates/apps/snake/src/tests/renderer.rs @@ -0,0 +1,32 @@ +use crate::game::Snake; +use crate::renderer::{render, RenderCommand}; + +#[test] +fn render_produces_commands() { + let snake = Snake::new(); + let mut commands = [RenderCommand { x: 0, y: 0, w: 0, h: 0, color: 0 }; 64]; + let count = render(&snake, &mut commands); + // background + border + food + 3 body segments = 6 + assert!(count >= 6); +} + +#[test] +fn first_command_is_background() { + let snake = Snake::new(); + let mut commands = [RenderCommand { x: 0, y: 0, w: 0, h: 0, color: 0 }; 64]; + render(&snake, &mut commands); + assert_eq!(commands[0].x, 0); + assert_eq!(commands[0].y, 0); + assert!(commands[0].w > 0); + assert!(commands[0].h > 0); +} + +#[test] +fn food_is_rendered() { + let snake = Snake::new(); + let mut commands = [RenderCommand { x: 0, y: 0, w: 0, h: 0, color: 0 }; 64]; + let count = render(&snake, &mut commands); + // Food is command index 2 (after bg and border) + assert!(count > 2); + assert_eq!(commands[2].color, 0xFF0040); +}