feat: Collision beginning

- Added system for detecting collisions with interactive objects
- Added interactive doors
This commit is contained in:
Alexey 2026-03-16 17:21:10 +03:00
commit 4c23a38b92
5 changed files with 524 additions and 10 deletions

View file

@ -1 +1,143 @@
use bevy::{ecs::query::QueryFilter, prelude::*};
use bevy_rapier2d::prelude::*;
use crate::{PIXELS_PER_METER, player::Player};
const DOOR_OPENED_ASSET: &'static str = "sprites/interactive/door_opened.png";
const DOOR_CLOSED_ASSET: &'static str = "sprites/interactive/door_closed.png";
#[derive(Component)]
pub struct MayInteract;
#[derive(Component, Default)]
pub struct InteractiveObject;
#[derive(Component)]
pub struct Wall;
#[derive(Component)]
#[require(Sprite, InteractiveObject)]
pub struct Door;
#[derive(EntityEvent)]
pub struct InteractionEvent {
pub entity: Entity,
}
fn get_interactive_id<F: QueryFilter>(
tested_id: Entity,
interactive_query: Query<(), F>,
parent_query: Query<&ChildOf, With<Collider>>,
) -> Option<Entity> {
if interactive_query.get(tested_id).is_ok() {
return Some(tested_id);
}
let Ok(parent_id) = parent_query.get(tested_id) else {
return None;
};
match interactive_query.get(parent_id.0) {
Ok(_) => Some(parent_id.0),
Err(_) => None,
}
}
pub fn detect_interact_collisions(
mut commands: Commands,
mut collision_events: MessageReader<CollisionEvent>,
player_query: Query<(), With<Player>>,
interactive_query1: Query<(), (With<InteractiveObject>, Without<MayInteract>)>,
interactive_query2: Query<(), (With<InteractiveObject>, With<MayInteract>)>,
parent_query: Query<&ChildOf, With<Collider>>,
) {
for collision_event in collision_events.read() {
match collision_event {
CollisionEvent::Started(first, second, _) => {
let Some(interactive_id) = get_interactive_id(*first, interactive_query1, parent_query) else {
continue;
};
if player_query.get(*second).is_err() {
continue;
}
commands.entity(interactive_id).insert(MayInteract);
},
CollisionEvent::Stopped(first, second, _) => {
let Some(interactive_id) = get_interactive_id(*first, interactive_query2, parent_query) else {
continue;
};
if player_query.get(*second).is_err() {
continue;
}
commands.entity(interactive_id).remove::<MayInteract>();
},
}
}
}
fn on_door_interact(
event: On<InteractionEvent>,
mut commands: Commands,
collider_query: Query<(), (With<Door>, With<Collider>)>,
no_collider_query: Query<(), (With<Door>, Without<Collider>)>,
mut sprite_query: Query<&mut Sprite, With<Door>>,
asset_server: Res<AssetServer>,
) {
let was_opened =
if collider_query.get(event.entity).is_ok() { false }
else if no_collider_query.get(event.entity).is_ok() { true }
else {
error!("on_door_interact fired but entity {} isn't door", event.entity);
return;
};
let Ok(mut sprite) = sprite_query.get_mut(event.entity) else {
error!("on_door_interact fired but entity {} has no sprite", event.entity);
return;
};
let asset_path = if was_opened { DOOR_CLOSED_ASSET } else { DOOR_OPENED_ASSET };
sprite.image = asset_server.load(asset_path);
if was_opened {
commands.entity(event.entity).insert(door_collider());
} else {
commands.entity(event.entity).remove::<Collider>();
}
}
fn door_collider() -> Collider {
Collider::cuboid(PIXELS_PER_METER * 0.125, PIXELS_PER_METER)
}
fn door_bundle(image: Handle<Image>, position: Vec2) -> impl Bundle {
(
Door,
Sprite::from_image(image),
Transform::from_xyz(position.x, position.y, 0.),
Name::new(format!("Door ({}, {})", position.x, position.y)),
door_collider(),
Observer::new(on_door_interact),
children![
(
Collider::cuboid(PIXELS_PER_METER * 0.5, PIXELS_PER_METER),
Sensor,
Transform::from_xyz(0., 0., 0.),
),
],
)
}
fn wall_bundle(position: Vec2) -> impl Bundle {
(
Wall,
Transform::from_xyz(position.x, position.y, 0.),
Collider::cuboid(PIXELS_PER_METER * 0.5, PIXELS_PER_METER),
Name::new(format!("Wall ({}, {})", position.x, position.y)),
)
}
pub fn setup_world(
mut commands: Commands,
asset_server: Res<AssetServer>,
) {
let door_image = asset_server.load(DOOR_CLOSED_ASSET);
commands.spawn(door_bundle(door_image, vec2(128., 0.)));
commands.spawn(wall_bundle(vec2(-128., 0.)));
}

