mirror of
https://gitlab.com/veloren/veloren.git
synced 2024-08-30 18:12:32 +00:00
Merge branch 'anomaluridae/cleanup-interactable' into 'master'
Seperation of targets vs interactables. Cleaner cursor interactions. See merge request veloren/veloren!2754
This commit is contained in:
commit
bc4455afe4
@ -199,7 +199,7 @@ pub struct ControllerInputs {
|
||||
pub move_z: f32, /* z axis (not combined with move_dir because they may have independent
|
||||
* limits) */
|
||||
pub look_dir: Dir,
|
||||
pub select_pos: Option<Vec3<f32>>,
|
||||
pub break_block_pos: Option<Vec3<f32>>,
|
||||
/// Attempt to enable strafing.
|
||||
/// Currently, setting this to false will *not* disable strafing during a
|
||||
/// wielding character state.
|
||||
@ -236,7 +236,7 @@ impl ControllerInputs {
|
||||
self.move_dir = new.move_dir;
|
||||
self.move_z = new.move_z;
|
||||
self.look_dir = new.look_dir;
|
||||
self.select_pos = new.select_pos;
|
||||
self.break_block_pos = new.break_block_pos;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -127,7 +127,7 @@ impl CharacterBehavior for Data {
|
||||
hit_count: 0,
|
||||
break_block: data
|
||||
.inputs
|
||||
.select_pos
|
||||
.break_block_pos
|
||||
.map(|p| {
|
||||
(
|
||||
p.map(|e| e.floor() as i32),
|
||||
|
@ -180,7 +180,7 @@ impl CharacterBehavior for Data {
|
||||
hit_count: 0,
|
||||
break_block: data
|
||||
.inputs
|
||||
.select_pos
|
||||
.break_block_pos
|
||||
.map(|p| {
|
||||
(
|
||||
p.map(|e| e.floor() as i32),
|
||||
|
@ -284,7 +284,7 @@ impl CharacterBehavior for Data {
|
||||
hit_count: 0,
|
||||
break_block: data
|
||||
.inputs
|
||||
.select_pos
|
||||
.break_block_pos
|
||||
.map(|p| {
|
||||
(
|
||||
p.map(|e| e.floor() as i32),
|
||||
|
@ -165,7 +165,7 @@ impl CharacterBehavior for Data {
|
||||
hit_count: 0,
|
||||
break_block: data
|
||||
.inputs
|
||||
.select_pos
|
||||
.break_block_pos
|
||||
.map(|p| {
|
||||
(
|
||||
p.map(|e| e.floor() as i32),
|
||||
@ -302,7 +302,7 @@ impl CharacterBehavior for Data {
|
||||
hit_count: 0,
|
||||
break_block: data
|
||||
.inputs
|
||||
.select_pos
|
||||
.break_block_pos
|
||||
.map(|p| {
|
||||
(
|
||||
p.map(|e| e.floor() as i32),
|
||||
|
@ -172,7 +172,7 @@ impl CharacterBehavior for Data {
|
||||
hit_count: 0,
|
||||
break_block: data
|
||||
.inputs
|
||||
.select_pos
|
||||
.break_block_pos
|
||||
.map(|p| {
|
||||
(
|
||||
p.map(|e| e.floor() as i32),
|
||||
|
@ -149,7 +149,7 @@ impl CharacterBehavior for Data {
|
||||
hit_count: 0,
|
||||
break_block: data
|
||||
.inputs
|
||||
.select_pos
|
||||
.break_block_pos
|
||||
.map(|p| {
|
||||
(
|
||||
p.map(|e| e.floor() as i32),
|
||||
|
@ -217,6 +217,7 @@ impl Block {
|
||||
}
|
||||
}
|
||||
|
||||
// Filled blocks or sprites
|
||||
#[inline]
|
||||
pub fn is_solid(&self) -> bool {
|
||||
self.get_sprite()
|
||||
|
@ -59,8 +59,8 @@ use crate::{
|
||||
render::UiDrawer,
|
||||
scene::camera::{self, Camera},
|
||||
session::{
|
||||
interactable::Interactable,
|
||||
settings_change::{Chat as ChatChange, Interface as InterfaceChange, SettingsChange},
|
||||
Interactable,
|
||||
},
|
||||
settings::chat::ChatFilter,
|
||||
ui::{
|
||||
|
@ -7,7 +7,7 @@ use common_base::span;
|
||||
use rand::prelude::*;
|
||||
use vek::*;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub enum Interaction {
|
||||
Collect,
|
||||
Craft(CraftingTab),
|
||||
|
207
voxygen/src/session/interactable.rs
Normal file
207
voxygen/src/session/interactable.rs
Normal file
@ -0,0 +1,207 @@
|
||||
use ordered_float::OrderedFloat;
|
||||
use specs::{Join, WorldExt};
|
||||
use vek::*;
|
||||
|
||||
use super::{
|
||||
find_shortest_distance,
|
||||
target::{self, Target, MAX_TARGET_RANGE},
|
||||
};
|
||||
use client::Client;
|
||||
use common::{
|
||||
comp,
|
||||
consts::MAX_PICKUP_RANGE,
|
||||
terrain::Block,
|
||||
util::find_dist::{Cube, Cylinder, FindDist},
|
||||
vol::ReadVol,
|
||||
};
|
||||
use common_base::span;
|
||||
|
||||
use crate::scene::{terrain::Interaction, Scene};
|
||||
|
||||
// TODO: extract mining blocks (the None case in the Block variant) from this
|
||||
// enum since they don't use the interaction key
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum Interactable {
|
||||
Block(Block, Vec3<i32>, Option<Interaction>),
|
||||
Entity(specs::Entity),
|
||||
}
|
||||
|
||||
impl Interactable {
|
||||
pub fn entity(self) -> Option<specs::Entity> {
|
||||
match self {
|
||||
Self::Entity(e) => Some(e),
|
||||
Self::Block(_, _, _) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select interactable to hightlight, display interaction text for, and to
|
||||
/// interact with if the interact key is pressed
|
||||
/// Selected in the following order:
|
||||
/// 1) Targeted items, in order of nearest under cursor:
|
||||
/// (a) entity (if within range)
|
||||
/// (b) collectable
|
||||
/// (c) can be mined, and is a mine sprite (Air) not a weak rock.
|
||||
/// 2) outside of targeted cam ray
|
||||
/// -> closest of nearest interactable entity/block
|
||||
pub(super) fn select_interactable(
|
||||
client: &Client,
|
||||
collect_target: Option<Target<target::Collectable>>,
|
||||
entity_target: Option<Target<target::Entity>>,
|
||||
mine_target: Option<Target<target::Mine>>,
|
||||
scene: &Scene,
|
||||
) -> Option<Interactable> {
|
||||
span!(_guard, "select_interactable");
|
||||
use common::{spiral::Spiral2d, terrain::TerrainChunk, vol::RectRasterableVol};
|
||||
|
||||
let nearest_dist = find_shortest_distance(&[
|
||||
mine_target.map(|t| t.distance),
|
||||
entity_target.map(|t| t.distance),
|
||||
collect_target.map(|t| t.distance),
|
||||
]);
|
||||
|
||||
fn get_block<T>(client: &Client, target: Target<T>) -> Option<Block> {
|
||||
client
|
||||
.state()
|
||||
.terrain()
|
||||
.get(target.position_int())
|
||||
.ok()
|
||||
.copied()
|
||||
}
|
||||
|
||||
if let Some(interactable) = entity_target
|
||||
.and_then(|t| {
|
||||
if t.distance < MAX_TARGET_RANGE && Some(t.distance) == nearest_dist {
|
||||
let entity = t.kind.0;
|
||||
Some(Interactable::Entity(entity))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.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(), Some(Interaction::Collect))
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
mine_target.and_then(|t| {
|
||||
if Some(t.distance) == nearest_dist {
|
||||
get_block(client, 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(), None))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
})
|
||||
{
|
||||
Some(interactable)
|
||||
} else {
|
||||
// If there are no directly targeted interactables select the closest one if any
|
||||
// are in range
|
||||
let ecs = client.state().ecs();
|
||||
let player_entity = client.entity();
|
||||
let positions = ecs.read_storage::<comp::Pos>();
|
||||
let player_pos = positions.get(player_entity)?.0;
|
||||
|
||||
let scales = ecs.read_storage::<comp::Scale>();
|
||||
let colliders = ecs.read_storage::<comp::Collider>();
|
||||
let char_states = ecs.read_storage::<comp::CharacterState>();
|
||||
|
||||
let player_cylinder = Cylinder::from_components(
|
||||
player_pos,
|
||||
scales.get(player_entity).copied(),
|
||||
colliders.get(player_entity),
|
||||
char_states.get(player_entity),
|
||||
);
|
||||
|
||||
let closest_interactable_entity = (
|
||||
&ecs.entities(),
|
||||
&positions,
|
||||
scales.maybe(),
|
||||
colliders.maybe(),
|
||||
char_states.maybe(),
|
||||
)
|
||||
.join()
|
||||
.filter(|(e, _, _, _, _)| *e != player_entity)
|
||||
.map(|(e, p, s, c, cs)| {
|
||||
let cylinder = Cylinder::from_components(p.0, s.copied(), c, cs);
|
||||
(e, cylinder)
|
||||
})
|
||||
// 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
|
||||
let search_dist = closest_interactable_entity.map_or(MAX_PICKUP_RANGE, |(_, dist)| dist);
|
||||
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();
|
||||
|
||||
// Find closest interactable block
|
||||
// TODO: consider doing this one first?
|
||||
let closest_interactable_block_pos = Spiral2d::new()
|
||||
// TODO: this formula for the number to take was guessed
|
||||
// Note: assumes RECT_SIZE.x == RECT_SIZE.y
|
||||
.take(((search_dist / TerrainChunk::RECT_SIZE.x as f32).ceil() as usize * 2 + 1).pow(2))
|
||||
.flat_map(|offset| {
|
||||
let chunk_pos = player_chunk + offset;
|
||||
let chunk_voxel_pos =
|
||||
Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
|
||||
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
|
||||
.flat_map(|(chunk_data, chunk_pos)| {
|
||||
chunk_data
|
||||
.blocks_of_interest
|
||||
.interactables
|
||||
.iter()
|
||||
.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,
|
||||
))
|
||||
.min_by_key(|(_, dist_sqr, _)| OrderedFloat(*dist_sqr))
|
||||
.map(|(block_pos, _, interaction)| (block_pos, interaction));
|
||||
|
||||
// Return the closest of the 2 closest
|
||||
closest_interactable_block_pos
|
||||
.filter(|(block_pos, _)| {
|
||||
player_cylinder.min_distance(Cube {
|
||||
min: block_pos.as_(),
|
||||
side_length: 1.0,
|
||||
}) < search_dist
|
||||
})
|
||||
.and_then(|(block_pos, interaction)| {
|
||||
client
|
||||
.state()
|
||||
.terrain()
|
||||
.get(block_pos)
|
||||
.ok()
|
||||
.copied()
|
||||
.map(|b| Interactable::Block(b, block_pos, Some(*interaction)))
|
||||
})
|
||||
.or_else(|| closest_interactable_entity.map(|(e, _)| Interactable::Entity(e)))
|
||||
}
|
||||
}
|
@ -1,4 +1,6 @@
|
||||
pub mod interactable;
|
||||
pub mod settings_change;
|
||||
mod target;
|
||||
|
||||
use std::{cell::RefCell, collections::HashSet, rc::Rc, result::Result, sync::Arc, time::Duration};
|
||||
|
||||
@ -19,14 +21,11 @@ use common::{
|
||||
item::{tool::ToolKind, ItemDef, ItemDesc},
|
||||
ChatMsg, ChatType, InputKind, InventoryUpdateEvent, Pos, Stats, UtteranceKind, Vel,
|
||||
},
|
||||
consts::{MAX_MOUNT_RANGE, MAX_PICKUP_RANGE},
|
||||
consts::MAX_MOUNT_RANGE,
|
||||
outcome::Outcome,
|
||||
terrain::{Block, BlockKind},
|
||||
trade::TradeResult,
|
||||
util::{
|
||||
find_dist::{Cube, Cylinder, FindDist},
|
||||
Dir, Plane,
|
||||
},
|
||||
util::{Dir, Plane},
|
||||
vol::ReadVol,
|
||||
};
|
||||
use common_base::{prof_span, span};
|
||||
@ -49,7 +48,9 @@ use crate::{
|
||||
Direction, GlobalState, PlayState, PlayStateResult,
|
||||
};
|
||||
use hashbrown::HashMap;
|
||||
use interactable::{select_interactable, Interactable};
|
||||
use settings_change::Language::ChangeLanguage;
|
||||
use target::targets_under_cursor;
|
||||
#[cfg(feature = "egui-ui")]
|
||||
use voxygen_egui::EguiDebugInfo;
|
||||
|
||||
@ -410,41 +411,56 @@ impl PlayState for SessionState {
|
||||
.map_or(false, |tool| tool.kind == ToolKind::Pick)
|
||||
&& client.is_wielding() == Some(true);
|
||||
|
||||
drop(client);
|
||||
|
||||
// Check to see whether we're aiming at anything
|
||||
let (build_pos, select_pos, target_entity) =
|
||||
under_cursor(&self.client.borrow(), cam_pos, cam_dir, |b| {
|
||||
b.is_filled()
|
||||
|| if is_mining {
|
||||
b.mine_tool().is_some()
|
||||
} else {
|
||||
b.is_collectible()
|
||||
}
|
||||
});
|
||||
self.inputs.select_pos = select_pos;
|
||||
// Throw out distance info, it will be useful in the future
|
||||
self.target_entity = target_entity.map(|x| x.0);
|
||||
let (build_target, collect_target, entity_target, mine_target, terrain_target) =
|
||||
targets_under_cursor(&client, cam_pos, cam_dir, can_build, is_mining);
|
||||
|
||||
self.interactable = select_interactable(
|
||||
&self.client.borrow(),
|
||||
target_entity,
|
||||
select_pos.map(|sp| sp.map(|e| e.floor() as i32)),
|
||||
&client,
|
||||
collect_target,
|
||||
entity_target,
|
||||
mine_target,
|
||||
&self.scene,
|
||||
|b| b.is_collectible() || (is_mining && b.mine_tool().is_some()),
|
||||
);
|
||||
|
||||
// Only highlight interactables
|
||||
// unless in build mode where select_pos highlighted
|
||||
self.scene.set_select_pos(
|
||||
select_pos
|
||||
.map(|sp| sp.map(|e| e.floor() as i32))
|
||||
.filter(|_| can_build || is_mining)
|
||||
.or_else(|| match self.interactable {
|
||||
Some(Interactable::Block(_, block_pos, _)) => Some(block_pos),
|
||||
_ => None,
|
||||
}),
|
||||
);
|
||||
drop(client);
|
||||
|
||||
// Nearest block to consider with GameInput primary or secondary key.
|
||||
let nearest_block_dist = find_shortest_distance(&[
|
||||
mine_target.filter(|_| is_mining).map(|t| t.distance),
|
||||
build_target.filter(|_| can_build).map(|t| t.distance),
|
||||
]);
|
||||
// Nearest block to be highlighted in the scene (self.scene.set_select_pos).
|
||||
let nearest_scene_dist = find_shortest_distance(&[
|
||||
nearest_block_dist,
|
||||
collect_target.filter(|_| !is_mining).map(|t| t.distance),
|
||||
]);
|
||||
// Set break_block_pos only if mining is closest.
|
||||
self.inputs.break_block_pos = if let Some(mt) =
|
||||
mine_target.filter(|mt| is_mining && nearest_scene_dist == Some(mt.distance))
|
||||
{
|
||||
self.scene.set_select_pos(Some(mt.position_int()));
|
||||
Some(mt.position)
|
||||
} else if let Some(bt) =
|
||||
build_target.filter(|bt| can_build && nearest_scene_dist == Some(bt.distance))
|
||||
{
|
||||
self.scene.set_select_pos(Some(bt.position_int()));
|
||||
None
|
||||
} else if let Some(ct) =
|
||||
collect_target.filter(|ct| nearest_scene_dist == Some(ct.distance))
|
||||
{
|
||||
self.scene.set_select_pos(Some(ct.position_int()));
|
||||
None
|
||||
} else {
|
||||
self.scene.set_select_pos(None);
|
||||
None
|
||||
};
|
||||
|
||||
// filled block in line of sight
|
||||
let default_select_pos = terrain_target.map(|tt| tt.position);
|
||||
|
||||
// Throw out distance info, it will be useful in the future
|
||||
self.target_entity = entity_target.map(|t| t.kind.0);
|
||||
|
||||
// Handle window events.
|
||||
for event in events {
|
||||
@ -463,61 +479,62 @@ impl PlayState for SessionState {
|
||||
if !self.inputs_state.insert(input) {
|
||||
self.inputs_state.remove(&input);
|
||||
}
|
||||
|
||||
match input {
|
||||
GameInput::Primary => {
|
||||
// If we can build, use LMB to break blocks, if not, use it to
|
||||
// attack
|
||||
let mut client = self.client.borrow_mut();
|
||||
if state && can_build {
|
||||
if let Some(select_pos) = select_pos {
|
||||
client.remove_block(select_pos.map(|e| e.floor() as i32));
|
||||
}
|
||||
// Mine and build targets can be the same block. make building take
|
||||
// precedence.
|
||||
// Order of precedence: build, then mining, then attack.
|
||||
if let Some(build_target) = build_target.filter(|bt| {
|
||||
state && can_build && nearest_block_dist == Some(bt.distance)
|
||||
}) {
|
||||
client.remove_block(build_target.position_int());
|
||||
} else {
|
||||
client.handle_input(
|
||||
InputKind::Primary,
|
||||
state,
|
||||
select_pos,
|
||||
target_entity.map(|t| t.0),
|
||||
default_select_pos,
|
||||
self.target_entity,
|
||||
);
|
||||
}
|
||||
},
|
||||
GameInput::Secondary => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
|
||||
if state && can_build {
|
||||
if let Some(build_pos) = build_pos {
|
||||
client.place_block(
|
||||
build_pos.map(|e| e.floor() as i32),
|
||||
self.selected_block,
|
||||
);
|
||||
}
|
||||
if let Some(build_target) = build_target.filter(|bt| {
|
||||
state && can_build && nearest_block_dist == Some(bt.distance)
|
||||
}) {
|
||||
let selected_pos = build_target.kind.0;
|
||||
client.place_block(
|
||||
selected_pos.map(|p| p.floor() as i32),
|
||||
self.selected_block,
|
||||
);
|
||||
} else {
|
||||
client.handle_input(
|
||||
InputKind::Secondary,
|
||||
state,
|
||||
select_pos,
|
||||
target_entity.map(|t| t.0),
|
||||
default_select_pos,
|
||||
self.target_entity,
|
||||
);
|
||||
}
|
||||
},
|
||||
GameInput::Block => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.handle_input(
|
||||
self.client.borrow_mut().handle_input(
|
||||
InputKind::Block,
|
||||
state,
|
||||
select_pos,
|
||||
target_entity.map(|t| t.0),
|
||||
None,
|
||||
self.target_entity,
|
||||
);
|
||||
},
|
||||
GameInput::Roll => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
if can_build {
|
||||
if state {
|
||||
if let Some(block) = select_pos.and_then(|sp| {
|
||||
if let Some(block) = build_target.and_then(|bt| {
|
||||
client
|
||||
.state()
|
||||
.terrain()
|
||||
.get(sp.map(|e| e.floor() as i32))
|
||||
.get(bt.position_int())
|
||||
.ok()
|
||||
.copied()
|
||||
}) {
|
||||
@ -528,8 +545,8 @@ impl PlayState for SessionState {
|
||||
client.handle_input(
|
||||
InputKind::Roll,
|
||||
state,
|
||||
select_pos,
|
||||
target_entity.map(|t| t.0),
|
||||
None,
|
||||
self.target_entity,
|
||||
);
|
||||
}
|
||||
},
|
||||
@ -540,12 +557,11 @@ impl PlayState for SessionState {
|
||||
}
|
||||
},
|
||||
GameInput::Jump => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.handle_input(
|
||||
self.client.borrow_mut().handle_input(
|
||||
InputKind::Jump,
|
||||
state,
|
||||
select_pos,
|
||||
target_entity.map(|t| t.0),
|
||||
None,
|
||||
self.target_entity,
|
||||
);
|
||||
},
|
||||
GameInput::SwimUp => {
|
||||
@ -616,12 +632,11 @@ impl PlayState for SessionState {
|
||||
// Syncing of inputs between mounter and mountee
|
||||
// broke with controller change
|
||||
self.key_state.fly ^= state;
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.handle_input(
|
||||
self.client.borrow_mut().handle_input(
|
||||
InputKind::Fly,
|
||||
self.key_state.fly,
|
||||
select_pos,
|
||||
target_entity.map(|t| t.0),
|
||||
None,
|
||||
self.target_entity,
|
||||
);
|
||||
},
|
||||
GameInput::Climb => {
|
||||
@ -694,17 +709,18 @@ impl PlayState for SessionState {
|
||||
match interactable {
|
||||
Interactable::Block(block, pos, interaction) => {
|
||||
match interaction {
|
||||
Interaction::Collect => {
|
||||
Some(Interaction::Collect) => {
|
||||
if block.is_collectible() {
|
||||
client.collect_block(pos);
|
||||
}
|
||||
},
|
||||
Interaction::Craft(tab) => {
|
||||
Some(Interaction::Craft(tab)) => {
|
||||
self.hud.show.open_crafting_tab(
|
||||
tab,
|
||||
block.get_sprite().map(|s| (pos, s)),
|
||||
)
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
},
|
||||
Interactable::Entity(entity) => {
|
||||
@ -1114,12 +1130,10 @@ impl PlayState for SessionState {
|
||||
},
|
||||
|
||||
HudEvent::RemoveBuff(buff_id) => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.remove_buff(buff_id);
|
||||
self.client.borrow_mut().remove_buff(buff_id);
|
||||
},
|
||||
HudEvent::UnlockSkill(skill) => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.unlock_skill(skill);
|
||||
self.client.borrow_mut().unlock_skill(skill);
|
||||
},
|
||||
HudEvent::UseSlot {
|
||||
slot,
|
||||
@ -1345,31 +1359,27 @@ impl PlayState for SessionState {
|
||||
info!("Event! -> ChangedHotbarState")
|
||||
},
|
||||
HudEvent::TradeAction(action) => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.perform_trade_action(action);
|
||||
self.client.borrow_mut().perform_trade_action(action);
|
||||
},
|
||||
HudEvent::Ability3(state) => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.handle_input(
|
||||
self.client.borrow_mut().handle_input(
|
||||
InputKind::Ability(0),
|
||||
state,
|
||||
select_pos,
|
||||
target_entity.map(|t| t.0),
|
||||
default_select_pos,
|
||||
self.target_entity,
|
||||
);
|
||||
},
|
||||
HudEvent::Ability4(state) => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.handle_input(
|
||||
self.client.borrow_mut().handle_input(
|
||||
InputKind::Ability(1),
|
||||
state,
|
||||
select_pos,
|
||||
target_entity.map(|t| t.0),
|
||||
default_select_pos,
|
||||
self.target_entity,
|
||||
);
|
||||
},
|
||||
|
||||
HudEvent::RequestSiteInfo(id) => {
|
||||
let mut client = self.client.borrow_mut();
|
||||
client.request_site_economy(id);
|
||||
self.client.borrow_mut().request_site_economy(id);
|
||||
},
|
||||
|
||||
HudEvent::CraftRecipe {
|
||||
@ -1532,256 +1542,8 @@ impl PlayState for SessionState {
|
||||
fn egui_enabled(&self) -> bool { true }
|
||||
}
|
||||
|
||||
/// Max distance an entity can be "targeted"
|
||||
const MAX_TARGET_RANGE: f32 = 300.0;
|
||||
/// Calculate what the cursor is pointing at within the 3d scene
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn under_cursor(
|
||||
client: &Client,
|
||||
cam_pos: Vec3<f32>,
|
||||
cam_dir: Vec3<f32>,
|
||||
mut hit: impl FnMut(Block) -> bool,
|
||||
) -> (
|
||||
Option<Vec3<f32>>,
|
||||
Option<Vec3<f32>>,
|
||||
Option<(specs::Entity, f32)>,
|
||||
) {
|
||||
span!(_guard, "under_cursor");
|
||||
// Choose a spot above the player's head for item distance checks
|
||||
let player_entity = client.entity();
|
||||
let ecs = client.state().ecs();
|
||||
let positions = ecs.read_storage::<comp::Pos>();
|
||||
let player_pos = match positions.get(player_entity) {
|
||||
Some(pos) => pos.0,
|
||||
None => cam_pos, // Should never happen, but a safe fallback
|
||||
};
|
||||
let scales = ecs.read_storage();
|
||||
let colliders = ecs.read_storage();
|
||||
let char_states = ecs.read_storage();
|
||||
// Get the player's cylinder
|
||||
let player_cylinder = Cylinder::from_components(
|
||||
player_pos,
|
||||
scales.get(player_entity).copied(),
|
||||
colliders.get(player_entity),
|
||||
char_states.get(player_entity),
|
||||
);
|
||||
let terrain = client.state().terrain();
|
||||
|
||||
let cam_ray = terrain
|
||||
.ray(cam_pos, cam_pos + cam_dir * 100.0)
|
||||
.until(|block| hit(*block))
|
||||
.cast();
|
||||
|
||||
let cam_dist = cam_ray.0;
|
||||
|
||||
// The ray hit something, is it within range?
|
||||
let (build_pos, select_pos) = if matches!(cam_ray.1, Ok(Some(_)) if
|
||||
player_cylinder.min_distance(cam_pos + cam_dir * (cam_dist + 0.01))
|
||||
<= MAX_PICKUP_RANGE)
|
||||
{
|
||||
(
|
||||
Some(cam_pos + cam_dir * (cam_dist - 0.01)),
|
||||
Some(cam_pos + cam_dir * (cam_dist + 0.01)),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// See if ray hits entities
|
||||
// Currently treated as spheres
|
||||
// Don't cast through blocks
|
||||
// Could check for intersection with entity from last frame to narrow this down
|
||||
let cast_dist = if let Ok(Some(_)) = cam_ray.1 {
|
||||
cam_dist.min(MAX_TARGET_RANGE)
|
||||
} else {
|
||||
MAX_TARGET_RANGE
|
||||
};
|
||||
|
||||
// Need to raycast by distance to cam
|
||||
// But also filter out by distance to the player (but this only needs to be done
|
||||
// on final result)
|
||||
let mut nearby = (
|
||||
&ecs.entities(),
|
||||
&positions,
|
||||
scales.maybe(),
|
||||
&ecs.read_storage::<comp::Body>(),
|
||||
ecs.read_storage::<comp::Item>().maybe(),
|
||||
)
|
||||
.join()
|
||||
.filter(|(e, _, _, _, _)| *e != player_entity)
|
||||
.filter_map(|(e, p, s, b, i)| {
|
||||
const RADIUS_SCALE: f32 = 3.0;
|
||||
// TODO: use collider radius instead of body radius?
|
||||
let radius = s.map_or(1.0, |s| s.0) * b.max_radius() * RADIUS_SCALE;
|
||||
// Move position up from the feet
|
||||
let pos = Vec3::new(p.0.x, p.0.y, p.0.z + radius);
|
||||
// Distance squared from camera to the entity
|
||||
let dist_sqr = pos.distance_squared(cam_pos);
|
||||
// We only care about interacting with entities that contain items,
|
||||
// or are not inanimate (to trade with)
|
||||
if i.is_some() || !matches!(b, comp::Body::Object(_)) {
|
||||
Some((e, pos, radius, dist_sqr))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
// Roughly filter out entities farther than ray distance
|
||||
.filter(|(_, _, r, d_sqr)| *d_sqr <= cast_dist.powi(2) + 2.0 * cast_dist * r + r.powi(2))
|
||||
// Ignore entities intersecting the camera
|
||||
.filter(|(_, _, r, d_sqr)| *d_sqr > r.powi(2))
|
||||
// Substract sphere radius from distance to the camera
|
||||
.map(|(e, p, r, d_sqr)| (e, p, r, d_sqr.sqrt() - r))
|
||||
.collect::<Vec<_>>();
|
||||
// Sort by distance
|
||||
nearby.sort_unstable_by(|a, b| a.3.partial_cmp(&b.3).unwrap());
|
||||
|
||||
let seg_ray = LineSegment3 {
|
||||
start: cam_pos,
|
||||
end: cam_pos + cam_dir * cam_dist,
|
||||
};
|
||||
// TODO: fuzzy borders
|
||||
let target_entity = nearby
|
||||
.iter()
|
||||
.map(|(e, p, r, _)| (e, *p, r))
|
||||
// Find first one that intersects the ray segment
|
||||
.find(|(_, p, r)| seg_ray.projected_point(*p).distance_squared(*p) < r.powi(2))
|
||||
.and_then(|(e, p, _)| {
|
||||
// Get the entity's cylinder
|
||||
let target_cylinder = Cylinder::from_components(
|
||||
p,
|
||||
scales.get(*e).copied(),
|
||||
colliders.get(*e),
|
||||
char_states.get(*e),
|
||||
);
|
||||
|
||||
let dist_to_player = player_cylinder.min_distance(target_cylinder);
|
||||
(dist_to_player < MAX_TARGET_RANGE).then_some((*e, dist_to_player))
|
||||
});
|
||||
|
||||
// TODO: consider setting build/select to None when targeting an entity
|
||||
(build_pos, select_pos, target_entity)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Interactable {
|
||||
Block(Block, Vec3<i32>, Interaction),
|
||||
Entity(specs::Entity),
|
||||
}
|
||||
|
||||
impl Interactable {
|
||||
pub fn entity(self) -> Option<specs::Entity> {
|
||||
match self {
|
||||
Self::Entity(e) => Some(e),
|
||||
Self::Block(_, _, _) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Select interactable to hightlight, display interaction text for, and to
|
||||
/// interact with if the interact key is pressed
|
||||
/// Selected in the following order
|
||||
/// 1) Targeted entity (if interactable) (entities can't be target through
|
||||
/// blocks) 2) Selected block (if interactabl)
|
||||
/// 3) Closest of nearest interactable entity/block
|
||||
fn select_interactable(
|
||||
client: &Client,
|
||||
target_entity: Option<(specs::Entity, f32)>,
|
||||
selected_pos: Option<Vec3<i32>>,
|
||||
scene: &Scene,
|
||||
mut hit: impl FnMut(Block) -> bool,
|
||||
) -> Option<Interactable> {
|
||||
span!(_guard, "select_interactable");
|
||||
// TODO: once there are multiple distances for different types of interactions
|
||||
// this code will need to be revamped to cull things by varying distances
|
||||
// based on the types of interactions available for those things
|
||||
use common::{spiral::Spiral2d, terrain::TerrainChunk, vol::RectRasterableVol};
|
||||
target_entity
|
||||
.and_then(|(e, dist_to_player)| (dist_to_player < MAX_PICKUP_RANGE).then_some(Interactable::Entity(e)))
|
||||
.or_else(|| selected_pos.and_then(|sp|
|
||||
client.state().terrain().get(sp).ok().copied()
|
||||
.filter(|b| hit(*b))
|
||||
.map(|b| Interactable::Block(b, sp, Interaction::Collect))
|
||||
))
|
||||
.or_else(|| {
|
||||
let ecs = client.state().ecs();
|
||||
let player_entity = client.entity();
|
||||
let positions = ecs.read_storage::<comp::Pos>();
|
||||
let player_pos = positions.get(player_entity)?.0;
|
||||
|
||||
let scales = ecs.read_storage::<comp::Scale>();
|
||||
let colliders = ecs.read_storage::<comp::Collider>();
|
||||
let char_states = ecs.read_storage::<comp::CharacterState>();
|
||||
|
||||
let player_cylinder = Cylinder::from_components(
|
||||
player_pos,
|
||||
scales.get(player_entity).copied(),
|
||||
colliders.get(player_entity),
|
||||
char_states.get(player_entity),
|
||||
);
|
||||
|
||||
let closest_interactable_entity = (
|
||||
&ecs.entities(),
|
||||
&positions,
|
||||
scales.maybe(),
|
||||
colliders.maybe(),
|
||||
char_states.maybe(),
|
||||
)
|
||||
.join()
|
||||
.filter(|(e, _, _, _, _)| *e != player_entity)
|
||||
.map(|(e, p, s, c, cs)| {
|
||||
let cylinder = Cylinder::from_components(p.0, s.copied(), c, cs);
|
||||
(e, cylinder)
|
||||
})
|
||||
// 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
|
||||
let search_dist = closest_interactable_entity
|
||||
.map_or(MAX_PICKUP_RANGE, |(_, dist)| dist);
|
||||
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();
|
||||
|
||||
// Find closest interactable block
|
||||
// TODO: consider doing this one first?
|
||||
let closest_interactable_block_pos = Spiral2d::new()
|
||||
// TODO: this formula for the number to take was guessed
|
||||
// Note: assume RECT_SIZE.x == RECT_SIZE.y
|
||||
.take(((search_dist / TerrainChunk::RECT_SIZE.x as f32).ceil() as usize * 2 + 1).pow(2))
|
||||
.flat_map(|offset| {
|
||||
let chunk_pos = player_chunk + offset;
|
||||
let chunk_voxel_pos =
|
||||
Vec3::<i32>::from(chunk_pos * TerrainChunk::RECT_SIZE.map(|e| e as i32));
|
||||
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
|
||||
.flat_map(|(chunk_data, chunk_pos)| {
|
||||
chunk_data
|
||||
.blocks_of_interest
|
||||
.interactables
|
||||
.iter()
|
||||
.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,
|
||||
))
|
||||
.min_by_key(|(_, dist_sqr, _)| OrderedFloat(*dist_sqr))
|
||||
.map(|(block_pos, _, interaction)| (block_pos, interaction));
|
||||
|
||||
// Pick closer one if they exist
|
||||
closest_interactable_block_pos
|
||||
.filter(|(block_pos, _)| player_cylinder.min_distance(Cube { min: block_pos.as_(), side_length: 1.0}) < search_dist)
|
||||
.and_then(|(block_pos, interaction)|
|
||||
client.state().terrain().get(block_pos).ok().copied()
|
||||
.map(|b| Interactable::Block(b, block_pos, *interaction))
|
||||
)
|
||||
.or_else(|| closest_interactable_entity.map(|(e, _)| Interactable::Entity(e)))
|
||||
})
|
||||
fn find_shortest_distance(arr: &[Option<f32>]) -> Option<f32> {
|
||||
arr.iter()
|
||||
.filter_map(|x| *x)
|
||||
.min_by(|d1, d2| OrderedFloat(*d1).cmp(&OrderedFloat(*d2)))
|
||||
}
|
||||
|
230
voxygen/src/session/target.rs
Normal file
230
voxygen/src/session/target.rs
Normal file
@ -0,0 +1,230 @@
|
||||
use specs::{Join, WorldExt};
|
||||
use vek::*;
|
||||
|
||||
use client::{self, Client};
|
||||
use common::{
|
||||
comp,
|
||||
consts::MAX_PICKUP_RANGE,
|
||||
terrain::Block,
|
||||
util::find_dist::{Cylinder, FindDist},
|
||||
vol::ReadVol,
|
||||
};
|
||||
use common_base::span;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Target<T> {
|
||||
pub kind: T,
|
||||
pub distance: f32,
|
||||
pub position: Vec3<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Build(pub Vec3<f32>);
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Collectable;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Entity(pub specs::Entity);
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Mine;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
// line of sight (if not bocked by entity). Not build/mine mode dependent.
|
||||
pub struct Terrain;
|
||||
|
||||
impl<T> Target<T> {
|
||||
pub fn position_int(self) -> Vec3<i32> { self.position.map(|p| p.floor() as i32) }
|
||||
}
|
||||
|
||||
/// Max distance an entity can be "targeted"
|
||||
pub const MAX_TARGET_RANGE: f32 = 300.0;
|
||||
|
||||
/// Calculate what the cursor is pointing at within the 3d scene
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn targets_under_cursor(
|
||||
client: &Client,
|
||||
cam_pos: Vec3<f32>,
|
||||
cam_dir: Vec3<f32>,
|
||||
can_build: bool,
|
||||
is_mining: bool,
|
||||
) -> (
|
||||
Option<Target<Build>>,
|
||||
Option<Target<Collectable>>,
|
||||
Option<Target<Entity>>,
|
||||
Option<Target<Mine>>,
|
||||
Option<Target<Terrain>>,
|
||||
) {
|
||||
span!(_guard, "targets_under_cursor");
|
||||
// Choose a spot above the player's head for item distance checks
|
||||
let player_entity = client.entity();
|
||||
let ecs = client.state().ecs();
|
||||
let positions = ecs.read_storage::<comp::Pos>();
|
||||
let player_pos = match positions.get(player_entity) {
|
||||
Some(pos) => pos.0,
|
||||
None => cam_pos, // Should never happen, but a safe fallback
|
||||
};
|
||||
let scales = ecs.read_storage();
|
||||
let colliders = ecs.read_storage();
|
||||
let char_states = ecs.read_storage();
|
||||
// Get the player's cylinder
|
||||
let player_cylinder = Cylinder::from_components(
|
||||
player_pos,
|
||||
scales.get(player_entity).copied(),
|
||||
colliders.get(player_entity),
|
||||
char_states.get(player_entity),
|
||||
);
|
||||
let terrain = client.state().terrain();
|
||||
|
||||
let find_pos = |hit: fn(Block) -> bool| {
|
||||
let cam_ray = terrain
|
||||
.ray(cam_pos, cam_pos + cam_dir * 100.0)
|
||||
.until(|block| hit(*block))
|
||||
.cast();
|
||||
let cam_ray = (cam_ray.0, cam_ray.1.map(|x| x.copied()));
|
||||
let cam_dist = cam_ray.0;
|
||||
|
||||
if matches!(
|
||||
cam_ray.1,
|
||||
Ok(Some(_)) if player_cylinder.min_distance(cam_pos + cam_dir * (cam_dist + 0.01)) <= MAX_PICKUP_RANGE
|
||||
) {
|
||||
(
|
||||
Some(cam_pos + cam_dir * (cam_dist + 0.01)),
|
||||
Some(cam_pos + cam_dir * (cam_dist - 0.01)),
|
||||
Some(cam_ray),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
}
|
||||
};
|
||||
|
||||
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));
|
||||
let (solid_pos, place_block_pos, solid_cam_ray) = find_pos(|b: Block| b.is_filled());
|
||||
|
||||
// See if ray hits entities
|
||||
// Don't cast through blocks, (hence why use shortest_cam_dist from non-entity
|
||||
// targets) Could check for intersection with entity from last frame to
|
||||
// narrow this down
|
||||
let cast_dist = solid_cam_ray
|
||||
.as_ref()
|
||||
.map(|(d, _)| d.min(MAX_TARGET_RANGE))
|
||||
.unwrap_or(MAX_TARGET_RANGE);
|
||||
|
||||
// Need to raycast by distance to cam
|
||||
// But also filter out by distance to the player (but this only needs to be done
|
||||
// on final result)
|
||||
let mut nearby = (
|
||||
&ecs.entities(),
|
||||
&positions,
|
||||
scales.maybe(),
|
||||
&ecs.read_storage::<comp::Body>(),
|
||||
ecs.read_storage::<comp::Item>().maybe(),
|
||||
)
|
||||
.join()
|
||||
.filter(|(e, _, _, _, _)| *e != player_entity)
|
||||
.filter_map(|(e, p, s, b, i)| {
|
||||
const RADIUS_SCALE: f32 = 3.0;
|
||||
// TODO: use collider radius instead of body radius?
|
||||
let radius = s.map_or(1.0, |s| s.0) * b.max_radius() * RADIUS_SCALE;
|
||||
// Move position up from the feet
|
||||
let pos = Vec3::new(p.0.x, p.0.y, p.0.z + radius);
|
||||
// Distance squared from camera to the entity
|
||||
let dist_sqr = pos.distance_squared(cam_pos);
|
||||
// We only care about interacting with entities that contain items,
|
||||
// or are not inanimate (to trade with)
|
||||
if i.is_some() || !matches!(b, comp::Body::Object(_)) {
|
||||
Some((e, pos, radius, dist_sqr))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
// Roughly filter out entities farther than ray distance
|
||||
.filter(|(_, _, r, d_sqr)| *d_sqr <= cast_dist.powi(2) + 2.0 * cast_dist * r + r.powi(2))
|
||||
// Ignore entities intersecting the camera
|
||||
.filter(|(_, _, r, d_sqr)| *d_sqr > r.powi(2))
|
||||
// Substract sphere radius from distance to the camera
|
||||
.map(|(e, p, r, d_sqr)| (e, p, r, d_sqr.sqrt() - r))
|
||||
.collect::<Vec<_>>();
|
||||
// Sort by distance
|
||||
nearby.sort_unstable_by(|a, b| a.3.partial_cmp(&b.3).unwrap());
|
||||
|
||||
let seg_ray = LineSegment3 {
|
||||
start: cam_pos,
|
||||
end: cam_pos + cam_dir * cast_dist,
|
||||
};
|
||||
// TODO: fuzzy borders
|
||||
let entity_target = nearby
|
||||
.iter()
|
||||
.map(|(e, p, r, _)| (e, *p, r))
|
||||
// Find first one that intersects the ray segment
|
||||
.find(|(_, p, r)| seg_ray.projected_point(*p).distance_squared(*p) < r.powi(2))
|
||||
.and_then(|(e, p, _)| {
|
||||
// Get the entity's cylinder
|
||||
let target_cylinder = Cylinder::from_components(
|
||||
p,
|
||||
scales.get(*e).copied(),
|
||||
colliders.get(*e),
|
||||
char_states.get(*e),
|
||||
);
|
||||
|
||||
let dist_to_player = player_cylinder.min_distance(target_cylinder);
|
||||
if dist_to_player < MAX_TARGET_RANGE {
|
||||
Some(Target {
|
||||
kind: Entity(*e),
|
||||
position: p,
|
||||
distance: dist_to_player,
|
||||
})
|
||||
} else { None }
|
||||
});
|
||||
|
||||
let solid_ray_dist = solid_cam_ray.map(|r| r.0);
|
||||
let terrain_target = if let (None, Some(distance)) = (entity_target, solid_ray_dist) {
|
||||
solid_pos.map(|position| Target {
|
||||
kind: Terrain,
|
||||
distance,
|
||||
position,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let build_target = if let (true, Some(distance)) = (can_build, solid_ray_dist) {
|
||||
place_block_pos
|
||||
.zip(solid_pos)
|
||||
.map(|(place_pos, position)| Target {
|
||||
kind: Build(place_pos),
|
||||
distance,
|
||||
position,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let collect_target = collect_pos
|
||||
.zip(collect_cam_ray)
|
||||
.map(|(position, ray)| Target {
|
||||
kind: Collectable,
|
||||
distance: ray.0,
|
||||
position,
|
||||
});
|
||||
|
||||
let mine_target = mine_pos.zip(mine_cam_ray).map(|(position, ray)| Target {
|
||||
kind: Mine,
|
||||
distance: ray.0,
|
||||
position,
|
||||
});
|
||||
|
||||
// Return multiple possible targets
|
||||
// GameInput events determine which target to use.
|
||||
(
|
||||
build_target,
|
||||
collect_target,
|
||||
entity_target,
|
||||
mine_target,
|
||||
terrain_target,
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue
Block a user