Initial commit
This commit is contained in:
44
.cursorrules
Normal file
44
.cursorrules
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Rust Cursor Rules
|
||||||
|
|
||||||
|
## Code Style & Formatting
|
||||||
|
- **Formatter**: Always use `rustfmt` for formatting code.
|
||||||
|
- **Naming Conventions**:
|
||||||
|
- Types (structs, enums, traits): `UpperCamelCase`
|
||||||
|
- Variables, functions, methods, modules: `snake_case`
|
||||||
|
- Constants, statics: `SCREAMING_SNAKE_CASE`
|
||||||
|
- **Imports**: Group imports by crate. Use `use crate::module::Item;` for internal items.
|
||||||
|
|
||||||
|
## Linting & Quality
|
||||||
|
- **Clippy**: Address all `clippy` warnings. Prefer idiomatic solutions suggested by clippy.
|
||||||
|
- **Safety**: Avoid `unsafe` blocks unless absolutely necessary and documented with a `// SAFETY:` comment explaining why it is safe.
|
||||||
|
- **Unwrap/Expect**: Avoid `.unwrap()` in production code. Use `.expect("msg")` if panic is acceptable, but prefer handling `Result` and `Option` with `match`, `if let`, or `?` operator.
|
||||||
|
|
||||||
|
## Idiomatic Rust
|
||||||
|
- **Ownership**: Prefer borrowing (`&T`) over cloning (`.clone()`) when possible.
|
||||||
|
- **Iterators**: Use iterator chains (`map`, `filter`, `fold`) over explicit loops for data transformation.
|
||||||
|
- **Pattern Matching**: Use extensive pattern matching (`match`) for control flow based on enum variants.
|
||||||
|
- **Types**: Use the Newtype pattern (tuple structs) to enforce type safety for primitives (e.g., `struct Width(u32);`).
|
||||||
|
- **Traits**: Prefer defining behavior via Traits. Use `impl Trait` or generics with trait bounds for flexible APIs.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
- **Result**: Return `Result<T, E>` for fallible operations.
|
||||||
|
- **Crates**:
|
||||||
|
- Use `thiserror` for library error types.
|
||||||
|
- Use `anyhow` for application-level error handling.
|
||||||
|
- **Propagation**: Use the `?` operator for error propagation.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
- **Unit Tests**: Place unit tests in the same file as the code in a `mod tests` module annotated with `#[cfg(test)]`.
|
||||||
|
- **Integration Tests**: Place integration tests in the `tests/` directory.
|
||||||
|
- **Doc Tests**: specific examples in documentation comments (`///`) are tested automatically.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- **Doc Comments**: Use `///` for function/struct documentation and `//!` for module-level documentation.
|
||||||
|
- **Examples**: Include usage examples in documentation.
|
||||||
|
|
||||||
|
## Snake Game Specifics
|
||||||
|
- **Game Loop**: Ensure the game loop is decoupled from rendering logic if possible.
|
||||||
|
- **State Management**: Consider using a `struct GameState` to hold the snake, food, and grid.
|
||||||
|
- **Coordinates**: Use a `struct Point { x: i32, y: i32 }` or similar for grid positions.
|
||||||
|
|
||||||
|
|
||||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
5550
Cargo.lock
generated
Normal file
5550
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
Cargo.toml
Normal file
8
Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "snake"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
bevy = "0.17.3"
|
||||||
|
rand = "0.9.2"
|
||||||
302
src/main.rs
Normal file
302
src/main.rs
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
use bevy::prelude::*;
|
||||||
|
use rand::random;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const ARENA_WIDTH: u32 = 50;
|
||||||
|
const ARENA_HEIGHT: u32 = 50;
|
||||||
|
const SNAKE_HEAD_COLOR: Color = Color::srgb(0.7, 0.7, 0.7);
|
||||||
|
const SNAKE_SEGMENT_COLOR: Color = Color::srgb(0.3, 0.3, 0.3);
|
||||||
|
const FOOD_COLOR: Color = Color::srgb(1.0, 0.0, 1.0);
|
||||||
|
|
||||||
|
#[derive(Component, Clone, Copy, PartialEq, Eq)]
|
||||||
|
struct Position {
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
struct Size {
|
||||||
|
width: f32,
|
||||||
|
height: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Size {
|
||||||
|
pub fn square(x: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
width: x,
|
||||||
|
height: x,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
struct SnakeHead {
|
||||||
|
direction: Direction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
struct SnakeSegment;
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
struct Food;
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct SnakeSegments(Vec<Entity>);
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct LastTailPosition(Option<Position>);
|
||||||
|
|
||||||
|
#[derive(PartialEq, Copy, Clone)]
|
||||||
|
enum Direction {
|
||||||
|
Left,
|
||||||
|
Up,
|
||||||
|
Right,
|
||||||
|
Down,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Direction {
|
||||||
|
fn opposite(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Self::Left => Self::Right,
|
||||||
|
Self::Right => Self::Left,
|
||||||
|
Self::Up => Self::Down,
|
||||||
|
Self::Down => Self::Up,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
App::new()
|
||||||
|
.add_plugins(DefaultPlugins.set(WindowPlugin {
|
||||||
|
primary_window: Some(Window {
|
||||||
|
title: "Snake!".to_string(),
|
||||||
|
resolution: bevy::window::WindowResolution::new(500, 500),
|
||||||
|
..default()
|
||||||
|
}),
|
||||||
|
..default()
|
||||||
|
}))
|
||||||
|
.insert_resource(ClearColor(Color::srgb(0.04, 0.04, 0.04)))
|
||||||
|
.insert_resource(SnakeSegments::default())
|
||||||
|
.insert_resource(LastTailPosition::default())
|
||||||
|
.add_message::<GameOverEvent>()
|
||||||
|
.add_message::<GrowthEvent>()
|
||||||
|
.add_systems(Startup, (setup_camera, spawn_snake))
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
snake_movement_input,
|
||||||
|
game_over.after(snake_movement),
|
||||||
|
food_spawner,
|
||||||
|
snake_movement,
|
||||||
|
snake_eating,
|
||||||
|
snake_growth,
|
||||||
|
size_scaling,
|
||||||
|
position_translation,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_camera(mut commands: Commands) {
|
||||||
|
commands.spawn(Camera2d::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_snake(mut commands: Commands, mut segments: ResMut<SnakeSegments>) {
|
||||||
|
*segments = SnakeSegments(vec![
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
SnakeHead {
|
||||||
|
direction: Direction::Up,
|
||||||
|
},
|
||||||
|
SnakeSegment,
|
||||||
|
Position { x: 3, y: 3 },
|
||||||
|
Size::square(0.8),
|
||||||
|
Sprite {
|
||||||
|
color: SNAKE_HEAD_COLOR,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id(),
|
||||||
|
spawn_segment(&mut commands, Position { x: 3, y: 2 }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_segment(commands: &mut Commands, position: Position) -> Entity {
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
SnakeSegment,
|
||||||
|
position,
|
||||||
|
Size::square(0.65),
|
||||||
|
Sprite {
|
||||||
|
color: SNAKE_SEGMENT_COLOR,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Message)]
|
||||||
|
struct GameOverEvent;
|
||||||
|
|
||||||
|
fn snake_movement_input(
|
||||||
|
keyboard_input: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut heads: Query<&mut SnakeHead>,
|
||||||
|
) {
|
||||||
|
if let Some(mut head) = heads.iter_mut().next() {
|
||||||
|
let dir: Direction = if keyboard_input.pressed(KeyCode::ArrowLeft) {
|
||||||
|
Direction::Left
|
||||||
|
} else if keyboard_input.pressed(KeyCode::ArrowDown) {
|
||||||
|
Direction::Down
|
||||||
|
} else if keyboard_input.pressed(KeyCode::ArrowUp) {
|
||||||
|
Direction::Up
|
||||||
|
} else if keyboard_input.pressed(KeyCode::ArrowRight) {
|
||||||
|
Direction::Right
|
||||||
|
} else {
|
||||||
|
head.direction
|
||||||
|
};
|
||||||
|
|
||||||
|
if dir != head.direction.opposite() {
|
||||||
|
head.direction = dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snake_movement(
|
||||||
|
mut heads: Query<(Entity, &mut SnakeHead)>,
|
||||||
|
mut positions: Query<&mut Position>,
|
||||||
|
segments: Res<SnakeSegments>,
|
||||||
|
mut last_tail_position: ResMut<LastTailPosition>,
|
||||||
|
mut game_over_writer: MessageWriter<GameOverEvent>,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut timer: Local<Timer>,
|
||||||
|
) {
|
||||||
|
if timer.duration() == Duration::ZERO {
|
||||||
|
*timer = Timer::from_seconds(0.15, TimerMode::Repeating);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !timer.tick(time.delta()).just_finished() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((head_entity, head)) = heads.iter_mut().next() {
|
||||||
|
let segment_positions: Vec<Position> = segments
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|e| *positions.get(*e).unwrap())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Check for collision with tail *before* moving
|
||||||
|
if segment_positions.iter().skip(1).any(|p| *p == segment_positions[0]) {
|
||||||
|
game_over_writer.write(GameOverEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut head_pos = positions.get_mut(head_entity).unwrap();
|
||||||
|
|
||||||
|
// Remember tail position for growth
|
||||||
|
*last_tail_position = LastTailPosition(Some(*segment_positions.last().unwrap()));
|
||||||
|
|
||||||
|
match &head.direction {
|
||||||
|
Direction::Left => head_pos.x -= 1,
|
||||||
|
Direction::Right => head_pos.x += 1,
|
||||||
|
Direction::Up => head_pos.y += 1,
|
||||||
|
Direction::Down => head_pos.y -= 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wall collision
|
||||||
|
if head_pos.x < 0 || head_pos.y < 0 || head_pos.x as u32 >= ARENA_WIDTH || head_pos.y as u32 >= ARENA_HEIGHT {
|
||||||
|
game_over_writer.write(GameOverEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the rest of the body
|
||||||
|
segment_positions
|
||||||
|
.iter()
|
||||||
|
.zip(segments.0.iter().skip(1))
|
||||||
|
.for_each(|(pos, segment)| {
|
||||||
|
*positions.get_mut(*segment).unwrap() = *pos;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snake_eating(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut growth_writer: MessageWriter<GrowthEvent>,
|
||||||
|
food_positions: Query<(Entity, &Position), With<Food>>,
|
||||||
|
head_positions: Query<&Position, With<SnakeHead>>,
|
||||||
|
) {
|
||||||
|
for head_pos in head_positions.iter() {
|
||||||
|
for (ent, food_pos) in food_positions.iter() {
|
||||||
|
if head_pos == food_pos {
|
||||||
|
commands.entity(ent).despawn();
|
||||||
|
growth_writer.write(GrowthEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Message)]
|
||||||
|
struct GrowthEvent;
|
||||||
|
|
||||||
|
fn snake_growth(
|
||||||
|
mut commands: Commands,
|
||||||
|
last_tail_position: Res<LastTailPosition>,
|
||||||
|
mut segments: ResMut<SnakeSegments>,
|
||||||
|
mut growth_reader: MessageReader<GrowthEvent>,
|
||||||
|
) {
|
||||||
|
if growth_reader.read().next().is_some() {
|
||||||
|
if let Some(pos) = last_tail_position.0 {
|
||||||
|
segments.0.push(spawn_segment(&mut commands, pos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_scaling(primary_query: Query<&Window, With<bevy::window::PrimaryWindow>>, mut q: Query<(&Size, &mut Transform)>) {
|
||||||
|
let window = primary_query.single().unwrap();
|
||||||
|
for (sprite_size, mut transform) in q.iter_mut() {
|
||||||
|
transform.scale = Vec3::new(
|
||||||
|
sprite_size.width / ARENA_WIDTH as f32 * window.width() as f32,
|
||||||
|
sprite_size.height / ARENA_HEIGHT as f32 * window.height() as f32,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn position_translation(primary_query: Query<&Window, With<bevy::window::PrimaryWindow>>, mut q: Query<(&Position, &mut Transform)>) {
|
||||||
|
fn convert(pos: f32, bound_window: f32, bound_game: f32) -> f32 {
|
||||||
|
let tile_size = bound_window / bound_game;
|
||||||
|
pos / bound_game * bound_window - (bound_window / 2.) + (tile_size / 2.)
|
||||||
|
}
|
||||||
|
let window = primary_query.single().unwrap();
|
||||||
|
for (pos, mut transform) in q.iter_mut() {
|
||||||
|
transform.translation = Vec3::new(
|
||||||
|
convert(pos.x as f32, window.width() as f32, ARENA_WIDTH as f32),
|
||||||
|
convert(pos.y as f32, window.height() as f32, ARENA_HEIGHT as f32),
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn food_spawner(mut commands: Commands, food: Query<&Food>) {
|
||||||
|
// Check if food exists
|
||||||
|
if food.iter().len() == 0 {
|
||||||
|
commands.spawn((
|
||||||
|
Food,
|
||||||
|
Position {
|
||||||
|
x: (random::<f32>() * ARENA_WIDTH as f32) as i32,
|
||||||
|
y: (random::<f32>() * ARENA_HEIGHT as f32) as i32,
|
||||||
|
},
|
||||||
|
Size::square(0.8),
|
||||||
|
Sprite {
|
||||||
|
color: FOOD_COLOR,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn game_over(mut reader: MessageReader<GameOverEvent>) {
|
||||||
|
if reader.read().next().is_some() {
|
||||||
|
println!("Game Over!");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user