0.1.0-0-pre.11
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// file: crates/engines/engine-v1-sdl/src/runtime.rs
|
||||
// version: 7
|
||||
// version: 8
|
||||
|
||||
/// Minimal SDL3 runtime used by Desktop and Android POC runners.
|
||||
pub struct SdlRuntime {
|
||||
@@ -47,9 +47,22 @@ impl SdlRuntime {
|
||||
match event {
|
||||
sdl3::event::Event::Quit { .. } => break 'running,
|
||||
sdl3::event::Event::KeyDown { keycode: Some(sdl3::keyboard::Keycode::Escape), .. } => break 'running,
|
||||
sdl3::event::Event::KeyDown { keycode: Some(sdl3::keyboard::Keycode::Left), repeat: false, .. } => {
|
||||
input = input.with_action(engine_v1_common::GameAction::Left, true);
|
||||
},
|
||||
sdl3::event::Event::KeyDown { keycode: Some(sdl3::keyboard::Keycode::Right), repeat: false, .. } => {
|
||||
input = input.with_action(engine_v1_common::GameAction::Right, true);
|
||||
},
|
||||
sdl3::event::Event::KeyDown { keycode: Some(sdl3::keyboard::Keycode::Up), repeat: false, .. } => {
|
||||
input = input.with_action(engine_v1_common::GameAction::Up, true);
|
||||
},
|
||||
sdl3::event::Event::KeyDown { keycode: Some(sdl3::keyboard::Keycode::Down), repeat: false, .. } => {
|
||||
input = input.with_action(engine_v1_common::GameAction::Down, true);
|
||||
},
|
||||
sdl3::event::Event::MouseButtonDown { mouse_btn: sdl3::mouse::MouseButton::Left, x, y, .. } => {
|
||||
pointer = normalize_mouse_pointer(&canvas, true, x, y);
|
||||
input = input.with_pointer(pointer).with_action(engine_v1_common::GameAction::Primary, true);
|
||||
input = apply_pointer_direction(input, pointer);
|
||||
},
|
||||
sdl3::event::Event::MouseButtonUp { mouse_btn: sdl3::mouse::MouseButton::Left, x, y, .. } => {
|
||||
pointer = normalize_mouse_pointer(&canvas, false, x, y);
|
||||
@@ -62,6 +75,7 @@ impl SdlRuntime {
|
||||
sdl3::event::Event::FingerDown { x, y, .. } => {
|
||||
pointer = engine_v1_common::PointerState::normalized(true, x, y);
|
||||
input = input.with_pointer(pointer).with_action(engine_v1_common::GameAction::Primary, true);
|
||||
input = apply_pointer_direction(input, pointer);
|
||||
},
|
||||
sdl3::event::Event::FingerMotion { x, y, .. } => {
|
||||
pointer = engine_v1_common::PointerState::normalized(true, x, y);
|
||||
@@ -119,3 +133,20 @@ fn render_scene(canvas: &mut sdl3::render::WindowCanvas, scene: engine_v1_common
|
||||
canvas.present();
|
||||
return std::result::Result::Ok(());
|
||||
}
|
||||
|
||||
fn apply_pointer_direction(mut input: engine_v1_common::InputState, pointer: engine_v1_common::PointerState) -> engine_v1_common::InputState {
|
||||
let horizontal = pointer.x() - 0.5;
|
||||
let vertical = pointer.y() - 0.5;
|
||||
if horizontal.abs() >= vertical.abs() {
|
||||
if horizontal < 0.0 {
|
||||
input = input.with_action(engine_v1_common::GameAction::Left, true);
|
||||
} else {
|
||||
input = input.with_action(engine_v1_common::GameAction::Right, true);
|
||||
}
|
||||
} else if vertical < 0.0 {
|
||||
input = input.with_action(engine_v1_common::GameAction::Up, true);
|
||||
} else {
|
||||
input = input.with_action(engine_v1_common::GameAction::Down, true);
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,55 @@
|
||||
// file: crates/games/game-snake-poc/src/state.rs
|
||||
// version: 3
|
||||
// version: 4
|
||||
|
||||
/// Minimal Snake state used to validate reusable engine dependencies.
|
||||
const GRID_HEIGHT: i16 = 20;
|
||||
const GRID_WIDTH: i16 = 12;
|
||||
const MAX_SEGMENTS: usize = 48;
|
||||
const MOVE_EVERY_FRAMES: u64 = 8;
|
||||
|
||||
/// Grid cell used by the Snake POC.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SnakeCell {
|
||||
x: i16,
|
||||
y: i16,
|
||||
}
|
||||
|
||||
impl SnakeCell {
|
||||
/// Creates one grid cell.
|
||||
#[must_use]
|
||||
pub const fn new(x: i16, y: i16) -> Self {
|
||||
return Self { x, y };
|
||||
}
|
||||
|
||||
/// Returns the horizontal grid coordinate.
|
||||
#[must_use]
|
||||
pub const fn x(self) -> i16 {
|
||||
return self.x;
|
||||
}
|
||||
|
||||
/// Returns the vertical grid coordinate.
|
||||
#[must_use]
|
||||
pub const fn y(self) -> i16 {
|
||||
return self.y;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
enum SnakeDirection {
|
||||
Left,
|
||||
Right,
|
||||
Up,
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Playable deterministic Snake POC state.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct SnakeState {
|
||||
length: u32,
|
||||
segments: [SnakeCell; MAX_SEGMENTS],
|
||||
length: usize,
|
||||
direction: SnakeDirection,
|
||||
food: SnakeCell,
|
||||
food_index: u64,
|
||||
score: u64,
|
||||
}
|
||||
|
||||
impl Default for SnakeState {
|
||||
@@ -17,28 +62,161 @@ impl SnakeState {
|
||||
/// Creates a fresh Snake state.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
return Self { length: 1 };
|
||||
let mut segments = [SnakeCell::new(0, 0); MAX_SEGMENTS];
|
||||
segments[0] = SnakeCell::new(5, 10);
|
||||
segments[1] = SnakeCell::new(4, 10);
|
||||
segments[2] = SnakeCell::new(3, 10);
|
||||
return Self {
|
||||
segments,
|
||||
length: 3,
|
||||
direction: SnakeDirection::Right,
|
||||
food: SnakeCell::new(8, 10),
|
||||
food_index: 0,
|
||||
score: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the current snake length.
|
||||
#[must_use]
|
||||
pub const fn length(self) -> u32 {
|
||||
pub const fn length(self) -> usize {
|
||||
return self.length;
|
||||
}
|
||||
|
||||
/// Registers one consumed growth item.
|
||||
pub const fn grow(&mut self) {
|
||||
self.length = self.length.saturating_add(1);
|
||||
/// Returns the current score.
|
||||
#[must_use]
|
||||
pub const fn score(self) -> u64 {
|
||||
return self.score;
|
||||
}
|
||||
|
||||
/// Returns the current food cell.
|
||||
#[must_use]
|
||||
pub const fn food(self) -> SnakeCell {
|
||||
return self.food;
|
||||
}
|
||||
|
||||
/// Returns the current head cell.
|
||||
#[must_use]
|
||||
pub const fn head(self) -> SnakeCell {
|
||||
return self.segments[0];
|
||||
}
|
||||
|
||||
fn apply_direction_input(&mut self, input: engine_v1_common::InputState) {
|
||||
if input.is_active(engine_v1_common::GameAction::Left) && self.direction != SnakeDirection::Right {
|
||||
self.direction = SnakeDirection::Left;
|
||||
} else if input.is_active(engine_v1_common::GameAction::Right) && self.direction != SnakeDirection::Left {
|
||||
self.direction = SnakeDirection::Right;
|
||||
} else if input.is_active(engine_v1_common::GameAction::Up) && self.direction != SnakeDirection::Down {
|
||||
self.direction = SnakeDirection::Up;
|
||||
} else if input.is_active(engine_v1_common::GameAction::Down) && self.direction != SnakeDirection::Up {
|
||||
self.direction = SnakeDirection::Down;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn move_once(&mut self) {
|
||||
let head = self.head();
|
||||
let next = match self.direction {
|
||||
SnakeDirection::Left => SnakeCell::new(head.x - 1, head.y),
|
||||
SnakeDirection::Right => SnakeCell::new(head.x + 1, head.y),
|
||||
SnakeDirection::Up => SnakeCell::new(head.x, head.y - 1),
|
||||
SnakeDirection::Down => SnakeCell::new(head.x, head.y + 1),
|
||||
};
|
||||
if self.hits_wall(next) || self.hits_body(next) {
|
||||
*self = Self::new();
|
||||
return;
|
||||
}
|
||||
let ate = next == self.food;
|
||||
let new_length = if ate { self.length.saturating_add(1).min(MAX_SEGMENTS) } else { self.length };
|
||||
let mut index = new_length.saturating_sub(1);
|
||||
while index > 0 {
|
||||
self.segments[index] = self.segments[index - 1];
|
||||
index -= 1;
|
||||
}
|
||||
self.segments[0] = next;
|
||||
self.length = new_length;
|
||||
if ate {
|
||||
self.score = self.score.saturating_add(1);
|
||||
self.food_index = self.food_index.saturating_add(1);
|
||||
self.food = food_for_index(self.food_index);
|
||||
if self.food_overlaps_snake() {
|
||||
self.food_index = self.food_index.saturating_add(1);
|
||||
self.food = food_for_index(self.food_index);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn hits_body(self, cell: SnakeCell) -> bool {
|
||||
let mut index = 0;
|
||||
while index < self.length {
|
||||
if self.segments[index] == cell {
|
||||
return true;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn hits_wall(self, cell: SnakeCell) -> bool {
|
||||
return cell.x < 0 || cell.y < 0 || cell.x >= GRID_WIDTH || cell.y >= GRID_HEIGHT;
|
||||
}
|
||||
|
||||
fn food_overlaps_snake(self) -> bool {
|
||||
return self.hits_body(self.food);
|
||||
}
|
||||
}
|
||||
|
||||
impl engine_v1_common::EngineGame for SnakeState {
|
||||
fn update(&mut self, _frame: engine_v1_common::EngineFrame, input: engine_v1_common::InputState) {
|
||||
if input.is_active(engine_v1_common::GameAction::Primary) {
|
||||
self.grow();
|
||||
fn update(&mut self, frame: engine_v1_common::EngineFrame, input: engine_v1_common::InputState) {
|
||||
self.apply_direction_input(input);
|
||||
if frame.index() % MOVE_EVERY_FRAMES == 0 {
|
||||
self.move_once();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fn scene(&self) -> engine_v1_common::EngineScene {
|
||||
let mut scene = engine_v1_common::EngineScene::empty(engine_v1_common::RenderColor::rgb(14, 20, 18));
|
||||
scene = scene.with_rect(engine_v1_common::RenderRect::new(cell_rect(self.food), engine_v1_common::RenderColor::rgb(255, 96, 72)));
|
||||
let mut index = 0;
|
||||
while index < self.length {
|
||||
let color = if index == 0 {
|
||||
engine_v1_common::RenderColor::rgb(120, 255, 160)
|
||||
} else {
|
||||
engine_v1_common::RenderColor::rgb(64, 192, 112)
|
||||
};
|
||||
scene = scene.with_rect(engine_v1_common::RenderRect::new(cell_rect(self.segments[index]), color));
|
||||
index += 1;
|
||||
}
|
||||
return scene;
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_rect(cell: SnakeCell) -> engine_v1_common::NormalizedRect {
|
||||
let cell_height = 1.0 / GRID_HEIGHT as f32;
|
||||
let cell_width = 1.0 / GRID_WIDTH as f32;
|
||||
let inset_x = cell_width * 0.08;
|
||||
let inset_y = cell_height * 0.08;
|
||||
return engine_v1_common::NormalizedRect::new(
|
||||
cell.x as f32 * cell_width + inset_x,
|
||||
cell.y as f32 * cell_height + inset_y,
|
||||
cell_width - inset_x * 2.0,
|
||||
cell_height - inset_y * 2.0,
|
||||
);
|
||||
}
|
||||
|
||||
fn food_for_index(index: u64) -> SnakeCell {
|
||||
const FOODS: [SnakeCell; 8] = [
|
||||
SnakeCell::new(8, 10),
|
||||
SnakeCell::new(8, 5),
|
||||
SnakeCell::new(2, 5),
|
||||
SnakeCell::new(2, 15),
|
||||
SnakeCell::new(9, 15),
|
||||
SnakeCell::new(9, 3),
|
||||
SnakeCell::new(1, 3),
|
||||
SnakeCell::new(6, 17),
|
||||
];
|
||||
return FOODS[index as usize % FOODS.len()];
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,25 +1,62 @@
|
||||
// file: crates/games/game-snake-poc/unit_tests/state.rs
|
||||
// version: 1
|
||||
// version: 2
|
||||
|
||||
fn frame(index: u64) -> engine_v1_common::EngineFrame {
|
||||
return engine_v1_common::EngineFrame::new(index, std::time::Duration::from_millis(16), std::time::Duration::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growth_increments_length() {
|
||||
game_logging_lib::with_test_tracing("growth_increments_length", || {
|
||||
fn eating_food_grows_and_scores() {
|
||||
game_logging_lib::with_test_tracing("eating_food_grows_and_scores", || {
|
||||
let mut state = crate::SnakeState::new();
|
||||
state.grow();
|
||||
assert_eq!(state.length(), 2);
|
||||
let initial_length = state.length();
|
||||
let mut step = 0;
|
||||
while step < 3 {
|
||||
engine_v1_common::EngineGame::update(&mut state, frame(step * 8), engine_v1_common::InputState::none());
|
||||
step += 1;
|
||||
}
|
||||
assert_eq!(state.length(), initial_length + 1);
|
||||
assert_eq!(state.score(), 1);
|
||||
assert_ne!(state.food(), crate::SnakeCell::new(8, 10));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_update_maps_primary_action_to_growth() {
|
||||
game_logging_lib::with_test_tracing("engine_update_maps_primary_action_to_growth", || {
|
||||
fn opposite_direction_is_rejected() {
|
||||
game_logging_lib::with_test_tracing("opposite_direction_is_rejected", || {
|
||||
let mut state = crate::SnakeState::new();
|
||||
let input = engine_v1_common::InputState::none().with_action(engine_v1_common::GameAction::Primary, true);
|
||||
engine_v1_common::EngineGame::update(
|
||||
&mut state,
|
||||
engine_v1_common::EngineFrame::new(0, std::time::Duration::from_millis(16), std::time::Duration::ZERO),
|
||||
input,
|
||||
);
|
||||
assert_eq!(state.length(), 2);
|
||||
let input = engine_v1_common::InputState::none().with_action(engine_v1_common::GameAction::Left, true);
|
||||
engine_v1_common::EngineGame::update(&mut state, frame(0), input);
|
||||
assert_eq!(state.head(), crate::SnakeCell::new(6, 10));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scene_contains_food_and_snake_segments() {
|
||||
game_logging_lib::with_test_tracing("scene_contains_food_and_snake_segments", || {
|
||||
let state = crate::SnakeState::new();
|
||||
let scene = engine_v1_common::EngineGame::scene(&state);
|
||||
let occupied = scene.rectangles().iter().filter(|slot| slot.is_some()).count();
|
||||
assert_eq!(occupied, state.length() + 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snake_moves_on_fixed_cadence() {
|
||||
game_logging_lib::with_test_tracing("snake_moves_on_fixed_cadence", || {
|
||||
let mut state = crate::SnakeState::new();
|
||||
let start = state.head();
|
||||
engine_v1_common::EngineGame::update(&mut state, frame(0), engine_v1_common::InputState::none());
|
||||
assert_eq!(state.head(), crate::SnakeCell::new(start.x() + 1, start.y()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn up_direction_changes_movement() {
|
||||
game_logging_lib::with_test_tracing("up_direction_changes_movement", || {
|
||||
let mut state = crate::SnakeState::new();
|
||||
let input = engine_v1_common::InputState::none().with_action(engine_v1_common::GameAction::Up, true);
|
||||
engine_v1_common::EngineGame::update(&mut state, frame(0), input);
|
||||
assert_eq!(state.head(), crate::SnakeCell::new(5, 9));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user