diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index c26981e3..2cf17d1b 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -403,8 +403,12 @@ pub(crate) const REGISTERED: &[Registered] = &[ // The default matches; Deviation: here the dial multiplies the camera's own rate, because // benilla does not change the OS pointer speed, a system-wide setting. same("mousespeed", "1"), - // MAX_FOLLOW_DIST (`UIOptionsFrame.lua:90`), a factor over `cameraDistanceMax`'s 15 yd - // (`0x84fbd0`); registered "1.0" (`0x82e92c`). + // The zoom-out limit is `cameraDistanceMax × cameraDistanceMaxFactor`, held to [0, 50] + // (`0x5112d6`). `cameraDistanceMax` (`0x50beb2`, default `0x84fbd0`) has no panel row; its + // validator `0x50b310` refuses a value outside [0, 50], and `player::camera::on_cvar` does the + // same. The factor is MAX_FOLLOW_DIST (`UIOptionsFrame.lua:90`), registered "1.0" (`0x82e92c`) + // with no validator: the slider's 1 to 2 is the slider's, not the CVar's. + same("cameraDistanceMax", "15.0"), same("cameraDistanceMaxFactor", "1"), // `cameraSmoothStyle` (`0x50ba92`, default `[0x84f4f4]` "1"), the auto-return behind the // character. The engine's enum is 0 Never, 1 Smart, 2 Always, as the stock dropdown writes it @@ -1438,9 +1442,8 @@ mod tests { use crate::chat_bubble::BubbleConfig; use crate::minimap::MinimapZoom; use crate::nameplates::NameConfig; - use crate::player::camera::{ - FollowConfig, FollowStyle, LookConfig, ZoomLimit, FOLLOW_SPEED_RANGE, - }; + use crate::player::camera::{FollowConfig, FollowStyle, LookConfig, FOLLOW_SPEED_RANGE}; + use crate::player::camera_zoom::ZoomLimit; use crate::portrait::PaneRate; use crate::sound::SoundConfig; use crate::target::ClickConfig; @@ -1577,6 +1580,7 @@ mod tests { LookConfig::default().invert_pitch ); assert_eq!(d["mousespeed"], LookConfig::default().sensitivity); + assert_eq!(d["cameraDistanceMax"], ZoomLimit::default().distance_max()); assert_eq!(d["cameraDistanceMaxFactor"], ZoomLimit::default().factor()); let follow = FollowConfig::default(); assert_eq!( @@ -1812,11 +1816,25 @@ mod tests { res::(&app).yaw_speed, *FOLLOW_SPEED_RANGE.end() ); - // The max-orbit factor lands as YARDS on the knob (base 15 x factor), clamped to 1..2. + // The max orbit lands as yards on the knob: `cameraDistanceMax` x the factor, held to + // [0, 50]; the factor is not held to its slider's 1 to 2. apply(&mut app, "cameraDistanceMaxFactor", "1"); assert_eq!(res::(&app).max, 15.0); - apply(&mut app, "cameradistancemaxfactor", "5"); - assert_eq!(res::(&app).max, 30.0); + apply(&mut app, "cameradistancemaxfactor", "3"); + assert_eq!(res::(&app).max, 45.0); + apply(&mut app, "cameraDistanceMaxFactor", "1"); + apply(&mut app, "cameraDistanceMax", "25"); + assert_eq!(res::(&app).max, 25.0); + apply(&mut app, "cameraDistanceMaxFactor", "3"); + assert_eq!(res::(&app).max, 50.0); + apply(&mut app, "cameraDistanceMaxFactor", "-1"); + assert_eq!(res::(&app).max, 0.0); + apply(&mut app, "cameraDistanceMaxFactor", "nan"); + assert_eq!(res::(&app).max, 50.0); + // Out of `cameraDistanceMax`'s validator range: refused at the knob, 25 stands. + apply(&mut app, "cameraDistanceMaxFactor", "1"); + apply(&mut app, "cameraDistanceMax", "60"); + assert_eq!(res::(&app).max, 25.0); apply(&mut app, "autoLootDefault", "1"); assert!(res::(&app).auto_loot); apply(&mut app, "showLootSpam", "0"); diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index fe9b8351..53186fb9 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -30,6 +30,7 @@ mod body_pose; pub(crate) mod camera; mod camera_channel; pub(crate) mod camera_dynamics; +pub(crate) mod camera_zoom; mod controller; mod world_focus; // The remembered camera pose, inside `player/` to read the rig's `pub(super)` fields. @@ -70,10 +71,9 @@ pub(crate) use camera::apply_self_model_fade; use controller::control; // The private camera and `state` items are re-imported for the modules beside this one, which // name them `super::X`. -use camera::{ - apply_zoom_scroll, model_pivot_height, run_look_session, FlyCam, LookButton, CAM_DIST_DEFAULT, -}; pub(crate) use camera::{head_height, CameraControl, CameraPivot}; +use camera::{model_pivot_height, run_look_session, FlyCam, LookButton}; +use camera_zoom::{apply_zoom_scroll, CAM_DIST_DEFAULT}; // `/follow`: chat sends the request, `crate::target` resolves the subject into the state, and // `follow` owns the motion. pub(crate) use follow::{FollowRequest, FollowState}; @@ -224,7 +224,7 @@ impl Plugin for PlayerPlugin { app.add_observer(camera::on_cvar); app.add_observer(camera_dynamics::on_cvar); app.init_resource::(); - app.init_resource::(); + app.init_resource::(); app.init_resource::(); app.init_resource::(); // Far sight resolves `PLAYER_FARSIGHT` to a pose before `control` seats the camera: a diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index 1e54b39e..df52a110 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -1,5 +1,5 @@ -//! The third-person camera rig: the two mouse-look modes, the wheel-zoom glide, the -//! collision-swept boom on the framing pivot, and the self-avatar fade into first person. +//! The third-person camera rig: the two mouse-look modes, the collision-swept boom on the +//! framing pivot, and the self-avatar fade into first person. The zoom is [`super::camera_zoom`]. use bevy::ecs::entity::EntityHashSet; use bevy::input::mouse::AccumulatedMouseMotion; @@ -9,6 +9,7 @@ use bevy::window::{CursorGrabMode, CursorOptions, PrimaryWindow}; use super::camera_channel::{Arm, SmoothChannel}; use super::camera_dynamics::{DynamicsInput, HeadBob, SmartPivot, TerrainTilt}; +use super::camera_zoom::{ZoomLimit, CAM_DISTANCE_MAX_RANGE}; use crate::creature_anim::wrap_pi; use crate::net::Embodied; use benilla_assets::materials::WowModelMaterial; @@ -57,53 +58,6 @@ impl PressGesture { } } -/// The zoom floor, yd: at 0 the eye sits at the framing pivot, inside the head, and the avatar -/// fades out. The reference clamps the orbit to `cameraDistanceMax × cameraDistanceMaxFactor`, -/// capped at 50 (`0x5112d0`). Deviation: the starting zoom, [`CAM_DIST_DEFAULT`], is 15 yd where -/// the reference's `cameraDistance` is 5.55 (`0x84f488`), for a wider view. -pub(super) const CAM_DIST_MIN: f32 = 0.0; -/// The reference's `cameraDistanceMax` (default 15); 1.12's panel offers only the factor. -pub(super) const CAM_DIST_BASE_MAX: f32 = 15.0; -/// `cameraDistanceMaxFactor`'s slider, MAX_FOLLOW_DIST (1 to 2 by 0.1, `UIOptionsFrame.lua:90`). -pub(crate) const CAM_DIST_FACTOR_RANGE: std::ops::RangeInclusive = 1.0..=2.0; -/// The factor slider's top: the clamp for a distance read back off disk ([`ZoomLimit`] is live). -pub(super) const CAM_DIST_MAX: f32 = CAM_DIST_BASE_MAX * 2.0; -pub(super) const CAM_DIST_DEFAULT: f32 = 15.0; - -/// The max orbit distance: 1.12's `cameraDistanceMaxFactor` over [`CAM_DIST_BASE_MAX`], 15 yd at -/// the reference's defaults (`cameraDistanceMax` "15.0" at `0x84fbd0`, `cameraDistanceMaxFactor` -/// "1.0" at `0x82e92c`). Lowering the factor pulls the live target in on the next frame. -#[derive(Resource)] -pub(crate) struct ZoomLimit { - pub(crate) max: f32, -} - -impl Default for ZoomLimit { - fn default() -> Self { - // Factor 1.0, the reference's default; `CAM_DIST_MAX` is the slider's top. - Self { - max: CAM_DIST_BASE_MAX, - } - } -} - -impl ZoomLimit { - pub(crate) fn set_factor(&mut self, factor: f32) { - let f = factor.clamp(*CAM_DIST_FACTOR_RANGE.start(), *CAM_DIST_FACTOR_RANGE.end()); - self.max = CAM_DIST_BASE_MAX * f; - } - - /// The live factor, the inverse of [`Self::set_factor`], for tests. - #[cfg(test)] - pub(crate) fn factor(&self) -> f32 { - self.max / CAM_DIST_BASE_MAX - } -} -/// Yards per wheel notch, the stock bindings' `CameraZoomIn(1.0)` (`Bindings.xml:707`). -const CAM_ZOOM_STEP: f32 = 1.0; -/// Zoom speed in yd/s, `cameraDistanceMoveSpeed`'s default: the reference glides the distance to -/// the wheel target at this constant speed (`0x5112d0`), not an ease. -const CAM_MOVE_SPEED: f32 = 8.33; /// Radians of camera rotation per raw mouse unit at the default move speeds and `mousespeed` 1.0. const LOOK_SENSITIVITY: f32 = 0.003; /// `mousespeed`'s slider, MOUSE_SENSITIVITY (0.5 to 1.5 by 0.05, `UIOptionsFrame.lua:87`). @@ -138,6 +92,16 @@ pub(crate) fn on_cvar( look.pitch_speed = v; } } + "cameradistancemax" => { + if !zoom.set_distance_max(v) { + warn!( + "cvar {}: value out of range ({} - {}) — ignored", + ev.name, + CAM_DISTANCE_MAX_RANGE.start(), + CAM_DISTANCE_MAX_RANGE.end() + ); + } + } "cameradistancemaxfactor" => zoom.set_factor(v), // The stock dropdown writes 1 Smart, 2 Always, 0 Never (`UIOptionsFrame.lua:518`). "camerasmoothstyle" => follow.style = FollowStyle::from_cvar(v), @@ -880,20 +844,6 @@ pub(super) fn run_look_session( rig.freelook = rig.look == Some(LookButton::Right) || (rig.look.is_some() && both_buttons); } -/// Wheel zoom, run every frame: `CAMERAZOOMIN`/`OUT` move the target, and the distance glides to it -/// at a constant `cameraDistanceMoveSpeed`, as the reference's does. `scroll` is this frame's net -/// zoom-in in notches (positive is closer). -pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl, max: f32) { - if scroll != 0.0 { - rig.target_distance = - (rig.target_distance - scroll * CAM_ZOOM_STEP).clamp(CAM_DIST_MIN, max); - } - // Re-clamp every frame, so lowering the max-distance slider pulls the camera in. - rig.target_distance = rig.target_distance.min(max); - let max_step = CAM_MOVE_SPEED * dt; - rig.distance += (rig.target_distance - rig.distance).clamp(-max_step, max_step); -} - /// Seat the camera on whatever it orbits: our own body, or the far-sight subject `PLAYER_FARSIGHT` /// names (Mind Vision, Sentry Totem, Mind Control), substituting the orbit centre, sweep origin and /// pivot target before [`seat_camera`]. Both of the controller's seat paths call it, as far sight diff --git a/crates/benilla-app/src/player/camera_saved.rs b/crates/benilla-app/src/player/camera_saved.rs index 6c6cf69b..32f637da 100644 --- a/crates/benilla-app/src/player/camera_saved.rs +++ b/crates/benilla-app/src/player/camera_saved.rs @@ -18,9 +18,8 @@ use bevy::prelude::*; use crate::char_select::{ClientState, InWorldGated}; -use super::camera::{ - CameraControl, FlyCam, CAM_DIST_DEFAULT, CAM_DIST_MAX, CAM_DIST_MIN, CAM_PITCH_LIMIT, -}; +use super::camera::{CameraControl, FlyCam, CAM_PITCH_LIMIT}; +use super::camera_zoom::{CAM_DIST_DEFAULT, CAM_DIST_MAX, CAM_DIST_MIN}; use super::Player; use benilla_world::view::WorldCamera; diff --git a/crates/benilla-app/src/player/camera_view.rs b/crates/benilla-app/src/player/camera_view.rs index c398b170..7f2cbd9f 100644 --- a/crates/benilla-app/src/player/camera_view.rs +++ b/crates/benilla-app/src/player/camera_view.rs @@ -25,8 +25,9 @@ use benilla_world::view::WorldCamera; use crate::creature_anim::wrap_pi; -use super::camera::{CameraControl, FlyCam, CAM_DIST_MAX, CAM_DIST_MIN, CAM_PITCH_LIMIT}; +use super::camera::{CameraControl, FlyCam, CAM_PITCH_LIMIT}; use super::camera_saved::{pitch_from_file, pitch_to_file}; +use super::camera_zoom::{CAM_DIST_MAX, CAM_DIST_MIN}; use super::Player; /// The reference's five views, through the UI crate's constant so the Lua range check agrees. diff --git a/crates/benilla-app/src/player/camera_zoom.rs b/crates/benilla-app/src/player/camera_zoom.rs new file mode 100644 index 00000000..b4cc6440 --- /dev/null +++ b/crates/benilla-app/src/player/camera_zoom.rs @@ -0,0 +1,196 @@ +//! The zoom: the orbit distance's range, from `cameraDistanceMax` × `cameraDistanceMaxFactor` +//! under the reference's 0 and 50 yd bounds, and the wheel's glide to the chosen distance. + +use bevy::prelude::*; + +use super::camera::CameraControl; + +/// The zoom floor, yd (`ds:0x8089a8` = 0.0): at 0 the eye sits at the framing pivot, inside the +/// head, and the avatar fades out. Deviation: the starting zoom, [`CAM_DIST_DEFAULT`], is 15 yd +/// where the reference's `cameraDistance` is 5.55 (`0x84f488`), for a wider view. +pub(super) const CAM_DIST_MIN: f32 = 0.0; +/// The zoom ceiling, yd (`ds:0x8089a4` = 50.0): the cap on [`ZoomLimit`] (`0x511309`) and the top +/// of the distance validator `0x50b310`; also the clamp for a distance read back off disk. +pub(super) const CAM_DIST_MAX: f32 = 50.0; +pub(super) const CAM_DIST_DEFAULT: f32 = 15.0; +/// `cameraDistanceMax`'s registered default, "15.0" (`0x84fbd0`). +const CAM_DISTANCE_MAX_DEFAULT: f32 = 15.0; +/// `cameraDistanceMaxFactor`'s registered default, "1.0" (`0x82e92c`). The CVar has no validator: +/// the options slider's 1 to 2 (`UIOptionsFrame.lua:90`) bounds the slider, not the value. +const CAM_DISTANCE_MAX_FACTOR_DEFAULT: f32 = 1.0; +/// `cameraDistanceMax`'s validator (`0x50b310` → `0x50b330(v, 0.0, 50.0)`) accepts `0 ≤ v ≤ 50`; +/// anything else, NaN included (`fcom` unordered sets C0), is refused and the old value stands. +pub(crate) const CAM_DISTANCE_MAX_RANGE: std::ops::RangeInclusive = + CAM_DIST_MIN..=CAM_DIST_MAX; +/// Yards per wheel notch, the stock bindings' `CameraZoomIn(1.0)` (`Bindings.xml:707`). +const CAM_ZOOM_STEP: f32 = 1.0; +/// Zoom speed in yd/s, `cameraDistanceMoveSpeed`'s default: the reference glides the distance to +/// the wheel target at this constant speed (`0x5112d0`), not an ease. +const CAM_MOVE_SPEED: f32 = 8.33; + +/// The max orbit distance, yd, from `cameraDistanceMax` and `cameraDistanceMaxFactor`: 15 at the +/// reference's defaults. Lowering it pulls the live target in on the next frame. +#[derive(Resource)] +pub(crate) struct ZoomLimit { + /// The live limit, [`orbit_limit`] of the two below: in `[0, 50]`, never NaN. + pub(crate) max: f32, + distance_max: f32, + factor: f32, +} + +impl Default for ZoomLimit { + fn default() -> Self { + let (distance_max, factor) = (CAM_DISTANCE_MAX_DEFAULT, CAM_DISTANCE_MAX_FACTOR_DEFAULT); + Self { + max: orbit_limit(distance_max, factor), + distance_max, + factor, + } + } +} + +impl ZoomLimit { + /// `cameraDistanceMax`, through its validator's range ([`CAM_DISTANCE_MAX_RANGE`]); `false` + /// when refused, the old value standing. + pub(crate) fn set_distance_max(&mut self, v: f32) -> bool { + if !CAM_DISTANCE_MAX_RANGE.contains(&v) { + return false; + } + self.distance_max = v; + self.max = orbit_limit(self.distance_max, self.factor); + true + } + + /// `cameraDistanceMaxFactor`, taken as it comes: the reference registers it with no validator. + pub(crate) fn set_factor(&mut self, factor: f32) { + self.factor = factor; + self.max = orbit_limit(self.distance_max, self.factor); + } + + #[cfg(test)] + pub(crate) fn distance_max(&self) -> f32 { + self.distance_max + } + + #[cfg(test)] + pub(crate) fn factor(&self) -> f32 { + self.factor + } +} + +/// The reference's orbit limit (`0x5112d6`–`0x51131e`): `cameraDistanceMax × cameraDistanceMaxFactor` +/// through two ordered x87 compares. Below 0.0 (`fcom ds:0x8089a8`, C0 alone) it is 0.0; else +/// below 50.0 (`fcom ds:0x8089a4`, C0 alone) it stands; else it is 50.0. A NaN product compares +/// unordered (C0, C2 and C3 all set), which passes the first test and fails the second: 50.0. +fn orbit_limit(distance_max: f32, factor: f32) -> f32 { + let product = distance_max * factor; + if product < CAM_DIST_MIN { + CAM_DIST_MIN + } else if product < CAM_DIST_MAX { + product + } else { + CAM_DIST_MAX + } +} + +/// Wheel zoom, run every frame: `CAMERAZOOMIN`/`OUT` move the target, and the distance glides to it +/// at a constant `cameraDistanceMoveSpeed`, as the reference's does. `scroll` is this frame's net +/// zoom-in in notches (positive is closer). `max` is [`ZoomLimit::max`], CVar-fed, so it bounds by +/// `max`/`min`, never `f32::clamp`, which panics on a NaN or inverted bound. +pub(super) fn apply_zoom_scroll(scroll: f32, dt: f32, rig: &mut CameraControl, max: f32) { + if scroll != 0.0 { + rig.target_distance = (rig.target_distance - scroll * CAM_ZOOM_STEP) + .max(CAM_DIST_MIN) + .min(max); + } + // Re-clamp every frame, so lowering the max-distance slider pulls the camera in. + rig.target_distance = rig.target_distance.min(max); + let max_step = CAM_MOVE_SPEED * dt; + rig.distance += (rig.target_distance - rig.distance).clamp(-max_step, max_step); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn limit(distance_max: f32, factor: f32) -> f32 { + let mut z = ZoomLimit::default(); + assert!( + z.set_distance_max(distance_max), + "{distance_max} is in range" + ); + z.set_factor(factor); + z.max + } + + #[test] + fn the_limit_is_the_product_of_both_cvars() { + assert_eq!(ZoomLimit::default().max, 15.0, "the reference's defaults"); + // The factor has no validator: 3 is not held to the slider's 2. + assert_eq!(limit(15.0, 3.0), 45.0); + assert_eq!(limit(15.0, 0.5), 7.5, "nor raised to the slider's 1"); + assert_eq!(limit(25.0, 1.0), 25.0); + } + + #[test] + fn the_limit_holds_to_zero_and_fifty() { + assert_eq!(limit(15.0, 4.0), 50.0); + assert_eq!(limit(50.0, 1.0), 50.0); + assert_eq!(limit(15.0, f32::INFINITY), 50.0); + assert_eq!(limit(15.0, -2.0), 0.0); + assert_eq!(limit(15.0, f32::NEG_INFINITY), 0.0); + } + + #[test] + fn a_nan_product_fails_both_compares_and_is_fifty() { + assert_eq!(limit(15.0, f32::NAN), 50.0); + // 0 × ∞ is NaN too, with both values accepted. + assert_eq!(limit(0.0, f32::INFINITY), 50.0); + } + + #[test] + fn cameradistancemax_is_refused_outside_its_validator_range() { + let mut z = ZoomLimit::default(); + for bad in [-0.5, 50.5, f32::NAN, f32::INFINITY] { + assert!(!z.set_distance_max(bad), "{bad} is refused"); + assert_eq!( + (z.distance_max(), z.max), + (15.0, 15.0), + "the old value stands" + ); + } + assert!( + z.set_distance_max(0.0) && z.max == 0.0, + "both ends are inclusive" + ); + assert!(z.set_distance_max(50.0) && z.max == 50.0); + } + + #[test] + fn a_nan_factor_zooms_without_panicking() { + let mut z = ZoomLimit::default(); + z.set_factor(f32::NAN); + let mut rig = CameraControl { + target_distance: 10.0, + distance: 10.0, + ..default() + }; + apply_zoom_scroll(-1.0, 0.1, &mut rig, z.max); + assert_eq!(rig.target_distance, 11.0); + // Even a NaN bound handed straight in bounds nothing and panics nowhere. + apply_zoom_scroll(-1.0, 0.1, &mut rig, f32::NAN); + assert_eq!(rig.target_distance, 12.0); + } + + #[test] + fn the_wheel_stops_at_the_limit() { + let mut rig = CameraControl { + target_distance: 44.5, + ..default() + }; + apply_zoom_scroll(-1.0, 0.1, &mut rig, limit(15.0, 3.0)); + assert_eq!(rig.target_distance, 45.0); + apply_zoom_scroll(1000.0, 0.1, &mut rig, 45.0); + assert_eq!(rig.target_distance, CAM_DIST_MIN); + } +} diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index 31433721..cea50091 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -16,7 +16,7 @@ pub(super) fn control( pointer: ( Res, Res, - Res, + Res, Res, Res, // Only `nearclip` is read here, for the self-avatar fade.