From 55219d4375862a1bfb68ea42dc83d4d37844acd6 Mon Sep 17 00:00:00 2001 From: Jan Hohenheim Date: Sun, 15 Sep 2024 18:37:55 +0200 Subject: [PATCH] Rework interaction opportunities based on The Outer Worlds --- assets/registry.json | 85 +++++-------- src/opportunities/available_opportunities.rs | 118 +++++++------------ src/opportunities/interact.rs | 23 ++-- src/opportunities/mod.rs | 2 +- src/opportunities/prompt.rs | 62 +++++----- src/player/initialize.rs | 6 +- 6 files changed, 119 insertions(+), 177 deletions(-) diff --git a/assets/registry.json b/assets/registry.json index ca82b7e5..d93b4584 100644 --- a/assets/registry.json +++ b/assets/registry.json @@ -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, @@ -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" + "$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, @@ -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, diff --git a/src/opportunities/available_opportunities.rs b/src/opportunities/available_opportunities.rs index 3ad3dbf0..f6c6fe9d 100644 --- a/src/opportunities/available_opportunities.rs +++ b/src/opportunities/available_opportunities.rs @@ -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::(); } +#[derive(Debug, Component, PartialEq, Eq, Clone, Default, Deref, DerefMut, Reflect)] +#[reflect(Component, PartialEq, Default)] +pub struct ActiveInteractable(pub Option); + /// 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); - -impl AvailableOpportunities { - pub fn pick_one(&self) -> Option { - // 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, Changed), - >, - mut q_player: Query<(Entity, &mut AvailableOpportunities), With>, + q_interactable: Query<&PlayerInteractable>, + mut q_player: Query<(Entity, &mut ActiveInteractable), With>, q_camera: Query<&Transform, With>, 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; } } diff --git a/src/opportunities/interact.rs b/src/opportunities/interact.rs index 5fb27d32..baf6ae0f 100644 --- a/src/opportunities/interact.rs +++ b/src/opportunities/interact.rs @@ -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, }; @@ -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, &mut AvailableOpportunities), With>, - q_opportunity_sensor: Query<(&OpportunitySensor, &ColliderParent)>, + mut q_player: Query<(&ActionState, &mut ActiveInteractable), With>, + 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()); } } diff --git a/src/opportunities/mod.rs b/src/opportunities/mod.rs index be633a74..d8693017 100644 --- a/src/opportunities/mod.rs +++ b/src/opportunities/mod.rs @@ -1,7 +1,7 @@ use crate::system_set::VariableGameSystem; use bevy::prelude::*; -mod available_opportunities; +pub mod available_opportunities; mod interact; mod prompt; diff --git a/src/opportunities/prompt.rs b/src/opportunities/prompt.rs index 34b1fb76..421dae81 100644 --- a/src/opportunities/prompt.rs +++ b/src/opportunities/prompt.rs @@ -3,7 +3,7 @@ use bevy::ui::Val::*; use sickle_ui::{prelude::*, ui_commands::SetTextExt as _}; use super::{ - available_opportunities::{AvailableOpportunities, OpportunitySensor}, + available_opportunities::{ActiveInteractable, PlayerInteractable}, OpportunitySystem, }; @@ -13,34 +13,33 @@ pub(super) fn plugin(app: &mut App) { } fn show_prompt( - mut q_available_opportunities: Query< - &mut AvailableOpportunities, - Changed, - >, - q_opportunity_sensor: Query<&OpportunitySensor>, + q_active_interactable: Query<&ActiveInteractable, Changed>, + q_interactable: Query<&PlayerInteractable>, mut q_prompt_text_node: Query<&mut Text, With>, - mut q_prompt_ui_node: Query<&mut Visibility, With>, + mut q_prompt_visibility: Query<&mut Visibility, With>, ) { - for mut opportunities in &mut q_available_opportunities { - let Ok(mut visibility) = q_prompt_ui_node.get_single_mut() else { - continue; - }; - let Some(opportunity) = opportunities.pick_one() else { - *visibility = Visibility::Hidden; - continue; - }; - let Ok(sensor) = q_opportunity_sensor.get(opportunity) else { - // Looks like the opportunity despawned. - opportunities.remove(&opportunity); - *visibility = Visibility::Hidden; - continue; - }; - let Ok(mut text) = q_prompt_text_node.get_single_mut() else { - continue; - }; - text.sections[0].value = sensor.prompt.clone(); - *visibility = Visibility::Inherited; - } + let Ok(active_interactable) = q_active_interactable.get_single() else { + // Nothing changed + return; + }; + let Ok(mut prompt_visibility) = q_prompt_visibility.get_single_mut() else { + return; + }; + let Some(interactable) = active_interactable + .0 + .and_then(|entity| q_interactable.get(entity).ok()) + else { + // The previous interactable is no longer available. + // Note that we don't check against previous values for change detection + // because this system is only run when the active interactable changes in the first place. + *prompt_visibility = Visibility::Hidden; + return; + }; + let Ok(mut prompt_text_node) = q_prompt_text_node.get_single_mut() else { + return; + }; + + prompt_text_node.sections[0].value = interactable.prompt.clone(); } fn spawn_prompt(mut commands: Commands) { @@ -56,13 +55,16 @@ fn spawn_prompt(mut commands: Commands) { .style() .position_type(PositionType::Absolute) .bottom(Percent(1. / 3.)) - .height(Val::Auto); + .height(Val::Auto) + .visibility(Visibility::Hidden) + .entity_commands() + .insert(PromptUiRootNode); } #[derive(Debug, Component, PartialEq, Eq, Clone, Reflect)] #[reflect(Component, PartialEq)] -pub struct PromptUiNode; +pub struct PromptTextNode; #[derive(Debug, Component, PartialEq, Eq, Clone, Reflect)] #[reflect(Component, PartialEq)] -pub struct PromptTextNode; +pub struct PromptUiRootNode; diff --git a/src/player/initialize.rs b/src/player/initialize.rs index 0348e31d..e611759d 100644 --- a/src/player/initialize.rs +++ b/src/player/initialize.rs @@ -4,7 +4,10 @@ use bevy::{ }; use leafwing_input_manager::InputManagerBundle; -use crate::{asset_tracking::LoadResource as _, character::{action::CharacterAction, controller::OverrideForwardDirection}}; +use crate::{ + asset_tracking::LoadResource as _, + character::{action::CharacterAction, controller::OverrideForwardDirection}, opportunities::available_opportunities::ActiveInteractable, +}; use super::{camera::PlayerCamera, Player}; @@ -24,6 +27,7 @@ fn add_player_components( commands.entity(trigger.entity()).insert(( InputManagerBundle::with_map(CharacterAction::default_input_map()), OverrideForwardDirection(camera), + ActiveInteractable::default(), )); }