feat: Interaction highlighting

- Added Collision{Started,Stopped}Event
- Added player headlight just for showcasing
This commit is contained in:
Alexey 2026-04-01 15:29:47 +03:00
commit 1f9dace4ce
11 changed files with 303 additions and 54 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 896 B

After

Width:  |  Height:  |  Size: 875 B

Before After
Before After

View file

@ -62,9 +62,13 @@ pub fn load_level (
for DoorData { pos, facing_left, lock } in level.interactive.doors.iter() {
let door_pos = vec2(meters(pos.x + 0.5), meters(pos.y));
let mut door = parent.spawn(door_bundle(&textures, door_pos, *facing_left));
let door_id = parent.spawn(door_bundle(&textures, door_pos, *facing_left))
.observe(super::door::on_door_interact)
.id();
if let Some(lock_facing_left) = lock {
door.with_child(padlock_bundle(&textures, *lock_facing_left));
parent.commands().spawn(padlock_bundle(&textures, *lock_facing_left))
.observe(super::lock::on_padlock_interaction)
.insert(ChildOf(door_id));
}
}
@ -89,6 +93,7 @@ pub fn load_level (
let pos = vec2(meters(pos.x), meters(pos.y));
let mut container = parent.spawn(container_bundle(&textures, pos, *size));
container.observe(super::container::on_container_interact);
for item in items {
// TODO: replace with proper item-by-id system

View file

@ -1,4 +1,4 @@
use bevy::prelude::*;
use bevy::{ecs::relationship::RelatedSpawner, prelude::*};
use bevy_rapier2d::prelude::*;
use crate::{
@ -35,6 +35,54 @@ pub fn on_container_interact(
next_state.set(GameState::Inventory);
}
pub fn on_container_collision_started(
event: On<CollisionStartedEvent>,
mut commands: Commands,
collider_query: Query<&ChildOf, With<Collider>>,
parent_query: Query<&Children>,
highlight_sprite_query: Query<(), With<Sprite>>,
) {
let Ok(parent_id) = collider_query.get(event.entity) else {
return;
};
let Ok(children) = parent_query.get(parent_id.0) else {
return;
};
for child in children {
if highlight_sprite_query.get(*child).is_ok() {
commands.entity(*child).insert(Visibility::Visible);
break;
}
}
commands.entity(parent_id.0).insert(Visibility::Hidden);
}
pub fn on_container_collision_stopped(
event: On<CollisionStoppedEvent>,
mut commands: Commands,
collider_query: Query<&ChildOf, With<Collider>>,
parent_query: Query<&Children>,
highlight_sprite_query: Query<(), With<Sprite>>,
) {
let Ok(parent_id) = collider_query.get(event.entity) else {
return;
};
let Ok(children) = parent_query.get(parent_id.0) else {
return;
};
for child in children {
if highlight_sprite_query.get(*child).is_ok() {
commands.entity(*child).insert(Visibility::Hidden);
break;
}
}
commands.entity(parent_id.0).insert(Visibility::Visible);
}
pub fn container_bundle(
textures: &Res<LayoutTextures>,
position: Vec2,
@ -48,14 +96,16 @@ pub fn container_bundle(
sprite,
Inventory::new(inventory_size),
Children::spawn((
Spawn((
SpawnWith(|parent: &mut RelatedSpawner<ChildOf>| {
parent.spawn((
Collider::cuboid(meters(1.), meters(1.)),
Sensor,
Transform::from_xyz(0., meters(0.5), 0.),
)),
)).observe(on_container_collision_started)
.observe(on_container_collision_stopped);
}),
Spawn((
highlight_sprite,
Transform::from_xyz(0., 0., 1.),
Visibility::Hidden,
)),
)),

View file

@ -1,8 +1,8 @@
use bevy::prelude::*;
use bevy::{ecs::relationship::RelatedSpawner, prelude::*};
use bevy_light_2d::prelude::*;
use bevy_rapier2d::prelude::*;
use crate::meters;
use crate::{layout::lock::Padlock, meters};
use super::*;
@ -61,7 +61,8 @@ pub fn on_door_interact(
},
Err(_) => continue,
}
let texture = if maybe_door_collider.is_none() { &textures.door_closed } else { &textures.door_opened };
let texture = if maybe_door_collider.is_none() { &textures.door_closed }
else { &textures.door_opened };
let (sprite, highlight_sprite) = door_sprites(texture, door.is_facing_left());
let needed_sprite = if highlight_query.get(*child).is_err() { sprite } else { highlight_sprite };
commands.entity(*child).insert(needed_sprite);
@ -74,6 +75,56 @@ pub fn on_door_interact(
}
}
pub fn on_door_collision_started(
event: On<CollisionStartedEvent>,
mut commands: Commands,
collider_query: Query<&ChildOf, With<Collider>>,
parent_query: Query<&Children>,
sprite_query: Query<(), (With<Sprite>, Without<Padlock>)>,
highlight_query: Query<(), With<DoorHighlight>>,
) {
let Ok(parent_id) = collider_query.get(event.entity) else {
return;
};
let Ok(children) = parent_query.get(parent_id.0) else {
return;
};
for child in children {
if sprite_query.get(*child).is_ok() {
match highlight_query.get(*child) {
Ok(_) => { commands.entity(*child).insert(Visibility::Visible); },
Err(_) => { commands.entity(*child).insert(Visibility::Hidden); },
}
}
}
}
pub fn on_door_collision_stopped(
event: On<CollisionStoppedEvent>,
mut commands: Commands,
collider_query: Query<&ChildOf, With<Collider>>,
parent_query: Query<&Children>,
sprite_query: Query<(), (With<Sprite>, Without<Padlock>)>,
highlight_query: Query<(), With<DoorHighlight>>,
) {
let Ok(parent_id) = collider_query.get(event.entity) else {
return;
};
let Ok(children) = parent_query.get(parent_id.0) else {
return;
};
for child in children {
if sprite_query.get(*child).is_ok() {
match highlight_query.get(*child) {
Ok(_) => { commands.entity(*child).insert(Visibility::Hidden); },
Err(_) => { commands.entity(*child).insert(Visibility::Visible); },
}
}
}
}
pub fn door_collider_bundle() -> impl Bundle {
let size = vec2(meters(0.06125), meters(1.));
(
@ -103,22 +154,22 @@ pub fn door_bundle(textures: &Res<LayoutTextures>, position: Vec2, facing_left:
Transform::from_xyz(position.x, position.y, 0.),
Name::new(format!("Door ({}, {})", position.x, position.y)),
InheritedVisibility::VISIBLE,
children![
door_collider_bundle(),
(
Children::spawn((
Spawn(door_collider_bundle()),
SpawnWith(|parent: &mut RelatedSpawner<ChildOf>| {
parent.spawn((
Collider::cuboid(meters(0.5), meters(1.)),
Transform::default(),
Sensor,
),
(
sprite,
),
(
)).observe(on_door_collision_started)
.observe(on_door_collision_stopped);
}),
Spawn(sprite),
Spawn((
highlight_sprite,
Transform::from_xyz(0., 0., 1.),
DoorHighlight,
Visibility::Hidden,
)
],
)),
)),
)
}

View file

@ -1,4 +1,4 @@
use bevy::prelude::*;
use bevy::{ecs::relationship::RelatedSpawner, prelude::*};
use bevy_rapier2d::prelude::*;
use crate::{
@ -49,6 +49,54 @@ pub fn on_padlock_interaction(
commands.entity(lockpick_id).despawn();
}
pub fn on_padlock_collision_started(
event: On<CollisionStartedEvent>,
mut commands: Commands,
collider_query: Query<&ChildOf, With<Collider>>,
parent_query: Query<&Children>,
highlight_sprite_query: Query<(), With<Sprite>>,
) {
let Ok(parent_id) = collider_query.get(event.entity) else {
return;
};
let Ok(children) = parent_query.get(parent_id.0) else {
return;
};
for child in children {
if highlight_sprite_query.get(*child).is_ok() {
commands.entity(*child).insert(Visibility::Visible);
break;
}
}
commands.entity(parent_id.0).insert(Visibility::Hidden);
}
pub fn on_padlock_collision_stopped(
event: On<CollisionStoppedEvent>,
mut commands: Commands,
collider_query: Query<&ChildOf, With<Collider>>,
parent_query: Query<&Children>,
highlight_sprite_query: Query<(), With<Sprite>>,
) {
let Ok(parent_id) = collider_query.get(event.entity) else {
return;
};
let Ok(children) = parent_query.get(parent_id.0) else {
return;
};
for child in children {
if highlight_sprite_query.get(*child).is_ok() {
commands.entity(*child).insert(Visibility::Hidden);
break;
}
}
commands.entity(parent_id.0).insert(Visibility::Visible);
}
pub fn padlock_bundle(textures: &Res<LayoutTextures>, facing_left: bool) -> impl Bundle {
let sign = if facing_left { -1. } else { 1. };
(
@ -57,14 +105,17 @@ pub fn padlock_bundle(textures: &Res<LayoutTextures>, facing_left: bool) -> impl
flip_x: !facing_left,
..textures.lock.sprite("main")
},
Transform::from_xyz(meters(sign * 0.125), meters(0.), 0.),
Transform::from_xyz(meters(sign * 0.125), meters(0.), 1.),
InheritedVisibility::VISIBLE,
Children::spawn((
Spawn((
SpawnWith(move |parent: &mut RelatedSpawner<ChildOf>| {
parent.spawn((
Transform::from_xyz(meters(sign * 0.1875), 0., 0.),
Collider::cuboid(meters(0.1875), meters(1.)),
Sensor,
)),
)).observe(on_padlock_collision_started)
.observe(on_padlock_collision_stopped);
}),
Spawn((
Sprite {
flip_x: !facing_left,

View file

@ -35,6 +35,21 @@ pub struct LevelAssetHandle(Handle<asset::structs::LevelAsset>);
#[derive(EntityEvent, Reflect, Clone, Copy, PartialEq, Eq, Debug)]
#[reflect(Event, Debug, PartialEq, Clone)]
pub struct InteractionEvent {
#[event_target]
pub entity: Entity,
}
#[derive(EntityEvent, Reflect, Clone, Copy, PartialEq, Eq, Debug)]
#[reflect(Event, Debug, PartialEq, Clone)]
pub struct CollisionStartedEvent {
#[event_target]
pub entity: Entity,
}
#[derive(EntityEvent, Reflect, Clone, Copy, PartialEq, Eq, Debug)]
#[reflect(Event, Debug, PartialEq, Clone)]
pub struct CollisionStoppedEvent {
#[event_target]
pub entity: Entity,
}

View file

@ -60,6 +60,40 @@ pub fn on_stairs_interact(
player_transform.translation.y += offset.y;
}
pub fn on_stairs_collision_started(
event: On<CollisionStartedEvent>,
mut commands: Commands,
collider_query: Query<&Children, With<StairCollider>>,
sprite_query: Query<(), With<Sprite>>,
) {
let Ok(children) = collider_query.get(event.entity) else {
return;
};
for child in children {
if sprite_query.get(*child).is_ok() {
commands.entity(*child).insert(Visibility::Visible);
}
}
}
pub fn on_stairs_collision_stopped(
event: On<CollisionStoppedEvent>,
mut commands: Commands,
collider_query: Query<&Children, With<StairCollider>>,
sprite_query: Query<(), With<Sprite>>,
) {
let Ok(children) = collider_query.get(event.entity) else {
return;
};
for child in children {
if sprite_query.get(*child).is_ok() {
commands.entity(*child).insert(Visibility::Hidden);
}
}
}
pub fn stairs_bundle(
textures: &Res<LayoutTextures>,
position: Vec2,
@ -89,10 +123,11 @@ pub fn stairs_bundle(
Visibility::Hidden,
)),
),
));
)).observe(on_stairs_collision_started)
.observe(on_stairs_collision_stopped);
parent.spawn((
stairs.sprite("main"),
Transform::from_xyz(0., meters(1.5), 0.),
Transform::from_xyz(0., meters(1.5), -1.),
));
}
@ -104,11 +139,15 @@ pub fn stairs_bundle(
InheritedVisibility::VISIBLE,
Children::spawn(
Spawn((
stairs.sprite("down"),
Sprite {
flip_y: true,
..stairs.sprite("up")
},
Visibility::Hidden,
)),
),
));
)).observe(on_stairs_collision_started)
.observe(on_stairs_collision_stopped);
}
}),
)),

