Improvements and fixes for interacting/collecting

* Inert entities like arrows no longer block interactions like picking
  up items! Logic looking for the closest entity will skip them.
* When pickaxe is not equipped and wielded we now show "Needs Pickaxe"
  as the hint text for mineable blocks.
* Mineable blocks that aren't pointed at now show the mining text hint
  instead of the text hint used for regular collectible blocks.
* Fixed recent bug where all interactables were showing the open text hint.
* Split `BlockInteraction` out of the `Interaction` enum in voxygen
  since we were using this enum for two different things.
This commit is contained in:
Imbris 2023-03-11 21:07:10 -05:00
parent 19b5ed3487
commit 6b8e22d6cc
9 changed files with 189 additions and 108 deletions

View File

@ -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 - Lamps, embers and campfires use glowing indices
- Non-potion drinks no longer heal as much as potions. - Non-potion drinks no longer heal as much as potions.
- Added SFX to the new sword abilities - 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 ## [0.14.0] - 2023-01-07

View File

@ -45,6 +45,7 @@ hud-use = Use
hud-unlock-requires = Open with { $item } hud-unlock-requires = Open with { $item }
hud-unlock-consumes = Use { $item } to open hud-unlock-consumes = Use { $item } to open
hud-mine = Mine hud-mine = Mine
hud-needs_pickaxe = Needs Pickaxe
hud-talk = Talk hud-talk = Talk
hud-trade = Trade hud-trade = Trade
hud-mount = Mount hud-mount = Mount

View File

