From 19e74682a820739c02eadf90a606181045f0b214 Mon Sep 17 00:00:00 2001 From: Alexander Baranov Date: Tue, 2 Jun 2026 17:09:31 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BD?= =?UTF-8?q?=D0=B0=D1=8F=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=20-=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- snake-game/Cargo.lock | 51 ++++++++ snake-game/Cargo.toml | 3 +- snake-game/examples/observers.rs | 211 +++++++++++++++++++++++++++++++ snake-game/src/domain.rs | 66 +++++++++- snake-game/src/gameplay.rs | 34 ++++- snake-game/src/lib.rs | 2 +- snake-game/src/main.rs | 1 + snake-game/src/startup.rs | 65 +++++++--- 8 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 snake-game/examples/observers.rs diff --git a/snake-game/Cargo.lock b/snake-game/Cargo.lock index 3ca6bd8..aabf1b0 100644 --- a/snake-game/Cargo.lock +++ b/snake-game/Cargo.lock @@ -725,6 +725,7 @@ dependencies = [ "bevy_text", "bevy_time", "bevy_transform", + "bevy_ui", "bevy_utils", "bevy_window", "bevy_winit", @@ -1114,6 +1115,38 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "bevy_ui" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1691a411014085e0d35f8bb8208e5f973edd7ace061a4b1c41c83de21579dc70" +dependencies = [ + "accesskit", + "bevy_a11y", + "bevy_app", + "bevy_asset", + "bevy_camera", + "bevy_color", + "bevy_derive", + "bevy_ecs", + "bevy_image", + "bevy_input", + "bevy_input_focus", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_sprite", + "bevy_text", + "bevy_transform", + "bevy_utils", + "bevy_window", + "derive_more", + "smallvec", + "taffy", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "bevy_utils" version = "0.18.1" @@ -2045,6 +2078,12 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + [[package]] name = "guillotiere" version = "0.6.2" @@ -3547,6 +3586,18 @@ dependencies = [ "libc", ] +[[package]] +name = "taffy" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + [[package]] name = "termcolor" version = "1.4.1" diff --git a/snake-game/Cargo.toml b/snake-game/Cargo.toml index 60aaa3b..24cf1da 100644 --- a/snake-game/Cargo.toml +++ b/snake-game/Cargo.toml @@ -4,7 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -bevy = { version = "0.18", default-features = false, features = ["2d_bevy_render", "bevy_log", "bevy_winit", "debug"] } +bevy = { version = "0.18", default-features = false, features = ["2d_bevy_render", "bevy_log", "bevy_winit", "bevy_text", "bevy_ui", "debug"] } +# bevy = { version = "0.18" } tracing = "0.1" tracing-subscriber = "0.3" diff --git a/snake-game/examples/observers.rs b/snake-game/examples/observers.rs new file mode 100644 index 0000000..f4c0619 --- /dev/null +++ b/snake-game/examples/observers.rs @@ -0,0 +1,211 @@ +use bevy::{ + platform::collections::{HashMap, HashSet}, + prelude::*, +}; +use rand::{RngExt, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .init_resource::() + .add_systems(Startup, setup) + .add_systems(Update, (draw_shapes, handle_click)) + // Observers are systems that run when an event is "triggered". This observer runs whenever + // `ExplodeMines` is triggered. + .add_observer( + |explode_mines: On, + mines: Query<&Mine>, + index: Res, + mut commands: Commands| { + // Access resources + for entity in index.get_nearby(explode_mines.pos) { + // Run queries + let mine = mines.get(entity).unwrap(); + if mine.pos.distance(explode_mines.pos) < mine.size + explode_mines.radius { + // And queue commands, including triggering additional events + // Here we trigger the `Explode` event for entity `e` + commands.trigger(Explode { entity }); + } + } + }, + ) + // This observer runs whenever the `Mine` component is added to an entity, and places it in a simple spatial index. + .add_observer(on_add_mine) + // This observer runs whenever the `Mine` component is removed from an entity (including despawning it) + // and removes it from the spatial index. + .add_observer(on_remove_mine) + .run(); +} + +#[derive(Component)] +struct Mine { + pos: Vec2, + size: f32, +} + +impl Mine { + fn random(rand: &mut ChaCha8Rng) -> Self { + Mine { + pos: Vec2::new( + (rand.random::() - 0.5) * 1200.0, + (rand.random::() - 0.5) * 600.0, + ), + size: 4.0 + rand.random::() * 16.0, + } + } +} + +/// This is a normal [`Event`]. Any observer that watches for it will run when it is triggered. +#[derive(Event)] +struct ExplodeMines { + pos: Vec2, + radius: f32, +} + +/// An [`EntityEvent`] is a specialized type of [`Event`] that can target a specific entity. In addition to +/// running normal "top level" observers when it is triggered (which target _any_ entity that Explodes), it will +/// also run any observers that target the _specific_ entity for that event. +#[derive(EntityEvent)] +struct Explode { + entity: Entity, +} + +fn setup(mut commands: Commands) { + commands.spawn(Camera2d); + commands.spawn(( + Text::new( + "Click on a \"Mine\" to trigger it.\n\ + When it explodes it will trigger all overlapping mines.", + ), + Node { + position_type: PositionType::Absolute, + top: px(12), + left: px(12), + ..default() + }, + )); + + let mut rng = ChaCha8Rng::seed_from_u64(19878367467713); + + commands + .spawn(Mine::random(&mut rng)) + // Observers can watch for events targeting a specific entity. + // This will create a new observer that runs whenever the Explode event + // is triggered for this spawned entity. + .observe(explode_mine); + + // We want to spawn a bunch of mines. We could just call the code above for each of them. + // That would create a new observer instance for every Mine entity. Having duplicate observers + // generally isn't worth worrying about as the overhead is low. But if you want to be maximally efficient, + // you can reuse observers across entities. + // + // First, observers are actually just entities with the Observer component! The `observe()` functions + // you've seen so far in this example are just shorthand for manually spawning an observer. + let mut observer = Observer::new(explode_mine); + + // As we spawn entities, we can make this observer watch each of them: + for _ in 0..1000 { + let entity = commands.spawn(Mine::random(&mut rng)).id(); + observer.watch_entity(entity); + } + + // By spawning the Observer component, it becomes active! + commands.spawn(observer); +} + +fn on_add_mine(add: On, query: Query<&Mine>, mut index: ResMut) { + let mine = query.get(add.entity).unwrap(); + let tile = ( + (mine.pos.x / CELL_SIZE).floor() as i32, + (mine.pos.y / CELL_SIZE).floor() as i32, + ); + index.map.entry(tile).or_default().insert(add.entity); +} + +// Remove despawned mines from our index +fn on_remove_mine(remove: On, query: Query<&Mine>, mut index: ResMut) { + let mine = query.get(remove.entity).unwrap(); + let tile = ( + (mine.pos.x / CELL_SIZE).floor() as i32, + (mine.pos.y / CELL_SIZE).floor() as i32, + ); + index.map.entry(tile).and_modify(|set| { + set.remove(&remove.entity); + }); +} + +fn explode_mine(explode: On, query: Query<&Mine>, mut commands: Commands) { + // Explode is an EntityEvent. `explode.entity` is the entity that Explode was triggered for. + let Ok(mut entity) = commands.get_entity(explode.entity) else { + return; + }; + info!("Boom! {} exploded.", explode.entity); + entity.despawn(); + let mine = query.get(explode.entity).unwrap(); + // Trigger another explosion cascade. + commands.trigger(ExplodeMines { + pos: mine.pos, + radius: mine.size, + }); +} + +// Draw a circle for each mine using `Gizmos` +fn draw_shapes(mut gizmos: Gizmos, mines: Query<&Mine>) { + for mine in &mines { + gizmos.circle_2d( + mine.pos, + mine.size, + Color::hsl((mine.size - 4.0) / 16.0 * 360.0, 1.0, 0.8), + ); + } +} + +// Trigger `ExplodeMines` at the position of a given click +fn handle_click( + mouse_button_input: Res>, + camera: Single<(&Camera, &GlobalTransform)>, + windows: Query<&Window>, + mut commands: Commands, +) { + let Ok(windows) = windows.single() else { + return; + }; + + let (camera, camera_transform) = *camera; + if let Some(pos) = windows + .cursor_position() + .and_then(|cursor| camera.viewport_to_world(camera_transform, cursor).ok()) + .map(|ray| ray.origin.truncate()) + && mouse_button_input.just_pressed(MouseButton::Left) + { + commands.trigger(ExplodeMines { pos, radius: 1.0 }); + } +} + +#[derive(Resource, Default)] +struct SpatialIndex { + map: HashMap<(i32, i32), HashSet>, +} + +/// Cell size has to be bigger than any `TriggerMine::radius` +const CELL_SIZE: f32 = 64.0; + +impl SpatialIndex { + // Lookup all entities within adjacent cells of our spatial index + fn get_nearby(&self, pos: Vec2) -> Vec { + let tile = ( + (pos.x / CELL_SIZE).floor() as i32, + (pos.y / CELL_SIZE).floor() as i32, + ); + let mut nearby = Vec::new(); + for x in -1..2 { + for y in -1..2 { + if let Some(mines) = self.map.get(&(tile.0 + x, tile.1 + y)) { + nearby.extend(mines.iter()); + } + } + } + nearby + } +} diff --git a/snake-game/src/domain.rs b/snake-game/src/domain.rs index 3572b2b..788ff6c 100644 --- a/snake-game/src/domain.rs +++ b/snake-game/src/domain.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use bevy::prelude::*; pub const FRAMES_PER_SECOND: u8 = 24; @@ -5,16 +7,28 @@ pub const GRID_SIZE: u8 = 48; pub const HEAD_SIZE: u8 = (GRID_SIZE as f32 * 1.1) as u8; pub const SNAKE_VELOCITY: u8 = (GRID_SIZE as f32 / 1.0) as u8; +/// Координатная сетка +#[derive(Component)] +pub struct Grid; + /// Голова змеи #[derive(Component)] pub struct Head; /// Сегмент змеиного хвоста #[derive(Component)] -pub struct Tail(pub u16); +pub struct Tail; + +/// Конец змеиного хвоста +#[derive(Component)] +pub struct End; + +/// Событие прохождения границы сетки +#[derive(EntityEvent)] +pub struct GridBarier(pub Entity); /// Направление движения -#[derive(Component, PartialEq, Clone, Copy)] +#[derive(Component, PartialEq, Clone, Copy, Debug)] pub enum Direction { Up, Left, @@ -81,3 +95,51 @@ pub struct Velocity(pub f32); /// Инерция #[derive(Component, Default)] pub struct Inert(pub Direction); + +/// Путь змеи +#[derive(Resource, Default, Debug)] +pub struct SnakePath(HashMap<(i16, i16), Direction>); + +impl SnakePath { + fn to_key(transform: &Transform) -> (i16, i16) { + ( + (transform.translation.x / GRID_SIZE as f32).floor() as i16, + (transform.translation.y / GRID_SIZE as f32).floor() as i16, + ) + } + + pub fn push(&mut self, transform: &Transform, direction: Direction) { + let key = SnakePath::to_key(transform); + self.0.insert(key, direction.clone()); + } + + pub fn pick(&self, transform: &Transform) -> Option { + let key = SnakePath::to_key(transform); + self.0.get(&key).cloned() + } + + pub fn pop(&mut self, transform: &Transform) -> Option { + let key = SnakePath::to_key(transform); + self.0.remove(&key) + } +} + +#[cfg(test)] +mod test { + use crate::domain::*; + + use std::collections::HashMap; + + #[test] + fn test_snake_path() { + let mut snake_path = SnakePath(HashMap::default()); + snake_path.push(&Transform::default(), Direction::default()); + assert_eq!(snake_path.0.len(), 1); + + assert!(snake_path.pick(&Transform::default()) == Some(Direction::default())); + assert_eq!(snake_path.0.len(), 1); + + assert!(snake_path.pop(&Transform::default()) == Some(Direction::default())); + assert_eq!(snake_path.0.len(), 0); + } +} diff --git a/snake-game/src/gameplay.rs b/snake-game/src/gameplay.rs index acfe660..69aacf8 100644 --- a/snake-game/src/gameplay.rs +++ b/snake-game/src/gameplay.rs @@ -1,27 +1,36 @@ use bevy::prelude::*; -use crate::domain::{Direction, GRID_SIZE, Head, Inert, Velocity}; +use crate::domain::{Direction, GRID_SIZE, GridBarier, Head, Inert, SnakePath, Velocity}; +/// Система поворота головы змеи pub fn head_rotation( input: Res>, query: Single<(&mut Transform, &mut Direction), With>, + mut snake_path: ResMut, ) { use core::f32::consts::PI; let (mut transform, mut direction) = query.into_inner(); if input.just_pressed(KeyCode::ArrowLeft) { - transform.rotate_z(PI / 2.); + transform.rotate_z(PI / 2.0); direction.rotate_left(); + snake_path.push(&transform, direction.clone()); } if input.just_pressed(KeyCode::ArrowRight) { - transform.rotate_z(-PI / 2.); + transform.rotate_z(-PI / 2.0); direction.rotate_right(); + snake_path.push(&transform, direction.clone()); } } -pub fn moving(time: Res