Skip to content

Commit

Permalink
Rework interaction opportunities based on The Outer Worlds
Browse files Browse the repository at this point in the history
  • Loading branch information
janhohenheim committed Sep 15, 2024
1 parent f34c6df commit 55219d4
Show file tree
Hide file tree
Showing 6 changed files with 119 additions and 177 deletions.
85 changes: 27 additions & 58 deletions assets/registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,27 +52,6 @@
"type": "array",
"typeInfo": "Tuple"
},
"(foxtrot::opportunities::available_opportunities::OpportunitySensor, foxtrot::opportunities::available_opportunities::AvailableOpportunities)": {
"isComponent": false,
"isResource": false,
"items": false,
"long_name": "(foxtrot::opportunities::available_opportunities::OpportunitySensor, foxtrot::opportunities::available_opportunities::AvailableOpportunities)",
"prefixItems": [
{
"type": {
"$ref": "#/$defs/foxtrot::opportunities::available_opportunities::OpportunitySensor"
}
},
{
"type": {
"$ref": "#/$defs/foxtrot::opportunities::available_opportunities::AvailableOpportunities"
}
}
],
"short_name": "(OpportunitySensor, AvailableOpportunities)",
"type": "array",
"typeInfo": "Tuple"
},
"(u8, u8)": {
"isComponent": false,
"isResource": false,
Expand Down Expand Up @@ -18325,26 +18304,41 @@
"type": "string",
"typeInfo": "Enum"
},
"foxtrot::opportunities::available_opportunities::AvailableOpportunities": {
"foxtrot::opportunities::available_opportunities::PlayerInteractable": {
"additionalProperties": false,
"isComponent": true,
"isResource": false,
"items": false,
"long_name": "foxtrot::opportunities::available_opportunities::AvailableOpportunities",
"prefixItems": [
{
"long_name": "foxtrot::opportunities::available_opportunities::PlayerInteractable",
"properties": {
"interaction": {
"type": {
"$ref": "#/$defs/bevy_utils::hashbrown::HashSet<bevy_ecs::entity::Entity, bevy_utils::hashbrown::hash_map::DefaultHashBuilder>"
"$ref": "#/$defs/foxtrot::opportunities::available_opportunities::PlayerInteraction"
}
},
"max_distance": {
"type": {
"$ref": "#/$defs/f32"
}
},
"prompt": {
"type": {
"$ref": "#/$defs/alloc::string::String"
}
}
},
"required": [
"prompt",
"interaction",
"max_distance"
],
"short_name": "AvailableOpportunities",
"type": "array",
"typeInfo": "TupleStruct"
"short_name": "PlayerInteractable",
"type": "object",
"typeInfo": "Struct"
},
"foxtrot::opportunities::available_opportunities::Opportunity": {
"foxtrot::opportunities::available_opportunities::PlayerInteraction": {
"isComponent": true,
"isResource": false,
"long_name": "foxtrot::opportunities::available_opportunities::Opportunity",
"long_name": "foxtrot::opportunities::available_opportunities::PlayerInteraction",
"oneOf": [
{
"items": false,
Expand All @@ -18361,35 +18355,10 @@
"typeInfo": "Tuple"
}
],
"short_name": "Opportunity",
"short_name": "PlayerInteraction",
"type": "object",
"typeInfo": "Enum"
},
"foxtrot::opportunities::available_opportunities::OpportunitySensor": {
"additionalProperties": false,
"isComponent": true,
"isResource": false,
"long_name": "foxtrot::opportunities::available_opportunities::OpportunitySensor",
"properties": {
"opportunity": {
"type": {
"$ref": "#/$defs/foxtrot::opportunities::available_opportunities::Opportunity"
}
},
"prompt": {
"type": {
"$ref": "#/$defs/alloc::string::String"
}
}
},
"required": [
"prompt",
"opportunity"
],
"short_name": "OpportunitySensor",
"type": "object",
"typeInfo": "Struct"
},
"foxtrot::player::Player": {
"additionalProperties": false,
"isComponent": true,
Expand Down
118 changes: 43 additions & 75 deletions src/opportunities/available_opportunities.rs
Original file line number Diff line number Diff line change
@@ -1,117 +1,85 @@
use crate::{
collision_layer::CollisionLayer,
dialog::conditions::dialog_running,
player::{camera::PlayerCamera, Player},
};
use avian3d::prelude::*;
use bevy::{prelude::*, utils::HashSet};
use bevy::prelude::*;

use super::OpportunitySystem;

pub(super) fn plugin(app: &mut App) {
app.add_systems(
Update,
update_available_opportunities.in_set(OpportunitySystem::UpdateAvailableOpportunities),
update_available_opportunities
.run_if(not(dialog_running))
.in_set(OpportunitySystem::UpdateAvailableOpportunities),
);
app.register_type::<(OpportunitySensor, AvailableOpportunities)>();
app.register_type::<PlayerInteractable>();
}

#[derive(Debug, Component, PartialEq, Eq, Clone, Default, Deref, DerefMut, Reflect)]
#[reflect(Component, PartialEq, Default)]
pub struct ActiveInteractable(pub Option<Entity>);

/// The general idea is as follows:
/// This sensor is on a collider that is bigger than the object that can be interacted with.
/// When the player stands inside this sensor, we check if a raycast from the camera's forward
/// direction hits the underlying interactable object.
/// Said interactable object is assumed to be the parent of the sensor.
/// For example, a door would have a physics collider, probably a RigidBody::Static.
/// It also has a sensor as a child, with a bigger collider. When the player stands in the
/// sensor, we check if the player has a line of sight to the physical door.
/// This component sits on a collider for an interactable object, e.g. a door or a character.
/// Every update, we send a raycast from the camera's forward direction to see if it hits a
/// [`PotentialOpportunity`] collider.
/// If so, we have an interaction opportunity.
#[derive(Debug, Component, PartialEq, Eq, Clone, Reflect)]
#[derive(Debug, Component, PartialEq, Clone, Reflect)]
#[reflect(Component, PartialEq)]
pub struct OpportunitySensor {
pub struct PlayerInteractable {
/// The prompt to display when the opportunity is available.
pub prompt: String,
/// The opportunity to activate when the player chooses to interact after the prompt is shown.
pub opportunity: Opportunity,
pub interaction: PlayerInteraction,
/// The maximum distance from the camera at which the opportunity can be interacted with.
pub max_distance: f32,
}

/// An interaction opportunity stored in an [`OpportunitySensor`]
#[derive(Debug, Clone, Component, PartialEq, Eq, Reflect)]
#[reflect(Component, PartialEq)]
pub enum Opportunity {
pub enum PlayerInteraction {
/// A dialog opportunity with a Yarn Spinner dialogue node.
Dialog(String),
}

/// A set of available opportunities. These are interaction opportunities that have already been
/// validated, i.e. the can interact with them *now* if they choose to.
/// The entities point to the respective [`OpportunitySensor`] holders.
#[derive(Debug, Component, PartialEq, Eq, Clone, Deref, DerefMut, Default, Reflect)]
#[reflect(Component, Default, PartialEq)]
pub struct AvailableOpportunities(HashSet<Entity>);

impl AvailableOpportunities {
pub fn pick_one(&self) -> Option<Entity> {
// We could use a variety of strategies to choose the best opportunity,
// such as prefer talking over interacting with objects.
// Let's just use the first available opportunity for now.
// Note that since `HashSet`s have no intrinsic ordering,
// the chosen opportunity is arbitrary, but consistent until the set changes.
self.iter().next().copied()
}
}

fn update_available_opportunities(
q_dialog_sensor: Query<
(Entity, &Parent, &CollidingEntities),
(With<OpportunitySensor>, Changed<CollidingEntities>),
>,
mut q_player: Query<(Entity, &mut AvailableOpportunities), With<Player>>,
q_interactable: Query<&PlayerInteractable>,
mut q_player: Query<(Entity, &mut ActiveInteractable), With<Player>>,
q_camera: Query<&Transform, With<PlayerCamera>>,
spatial_query: SpatialQuery,
) {
let Ok((player_entity, mut opportunities)) = q_player.get_single_mut() else {
let Ok((player_entity, mut active_interactable)) = q_player.get_single_mut() else {
return;
};
let Ok(camera_transform) = q_camera.get_single() else {
return;
};
for (sensor, parent, colliding_entities) in &q_dialog_sensor {
if !colliding_entities.contains(&player_entity) {
if opportunities.contains(&sensor) {
// This used to be an opportunity, but the player has left the sensor.
opportunities.remove(&sensor);
}
continue;
};
let underlying_entity = parent.get();

let origin = camera_transform.translation;
let direction = camera_transform.forward();
// Not relevant because we are already inside the sensor,
// i.e. close enough to interact with the object if nothing is in the way.
let max_distance = f32::INFINITY;
// Little bit more efficient than `false`, as we don't care about the actual hit result,
// only if we hit anything at all.
let solid = true;
let query_filter = SpatialQueryFilter::from_mask([
CollisionLayer::Character,
CollisionLayer::Prop,
CollisionLayer::Terrain,
])
.with_excluded_entities([player_entity]);
let origin = camera_transform.translation;
let direction = camera_transform.forward();
// Not relevant because we the maximum distance is determined by the object hit by the raycast.
let max_distance = f32::INFINITY;
// Little bit more efficient than `false`, as we don't care about the actual hit result,
// only if we hit anything at all.
let solid = true;
// Layers that either contain interactable objects or those able to block line of sight with interactable objects.
let query_filter = SpatialQueryFilter::from_mask([
CollisionLayer::Character,
CollisionLayer::Prop,
CollisionLayer::Terrain,
])
.with_excluded_entities([player_entity]);

let hit = spatial_query.cast_ray(origin, direction, max_distance, solid, &query_filter);
let interactable = spatial_query
.cast_ray(origin, direction, max_distance, solid, &query_filter)
.map(|hit| hit.entity)
.filter(|entity| q_interactable.contains(*entity));
let new_interactable = ActiveInteractable(interactable);

let has_line_of_sight = hit.is_some_and(|hit| hit.entity == underlying_entity);
if !has_line_of_sight {
if opportunities.contains(&sensor) {
// This used to be an opportunity, but the player does not have a line of sight to the underlying object anymore.
opportunities.remove(&sensor);
}
continue;
}
if !opportunities.contains(&sensor) {
// This is a new opportunity.
opportunities.insert(sensor);
}
if active_interactable.as_ref() != &new_interactable {
*active_interactable = new_interactable;
}
}
23 changes: 11 additions & 12 deletions src/opportunities/interact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use leafwing_input_manager::prelude::*;
use crate::{character::action::CharacterAction, dialog::StartDialog, player::Player};

use super::{
available_opportunities::{AvailableOpportunities, Opportunity, OpportunitySensor},
available_opportunities::{ActiveInteractable, PlayerInteractable, PlayerInteraction},
OpportunitySystem,
};

Expand All @@ -16,24 +16,23 @@ pub(super) fn plugin(app: &mut App) {
/// Triggers dialog opportunity events when the player has an interaction opportunity and presses
/// the interact button. The target entity is the entity that has the [`RigidBody`] component.
fn usher_interact(
mut q_player: Query<(&ActionState<CharacterAction>, &mut AvailableOpportunities), With<Player>>,
q_opportunity_sensor: Query<(&OpportunitySensor, &ColliderParent)>,
mut q_player: Query<(&ActionState<CharacterAction>, &mut ActiveInteractable), With<Player>>,
q_interactable: Query<(&PlayerInteractable, &ColliderParent)>,
mut commands: Commands,
) {
for (action_state, mut opportunities) in &mut q_player {
for (action_state, mut active_interactable) in &mut q_player {
if !action_state.just_pressed(&CharacterAction::Interact) {
continue;
}
let Some(opportunity) = opportunities.pick_one() else {
let Some((interactable, rigid_body)) = active_interactable
// Clear the current interactable so that it won't show up if we end up in a dialog
.take()
.and_then(|e| q_interactable.get(e).ok())
else {
continue;
};
let Ok((sensor, rigid_body)) = q_opportunity_sensor.get(opportunity) else {
// Looks like the opportunity despawned.
opportunities.remove(&opportunity);
continue;
};
match &sensor.opportunity {
Opportunity::Dialog(node) => {
match &interactable.interaction {
PlayerInteraction::Dialog(node) => {
commands.trigger_targets(StartDialog(node.clone()), rigid_body.get());
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/opportunities/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::system_set::VariableGameSystem;
use bevy::prelude::*;

mod available_opportunities;
pub mod available_opportunities;
mod interact;
mod prompt;

Expand Down
Loading

0 comments on commit 55219d4

Please sign in to comment.