diff --git a/assets/voxygen/i18n/en/hud/settings.ftl b/assets/voxygen/i18n/en/hud/settings.ftl index c5a4866dbf..f0e3d6c693 100644 --- a/assets/voxygen/i18n/en/hud/settings.ftl +++ b/assets/voxygen/i18n/en/hud/settings.ftl @@ -57,6 +57,8 @@ hud-settings-walking_speed_behavior = Walking speed behavior hud-settings-walking_speed = Walking speed hud-settings-camera_clamp_behavior = Camera clamp behavior hud-settings-zoom_lock_behavior = Camera zoom lock behavior +hud-settings-aim_offset_x = Horizontal Aim Offset +hud-settings-aim_offset_y = Vertical Aim Offset hud-settings-player_physics_behavior = Player physics (experimental) hud-settings-stop_auto_walk_on_input = Stop auto walk on movement hud-settings-auto_camera = Auto camera @@ -159,4 +161,4 @@ hud-settings-group_only = Group only hud-settings-reset_chat = Reset to Defaults hud-settings-third_party_integrations = Third-party Integrations hud-settings-enable_discord_integration = Enable Discord Integration -hud-settings-subtitles = Subtitles +hud-settings-subtitles = Subtitles \ No newline at end of file diff --git a/common/systems/src/phys.rs b/common/systems/src/phys.rs index f12a5738e0..ed87707b8b 100644 --- a/common/systems/src/phys.rs +++ b/common/systems/src/phys.rs @@ -2200,6 +2200,43 @@ fn closest_points(n: LineSegment2, m: LineSegment2) -> (Vec2, Vec } } +// Get closest point between 2 3D line segments https://math.stackexchange.com/a/4289668 +pub fn closest_points_3d(n: LineSegment3, m: LineSegment3) -> (Vec3, Vec3) { + let p1 = n.start; + let p2 = n.end; + let p3 = m.start; + let p4 = m.end; + + let d1 = p2 - p1; + let d2 = p4 - p3; + let d21 = p3 - p1; + + let v22 = d2.dot(d2); + let v11 = d1.dot(d1); + let v21 = d2.dot(d1); + let v21_1 = d21.dot(d1); + let v21_2 = d21.dot(d2); + + let denom = v21 * v21 - v22 * v11; + + let (s, t) = if denom == 0.0 { + let s = 0.0; + let t = (v11 * s - v21_1) / v21; + (s, t) + } else { + let s = (v21_2 * v21 - v22 * v21_1) / denom; + let t = (-v21_1 * v21 + v11 * v21_2) / denom; + (s, t) + }; + + let (s, t) = (s.clamp(0.0, 1.0), t.clamp(0.0, 1.0)); + + let p_a = p1 + s * d1; + let p_b = p3 + t * d2; + + (p_a, p_b) +} + /// Find pushback vector and collision_distance we assume between this /// colliders assuming that only one of them is capsule prism. fn capsule2cylinder(c0: ColliderContext, c1: ColliderContext) -> (Vec2, f32) { diff --git a/voxygen/src/hud/settings_window/gameplay.rs b/voxygen/src/hud/settings_window/gameplay.rs index d64d4614c6..bd3d641af0 100644 --- a/voxygen/src/hud/settings_window/gameplay.rs +++ b/voxygen/src/hud/settings_window/gameplay.rs @@ -59,6 +59,12 @@ widget_ids! { bow_zoom_label, zoom_lock_button, zoom_lock_label, + aim_offset_x_slider, + aim_offset_x_label, + aim_offset_x_value, + aim_offset_y_slider, + aim_offset_y_label, + aim_offset_y_value, } } @@ -662,12 +668,78 @@ impl<'a> Widget for Gameplay<'a> { .color(TEXT_COLOR) .set(state.ids.zoom_lock_label, ui); + // Aim offset x + let display_aim_offset_x = self.global_state.settings.gameplay.aim_offset_x; + Text::new(&self.localized_strings.get_msg("hud-settings-aim_offset_x")) + .down_from(state.ids.zoom_lock_behavior_list, 10.0) + .font_size(self.fonts.cyri.scale(14)) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(state.ids.aim_offset_x_label, ui); + + if let Some(new_val) = ImageSlider::continuous( + display_aim_offset_x, + -3.0, + 3.0, + self.imgs.slider_indicator, + self.imgs.slider, + ) + .w_h(550.0, 22.0) + .down_from(state.ids.aim_offset_x_label, 10.0) + .track_breadth(30.0) + .slider_length(10.0) + .pad_track((5.0, 5.0)) + .set(state.ids.aim_offset_x_slider, ui) + { + events.push(AdjustAimOffsetX(new_val)); + } + + Text::new(&format!("{:.2}", display_aim_offset_x)) + .right_from(state.ids.aim_offset_x_slider, 8.0) + .font_size(self.fonts.cyri.scale(14)) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(state.ids.aim_offset_x_value, ui); + + // Aim offset y + let display_aim_offset_y = self.global_state.settings.gameplay.aim_offset_y; + Text::new(&self.localized_strings.get_msg("hud-settings-aim_offset_y")) + .down_from(state.ids.aim_offset_x_slider, 10.0) + .font_size(self.fonts.cyri.scale(14)) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(state.ids.aim_offset_y_label, ui); + + if let Some(new_val) = ImageSlider::continuous( + display_aim_offset_y, + -3.0, + 3.0, + self.imgs.slider_indicator, + self.imgs.slider, + ) + .w_h(550.0, 22.0) + .down_from(state.ids.aim_offset_y_label, 10.0) + .track_breadth(30.0) + .slider_length(10.0) + .pad_track((5.0, 5.0)) + .set(state.ids.aim_offset_y_slider, ui) + { + events.push(AdjustAimOffsetY(new_val)); + } + + Text::new(&format!("{:.2}", display_aim_offset_y)) + .right_from(state.ids.aim_offset_y_slider, 8.0) + .font_size(self.fonts.cyri.scale(14)) + .font_id(self.fonts.cyri.conrod_id) + .color(TEXT_COLOR) + .set(state.ids.aim_offset_y_value, ui); + // Reset the gameplay settings to the default settings if Button::image(self.imgs.button) .w_h(RESET_BUTTONS_WIDTH, RESET_BUTTONS_HEIGHT) .hover_image(self.imgs.button_hover) .press_image(self.imgs.button_press) - .down_from(state.ids.zoom_lock_behavior_list, 12.0) + .down_from(state.ids.aim_offset_y_slider, 12.0) .label( &self .localized_strings diff --git a/voxygen/src/scene/mod.rs b/voxygen/src/scene/mod.rs index 0b9aa85653..c87821d971 100644 --- a/voxygen/src/scene/mod.rs +++ b/voxygen/src/scene/mod.rs @@ -33,7 +33,10 @@ use crate::{ use client::Client; use common::{ calendar::Calendar, - comp::{self, ship::figuredata::VOXEL_COLLIDER_MANIFEST}, + comp::{ + self, item::ItemDesc, ship::figuredata::VOXEL_COLLIDER_MANIFEST, slot::EquipSlot, + tool::ToolKind, + }, outcome::Outcome, resources::{DeltaTime, TimeScale}, terrain::{BlockKind, TerrainChunk, TerrainGrid}, @@ -526,6 +529,7 @@ impl Scene { audio: &mut AudioFrontend, scene_data: &SceneData, client: &Client, + settings: &Settings, ) { span!(_guard, "maintain", "Scene::maintain"); // Get player position. @@ -621,6 +625,19 @@ impl Scene { .get(scene_data.viewpoint_entity) .map(|p| p.on_ground.is_some()); + let player_entity = client.entity(); + let holding_ranged = client + .inventories() + .get(player_entity) + .and_then(|inv| inv.equipped(EquipSlot::ActiveMainhand)) + .and_then(|item| item.tool_info()) + .is_some_and(|tool_kind| { + matches!( + tool_kind, + ToolKind::Bow | ToolKind::Staff | ToolKind::Sceptre + ) + }); + let up = match self.camera.get_mode() { CameraMode::FirstPerson => { if viewpoint_rolling { @@ -632,15 +649,29 @@ impl Scene { viewpoint_eye_height } }, + CameraMode::ThirdPerson if scene_data.is_aiming && holding_ranged => { + viewpoint_height * 1.16 + settings.gameplay.aim_offset_y + }, CameraMode::ThirdPerson if scene_data.is_aiming => viewpoint_height * 1.16, CameraMode::ThirdPerson => viewpoint_eye_height, CameraMode::Freefly => 0.0, }; + + let right = match self.camera.get_mode() { + CameraMode::FirstPerson => 0.0, + CameraMode::ThirdPerson if scene_data.is_aiming && holding_ranged => { + settings.gameplay.aim_offset_x + }, + CameraMode::ThirdPerson => 0.0, + CameraMode::Freefly => 0.0, + }; + // Alter camera position to match player. let tilt = self.camera.get_orientation().y; let dist = self.camera.get_distance(); Vec3::unit_z() * (up * viewpoint_scale - tilt.min(0.0).sin() * dist * 0.6) + + self.camera.right() * (right * viewpoint_scale) } else { self.figure_mgr .viewpoint_offset(scene_data, scene_data.viewpoint_entity) diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs index 1ec6afb16e..12213465cd 100644 --- a/voxygen/src/session/mod.rs +++ b/voxygen/src/session/mod.rs @@ -50,6 +50,7 @@ use crate::{ menu::char_selection::CharSelectionState, render::{Drawer, GlobalsBindGroup}, scene::{camera, CameraMode, DebugShapeId, Scene, SceneData}, + session::target::ray_entities, settings::Settings, window::{AnalogGameInput, Event}, Direction, GlobalState, PlayState, PlayStateResult, @@ -1349,8 +1350,88 @@ impl PlayState for SessionState { if !self.free_look { self.walk_forward_dir = self.scene.camera().forward_xy(); self.walk_right_dir = self.scene.camera().right_xy(); - self.inputs.look_dir = - Dir::from_unnormalized(cam_dir + aim_dir_offset).unwrap(); + + let client = self.client.borrow(); + + let holding_ranged = client + .inventories() + .get(player_entity) + .and_then(|inv| inv.equipped(EquipSlot::ActiveMainhand)) + .and_then(|item| item.tool_info()) + .is_some_and(|tool_kind| { + matches!( + tool_kind, + ToolKind::Bow | ToolKind::Staff | ToolKind::Sceptre + ) + }); + + let dir = if is_aiming + && holding_ranged + && self.scene.camera().get_mode() == CameraMode::ThirdPerson + { + // Shoot ray from camera focus forwards and get the point it hits an + // entity or terrain. The ray starts from the camera focus point + // so that the player won't aim at things behind them, in front of the + // camera. + let ray_start = self.scene.camera().get_focus_pos(); + let entity_ray_end = ray_start + cam_dir * 1000.0; + let terrain_ray_end = ray_start + cam_dir * 1000.0; + + let aim_point = { + // Get the distance to nearest entity and terrain + let entity_dist = + ray_entities(&client, ray_start, entity_ray_end, 1000.0).0; + let terrain_ray_distance = client + .state() + .terrain() + .ray(ray_start, terrain_ray_end) + .max_iter(1000) + .until(Block::is_solid) + .cast() + .0; + + // Return the hit point of whichever was smaller + ray_start + cam_dir * entity_dist.min(terrain_ray_distance) + }; + + // Get player orientation + let ori = client + .state() + .read_storage::() + .get(player_entity) + .copied() + .unwrap(); + // Get player scale + let scale = client + .state() + .read_storage::() + .get(player_entity) + .copied() + .unwrap_or(comp::Scale(1.0)); + // Get player body offsets + let body = client + .state() + .read_storage::() + .get(player_entity) + .copied() + .unwrap(); + let body_offsets = body.projectile_offsets(ori.look_vec(), scale.0); + + // Get direction from player character to aim point + let player_pos = client + .state() + .read_storage::() + .get(player_entity) + .copied() + .unwrap(); + + drop(client); + aim_point - (player_pos.0 + body_offsets) + } else { + cam_dir + aim_dir_offset + }; + + self.inputs.look_dir = Dir::from_unnormalized(dir).unwrap(); } } self.inputs.strafing = matches!( @@ -2040,6 +2121,7 @@ impl PlayState for SessionState { &mut global_state.audio, &scene_data, &client, + &global_state.settings, ); // Process outcomes from client diff --git a/voxygen/src/session/settings_change.rs b/voxygen/src/session/settings_change.rs index a4be0be0d8..0e7096230a 100644 --- a/voxygen/src/session/settings_change.rs +++ b/voxygen/src/session/settings_change.rs @@ -77,6 +77,9 @@ pub enum Gameplay { ChangeBowZoom(bool), ChangeZoomLock(bool), + AdjustAimOffsetX(f32), + AdjustAimOffsetY(f32), + ResetGameplaySettings, } #[derive(Clone)] @@ -427,6 +430,12 @@ impl SettingsChange { Gameplay::ChangeZoomLock(state) => { settings.gameplay.zoom_lock = state; }, + Gameplay::AdjustAimOffsetX(offset) => { + settings.gameplay.aim_offset_x = offset; + }, + Gameplay::AdjustAimOffsetY(offset) => { + settings.gameplay.aim_offset_y = offset; + }, Gameplay::ResetGameplaySettings => { // Reset Gameplay Settings settings.gameplay = GameplaySettings::default(); diff --git a/voxygen/src/session/target.rs b/voxygen/src/session/target.rs index c61f5920b2..bc9a9ff06d 100644 --- a/voxygen/src/session/target.rs +++ b/voxygen/src/session/target.rs @@ -13,6 +13,7 @@ use common::{ vol::ReadVol, }; use common_base::span; +use common_systems::phys::closest_points_3d; #[derive(Clone, Copy, Debug)] pub struct Target { @@ -238,3 +239,111 @@ pub(super) fn targets_under_cursor( terrain_target, ) } + +pub(super) fn ray_entities( + client: &Client, + start: Vec3, + end: Vec3, + cast_dist: f32, +) -> (f32, Option) { + let player_entity = client.entity(); + let ecs = client.state().ecs(); + let positions = ecs.read_storage::(); + let colliders = ecs.read_storage::(); + + let mut nearby = ( + &ecs.entities(), + &positions, + &colliders, + ) + .join() + .filter(|(e, _, _)| *e != player_entity) + .map(|(e, p, c)| { + let height = c.get_height(); + let radius = c.bounding_radius().max(height / 2.0); + // Move position up from the feet + let pos = Vec3::new(p.0.x, p.0.y, p.0.z + c.get_z_limits(1.0).0 + height/2.0); + // Distance squared from start to the entity + let dist_sqr = pos.distance_squared(start); + (e, pos, radius, dist_sqr, c) + }) + // Roughly filter out entities farther than ray distance + .filter(|(_, _, _, d_sqr, _)| *d_sqr <= cast_dist.powi(2)) + .collect::>(); + // Sort by distance + nearby.sort_unstable_by(|a, b| a.3.partial_cmp(&b.3).unwrap()); + + let seg_ray = LineSegment3 { start, end }; + + let entity = nearby.iter().find_map(|(e, p, r, _, c)| { + let nearest = seg_ray.projected_point(*p); + + return match c { + comp::Collider::CapsulePrism { + p0, + p1, + radius, + z_min, + z_max, + } => { + // Check if the nearest point is within the capsule's inclusive radius (radius + // from center to furthest possible edge corner) If not, then + // the ray doesn't intersect the capsule at all and we can skip it + if nearest.distance_squared(*p) > (r * 3.0_f32.sqrt()).powi(2) { + return None; + } + + let entity_rotation = ecs + .read_storage::() + .get(*e) + .copied() + .unwrap_or_default(); + let entity_position = ecs.read_storage::().get(*e).copied().unwrap(); + let world_p0 = entity_position.0 + + (entity_rotation.to_quat() + * Vec3::new(p0.x, p0.y, z_min + c.get_height() / 2.0)); + let world_p1 = entity_position.0 + + (entity_rotation.to_quat() + * Vec3::new(p1.x, p1.y, z_min + c.get_height() / 2.0)); + + // Get the closest points between the ray and the capsule's line segment + // If the capsule's line segment is a point, then the closest point is the point + // itself + let (p_a, p_b) = if p0 != p1 { + let seg_capsule = LineSegment3 { + start: world_p0, + end: world_p1, + }; + closest_points_3d(seg_ray, seg_capsule) + } else { + let nearest = seg_ray.projected_point(world_p0); + (nearest, world_p0) + }; + + // Check if the distance between the closest points are within the capsule + // prism's radius on the xy plane and if the closest points are + // within the capsule prism's z range + let distance = p_a.xy().distance_squared(p_b.xy()); + if distance < radius.powi(2) + && p_a.z >= entity_position.0.z + z_min + && p_a.z <= entity_position.0.z + z_max + { + return Some((p_a.distance(start), Entity(*e))); + } + + // If all else fails, then the ray doesn't intersect the capsule + None + }, + // TODO: handle other collider types, for now just use the bounding sphere + _ => { + if nearest.distance_squared(*p) < r.powi(2) { + return Some((nearest.distance(start), Entity(*e))); + } + None + }, + }; + }); + entity + .map(|(dist, e)| (dist, Some(e))) + .unwrap_or((cast_dist, None)) +} diff --git a/voxygen/src/settings/gameplay.rs b/voxygen/src/settings/gameplay.rs index ebb06d6627..839d50c260 100644 --- a/voxygen/src/settings/gameplay.rs +++ b/voxygen/src/settings/gameplay.rs @@ -21,6 +21,8 @@ pub struct GameplaySettings { pub auto_camera: bool, pub bow_zoom: bool, pub zoom_lock: bool, + pub aim_offset_x: f32, + pub aim_offset_y: f32, } impl Default for GameplaySettings { @@ -42,6 +44,8 @@ impl Default for GameplaySettings { auto_camera: false, bow_zoom: true, zoom_lock: false, + aim_offset_x: 1.0, + aim_offset_y: 0.0, } } }