@ -17,9 +17,9 @@ use crate::{
event::{LocalEvent, ServerEvent}, event::{LocalEvent, ServerEvent},
outcome::Outcome, outcome::Outcome,
states::{behavior::JoinData, utils::CharacterState::Idle, *}, states::{behavior::JoinData, utils::CharacterState::Idle, *},
terrain::{TerrainChunkSize, UnlockKind}, terrain::{TerrainGrid, UnlockKind},
util::Dir, util::Dir,
vol::{ReadVol, RectVolSize}, vol::ReadVol,
}; };
use core::hash::BuildHasherDefault; use core::hash::BuildHasherDefault;
use fxhash::FxHasher64; use fxhash::FxHasher64;
@ -844,10 +844,7 @@ pub fn handle_manipulate_loadout(
if close_to_sprite { if close_to_sprite {
// First, get sprite data for position, if there is a sprite // First, get sprite data for position, if there is a sprite
use sprite_interact::SpriteInteractKind; use sprite_interact::SpriteInteractKind;
let sprite_chunk_pos = sprite_pos let sprite_chunk_pos = TerrainGrid::chunk_offs(sprite_pos);
.xy()
.map2(TerrainChunkSize::RECT_SIZE, |e, sz| e.rem_euclid(sz as i32))
.with_z(sprite_pos.z);
let sprite_cfg = data let sprite_cfg = data
.terrain .terrain
.pos_chunk(sprite_pos) .pos_chunk(sprite_pos)

View File

@ -65,12 +65,9 @@ use crate::{
game_input::GameInput, game_input::GameInput,
hud::{img_ids::ImgsRot, prompt_dialog::DialogOutcomeEvent}, hud::{img_ids::ImgsRot, prompt_dialog::DialogOutcomeEvent},
render::UiDrawer, render::UiDrawer,
scene::{ scene::camera::{self, Camera},
camera::{self, Camera},
terrain::Interaction,
},
session::{ session::{
interactable::Interactable, interactable::{BlockInteraction, Interactable},
settings_change::{ settings_change::{
Audio, Chat as ChatChange, Interface as InterfaceChange, SettingsChange, Audio, Chat as ChatChange, Interface as InterfaceChange, SettingsChange,
}, },
@ -673,6 +670,7 @@ pub struct DebugInfo {
pub struct HudInfo { pub struct HudInfo {
pub is_aiming: bool, pub is_aiming: bool,
pub is_mining: bool,
pub is_first_person: bool, pub is_first_person: bool,
pub viewpoint_entity: specs::Entity, pub viewpoint_entity: specs::Entity,
pub mutable_viewpoint: bool, pub mutable_viewpoint: bool,
@ -2019,7 +2017,10 @@ impl Hud {
pickup_failed_pulse: self.failed_entity_pickups.get(&entity).cloned(), pickup_failed_pulse: self.failed_entity_pickups.get(&entity).cloned(),
}, },
&self.fonts, &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); .set(overitem_id, ui_widgets);
} }
@ -2039,13 +2040,20 @@ impl Hud {
let over_pos = pos + Vec3::unit_z() * 0.7; let over_pos = pos + Vec3::unit_z() * 0.7;
let interaction_text = || match interaction { let interaction_text = || match interaction {
Interaction::Collect => { BlockInteraction::Collect => {
vec![(GameInput::Interact, i18n.get_msg("hud-collect").to_string())] vec![(
Some(GameInput::Interact),
i18n.get_msg("hud-collect").to_string(),
)]
}, },
Interaction::Craft(_) => { BlockInteraction::Craft(_) => {
vec![(GameInput::Interact, i18n.get_msg("hud-use").to_string())] vec![(
Some(GameInput::Interact),
i18n.get_msg("hud-use").to_string(),
)]
}, },
Interaction::Unlock(kind) => vec![(GameInput::Interact, match kind { BlockInteraction::Unlock(kind) => {
vec![(Some(GameInput::Interact), match kind {
UnlockKind::Free => i18n.get_msg("hud-open").to_string(), UnlockKind::Free => i18n.get_msg("hud-open").to_string(),
UnlockKind::Requires(item) => i18n UnlockKind::Requires(item) => i18n
.get_msg_ctx("hud-unlock-requires", &i18n::fluent_args! { .get_msg_ctx("hud-unlock-requires", &i18n::fluent_args! {
@ -2061,9 +2069,17 @@ impl Hud {
.unwrap_or_else(|| "modular item".to_string()), .unwrap_or_else(|| "modular item".to_string()),
}) })
.to_string(), .to_string(),
})], })]
Interaction::Mine => { },
vec![(GameInput::Primary, i18n.get_msg("hud-mine").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, overitem_properties,
self.pulse, self.pulse,
&global_state.window.key_layout, &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) .x_y(0.0, 100.0)
.position_ingame(over_pos) .position_ingame(over_pos)
@ -2152,7 +2171,10 @@ impl Hud {
overitem_properties, overitem_properties,
self.pulse, self.pulse,
&global_state.window.key_layout, &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) .x_y(0.0, 100.0)
.position_ingame(over_pos) .position_ingame(over_pos)

View File

@ -46,7 +46,8 @@ pub struct Overitem<'a> {
properties: OveritemProperties, properties: OveritemProperties,
pulse: f32, pulse: f32,
key_layout: &'a Option<KeyLayout>, key_layout: &'a Option<KeyLayout>,
interaction_options: Vec<(GameInput, String)>, // GameInput optional so we can just show stuff like "needs pickaxe"
interaction_options: Vec<(Option<GameInput>, String)>,
} }
impl<'a> Overitem<'a> { impl<'a> Overitem<'a> {
@ -60,7 +61,7 @@ impl<'a> Overitem<'a> {
properties: OveritemProperties, properties: OveritemProperties,
pulse: f32, pulse: f32,
key_layout: &'a Option<KeyLayout>, key_layout: &'a Option<KeyLayout>,
interaction_options: Vec<(GameInput, String)>, interaction_options: Vec<(Option<GameInput>, String)>,
) -> Self { ) -> Self {
Self { Self {
name, name,
@ -177,19 +178,20 @@ impl<'a> Widget for Overitem<'a> {
.interaction_options .interaction_options
.iter() .iter()
.filter_map(|(input, action)| { .filter_map(|(input, action)| {
Some(( let binding = if let Some(input) = input {
self.controls Some(self.controls.get_binding(*input)?)
.get_binding(*input) } else {
.filter(|_| self.properties.active)?, None
action, };
)) Some((binding, action))
}) })
.map(|(input, action)| { .map(|(input, action)| {
format!( if let Some(input) = input {
"{} {}", let input = input.display_string(self.key_layout);
input.display_string(self.key_layout).as_str(), format!("{} {action}", input.as_str())
action } else {
) action.to_string()
}
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n"); .join("\n");

View File

@ -1,16 +1,16 @@
use crate::hud::CraftingTab; use crate::hud::CraftingTab;
use common::terrain::{BlockKind, SpriteKind, TerrainChunk, UnlockKind}; use common::terrain::{BlockKind, SpriteKind, TerrainChunk};
use common_base::span; use common_base::span;
use rand::prelude::*; use rand::prelude::*;
use rand_chacha::ChaCha8Rng; use rand_chacha::ChaCha8Rng;
use vek::*; use vek::*;
#[derive(Clone, Debug)] #[derive(Clone, Copy, Debug)]
pub enum Interaction { pub enum Interaction {
/// This covers mining, unlocking, and regular collectable things (e.g.
/// twigs).
Collect, Collect,
Unlock(UnlockKind),
Craft(CraftingTab), Craft(CraftingTab),
Mine,
} }
pub enum FireplaceType { pub enum FireplaceType {
@ -169,17 +169,7 @@ impl BlocksOfInterest {
}, },
} }
if block.collectible_id().is_some() { if block.collectible_id().is_some() {
interactables.push(( interactables.push((pos, Interaction::Collect));
pos,
block
.get_sprite()
.map(|s| {
Interaction::Unlock(
s.unlock_condition(chunk.meta().sprite_cfg_at(pos).cloned()),
)
})
.unwrap_or(Interaction::Collect),
));
} }
if let Some(glow) = block.get_glow() { if let Some(glow) = block.get_glow() {
// Currently, we count filled blocks as 'minor' lights, and sprites as // Currently, we count filled blocks as 'minor' lights, and sprites as

View File

@ -12,19 +12,30 @@ use common::{
consts::MAX_PICKUP_RANGE, consts::MAX_PICKUP_RANGE,
link::Is, link::Is,
mounting::Mount, mounting::Mount,
terrain::Block, terrain::{Block, TerrainGrid, UnlockKind},
util::find_dist::{Cube, Cylinder, FindDist}, util::find_dist::{Cube, Cylinder, FindDist},
vol::ReadVol, vol::ReadVol,
}; };
use common_base::span; 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)] #[derive(Clone, Debug)]
pub enum Interactable { pub enum Interactable {
Block(Block, Vec3<i32>, Interaction), Block(Block, Vec3<i32>, BlockInteraction),
Entity(specs::Entity), Entity(specs::Entity),
} }
@ -35,6 +46,44 @@ impl Interactable {
Self::Block(_, _, _) => None, Self::Block(_, _, _) => None,
} }
} }
fn from_block_pos(
terrain: &TerrainGrid,
pos: Vec3<i32>,
interaction: Interaction,
) -> Option<Self> {
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 /// 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), collect_target.map(|t| t.distance),
]); ]);
fn get_block<T>(client: &Client, target: Target<T>) -> Option<Block> { let terrain = client.state().terrain();
client
.state() fn get_block<T>(terrain: &common::terrain::TerrainGrid, target: Target<T>) -> Option<Block> {
.terrain() terrain.get(target.position_int()).ok().copied()
.get(target.position_int())
.ok()
.copied()
} }
if let Some(interactable) = entity_target if let Some(interactable) = entity_target
@ -83,8 +129,9 @@ pub(super) fn select_interactable(
.or_else(|| { .or_else(|| {
collect_target.and_then(|t| { collect_target.and_then(|t| {
if Some(t.distance) == nearest_dist { if Some(t.distance) == nearest_dist {
get_block(client, t) get_block(&terrain, t).map(|b| {
.map(|b| Interactable::Block(b, t.position_int(), Interaction::Collect)) Interactable::Block(b, t.position_int(), BlockInteraction::Collect)
})
} else { } else {
None None
} }
@ -93,14 +140,18 @@ pub(super) fn select_interactable(
.or_else(|| { .or_else(|| {
mine_target.and_then(|t| { mine_target.and_then(|t| {
if Some(t.distance) == nearest_dist { 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 // Handling edge detection. sometimes the casting (in Target mod) returns a
// position which is actually empty, which we do not want labeled as an // position which is actually empty, which we do not want labeled as an
// interactable. We are only returning the mineable air // interactable. We are only returning the mineable air
// elements (e.g. minerals). The mineable weakrock are used // elements (e.g. minerals). The mineable weakrock are used
// in the terrain selected_pos, but is not an interactable. // in the terrain selected_pos, but is not an interactable.
if b.mine_tool().is_some() && b.is_air() { 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 { } else {
None None
} }
@ -124,6 +175,9 @@ pub(super) fn select_interactable(
let colliders = ecs.read_storage::<comp::Collider>(); let colliders = ecs.read_storage::<comp::Collider>();
let char_states = ecs.read_storage::<comp::CharacterState>(); let char_states = ecs.read_storage::<comp::CharacterState>();
let is_mount = ecs.read_storage::<Is<Mount>>(); let is_mount = ecs.read_storage::<Is<Mount>>();
let bodies = ecs.read_storage::<comp::Body>();
let items = ecs.read_storage::<comp::Item>();
let stats = ecs.read_storage::<comp::Stats>();
let player_cylinder = Cylinder::from_components( let player_cylinder = Cylinder::from_components(
player_pos, player_pos,
@ -135,20 +189,39 @@ pub(super) fn select_interactable(
let closest_interactable_entity = ( let closest_interactable_entity = (
&ecs.entities(), &ecs.entities(),
&positions, &positions,
&bodies,
scales.maybe(), scales.maybe(),
colliders.maybe(), colliders.maybe(),
char_states.maybe(), char_states.maybe(),
!&is_mount, !&is_mount,
(stats.mask() | items.mask()).maybe(),
) )
.join() .join()
.filter(|(e, _, _, _, _, _)| *e != player_entity) .filter_map(|(e, p, b, s, c, cs, _, has_stats_or_item)| {
.map(|(e, p, s, c, cs, _)| { // 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); let cylinder = Cylinder::from_components(p.0, s.copied(), c, cs);
(e, cylinder)
})
// Roughly filter out entities farther than interaction distance // Roughly filter out entities farther than interaction distance
.filter(|(_, cylinder)| player_cylinder.approx_in_range(*cylinder, MAX_PICKUP_RANGE)) if player_cylinder.approx_in_range(cylinder, MAX_PICKUP_RANGE) {
.map(|(e, cylinder)| (e, player_cylinder.min_distance(cylinder))) Some((e, player_cylinder.min_distance(cylinder)))
} else {
None
}
})
.min_by_key(|(_, dist)| OrderedFloat(*dist)); .min_by_key(|(_, dist)| OrderedFloat(*dist));
// Only search as far as closest interactable entity // 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| { let player_chunk = player_pos.xy().map2(TerrainChunk::RECT_SIZE, |e, sz| {
(e.floor() as i32).div_euclid(sz as i32) (e.floor() as i32).div_euclid(sz as i32)
}); });
let terrain = scene.terrain(); let scene_terrain = scene.terrain();
// Find closest interactable block // Find closest interactable block
// TODO: consider doing this one first? // TODO: consider doing this one first?
@ -168,7 +241,7 @@ pub(super) fn select_interactable(
let chunk_pos = player_chunk + offset; let chunk_pos = player_chunk + offset;
let chunk_voxel_pos = let chunk_voxel_pos =
Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32)); Vec3::<i32>::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 // TODO: maybe we could make this more efficient by putting the
// interactables is some sort of spatial structure // interactables is some sort of spatial structure
@ -197,13 +270,7 @@ pub(super) fn select_interactable(
}) < search_dist }) < search_dist
}) })
.and_then(|(block_pos, interaction)| { .and_then(|(block_pos, interaction)| {
client Interactable::from_block_pos(&terrain, block_pos, *interaction)
.state()
.terrain()
.get(block_pos)
.ok()
.copied()
.map(|b| Interactable::Block(b, block_pos, interaction.clone()))
}) })
.or_else(|| closest_interactable_entity.map(|(e, _)| Interactable::Entity(e))) .or_else(|| closest_interactable_entity.map(|(e, _)| Interactable::Entity(e)))
} }

View File

@ -49,13 +49,13 @@ use crate::{
key_state::KeyState, key_state::KeyState,
menu::char_selection::CharSelectionState, menu::char_selection::CharSelectionState,
render::{Drawer, GlobalsBindGroup}, render::{Drawer, GlobalsBindGroup},
scene::{camera, terrain::Interaction, CameraMode, DebugShapeId, Scene, SceneData}, scene::{camera, CameraMode, DebugShapeId, Scene, SceneData},
settings::Settings, settings::Settings,
window::{AnalogGameInput, Event}, window::{AnalogGameInput, Event},
Direction, GlobalState, PlayState, PlayStateResult, Direction, GlobalState, PlayState, PlayStateResult,
}; };
use hashbrown::HashMap; use hashbrown::HashMap;
use interactable::{select_interactable, Interactable}; use interactable::{select_interactable, BlockInteraction, Interactable};
use settings_change::Language::ChangeLanguage; use settings_change::Language::ChangeLanguage;
use target::targets_under_cursor; use target::targets_under_cursor;
#[cfg(feature = "egui-ui")] #[cfg(feature = "egui-ui")]
@ -930,19 +930,19 @@ impl PlayState for SessionState {
match interactable { match interactable {
Interactable::Block(block, pos, interaction) => { Interactable::Block(block, pos, interaction) => {
match interaction { match interaction {
Interaction::Collect BlockInteraction::Collect
| Interaction::Unlock(_) => { | BlockInteraction::Unlock(_) => {
if block.is_collectible() { if block.is_collectible() {
client.collect_block(*pos); client.collect_block(*pos);
} }
}, },
Interaction::Craft(tab) => { BlockInteraction::Craft(tab) => {
self.hud.show.open_crafting_tab( self.hud.show.open_crafting_tab(
*tab, *tab,
block.get_sprite().map(|s| (*pos, s)), block.get_sprite().map(|s| (*pos, s)),
) )
}, },
Interaction::Mine => {}, BlockInteraction::Mine => {},
} }
}, },
Interactable::Entity(entity) => { Interactable::Entity(entity) => {
@ -1366,6 +1366,7 @@ impl PlayState for SessionState {
global_state.clock.get_stable_dt(), global_state.clock.get_stable_dt(),
HudInfo { HudInfo {
is_aiming, is_aiming,
is_mining,
is_first_person: matches!( is_first_person: matches!(
self.scene.camera().get_mode(), self.scene.camera().get_mode(),
camera::CameraMode::FirstPerson camera::CameraMode::FirstPerson

View File

@ -100,8 +100,7 @@ pub(super) fn targets_under_cursor(
} }
}; };
let (collect_pos, _, collect_cam_ray) = let (collect_pos, _, collect_cam_ray) = find_pos(|b: Block| b.is_collectible());
find_pos(|b: Block| matches!(b.collectible_id(), Some(Some(_))));
let (mine_pos, _, mine_cam_ray) = is_mining let (mine_pos, _, mine_cam_ray) = is_mining
.then(|| find_pos(|b: Block| b.mine_tool().is_some())) .then(|| find_pos(|b: Block| b.mine_tool().is_some()))
.unwrap_or((None, None, None)); .unwrap_or((None, None, None));