feat(snake): implement snake game with pure game logic, renderer, and IPC-based main loop
This commit is contained in:
@@ -4,3 +4,5 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
types = { workspace = true }
|
||||
userspace = { workspace = true }
|
||||
|
||||
159
crates/apps/snake/src/game.rs
Normal file
159
crates/apps/snake/src/game.rs
Normal file
@@ -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 }
|
||||
}
|
||||
8
crates/apps/snake/src/lib.rs
Normal file
8
crates/apps/snake/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#![no_std]
|
||||
|
||||
pub mod game;
|
||||
pub mod renderer;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/mod.rs"]
|
||||
mod tests;
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
74
crates/apps/snake/src/renderer.rs
Normal file
74
crates/apps/snake/src/renderer.rs
Normal file
@@ -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 }
|
||||
82
crates/apps/snake/src/tests/game.rs
Normal file
82
crates/apps/snake/src/tests/game.rs
Normal file
@@ -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);
|
||||
}
|
||||
2
crates/apps/snake/src/tests/mod.rs
Normal file
2
crates/apps/snake/src/tests/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
mod game;
|
||||
mod renderer;
|
||||
32
crates/apps/snake/src/tests/renderer.rs
Normal file
32
crates/apps/snake/src/tests/renderer.rs
Normal file
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user