diff --git a/crates/benilla-app/src/cursor.rs b/crates/benilla-app/src/cursor.rs index 406cedd6..a1b518f8 100644 --- a/crates/benilla-app/src/cursor.rs +++ b/crates/benilla-app/src/cursor.rs @@ -483,7 +483,7 @@ mod macos { } // A cinematic hides the pointer too (`0x58b590`); `CursorOptions.visible` is inert here, so // it joins the look's hide/unhide pair, keeping AppKit's hide counter balanced. - let looking = rig.is_looking() || cinematic.is_some_and(|c| c.is_playing()); + let looking = rig.holds_cursor() || cinematic.is_some_and(|c| c.is_playing()); let stem = mode.0.stem(); // The key names the chosen cursor, so `NSCursor::set`, a WindowServer round-trip that can // stall the main thread for milliseconds, fires only on a change or a drift. diff --git a/crates/benilla-app/src/player/camera.rs b/crates/benilla-app/src/player/camera.rs index f48deaa8..33c00431 100644 --- a/crates/benilla-app/src/player/camera.rs +++ b/crates/benilla-app/src/player/camera.rs @@ -545,6 +545,8 @@ pub(crate) struct CameraControl { pub(super) world_mouse: WorldMouse, /// Logical cursor position captured when look began, to restore on release. pub(super) cursor_stash: Option, + /// A look session is running with the OS cursor handed back: the window is unfocused. + pub(super) cursor_released: bool, /// The self-avatar's alpha from the camera-to-pivot distance, 1 in third person to 0 in first /// (`self_model_fade_alpha`); applied by [`apply_self_model_fade`]. pub(super) self_fade_alpha: f32, @@ -573,11 +575,18 @@ impl CameraControl { self.target_distance = d; } - /// True while a mouse-look drag is active (right- or left-button). The cursor is hidden then. + /// True while a mouse-look drag is active (right- or left-button). pub(crate) fn is_looking(&self) -> bool { self.look.is_some() } + /// True while a look session holds the OS cursor, hidden and locked: looking, and focused. + /// Read by the macOS cursor hide, the one platform that hides the cursor itself. + #[cfg(any(target_os = "macos", test))] + pub(crate) fn holds_cursor(&self) -> bool { + self.look.is_some() && !self.cursor_released + } + /// The self-avatar's render alpha this frame. The blob shadow multiplies it in, as the /// reference's shadow diffuse rides the body's model fade slot (`[model+0x180]`). pub(crate) fn self_fade(&self) -> f32 { @@ -601,16 +610,23 @@ impl LookButton { } } -/// The mouse buttons the world owns, latched at the press. 1.12 reads the `TurnOrAction` and -/// `CameraOrSelectOrMove` bindings, which a press a UI frame captured never dispatches: no mouse -/// bit in `[InputControl+0x4]`, no look session, no [`FollowState`]. The world's down sets the bit -/// and that button's up clears it, so a frame appearing under the locked cursor cannot steal it. +/// The mouse buttons the world owns, latched at the press, and the two channels they drive. 1.12 +/// reads the `TurnOrAction` and `CameraOrSelectOrMove` bindings, which a press a UI frame captured +/// never dispatches: no mouse bit in `[InputControl+0x4]`, no look session, no [`FollowState`]. +/// The world's down sets the bit and that button's up clears it, so a frame appearing under the +/// locked cursor cannot steal it. `MouselookStart` sets the TurnOrAction bit with no button. #[derive(Default)] pub(super) struct WorldMouse { /// Held by the world right now, indexed by [`LookButton`]. held: [bool; 2], /// Took its down edge from the world this frame, same index. down: [bool; 2], + /// `MouselookStart` holds the TurnOrAction channel (`0x515090(1, 1, …)` at `0x514231`). + scripted: bool, + /// This frame's `MouselookStart` raised the channel. + scripted_rise: bool, + /// A `MouselookStart`/`Stop` ran this frame; each disarms the pending click (`0x514810(0)`). + disarmed: bool, } impl WorldMouse { @@ -622,20 +638,60 @@ impl WorldMouse { self.down[b as usize] } - /// Both primaries in the world's hand: the both-button run. + /// The TurnOrAction channel, `[InputControl+4] & 1`: the world's right button, or a + /// `MouselookStart` with none. + pub(super) fn turn(&self) -> bool { + self.held(LookButton::Right) || self.scripted + } + + /// The channel `b`'s look session runs on: TurnOrAction for right, the left button's own. + pub(super) fn channel(&self, b: LookButton) -> bool { + match b { + LookButton::Right => self.turn(), + LookButton::Left => self.held(LookButton::Left), + } + } + + /// Both channels held: the both-button run (`0x514da0` nets `0x1` and `0x2` as forward). pub(super) fn both(&self) -> bool { - self.held(LookButton::Right) && self.held(LookButton::Left) + self.turn() && self.held(LookButton::Left) + } + + /// A channel rose this frame, by a press or a `MouselookStart`: with [`Self::both`], the + /// transition into the both-button run (`0x514a73`, in the set helper both reach). + pub(super) fn rose(&self) -> bool { + self.down(LookButton::Left) || self.down(LookButton::Right) || self.scripted_rise + } + + /// A `MouselookStart`/`Stop` this frame: no click may settle from the gesture in flight. + pub(super) fn disarmed(&self) -> bool { + self.disarmed } /// Latch this frame; `world_press` says whether a down edge belongs to the world. A held bit /// rides to its release, including a cover's emptying of the button planes. fn update(&mut self, buttons: &ButtonInput, world_press: bool) { + self.scripted_rise = false; + self.disarmed = false; for b in [LookButton::Right, LookButton::Left] { let i = b as usize; self.down[i] = world_press && buttons.just_pressed(b.button()); self.held[i] = (self.held[i] || self.down[i]) && buttons.pressed(b.button()); } } + + /// `MouselookStart` (`on`) or `MouselookStop`: one bit, whoever set it, so a stop also ends + /// a right drag in flight, whose release then clears nothing. + fn mouselook(&mut self, on: bool) { + self.disarmed = true; + if on { + self.scripted_rise |= !self.turn(); + self.scripted = true; + } else { + self.scripted = false; + self.held[LookButton::Right as usize] = false; + } + } } /// Decide once per frame which mouse buttons the world owns: a press in the viewport off the UI, @@ -655,18 +711,39 @@ pub(super) fn latch_world_mouse( mut rig: ResMut, cameras: Query<&Camera, With>, window: Single<&Window, With>, - script: Option>, + mut script: Option>, + cover: Option>, ) { + let calls = script + .as_mut() + .map(|s| s.take_mouselook_calls()) + .unwrap_or_default(); let Ok(camera) = cameras.single() else { return; }; let over_ui = pointer_over_ui.0; let world_press = rig.look.is_some() || (cursor_in_viewport(&window, camera) && !over_ui); - rig.world_mouse.update(&buttons, world_press); - // `IsMouselooking` (`0x514270`) reads `[InputControl+4] & 1`, the TurnOrAction channel: the - // world's right button, the press the TURNORACTION binding latches. + let wm = &mut rig.world_mouse; + wm.update(&buttons, world_press); + // The script's calls, made since the last latch, before this frame's release. + for on in calls { + wm.mouselook(on); + } + // The right button's release ends a scripted channel too, wherever its press went: in + // freelook every button event goes to the `WorldFrame` (`0x492b50`), whose TURNORACTION up + // runs `TurnOrActionStop` (`0x514160`), `0x515090(1, 0, …)`. + if buttons.just_released(MouseButton::Right) { + wm.scripted = false; + } + // The world enter behind the cover clears the whole word (`0x5144c0` → `0x514b70(-1, …)`). + // A window deactivate clears none of it (`0x5144a3 and eax,0xfffff00f` keeps the low bits): + // the session outlives the focus, and only the OS cursor goes back ([`sync_look_focus`]). + if cover.is_some_and(|c| c.covering()) { + wm.scripted = false; + } + // `IsMouselooking` (`0x514270`) reads `[InputControl+4] & 1`, the TurnOrAction channel. if let Some(mut script) = script { - script.set_turn_or_action_held(rig.world_mouse.held(LookButton::Right)); + script.set_turn_or_action_held(rig.world_mouse.turn()); } } @@ -714,9 +791,9 @@ pub(crate) struct CameraPivot { /// The mouse-look session: start, stop and hand-off between the buttons, cursor grab and restore, /// the look rotation, and the click tests behind [`WorldClick`]/[`WorldRightClick`]. Orbit and /// select are independent: each primary press engages its session at once and arms a click test, -/// and the release decides on [`PressGesture::is_click`] alone. +/// and the release decides on [`PressGesture::is_click`] alone. A session lasts while its +/// [`WorldMouse::channel`] is held, so a `MouselookStart` runs the right button's session. pub(super) fn run_look_session( - buttons: &ButtonInput, mouse_motion: &AccumulatedMouseMotion, both_buttons: bool, rig: &mut CameraControl, @@ -737,18 +814,30 @@ pub(super) fn run_look_session( dynamics: &DynamicsInput, // Seconds on the app clock, for the press predicate's two time gates. now: f32, + // The primary window has the OS focus. + focused: bool, ) { + // The mouse moves the view only through a held cursor: motion while unfocused, or on the frame + // that takes the cursor back, is the pointer travelling in another app. + let motion = if sync_look_focus(rig, focused, window, cursor_opts) { + mouse_motion.delta + } else { + Vec2::ZERO + }; // A chord is a both-button run, never a select: the reference kills the pending click and arms - // none while another primary's binding is held (`0x514ac1`, `0x51481a`). - if rig.world_mouse.both() { + // none while another primary's binding is held (`0x514ac1`, `0x51481a`). A `MouselookStart` or + // `Stop` disarms it too, ahead of the channel's move (`0x514810(0)` stores mode 0 unguarded, + // then `0x515090`), so a stop's release, or the button's after it, selects nothing. + let disarmed = rig.world_mouse.disarmed(); + if rig.world_mouse.both() || disarmed { *left_click = None; *right_click = None; } // Both tests accumulate the rotation the press asked for, charged before the pitch clamp: the // reference accumulates raw motion, so a drag pinned at the pitch limit still spends budget. let (yaw_rate, pitch_rate) = (look_cfg.yaw_rate(), look_cfg.pitch_rate()); - let dyaw = (mouse_motion.delta.x * yaw_rate).abs(); - let dpitch = (mouse_motion.delta.y * pitch_rate).abs(); + let dyaw = (motion.x * yaw_rate).abs(); + let dpitch = (motion.y * pitch_rate).abs(); for test in [&mut *left_click, &mut *right_click].into_iter().flatten() { test.yaw_travel += dyaw; test.pitch_travel += dpitch; @@ -757,8 +846,8 @@ pub(super) fn run_look_session( // Both buttons engage their session on the down edge, with no threshold (`0x51491f`); the click // test rides along to the release. Looking hides and locks the cursor until the release. if let Some(active) = rig.look { - if !buttons.pressed(active.button()) { - // The button went up: settle its click test (a chord already cancelled both). + if !rig.world_mouse.channel(active) { + // The channel went down: settle its click test (a chord already cancelled both). let test = match active { LookButton::Left => left_click.take(), LookButton::Right => right_click.take(), @@ -775,51 +864,43 @@ pub(super) fn run_look_session( } } } - // Hand off to the other button if the world holds it, as the reference keeps turning; - // a button the UI holds never fired its binding. + // Hand off to the other channel if it is held, as the reference keeps turning; a + // button the UI holds never fired its binding. let other = match active { LookButton::Right => LookButton::Left, LookButton::Left => LookButton::Right, }; - if rig.world_mouse.held(other) { + if rig.world_mouse.channel(other) { rig.look = Some(other); } else { - rig.look = None; - cursor_opts.grab_mode = CursorGrabMode::None; - // Show the cursor again; on macOS hiding is the cursor subsystem's job. - cursor_opts.visible = true; - if let Some(pos) = rig.cursor_stash.take() { - window.set_cursor_position(Some(pos)); - } + disengage_look(rig, window, cursor_opts); } } } else { // `WorldMouse` has already dropped a press over the UI, the dev overlay or outside the // viewport. Right-drag turns, and arms its click test unless left is held. if rig.world_mouse.down(LookButton::Right) { - rig.look = Some(LookButton::Right); - rig.cursor_stash = window.cursor_position(); - cursor_opts.grab_mode = CursorGrabMode::Locked; - cursor_opts.visible = false; - *right_click = - (!rig.world_mouse.held(LookButton::Left)).then(|| PressGesture::new(now)); + engage_look(rig, LookButton::Right, focused, window, cursor_opts); + *right_click = (!rig.world_mouse.held(LookButton::Left) && !disarmed) + .then(|| PressGesture::new(now)); + } else if rig.world_mouse.turn() { + // `MouselookStart` with no button: the channel's rise enters the same freelook + // (`0x514840` → `0x51491f`), and no click is armed (`0x514810(0)`). + engage_look(rig, LookButton::Right, focused, window, cursor_opts); } else if rig.world_mouse.down(LookButton::Left) && !inspect_enabled { // Left-drag orbits, engaged on the press like right (`0x51491f`); the select settles // at the release. While the inspector is armed, left belongs to it. - rig.look = Some(LookButton::Left); - rig.cursor_stash = window.cursor_position(); - cursor_opts.grab_mode = CursorGrabMode::Locked; - cursor_opts.visible = false; + engage_look(rig, LookButton::Left, focused, window, cursor_opts); // A cursor-payload world drop still orbits (`0x51491f`) but must not also select. *right_click = None; - *left_click = (!click_consumed && !rig.world_mouse.held(LookButton::Right)) + *left_click = (!click_consumed && !rig.world_mouse.turn() && !disarmed) .then(|| PressGesture::new(now)); } } // Look rotation; a right-drag also turns the character. if let Some(active) = rig.look { - let delta = mouse_motion.delta; + let delta = motion; let d_yaw = -delta.x * yaw_rate; cam.yaw += d_yaw; // `mouseInvertPitch` flips only the pitch axis. @@ -850,6 +931,81 @@ pub(super) fn run_look_session( rig.freelook = rig.look == Some(LookButton::Right) || (rig.look.is_some() && both_buttons); } +/// Start `button`'s look session: stash the cursor, then lock and hide it until the session ends, +/// or leave it free while the window is unfocused ([`sync_look_focus`]). +fn engage_look( + rig: &mut CameraControl, + button: LookButton, + focused: bool, + window: &Window, + cursor_opts: &mut CursorOptions, +) { + rig.look = Some(button); + if focused { + take_cursor(rig, window, cursor_opts); + } else { + rig.cursor_released = true; + } +} + +/// End the look session: free and show the cursor, and put it back where the look began unless +/// it was already free, when it is wherever the player left it. +fn disengage_look(rig: &mut CameraControl, window: &mut Window, cursor_opts: &mut CursorOptions) { + rig.look = None; + let stash = rig.cursor_stash.take(); + if !std::mem::take(&mut rig.cursor_released) { + release_cursor(cursor_opts); + if let Some(pos) = stash { + window.set_cursor_position(Some(pos)); + } + } +} + +/// Stash the cursor, then lock and hide it for the session. +fn take_cursor(rig: &mut CameraControl, window: &Window, cursor_opts: &mut CursorOptions) { + rig.cursor_stash = window.cursor_position(); + cursor_opts.grab_mode = CursorGrabMode::Locked; + cursor_opts.visible = false; +} + +/// Unlock and show the cursor; on macOS hiding is the cursor subsystem's job. +fn release_cursor(cursor_opts: &mut CursorOptions) { + cursor_opts.grab_mode = CursorGrabMode::None; + cursor_opts.visible = true; +} + +/// Hand the OS cursor back while the window is unfocused and take it again on focus, without +/// touching the session: the look state outlives a deactivate (`0x514490` keeps the mouse bits). +/// Platform behaviour, not a reference fact. On macOS winit's `Locked` is the global +/// `CGAssociateMouseAndMouseCursorPosition(false)`, and losing key status (`windowDidResignKey`) +/// leaves it, so a lock held while unfocused freezes the pointer in every other app; 1.12.1's +/// Windows client gives the cursor back to other applications while inactive. Returns whether the +/// session holds the cursor through this whole frame, so its motion may turn the view. +fn sync_look_focus( + rig: &mut CameraControl, + focused: bool, + window: &Window, + cursor_opts: &mut CursorOptions, +) -> bool { + if rig.look.is_none() { + return focused; + } + match (focused, rig.cursor_released) { + (false, false) => { + release_cursor(cursor_opts); + rig.cursor_released = true; + false + } + (true, true) => { + // The pointer is where the player brought it back: the release restores it there. + take_cursor(rig, window, cursor_opts); + rig.cursor_released = false; + false + } + (focused, _) => focused, + } +} + /// 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 @@ -1366,6 +1522,9 @@ pub(crate) fn cam_dump_enabled() -> bool { *ON.get_or_init(|| std::env::var_os("WOW_CAM_DUMP").is_some()) } +#[cfg(test)] +mod mouselook_tests; + #[cfg(test)] mod tests { use super::super::camera_channel::CHANNEL_EPS; diff --git a/crates/benilla-app/src/player/camera/mouselook_tests.rs b/crates/benilla-app/src/player/camera/mouselook_tests.rs new file mode 100644 index 00000000..fb813748 --- /dev/null +++ b/crates/benilla-app/src/player/camera/mouselook_tests.rs @@ -0,0 +1,389 @@ +//! `MouselookStart`/`MouselookStop` against the look session: the latch and the session run as the +//! frame runs them, with the VM's calls in between. + +use benilla_ui::script::UiScript; +use bevy::ecs::system::RunSystemOnce; +use bevy::math::DVec2; + +use super::super::camera_dynamics::{CameraOptions, SubjectState}; +use super::*; + +/// What the session's caller keeps between frames: the facing and the two click tests. +#[derive(Resource, Default)] +struct Hand { + face_yaw: f32, + left: Option, + right: Option, +} + +/// `control`'s call of [`run_look_session`], with the both-button run read off the latch. +fn look_frame( + motion: Res, + mut rig: ResMut, + cam: Single<&mut FlyCam>, + window: Single<(&mut Window, &mut CursorOptions), With>, + mut clicks: (MessageWriter, MessageWriter), + mut hand: ResMut, +) { + let (mut window, mut opts) = window.into_inner(); + let focused = window.focused; + let mut cam = cam.into_inner(); + let hand = &mut *hand; + let both = rig.world_mouse.both(); + let dynamics = DynamicsInput { + options: CameraOptions::default(), + smooth_style: FollowStyle::default(), + tracking_style: FollowStyle::default(), + subject: SubjectState::default(), + nearclip: 0.2, + surface_y: None, + }; + run_look_session( + &motion, + both, + &mut rig, + &mut cam, + &mut hand.face_yaw, + &mut window, + &mut opts, + false, + false, + &mut clicks.0, + &mut clicks.1, + &mut hand.left, + &mut hand.right, + LookConfig::default(), + &dynamics, + 0.0, + focused, + ); +} + +/// A world with the camera, the window, a VM and nothing held; `over_ui` says where the cursor is. +fn world(over_ui: bool) -> World { + let mut world = World::new(); + world.init_resource::>(); + world.init_resource::(); + world.insert_resource(crate::ui_script::PointerOverUi(over_ui)); + world.init_resource::(); + world.init_resource::(); + world.init_resource::>(); + world.init_resource::>(); + world.spawn(( + Camera::default(), + FlyCam { + yaw: 0.0, + pitch: 0.0, + speed: 0.0, + }, + )); + let mut window = Window::default(); + window.set_physical_cursor_position(Some(DVec2::new(100.0, 100.0))); + world.spawn((window, CursorOptions::default(), PrimaryWindow)); + world.insert_non_send_resource(UiScript::new().unwrap()); + world +} + +/// One frame with the mouse moved by `dx` pixels: the latch, then the session, then the input +/// planes age as bevy's do. +fn frame(world: &mut World, dx: f32) { + world.resource_mut::().delta = Vec2::new(dx, 0.0); + world.run_system_once(latch_world_mouse).unwrap(); + world.run_system_once(look_frame).unwrap(); + world.resource_mut::>().clear(); +} + +/// The OS gives the window focus or takes it. +fn focus(world: &mut World, focused: bool) { + world + .query::<&mut Window>() + .single_mut(world) + .unwrap() + .focused = focused; +} + +/// Where the OS pointer is, in logical window pixels. +fn pointer(world: &mut World) -> Option { + world + .query::<&Window>() + .single(world) + .unwrap() + .cursor_position() +} + +fn lua(world: &mut World, chunk: &str) { + world.non_send_resource::().run(chunk).unwrap(); +} + +fn is_mouselooking(world: &World) -> bool { + world + .non_send_resource::() + .eval::>("return IsMouselooking()") + .unwrap() + == Some(1) +} + +fn yaw(world: &mut World) -> f32 { + world.query::<&FlyCam>().single(world).unwrap().yaw +} + +fn cursor(world: &mut World) -> CursorOptions { + world + .query::<&CursorOptions>() + .single(world) + .unwrap() + .clone() +} + +fn press(world: &mut World, b: MouseButton) { + world.resource_mut::>().press(b); +} + +fn release(world: &mut World, b: MouseButton) { + world.resource_mut::>().release(b); +} + +/// The right button's session, entered with no button: the mouse turns the camera and the body +/// together, the cursor is locked and hidden, and `IsMouselooking` answers 1 (`0x514210`). +#[test] +fn mouselook_start_turns_the_camera_and_the_character_with_no_button_held() { + let mut w = world(false); + frame(&mut w, 0.0); + assert!(!is_mouselooking(&w)); + + lua(&mut w, "MouselookStart()"); + frame(&mut w, 40.0); + let rig = w.resource::(); + assert_eq!( + rig.look, + Some(LookButton::Right), + "the right button's session" + ); + assert!(rig.freelook); + let turned = yaw(&mut w); + assert!(turned < 0.0, "the mouse turns the camera: {turned}"); + assert_eq!( + w.resource::().face_yaw, + turned, + "and the character with it" + ); + let c = cursor(&mut w); + assert_eq!(c.grab_mode, CursorGrabMode::Locked); + assert!(!c.visible); + assert!(is_mouselooking(&w)); + + // It holds with no button while the mouse keeps moving. + frame(&mut w, 40.0); + assert!(yaw(&mut w) < turned); + assert!(w.resource::().is_looking()); +} + +/// `MouselookStop` (`0x514240`) ends the session: the cursor comes back and the mouse turns +/// nothing, and `IsMouselooking` answers nil. +#[test] +fn mouselook_stop_ends_the_session() { + let mut w = world(false); + lua(&mut w, "MouselookStart()"); + frame(&mut w, 40.0); + let turned = yaw(&mut w); + + lua(&mut w, "MouselookStop()"); + frame(&mut w, 40.0); + assert_eq!(w.resource::().look, None); + assert_eq!(yaw(&mut w), turned, "the mouse turns nothing"); + let c = cursor(&mut w); + assert_eq!(c.grab_mode, CursorGrabMode::None); + assert!(c.visible); + assert!(!is_mouselooking(&w)); +} + +/// CustomNameplates' steer: a right press a plate took calls `MouselookStart`, and the button's +/// release ends it (`0x492b50` routes it to `TurnOrActionStop`) without a world click. +#[test] +fn the_right_release_ends_a_mouselook_its_press_started_on_the_ui() { + let mut w = world(true); + press(&mut w, MouseButton::Right); + frame(&mut w, 0.0); + assert_eq!( + w.resource::().look, + None, + "the plate's press alone starts no look" + ); + + lua(&mut w, "MouselookStart()"); + frame(&mut w, 40.0); + assert_eq!(w.resource::().look, Some(LookButton::Right)); + assert!(is_mouselooking(&w)); + + release(&mut w, MouseButton::Right); + frame(&mut w, 0.0); + assert_eq!(w.resource::().look, None); + assert!(!is_mouselooking(&w)); + assert!( + w.resource::>().is_empty(), + "no click was armed" + ); +} + +/// The loading cover's world enter clears the whole word (`0x5144c0`). +#[test] +fn the_cover_ends_a_scripted_mouselook() { + let mut w = world(false); + lua(&mut w, "MouselookStart()"); + frame(&mut w, 0.0); + w.insert_resource(crate::loading_screen::LoadingScreen::test_covering()); + frame(&mut w, 0.0); + assert_eq!(w.resource::().look, None); + assert!(!is_mouselooking(&w)); +} + +/// A session across a focus loss: the look state stays as the deactivate leaves it (`0x514490` +/// keeps the mouse bits), the OS cursor is free and shown meanwhile and the mouse turns nothing, +/// and the focus takes it back locked and hidden, with no jolt on the frame it returns. +fn a_session_survives_focus_loss(start: impl Fn(&mut World)) { + let mut w = world(false); + start(&mut w); + frame(&mut w, 40.0); + assert_eq!(w.resource::().look, Some(LookButton::Right)); + let before = yaw(&mut w); + + focus(&mut w, false); + frame(&mut w, 40.0); + assert_eq!( + w.resource::().look, + Some(LookButton::Right), + "the session outlives the focus" + ); + assert!(is_mouselooking(&w)); + let c = cursor(&mut w); + assert_eq!(c.grab_mode, CursorGrabMode::None, "the OS pointer is free"); + assert!(c.visible); + assert!(!w.resource::().holds_cursor()); + assert_eq!( + yaw(&mut w), + before, + "the pointer in another app turns nothing" + ); + + focus(&mut w, true); + frame(&mut w, 40.0); + let c = cursor(&mut w); + assert_eq!(c.grab_mode, CursorGrabMode::Locked); + assert!(!c.visible); + assert!(w.resource::().holds_cursor()); + assert_eq!( + yaw(&mut w), + before, + "the returning frame's travel is not a turn" + ); + frame(&mut w, 40.0); + assert!(yaw(&mut w) < before, "and the mouse turns the view again"); + assert!(is_mouselooking(&w)); +} + +#[test] +fn a_scripted_mouselook_survives_focus_loss() { + a_session_survives_focus_loss(|w| lua(w, "MouselookStart()")); +} + +#[test] +fn a_right_drag_survives_focus_loss() { + a_session_survives_focus_loss(|w| press(w, MouseButton::Right)); +} + +/// `MouselookStop` while unfocused ends the session; the pointer stays where the player left it +/// in the other app, and the focus coming back takes nothing. +#[test] +fn mouselook_stop_while_unfocused_ends_it_and_the_focus_takes_nothing_back() { + let mut w = world(false); + lua(&mut w, "MouselookStart()"); + frame(&mut w, 0.0); + focus(&mut w, false); + frame(&mut w, 0.0); + let away = Vec2::new(300.0, 250.0); + w.query::<&mut Window>() + .single_mut(&mut w) + .unwrap() + .set_cursor_position(Some(away)); + + lua(&mut w, "MouselookStop()"); + frame(&mut w, 0.0); + assert_eq!(w.resource::().look, None); + assert!(!is_mouselooking(&w)); + assert_eq!(pointer(&mut w), Some(away), "no warp back to the stash"); + + focus(&mut w, true); + frame(&mut w, 0.0); + let c = cursor(&mut w); + assert_eq!(c.grab_mode, CursorGrabMode::None); + assert!(c.visible); + assert!(!w.resource::().is_looking()); +} + +/// One bit, whoever set it: `MouselookStop` ends a right drag in flight and disarms its click, so +/// the release after it selects nothing (`0x514810(0)`, then `0x515090(1, 0, …)`). +#[test] +fn mouselook_stop_ends_a_right_drag_and_its_release_clicks_nothing() { + let mut w = world(false); + press(&mut w, MouseButton::Right); + frame(&mut w, 0.0); + assert!( + w.resource::().right.is_some(), + "the press armed a click" + ); + + lua(&mut w, "MouselookStop()"); + frame(&mut w, 0.0); + assert_eq!(w.resource::().look, None); + assert!(!is_mouselooking(&w)); + release(&mut w, MouseButton::Right); + frame(&mut w, 0.0); + assert!(w.resource::>().is_empty()); + assert!(!is_mouselooking(&w)); + + // The control: the same press and release with no stop is a click. + let mut w = world(false); + press(&mut w, MouseButton::Right); + frame(&mut w, 0.0); + release(&mut w, MouseButton::Right); + frame(&mut w, 0.0); + assert_eq!(w.resource::>().len(), 1); +} + +/// `MouselookStart` under a left drag sets the other primary channel: the both-button run, its +/// rise, and the left click disarmed; the left release hands the session to the mouselook. +#[test] +fn mouselook_start_under_a_left_drag_is_the_both_button_run() { + let mut w = world(false); + press(&mut w, MouseButton::Left); + frame(&mut w, 0.0); + assert_eq!(w.resource::().look, Some(LookButton::Left)); + assert!(w.resource::().left.is_some()); + + lua(&mut w, "MouselookStart()"); + w.resource_mut::().delta = Vec2::new(40.0, 0.0); + w.run_system_once(latch_world_mouse).unwrap(); + { + let wm = &w.resource::().world_mouse; + assert!(wm.both(), "both channels held"); + assert!(wm.rose(), "the transition into the run (0x514a73)"); + } + w.run_system_once(look_frame).unwrap(); + w.resource_mut::>().clear(); + assert!( + w.resource::().left.is_none(), + "the left click disarmed" + ); + let cam_yaw = yaw(&mut w); + assert!(cam_yaw < 0.0); + assert_eq!( + w.resource::().face_yaw, + cam_yaw, + "the body turns with the camera" + ); + + release(&mut w, MouseButton::Left); + frame(&mut w, 0.0); + assert_eq!(w.resource::().look, Some(LookButton::Right)); + assert!(w.resource::>().is_empty()); +} diff --git a/crates/benilla-app/src/player/controller.rs b/crates/benilla-app/src/player/controller.rs index cea50091..78ffb5c4 100644 --- a/crates/benilla-app/src/player/controller.rs +++ b/crates/benilla-app/src/player/controller.rs @@ -227,8 +227,8 @@ pub(super) fn control( *left_click = None; *right_click = None; } + let focused = window.focused; run_look_session( - &buttons, mouse_motion, both_buttons, &mut rig, @@ -245,6 +245,7 @@ pub(super) fn control( look_cfg, &dynamics, time.elapsed_secs(), + focused, ); // The mouse turns the view, but the body hand-off (`0x514474`) skips a body that is stunned, // dead or seated (`0x5145e0`) or not ours to drive (`control_lost`, `reseat`), while the diff --git a/crates/benilla-app/src/player/follow.rs b/crates/benilla-app/src/player/follow.rs index 4f05bcc3..96ff7b77 100644 --- a/crates/benilla-app/src/player/follow.rs +++ b/crates/benilla-app/src/player/follow.rs @@ -212,9 +212,7 @@ pub(super) fn steer_follow( // ── The cancel set ── // The world's buttons, not the device's: the both-button run is two bindings held, and a // press a UI frame captured dispatches neither. - let both_engaged = input.rig.world_mouse.both() - && (input.rig.world_mouse.down(LookButton::Left) - || input.rig.world_mouse.down(LookButton::Right)); + let both_engaged = input.rig.world_mouse.both() && input.rig.world_mouse.rose(); if follow_cancelled( input.move_start(), // The on edge: `control` toggles `autorun` after this, so the flag is the pre-toggle value. diff --git a/crates/benilla-app/src/player/input.rs b/crates/benilla-app/src/player/input.rs index e1b3bf26..6d7734ec 100644 --- a/crates/benilla-app/src/player/input.rs +++ b/crates/benilla-app/src/player/input.rs @@ -36,10 +36,7 @@ pub(super) fn look_input( w |= b; } }; - set( - rig.world_mouse.held(LookButton::Right) || steer_held, - bit::RIGHT_MOUSE, - ); + set(rig.world_mouse.turn() || steer_held, bit::RIGHT_MOUSE); set( rig.world_mouse.held(LookButton::Left) || steer_held, bit::LEFT_MOUSE, @@ -143,8 +140,7 @@ pub(super) fn move_axes( // emit; ours is `state::may_translate`. The server-ride term is benilla's own. // A jump, a chat EditBox taking focus and a zone change leave it set; mounting is untraced and // leaves it set here. A focused chat box releases nothing, so W held through ENTER still runs. - let both_buttons_engaged = (both_buttons - && (rig.world_mouse.down(LookButton::Left) || rig.world_mouse.down(LookButton::Right))) + let both_buttons_engaged = (both_buttons && rig.world_mouse.rose()) || binds.just_pressed(crate::bindings::cmd::MOVE_AND_STEER); if state::autorun_cancelled( binds.just_pressed(crate::bindings::cmd::MOVE_FORWARD), diff --git a/crates/benilla-ui/src/script/input_verbs.rs b/crates/benilla-ui/src/script/input_verbs.rs index 3f09c658..60d9ae32 100644 --- a/crates/benilla-ui/src/script/input_verbs.rs +++ b/crates/benilla-ui/src/script/input_verbs.rs @@ -74,8 +74,10 @@ pub(crate) struct InputState { /// How many binding bodies are running: the hardware-event gate's `[[0xb71290]+0xd8]`. binding_depth: u32, /// `[InputControl+4] & 1`, the TurnOrAction channel `IsMouselooking` tests (`0x514278`), as - /// the app pushed it. + /// the app pushed it or a `MouselookStart`/`Stop` since moved it. turn_or_action: bool, + /// The `MouselookStart` (`true`) and `MouselookStop` calls since the app last drained them. + mouselook: Vec, } impl UiScript { @@ -93,10 +95,17 @@ impl UiScript { result } - /// Push whether the TurnOrAction channel is held: the world holds the right mouse button. + /// Push whether the TurnOrAction channel is held: the world holds the right mouse button, or + /// a `MouselookStart` holds it with none. pub fn set_turn_or_action_held(&mut self, held: bool) { self.model_mut().input.turn_or_action = held; } + + /// Take the `MouselookStart` (`true`) and `MouselookStop` calls made since the last call, in + /// call order, for the app's look session. + pub fn take_mouselook_calls(&mut self) -> Vec { + std::mem::take(&mut self.model_mut().input.mouselook) + } } /// The hardware-event gate `0x494a50(0)`: inside a binding body. @@ -210,13 +219,26 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { Ok(flag(model.input.turn_or_action)) })?, )?; + // `MouselookStart`/`MouselookStop` (`0x514210`/`0x514240`) pass no gate: each disarms the + // pending world click (`0x514810(0)`) and sets or clears the TurnOrAction channel + // (`0x515090(1, set, now, 0)`), the bit the held right button drives. The bit moves at the + // call, so `IsMouselooking` answers it at once; the app's look session applies the call. + for (name, on) in [("MouselookStart", true), ("MouselookStop", false)] { + g.set( + name, + lua.create_function(move |lua, _: MultiValue| { + let mut model = lua.app_data_mut::().expect("model app_data"); + model.input.turn_or_action = on; + model.input.mouselook.push(on); + Ok(()) + })?, + )?; + } // Not built, so these answer and do nothing: // - the pitch pair (`0x400`/`0x800`): the mover has no keyboard pitch axis (`ABSENT` PITCHUP); // - TurnOrAction and CameraOrSelectOrMove (`0x514120`-`0x5141d0`), which arm the world click - // (`0x514810`) and set the mouse bits: the look session is keyed to the physical buttons - // (`ABSENT` TURNORACTION, CAMERAORSELECTORMOVE); - // - `MouselookStart`/`Stop` (`0x514210`/`0x514240`, ungated), the same channel with no click - // armed: a session without a held button is not built. + // (`0x514810`) and set the mouse bits: the look session reads the physical buttons for + // these (`ABSENT` TURNORACTION, CAMERAORSELECTORMOVE). inert( lua, &[ @@ -228,8 +250,6 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "TurnOrActionStop", "CameraOrSelectOrMoveStart", "CameraOrSelectOrMoveStop", - "MouselookStart", - "MouselookStop", ], )?; @@ -463,6 +483,36 @@ mod tests { ); } + /// `MouselookStart`/`Stop` pass no gate (`0x514210`/`0x514240`), queue for the look session in + /// call order, and move the channel at the call, so `IsMouselooking` answers them at once. + #[test] + fn mouselook_start_and_stop_move_the_channel_from_any_caller() { + let mut s = UiScript::new().unwrap(); + assert_eq!(s.arity("MouselookStart()").unwrap(), 0); + s.run("MouselookStop()").unwrap(); + s.take_mouselook_calls(); + let looking = |s: &UiScript| s.eval::>("return IsMouselooking()").unwrap(); + s.run("MouselookStart()").unwrap(); + assert_eq!( + looking(&s), + Some(1), + "an addon's own call, outside any binding" + ); + s.run("MouselookStop() MouselookStart() MouselookStop()") + .unwrap(); + assert_eq!(looking(&s), None); + assert_eq!(s.take_mouselook_calls(), vec![true, false, true, false]); + assert!(s.take_mouselook_calls().is_empty(), "drained"); + assert!( + s.take_binding_input().is_empty(), + "not a movement-binding call" + ); + // The app's push is the channel's truth from then on: a right release ended it. + s.run("MouselookStart()").unwrap(); + s.set_turn_or_action_held(false); + assert_eq!(looking(&s), None); + } + /// `TargetLastTarget` and `AttackTarget` join the call queue, in call order with the rest. #[test] fn the_targeting_calls_join_the_call_queue_in_order() {