View file

@ -7,9 +7,12 @@ pub mod ui;
mod tests;
use bevy::{prelude::*, ui_widgets::ScrollbarPlugin};
use bevy_rapier2d::{prelude::*, rapier::prelude::IntegrationParameters};
use leafwing_input_manager::prelude::*;
use serde::{Deserialize, Serialize};
pub const PIXELS_PER_METER: f32 = 16.0;
pub struct ExpeditionPlugin;
#[derive(Actionlike, PartialEq, Eq, Hash, Debug, Clone, Reflect, Serialize, Deserialize)]
@ -87,15 +90,30 @@ impl Plugin for ExpeditionPlugin {
input::InputAssetPlugin::<InputAction>::default(),
input::InputAssetPlugin::<UiAction>::default(),
ScrollbarPlugin,
RapierPhysicsPlugin::<()>::default()
.with_custom_initialization(RapierContextInitialization::InitializeDefaultRapierContext {
integration_parameters: IntegrationParameters {
length_unit: PIXELS_PER_METER,
..default()
},
rapier_configuration: RapierConfiguration {
gravity: Vec2::ZERO,
physics_pipeline_active: true,
scaled_shape_subdivision: 10,
force_update_from_transform_changes: false,
},
}),
RapierDebugRenderPlugin::default(),
))
.init_state::<GameState>()
.insert_resource(ui::WindowSize::default())
.add_systems(Startup, (player::setup_player, setup_global))
.add_systems(Startup, (player::setup_player, setup_global, layout::setup_world))
.add_systems(Update, (
player::handle_input,
ui::update_window_size,
ui::handle_input,
insert_entity_name,
layout::detect_interact_collisions,
))
.add_systems(OnEnter(GameState::Inventory), inventory::ui::setup_ui_inventory)
.add_systems(OnExit(GameState::Inventory), inventory::ui::clear_ui_inventory)

View file

@ -1,7 +1,8 @@
use bevy::prelude::*;
use bevy_rapier2d::prelude::*;
use leafwing_input_manager::prelude::*;
use crate::{GameState, InputAction as Action, inventory::{ActiveInventory, Inventory, item::Item}};
use crate::{GameState, InputAction as Action, PIXELS_PER_METER, inventory::{ActiveInventory, Inventory, item::Item}, layout::{InteractionEvent, MayInteract}};
#[derive(Component, Reflect)]
pub struct Player {
@ -19,6 +20,11 @@ fn player_bundle(asset_server: &Res<AssetServer>) -> impl Bundle {
Transform::from_xyz(0f32, 0f32, 1f32),
Action::default_input_map(),
Name::new("Player"),
RigidBody::KinematicPositionBased,
KinematicCharacterController::default(),
ActiveCollisionTypes::default() | ActiveCollisionTypes::KINEMATIC_STATIC,
Collider::cuboid(PIXELS_PER_METER * 0.5, PIXELS_PER_METER),
ActiveEvents::COLLISION_EVENTS,
children![
(Inventory::new(UVec2::new(6, 2)), ActiveInventory),
(Inventory::new(UVec2::new(5, 3)), ActiveInventory),
@ -63,12 +69,13 @@ pub fn handle_input(
time: Res<Time>,
state: Res<State<GameState>>,
mut next_state: ResMut<NextState<GameState>>,
mut player: Query<(&Player, &mut ActionState<Action>, &mut Transform, &mut Sprite)>,
interactables: Query<Entity, With<MayInteract>>,
mut player: Query<(&Player, &mut ActionState<Action>, &mut KinematicCharacterController, &mut Sprite)>,
) {
let player = player.single_mut().expect("Player should be single");
match state.get() {
GameState::Running => {
let (Player {speed}, mut action_state, mut transform, mut sprite) = player;
let (Player {speed}, mut action_state, mut controller, mut sprite) = player;
if action_state.just_released(&Action::ToggleInventory) {
next_state.set(GameState::Inventory);
@ -77,13 +84,20 @@ pub fn handle_input(
let direction = action_state.clamped_value(&Action::Move);
transform.translation.x += direction * speed * time.delta_secs();
controller.translation = Some(vec2(direction * speed * time.delta_secs(), 0.));
if direction != 0f32 {
sprite.flip_x = direction < 0f32;
}
if action_state.just_pressed(&Action::Interact) {
commands.run_system_cached(try_insert_item);
let mut action_happened = false;
for interactable_id in interactables {
commands.trigger(InteractionEvent { entity: interactable_id });
action_happened = true;
}
if !action_happened {
commands.run_system_cached(try_insert_item);
}
}
},
GameState::Inventory => {