View file

@ -54,19 +54,35 @@ pub fn detect_interact_collisions(
for collision_event in collision_events.read() {
match collision_event {
CollisionEvent::Started(first, second, _) => {
if let Some(interactive_id) = interact_collisions_inner(*first, *second, interactive_query1, player_query, parent_query) {
if let Some(interactive_id) = interact_collisions_inner(
*first,
*second,
interactive_query1,
player_query,
parent_query
) {
commands.entity(interactive_id).insert(MayInteract);
commands.trigger(CollisionStartedEvent { entity: *first });
}
if let Some(interactive_id) = interact_collisions_inner(*second, *first, interactive_query1, player_query, parent_query) {
if let Some(interactive_id) = interact_collisions_inner(
*second,
*first,
interactive_query1,
player_query,
parent_query
) {
commands.entity(interactive_id).insert(MayInteract);
commands.trigger(CollisionStartedEvent { entity: *second });
}
},
CollisionEvent::Stopped(first, second, _) => {
if let Some(interactive_id) = interact_collisions_inner(*first, *second, interactive_query2, player_query, parent_query) {
commands.entity(interactive_id).remove::<MayInteract>();
commands.trigger(CollisionStoppedEvent { entity: *first });
}
if let Some(interactive_id) = interact_collisions_inner(*second, *first, interactive_query2, player_query, parent_query) {
commands.entity(interactive_id).remove::<MayInteract>();
commands.trigger(CollisionStoppedEvent { entity: *second });
}
},
}
@ -166,13 +182,6 @@ pub fn load_layout_textures(
uvec2(72, 8),
)),
);
indices.insert(
"down".to_owned(),
atlas.add_texture(URect::from_corners(
uvec2(64, 8),
uvec2(72, 16),
)),
);
atlases.add(atlas)
};
textures.stairs = AtlasLayoutTexture::new(image, atlas, indices);

View file

@ -62,7 +62,7 @@ pub fn camera_bundle() -> impl Bundle {
..OrthographicProjection::default_2d()
}),
Light2d {
ambient_light: AmbientLight2d { brightness: 0.25, ..default() }
ambient_light: AmbientLight2d { brightness: 0.1, ..default() }
},
Name::new("Camera2d"),
)
@ -131,9 +131,6 @@ impl Plugin for ExpeditionPlugin {
.add_systems(OnEnter(GameState::Inventory), ui::inventory::systems::setup_ui_inventory)
.add_systems(OnExit(GameState::Inventory), ui::inventory::systems::clear_ui_inventory)
.add_observer(ui::inventory::observers::on_ui_rotate)
.add_observer(layout::container::on_container_interact)
.add_observer(layout::door::on_door_interact)
.add_observer(layout::lock::on_padlock_interaction)
.add_observer(layout::stairs::on_stairs_interact);
}
}

