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() { for DoorData { pos, facing_left, lock } in level.interactive.doors.iter() {
let door_pos = vec2(meters(pos.x + 0.5), meters(pos.y)); 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 { 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 pos = vec2(meters(pos.x), meters(pos.y));
let mut container = parent.spawn(container_bundle(&textures, pos, *size)); let mut container = parent.spawn(container_bundle(&textures, pos, *size));
container.observe(super::container::on_container_interact);
for item in items { for item in items {
// TODO: replace with proper item-by-id system // 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 bevy_rapier2d::prelude::*;
use crate::{ use crate::{
@ -35,6 +35,54 @@ pub fn on_container_interact(
next_state.set(GameState::Inventory); 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( pub fn container_bundle(
textures: &Res<LayoutTextures>, textures: &Res<LayoutTextures>,
position: Vec2, position: Vec2,
@ -48,14 +96,16 @@ pub fn container_bundle(
sprite, sprite,
Inventory::new(inventory_size), Inventory::new(inventory_size),
Children::spawn(( Children::spawn((
Spawn(( SpawnWith(|parent: &mut RelatedSpawner<ChildOf>| {
parent.spawn((
Collider::cuboid(meters(1.), meters(1.)), Collider::cuboid(meters(1.), meters(1.)),
Sensor, Sensor,
Transform::from_xyz(0., meters(0.5), 0.), Transform::from_xyz(0., meters(0.5), 0.),
)), )).observe(on_container_collision_started)
.observe(on_container_collision_stopped);
}),
Spawn(( Spawn((
highlight_sprite, highlight_sprite,
Transform::from_xyz(0., 0., 1.),
Visibility::Hidden, Visibility::Hidden,
)), )),
)), )),

View file

@ -1,8 +1,8 @@
use bevy::prelude::*; use bevy::{ecs::relationship::RelatedSpawner, prelude::*};
use bevy_light_2d::prelude::*; use bevy_light_2d::prelude::*;
use bevy_rapier2d::prelude::*; use bevy_rapier2d::prelude::*;
use crate::meters; use crate::{layout::lock::Padlock, meters};
use super::*; use super::*;
@ -61,7 +61,8 @@ pub fn on_door_interact(
}, },
Err(_) => continue, 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 (sprite, highlight_sprite) = door_sprites(texture, door.is_facing_left());
let needed_sprite = if highlight_query.get(*child).is_err() { sprite } else { highlight_sprite }; let needed_sprite = if highlight_query.get(*child).is_err() { sprite } else { highlight_sprite };
commands.entity(*child).insert(needed_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 { pub fn door_collider_bundle() -> impl Bundle {
let size = vec2(meters(0.06125), meters(1.)); 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.), Transform::from_xyz(position.x, position.y, 0.),
Name::new(format!("Door ({}, {})", position.x, position.y)), Name::new(format!("Door ({}, {})", position.x, position.y)),
InheritedVisibility::VISIBLE, InheritedVisibility::VISIBLE,
children![ Children::spawn((
door_collider_bundle(), Spawn(door_collider_bundle()),
( SpawnWith(|parent: &mut RelatedSpawner<ChildOf>| {
parent.spawn((
Collider::cuboid(meters(0.5), meters(1.)), Collider::cuboid(meters(0.5), meters(1.)),
Transform::default(), Transform::default(),
Sensor, Sensor,
), )).observe(on_door_collision_started)
( .observe(on_door_collision_stopped);
sprite, }),
), Spawn(sprite),
( Spawn((
highlight_sprite, highlight_sprite,
Transform::from_xyz(0., 0., 1.),
DoorHighlight, DoorHighlight,
Visibility::Hidden, Visibility::Hidden,
) )),
], )),
) )
} }

View file

@ -1,4 +1,4 @@
use bevy::prelude::*; use bevy::{ecs::relationship::RelatedSpawner, prelude::*};
use bevy_rapier2d::prelude::*; use bevy_rapier2d::prelude::*;
use crate::{ use crate::{
@ -49,6 +49,54 @@ pub fn on_padlock_interaction(
commands.entity(lockpick_id).despawn(); 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 { pub fn padlock_bundle(textures: &Res<LayoutTextures>, facing_left: bool) -> impl Bundle {
let sign = if facing_left { -1. } else { 1. }; 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, flip_x: !facing_left,
..textures.lock.sprite("main") ..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, InheritedVisibility::VISIBLE,
Children::spawn(( Children::spawn((
Spawn(( SpawnWith(move |parent: &mut RelatedSpawner<ChildOf>| {
parent.spawn((
Transform::from_xyz(meters(sign * 0.1875), 0., 0.), Transform::from_xyz(meters(sign * 0.1875), 0., 0.),
Collider::cuboid(meters(0.1875), meters(1.)), Collider::cuboid(meters(0.1875), meters(1.)),
Sensor, Sensor,
)), )).observe(on_padlock_collision_started)
.observe(on_padlock_collision_stopped);
}),
Spawn(( Spawn((
Sprite { Sprite {
flip_x: !facing_left, 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)] #[derive(EntityEvent, Reflect, Clone, Copy, PartialEq, Eq, Debug)]
#[reflect(Event, Debug, PartialEq, Clone)] #[reflect(Event, Debug, PartialEq, Clone)]
pub struct InteractionEvent { 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, pub entity: Entity,
} }

View file

@ -60,6 +60,40 @@ pub fn on_stairs_interact(
player_transform.translation.y += offset.y; 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( pub fn stairs_bundle(
textures: &Res<LayoutTextures>, textures: &Res<LayoutTextures>,
position: Vec2, position: Vec2,
@ -89,10 +123,11 @@ pub fn stairs_bundle(
Visibility::Hidden, Visibility::Hidden,
)), )),
), ),
)); )).observe(on_stairs_collision_started)
.observe(on_stairs_collision_stopped);
parent.spawn(( parent.spawn((
stairs.sprite("main"), 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, InheritedVisibility::VISIBLE,
Children::spawn( Children::spawn(
Spawn(( Spawn((
stairs.sprite("down"), Sprite {
flip_y: true,
..stairs.sprite("up")
},
Visibility::Hidden, 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() { for collision_event in collision_events.read() {
match collision_event { match collision_event {
CollisionEvent::Started(first, second, _) => { 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.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.entity(interactive_id).insert(MayInteract);
commands.trigger(CollisionStartedEvent { entity: *second });
} }
}, },
CollisionEvent::Stopped(first, second, _) => { CollisionEvent::Stopped(first, second, _) => {
if let Some(interactive_id) = interact_collisions_inner(*first, *second, interactive_query2, player_query, parent_query) { if let Some(interactive_id) = interact_collisions_inner(*first, *second, interactive_query2, player_query, parent_query) {
commands.entity(interactive_id).remove::<MayInteract>(); 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) { if let Some(interactive_id) = interact_collisions_inner(*second, *first, interactive_query2, player_query, parent_query) {
commands.entity(interactive_id).remove::<MayInteract>(); commands.entity(interactive_id).remove::<MayInteract>();
commands.trigger(CollisionStoppedEvent { entity: *second });
} }
}, },
} }
@ -166,13 +182,6 @@ pub fn load_layout_textures(
uvec2(72, 8), uvec2(72, 8),
)), )),
); );
indices.insert(
"down".to_owned(),
atlas.add_texture(URect::from_corners(
uvec2(64, 8),
uvec2(72, 16),
)),
);
atlases.add(atlas) atlases.add(atlas)
}; };
textures.stairs = AtlasLayoutTexture::new(image, atlas, indices); textures.stairs = AtlasLayoutTexture::new(image, atlas, indices);

View file

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

View file

@ -1,4 +1,5 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy_light_2d::prelude::*;
use bevy_rapier2d::prelude::*; use bevy_rapier2d::prelude::*;
use crate::{ 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(5, 3)),
Inventory::new(UVec2::new(4, 4)), Inventory::new(UVec2::new(4, 4)),
camera_bundle(), 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>>, state: Res<State<GameState>>,
mut next_state: ResMut<NextState<GameState>>, mut next_state: ResMut<NextState<GameState>>,
interactables: Query<Entity, With<MayInteract>>, 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 { let Ok(player) = player.single_mut() else {
return; return;
}; };
match state.get() { match state.get() {
GameState::Running => { 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) { if action_state.just_released(&Action::ToggleInventory) {
next_state.set(GameState::Inventory); next_state.set(GameState::Inventory);
@ -35,7 +42,18 @@ pub fn handle_input(
controller.translation = Some(vec2(direction * speed * time.delta_secs(), 0.)); controller.translation = Some(vec2(direction * speed * time.delta_secs(), 0.));
if direction != 0f32 { 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) { if action_state.just_released(&Action::Interact) {
@ -46,7 +64,7 @@ pub fn handle_input(
} }
}, },
GameState::Inventory => { GameState::Inventory => {
let (_, mut action_state, _, _) = player; let (_, mut action_state, _, _, _) = player;
if action_state.just_released(&Action::ToggleInventory) if action_state.just_released(&Action::ToggleInventory)
|| action_state.just_released(&Action::Interact) { || action_state.just_released(&Action::Interact) {
next_state.set(GameState::Running); next_state.set(GameState::Running);