0.1.0-0-pre.11

This commit is contained in:
2026-09-16 11:32:07 +02:00
parent fa098943bf
commit ab9403384f
14 changed files with 465 additions and 44 deletions

View File

@@ -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;
}

View File

@@ -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)]

View File

@@ -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));
});
}