View file

@ -1,4 +1,5 @@
use bevy::prelude::*;
use bevy_light_2d::prelude::*;
use bevy_rapier2d::prelude::*;
use crate::{
@ -39,6 +40,19 @@ pub fn player_bundle(asset_server: &Res<AssetServer>, position: Vec2) -> impl Bu
Inventory::new(UVec2::new(5, 3)),
Inventory::new(UVec2::new(4, 4)),
camera_bundle(),
(
SpotLight2d {
color: Color::linear_rgb(0.98, 0.98, 0.824),
intensity: 1.,
radius: meters(6.),
source_width: 0.,
cast_shadows: true,
falloff: meters(8.),
direction: 0.,
..default()
},
Transform::from_xyz(meters(0.1875), meters(0.625), 0.),
)
],
)
}

View file

@ -17,14 +17,21 @@ pub fn handle_input(
state: Res<State<GameState>>,
mut next_state: ResMut<NextState<GameState>>,
interactables: Query<Entity, With<MayInteract>>,
mut player: Query<(&Player, &mut ActionState<Action>, &mut KinematicCharacterController, &mut Sprite)>,
mut player: Query<(
&Player,
&mut ActionState<Action>,
&mut KinematicCharacterController,
&mut Sprite,
&Children
)>,
mut player_light: Query<(&mut SpotLight2d, &mut Transform)>,
) {
let Ok(player) = player.single_mut() else {
return;
};
match state.get() {
GameState::Running => {
let (Player {speed}, mut action_state, mut controller, mut sprite) = player;
let (Player {speed}, mut action_state, mut controller, mut sprite, children) = player;
if action_state.just_released(&Action::ToggleInventory) {
next_state.set(GameState::Inventory);
@ -35,7 +42,18 @@ pub fn handle_input(
controller.translation = Some(vec2(direction * speed * time.delta_secs(), 0.));
if direction != 0f32 {
sprite.flip_x = direction < 0f32;
let facing_left = direction < 0f32;
sprite.flip_x = facing_left;
for child in children {
if let Ok((mut spotlight, mut transform)) = player_light.get_mut(*child) {
spotlight.direction = if facing_left { 180. } else { 0. };
let last_x = transform.translation.x;
if last_x < 0. && !facing_left || last_x > 0. && facing_left {
transform.translation.x = -transform.translation.x;
}
break;
}
}
}
if action_state.just_released(&Action::Interact) {
@ -46,7 +64,7 @@ pub fn handle_input(
}
},
GameState::Inventory => {
let (_, mut action_state, _, _) = player;
let (_, mut action_state, _, _, _) = player;
if action_state.just_released(&Action::ToggleInventory)
|| action_state.just_released(&Action::Interact) {
next_state.set(GameState::Running);