diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d59b08a17..ca830cfa94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Lamps, embers and campfires use glowing indices - Non-potion drinks no longer heal as much as potions. - Added SFX to the new sword abilities +- Fixed various issues with showing the correct text hint for interactable blocks. +- Intert entities like arrows no longer obstruct interacting with nearby entities/blocks. ## [0.14.0] - 2023-01-07 diff --git a/assets/voxygen/i18n/en/hud/misc.ftl b/assets/voxygen/i18n/en/hud/misc.ftl index 54c4a4612a..bf765653c9 100644 --- a/assets/voxygen/i18n/en/hud/misc.ftl +++ b/assets/voxygen/i18n/en/hud/misc.ftl @@ -45,6 +45,7 @@ hud-use = Use hud-unlock-requires = Open with { $item } hud-unlock-consumes = Use { $item } to open hud-mine = Mine +hud-needs_pickaxe = Needs Pickaxe hud-talk = Talk hud-trade = Trade hud-mount = Mount diff --git a/common/src/states/utils.rs b/common/src/states/utils.rs index 3ff9b0340b..4df839538b 100644 --- a/common/src/states/utils.rs +++ b/common/src/states/utils.rs @@ -17,9 +17,9 @@ use crate::{ event::{LocalEvent, ServerEvent}, outcome::Outcome, states::{behavior::JoinData, utils::CharacterState::Idle, *}, - terrain::{TerrainChunkSize, UnlockKind}, + terrain::{TerrainGrid, UnlockKind}, util::Dir, - vol::{ReadVol, RectVolSize}, + vol::ReadVol, }; use core::hash::BuildHasherDefault; use fxhash::FxHasher64; @@ -844,10 +844,7 @@ pub fn handle_manipulate_loadout( if close_to_sprite { // First, get sprite data for position, if there is a sprite use sprite_interact::SpriteInteractKind; - let sprite_chunk_pos = sprite_pos - .xy() - .map2(TerrainChunkSize::RECT_SIZE, |e, sz| e.rem_euclid(sz as i32)) - .with_z(sprite_pos.z); + let sprite_chunk_pos = TerrainGrid::chunk_offs(sprite_pos); let sprite_cfg = data .terrain .pos_chunk(sprite_pos) diff --git a/voxygen/src/hud/mod.rs b/voxygen/src/hud/mod.rs index 159fb4867c..935c222d0c 100644 --- a/voxygen/src/hud/mod.rs +++ b/voxygen/src/hud/mod.rs @@ -65,12 +65,9 @@ use crate::{ game_input::GameInput, hud::{img_ids::ImgsRot, prompt_dialog::DialogOutcomeEvent}, render::UiDrawer, - scene::{ - camera::{self, Camera}, - terrain::Interaction, - }, + scene::camera::{self, Camera}, session::{ - interactable::Interactable, + interactable::{BlockInteraction, Interactable}, settings_change::{ Audio, Chat as ChatChange, Interface as InterfaceChange, SettingsChange, }, @@ -673,6 +670,7 @@ pub struct DebugInfo { pub struct HudInfo { pub is_aiming: bool, + pub is_mining: bool, pub is_first_person: bool, pub viewpoint_entity: specs::Entity, pub mutable_viewpoint: bool, @@ -2019,7 +2017,10 @@ impl Hud { pickup_failed_pulse: self.failed_entity_pickups.get(&entity).cloned(), }, &self.fonts, - vec![(GameInput::Interact, i18n.get_msg("hud-pick_up").to_string())], + vec![( + Some(GameInput::Interact), + i18n.get_msg("hud-pick_up").to_string(), + )], ) .set(overitem_id, ui_widgets); } @@ -2039,31 +2040,46 @@ impl Hud { let over_pos = pos + Vec3::unit_z() * 0.7; let interaction_text = || match interaction { - Interaction::Collect => { - vec![(GameInput::Interact, i18n.get_msg("hud-collect").to_string())] + BlockInteraction::Collect => { + vec![( + Some(GameInput::Interact), + i18n.get_msg("hud-collect").to_string(), + )] }, - Interaction::Craft(_) => { - vec![(GameInput::Interact, i18n.get_msg("hud-use").to_string())] + BlockInteraction::Craft(_) => { + vec![( + Some(GameInput::Interact), + i18n.get_msg("hud-use").to_string(), + )] }, - Interaction::Unlock(kind) => vec![(GameInput::Interact, match kind { - UnlockKind::Free => i18n.get_msg("hud-open").to_string(), - UnlockKind::Requires(item) => i18n - .get_msg_ctx("hud-unlock-requires", &i18n::fluent_args! { - "item" => item.as_ref().itemdef_id() - .map(|id| Item::new_from_asset_expect(id).describe()) - .unwrap_or_else(|| "modular item".to_string()), - }) - .to_string(), - UnlockKind::Consumes(item) => i18n - .get_msg_ctx("hud-unlock-requires", &i18n::fluent_args! { - "item" => item.as_ref().itemdef_id() - .map(|id| Item::new_from_asset_expect(id).describe()) - .unwrap_or_else(|| "modular item".to_string()), - }) - .to_string(), - })], - Interaction::Mine => { - vec![(GameInput::Primary, i18n.get_msg("hud-mine").to_string())] + BlockInteraction::Unlock(kind) => { + vec![(Some(GameInput::Interact), match kind { + UnlockKind::Free => i18n.get_msg("hud-open").to_string(), + UnlockKind::Requires(item) => i18n + .get_msg_ctx("hud-unlock-requires", &i18n::fluent_args! { + "item" => item.as_ref().itemdef_id() + .map(|id| Item::new_from_asset_expect(id).describe()) + .unwrap_or_else(|| "modular item".to_string()), + }) + .to_string(), + UnlockKind::Consumes(item) => i18n + .get_msg_ctx("hud-unlock-requires", &i18n::fluent_args! { + "item" => item.as_ref().itemdef_id() + .map(|id| Item::new_from_asset_expect(id).describe()) + .unwrap_or_else(|| "modular item".to_string()), + }) + .to_string(), + })] + }, + BlockInteraction::Mine => { + if info.is_mining { + vec![( + Some(GameInput::Primary), + i18n.get_msg("hud-mine").to_string(), + )] + } else { + vec![(None, i18n.get_msg("hud-needs_pickaxe").to_string())] + } }, }; @@ -2083,7 +2099,10 @@ impl Hud { overitem_properties, self.pulse, &global_state.window.key_layout, - vec![(GameInput::Interact, i18n.get_msg("hud-open").to_string())], + vec![( + Some(GameInput::Interact), + i18n.get_msg("hud-open").to_string(), + )], ) .x_y(0.0, 100.0) .position_ingame(over_pos) @@ -2152,7 +2171,10 @@ impl Hud { overitem_properties, self.pulse, &global_state.window.key_layout, - vec![(GameInput::Interact, i18n.get_msg("hud-sit").to_string())], + vec![( + Some(GameInput::Interact), + i18n.get_msg("hud-sit").to_string(), + )], ) .x_y(0.0, 100.0) .position_ingame(over_pos) diff --git a/voxygen/src/hud/overitem.rs b/voxygen/src/hud/overitem.rs index 00112ab29b..e12e71f697 100644 --- a/voxygen/src/hud/overitem.rs +++ b/voxygen/src/hud/overitem.rs @@ -46,7 +46,8 @@ pub struct Overitem<'a> { properties: OveritemProperties, pulse: f32, key_layout: &'a Option, - interaction_options: Vec<(GameInput, String)>, + // GameInput optional so we can just show stuff like "needs pickaxe" + interaction_options: Vec<(Option, String)>, } impl<'a> Overitem<'a> { @@ -60,7 +61,7 @@ impl<'a> Overitem<'a> { properties: OveritemProperties, pulse: f32, key_layout: &'a Option, - interaction_options: Vec<(GameInput, String)>, + interaction_options: Vec<(Option, String)>, ) -> Self { Self { name, @@ -177,19 +178,20 @@ impl<'a> Widget for Overitem<'a> { .interaction_options .iter() .filter_map(|(input, action)| { - Some(( - self.controls - .get_binding(*input) - .filter(|_| self.properties.active)?, - action, - )) + let binding = if let Some(input) = input { + Some(self.controls.get_binding(*input)?) + } else { + None + }; + Some((binding, action)) }) .map(|(input, action)| { - format!( - "{} {}", - input.display_string(self.key_layout).as_str(), - action - ) + if let Some(input) = input { + let input = input.display_string(self.key_layout); + format!("{} {action}", input.as_str()) + } else { + action.to_string() + } }) .collect::>() .join("\n"); diff --git a/voxygen/src/scene/terrain/watcher.rs b/voxygen/src/scene/terrain/watcher.rs index 0edc35c696..b783cb07cc 100644 --- a/voxygen/src/scene/terrain/watcher.rs +++ b/voxygen/src/scene/terrain/watcher.rs @@ -1,16 +1,16 @@ use crate::hud::CraftingTab; -use common::terrain::{BlockKind, SpriteKind, TerrainChunk, UnlockKind}; +use common::terrain::{BlockKind, SpriteKind, TerrainChunk}; use common_base::span; use rand::prelude::*; use rand_chacha::ChaCha8Rng; use vek::*; -#[derive(Clone, Debug)] +#[derive(Clone, Copy, Debug)] pub enum Interaction { + /// This covers mining, unlocking, and regular collectable things (e.g. + /// twigs). Collect, - Unlock(UnlockKind), Craft(CraftingTab), - Mine, } pub enum FireplaceType { @@ -169,17 +169,7 @@ impl BlocksOfInterest { }, } if block.collectible_id().is_some() { - interactables.push(( - pos, - block - .get_sprite() - .map(|s| { - Interaction::Unlock( - s.unlock_condition(chunk.meta().sprite_cfg_at(pos).cloned()), - ) - }) - .unwrap_or(Interaction::Collect), - )); + interactables.push((pos, Interaction::Collect)); } if let Some(glow) = block.get_glow() { // Currently, we count filled blocks as 'minor' lights, and sprites as diff --git a/voxygen/src/session/interactable.rs b/voxygen/src/session/interactable.rs index 5dc6e27c21..a78a6563c0 100644 --- a/voxygen/src/session/interactable.rs +++ b/voxygen/src/session/interactable.rs @@ -12,19 +12,30 @@ use common::{ consts::MAX_PICKUP_RANGE, link::Is, mounting::Mount, - terrain::Block, + terrain::{Block, TerrainGrid, UnlockKind}, util::find_dist::{Cube, Cylinder, FindDist}, vol::ReadVol, }; use common_base::span; -use crate::scene::{terrain::Interaction, Scene}; +use crate::{ + hud::CraftingTab, + scene::{terrain::Interaction, Scene}, +}; + +#[derive(Clone, Debug)] +pub enum BlockInteraction { + Collect, + Unlock(UnlockKind), + Craft(CraftingTab), + // TODO: mining blocks don't use the interaction key, so it might not be the best abstraction + // to have them here, will see how things turn out + Mine, +} -// TODO: extract mining blocks (the None case in the Block variant) from this -// enum since they don't use the interaction key #[derive(Clone, Debug)] pub enum Interactable { - Block(Block, Vec3, Interaction), + Block(Block, Vec3, BlockInteraction), Entity(specs::Entity), } @@ -35,6 +46,44 @@ impl Interactable { Self::Block(_, _, _) => None, } } + + fn from_block_pos( + terrain: &TerrainGrid, + pos: Vec3, + interaction: Interaction, + ) -> Option { + let Ok(&block) = terrain.get(pos) else { return None }; + let block_interaction = match interaction { + Interaction::Collect => { + // Check if this is an unlockable sprite + let unlock = block.get_sprite().and_then(|sprite| { + let Some(chunk) = terrain.pos_chunk(pos) else { return None }; + let sprite_chunk_pos = TerrainGrid::chunk_offs(pos); + let sprite_cfg = chunk.meta().sprite_cfg_at(sprite_chunk_pos); + let unlock_condition = sprite.unlock_condition(sprite_cfg.cloned()); + // HACK: No other way to distinguish between things that should be unlockable + // and regular sprites with the current unlock_condition method so we hack + // around that by saying that it is a regular collectible sprite if + // `unlock_condition` returns UnlockKind::Free and the cfg was `None`. + if sprite_cfg.is_some() || !matches!(&unlock_condition, UnlockKind::Free) { + Some(unlock_condition) + } else { + None + } + }); + + if let Some(unlock) = unlock { + BlockInteraction::Unlock(unlock) + } else if block.mine_tool().is_some() { + BlockInteraction::Mine + } else { + BlockInteraction::Collect + } + }, + Interaction::Craft(tab) => BlockInteraction::Craft(tab), + }; + Some(Self::Block(block, pos, block_interaction)) + } } /// Select interactable to highlight, display interaction text for, and to @@ -62,13 +111,10 @@ pub(super) fn select_interactable( collect_target.map(|t| t.distance), ]); - fn get_block(client: &Client, target: Target) -> Option { - client - .state() - .terrain() - .get(target.position_int()) - .ok() - .copied() + let terrain = client.state().terrain(); + + fn get_block(terrain: &common::terrain::TerrainGrid, target: Target) -> Option { + terrain.get(target.position_int()).ok().copied() } if let Some(interactable) = entity_target @@ -83,8 +129,9 @@ pub(super) fn select_interactable( .or_else(|| { collect_target.and_then(|t| { if Some(t.distance) == nearest_dist { - get_block(client, t) - .map(|b| Interactable::Block(b, t.position_int(), Interaction::Collect)) + get_block(&terrain, t).map(|b| { + Interactable::Block(b, t.position_int(), BlockInteraction::Collect) + }) } else { None } @@ -93,14 +140,18 @@ pub(super) fn select_interactable( .or_else(|| { mine_target.and_then(|t| { if Some(t.distance) == nearest_dist { - get_block(client, t).and_then(|b| { + get_block(&terrain, t).and_then(|b| { // Handling edge detection. sometimes the casting (in Target mod) returns a // position which is actually empty, which we do not want labeled as an // interactable. We are only returning the mineable air // elements (e.g. minerals). The mineable weakrock are used // in the terrain selected_pos, but is not an interactable. if b.mine_tool().is_some() && b.is_air() { - Some(Interactable::Block(b, t.position_int(), Interaction::Mine)) + Some(Interactable::Block( + b, + t.position_int(), + BlockInteraction::Mine, + )) } else { None } @@ -124,6 +175,9 @@ pub(super) fn select_interactable( let colliders = ecs.read_storage::(); let char_states = ecs.read_storage::(); let is_mount = ecs.read_storage::>(); + let bodies = ecs.read_storage::(); + let items = ecs.read_storage::(); + let stats = ecs.read_storage::(); let player_cylinder = Cylinder::from_components( player_pos, @@ -135,20 +189,39 @@ pub(super) fn select_interactable( let closest_interactable_entity = ( &ecs.entities(), &positions, + &bodies, scales.maybe(), colliders.maybe(), char_states.maybe(), !&is_mount, + (stats.mask() | items.mask()).maybe(), ) .join() - .filter(|(e, _, _, _, _, _)| *e != player_entity) - .map(|(e, p, s, c, cs, _)| { + .filter_map(|(e, p, b, s, c, cs, _, has_stats_or_item)| { + // Note, if this becomes expensive to compute do it after the distance check! + // + // The entities that can be interacted with: + // * Sitting at campfires (Body::is_campfire) + // * Talking/trading with npcs (note hud code uses Alignment but I can't bring + // myself to write more code on that depends on having this on the client so + // we just check for presence of Stats component for now, it is okay to have + // some false positives here as long as it doesn't frequently prevent us from + // interacting with actual interactable entities that are closer by) + // * Dropped items that can be picked up (Item component) + let is_interactable = b.is_campfire() || has_stats_or_item.is_some(); + + if e == player_entity || !is_interactable { + return None; + }; + let cylinder = Cylinder::from_components(p.0, s.copied(), c, cs); - (e, cylinder) + // Roughly filter out entities farther than interaction distance + if player_cylinder.approx_in_range(cylinder, MAX_PICKUP_RANGE) { + Some((e, player_cylinder.min_distance(cylinder))) + } else { + None + } }) - // Roughly filter out entities farther than interaction distance - .filter(|(_, cylinder)| player_cylinder.approx_in_range(*cylinder, MAX_PICKUP_RANGE)) - .map(|(e, cylinder)| (e, player_cylinder.min_distance(cylinder))) .min_by_key(|(_, dist)| OrderedFloat(*dist)); // Only search as far as closest interactable entity @@ -156,7 +229,7 @@ pub(super) fn select_interactable( let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| { (e.floor() as i32).div_euclid(sz as i32) }); - let terrain = scene.terrain(); + let scene_terrain = scene.terrain(); // Find closest interactable block // TODO: consider doing this one first? @@ -168,7 +241,7 @@ pub(super) fn select_interactable( let chunk_pos = player_chunk + offset; let chunk_voxel_pos = Vec3::::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32)); - terrain.get(chunk_pos).map(|data| (data, chunk_voxel_pos)) + scene_terrain.get(chunk_pos).map(|data| (data, chunk_voxel_pos)) }) // TODO: maybe we could make this more efficient by putting the // interactables is some sort of spatial structure @@ -180,10 +253,10 @@ pub(super) fn select_interactable( .map(move |(block_offset, interaction)| (chunk_pos + block_offset, interaction)) }) .map(|(block_pos, interaction)| ( - block_pos, - block_pos.map(|e| e as f32 + 0.5) - .distance_squared(player_pos), - interaction, + block_pos, + block_pos.map(|e| e as f32 + 0.5) + .distance_squared(player_pos), + interaction, )) .min_by_key(|(_, dist_sqr, _)| OrderedFloat(*dist_sqr)) .map(|(block_pos, _, interaction)| (block_pos, interaction)); @@ -197,13 +270,7 @@ pub(super) fn select_interactable( }) < search_dist }) .and_then(|(block_pos, interaction)| { - client - .state() - .terrain() - .get(block_pos) - .ok() - .copied() - .map(|b| Interactable::Block(b, block_pos, interaction.clone())) + Interactable::from_block_pos(&terrain, block_pos, *interaction) }) .or_else(|| closest_interactable_entity.map(|(e, _)| Interactable::Entity(e))) } diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs index 073728b03b..f2c541ea5f 100644 --- a/voxygen/src/session/mod.rs +++ b/voxygen/src/session/mod.rs @@ -49,13 +49,13 @@ use crate::{ key_state::KeyState, menu::char_selection::CharSelectionState, render::{Drawer, GlobalsBindGroup}, - scene::{camera, terrain::Interaction, CameraMode, DebugShapeId, Scene, SceneData}, + scene::{camera, CameraMode, DebugShapeId, Scene, SceneData}, settings::Settings, window::{AnalogGameInput, Event}, Direction, GlobalState, PlayState, PlayStateResult, }; use hashbrown::HashMap; -use interactable::{select_interactable, Interactable}; +use interactable::{select_interactable, BlockInteraction, Interactable}; use settings_change::Language::ChangeLanguage; use target::targets_under_cursor; #[cfg(feature = "egui-ui")] @@ -930,19 +930,19 @@ impl PlayState for SessionState { match interactable { Interactable::Block(block, pos, interaction) => { match interaction { - Interaction::Collect - | Interaction::Unlock(_) => { + BlockInteraction::Collect + | BlockInteraction::Unlock(_) => { if block.is_collectible() { client.collect_block(*pos); } }, - Interaction::Craft(tab) => { + BlockInteraction::Craft(tab) => { self.hud.show.open_crafting_tab( *tab, block.get_sprite().map(|s| (*pos, s)), ) }, - Interaction::Mine => {}, + BlockInteraction::Mine => {}, } }, Interactable::Entity(entity) => { @@ -1366,6 +1366,7 @@ impl PlayState for SessionState { global_state.clock.get_stable_dt(), HudInfo { is_aiming, + is_mining, is_first_person: matches!( self.scene.camera().get_mode(), camera::CameraMode::FirstPerson diff --git a/voxygen/src/session/target.rs b/voxygen/src/session/target.rs index bb904916e4..d19d990f28 100644 --- a/voxygen/src/session/target.rs +++ b/voxygen/src/session/target.rs @@ -100,8 +100,7 @@ pub(super) fn targets_under_cursor( } }; - let (collect_pos, _, collect_cam_ray) = - find_pos(|b: Block| matches!(b.collectible_id(), Some(Some(_)))); + let (collect_pos, _, collect_cam_ray) = find_pos(|b: Block| b.is_collectible()); let (mine_pos, _, mine_cam_ray) = is_mining .then(|| find_pos(|b: Block| b.mine_tool().is_some())) .unwrap_or((None, None, None));