Fix mouse look swallowing by the HUD
Move guard look handling to the _input stage, recapture the cursor on window-focus return and when clicking back into gameplay, and make HUD controls mouse-transparent. Add regression tests for yaw/pitch and HUD mouse filters. Verified under Xvfb with real relative mouse motion. Headless 237/237, Python 94/94, movement smoke 60 fps.
This commit is contained in:
1 parent
f4a53e2d4f
commit
76eb5e3a79
10 files changed
+97
-14
No files matched your search
@@ -115,14 +115,36 @@ static func _ensure_gamepad_bindings() -> void:
|
||||
press.pressed = false
|
||||
InputMap.action_add_event(action, press)
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
## Mouse look is read at the `_input` stage, before GUI/Control handling. A
|
||||
## full-screen HUD or a focused control must never be able to swallow relative
|
||||
## mouse motion while the game is in captured mode.
|
||||
func _input(event: InputEvent) -> void:
|
||||
if input_locked:
|
||||
return
|
||||
if event is InputEventMouseMotion and Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
|
||||
rotate_y(-event.relative.x * look_sensitivity)
|
||||
var pitch_sign := 1.0 if invert_y else -1.0
|
||||
head.rotate_x(pitch_sign * event.relative.y * look_sensitivity)
|
||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||
if event is InputEventMouseMotion:
|
||||
if Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||
return
|
||||
_apply_mouse_look(event.relative)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif event is InputEventMouseButton and event.pressed and Input.get_mouse_mode() != Input.MOUSE_MODE_CAPTURED:
|
||||
# Recover capture if the window lost it (alt-tab, compositor focus,
|
||||
# clicking back into the game) without opening the radial menu.
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
## Pure look update, kept separate so the rotation math is testable.
|
||||
func _apply_mouse_look(relative: Vector2) -> void:
|
||||
rotate_y(-relative.x * look_sensitivity)
|
||||
var pitch_sign := 1.0 if invert_y else -1.0
|
||||
head.rotate_x(pitch_sign * relative.y * look_sensitivity)
|
||||
head.rotation.x = clampf(head.rotation.x, deg_to_rad(-80.0), deg_to_rad(80.0))
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
# A window/compositor focus change can silently release capture. Restore it
|
||||
# when gameplay owns the input layer; menus set input_locked instead.
|
||||
if what == NOTIFICATION_APPLICATION_FOCUS_IN or what == NOTIFICATION_WM_WINDOW_FOCUS_IN:
|
||||
if is_inside_tree() and not input_locked:
|
||||
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if focus_suppress_s > 0.0:
|
||||
|
||||
@@ -356,6 +356,17 @@ func _init() -> void:
|
||||
debug_label.add_theme_font_size_override("font_size", 11)
|
||||
debug_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(debug_label)
|
||||
_ignore_mouse(self)
|
||||
|
||||
## HUD controls are presentation-only. Leaving the full-screen containers on the
|
||||
## default MOUSE_FILTER_STOP can swallow mouse motion/buttons before the guard's
|
||||
## captured-look input sees them. Make every non-gameplay HUD control inert to
|
||||
## the mouse; modal menus are separate CanvasLayers and keep their own filters.
|
||||
func _ignore_mouse(node: Node) -> void:
|
||||
for child in node.get_children():
|
||||
if child is Control:
|
||||
(child as Control).mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_ignore_mouse(child)
|
||||
|
||||
func set_shift_state(coordinator: Object, scenario: Resource) -> void:
|
||||
top_label.text = scenario.display_name if scenario else "Gig Line"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
extends RefCounted
|
||||
## Regression tests for the first-person mouse-look path and non-blocking HUD.
|
||||
|
||||
const GameManager := preload("res://src/game/game_manager.gd")
|
||||
const GuardController := preload("res://src/game/guard_controller.gd")
|
||||
const Hud := preload("res://src/game/ui/hud.gd")
|
||||
|
||||
func test_mouse_look_rotates_yaw_and_pitch() -> bool:
|
||||
var root := Node3D.new()
|
||||
var gm: Node = GameManager.new()
|
||||
var guard: GuardController = gm.call("_build_guard")
|
||||
guard._ready()
|
||||
var yaw0 := guard.rotation.y
|
||||
var pitch0 := guard.head.rotation.x
|
||||
guard._apply_mouse_look(Vector2(100, 50))
|
||||
var a := TestUtils.check(guard.rotation.y < yaw0 - 0.1, "horizontal motion yaws the guard")
|
||||
var b := TestUtils.check(guard.head.rotation.x < pitch0 - 0.1, "vertical motion pitches the head")
|
||||
guard._apply_mouse_look(Vector2(0, 100000))
|
||||
var c := TestUtils.check(absf(rad_to_deg(guard.head.rotation.x)) <= 80.01,
|
||||
"pitch stays clamped")
|
||||
guard.free()
|
||||
gm.free()
|
||||
root.free()
|
||||
return a and b and c
|
||||
|
||||
func test_hud_does_not_capture_mouse_input() -> bool:
|
||||
var hud: Hud = Hud.new()
|
||||
var root := hud.get_child(0) as Control
|
||||
var a := TestUtils.check(root != null and root.mouse_filter == Control.MOUSE_FILTER_IGNORE,
|
||||
"full-screen HUD root ignores mouse input")
|
||||
var labels := hud.find_children("*", "Label", true, false)
|
||||
var blocking := 0
|
||||
for node in labels:
|
||||
if (node as Control).mouse_filter != Control.MOUSE_FILTER_IGNORE:
|
||||
blocking += 1
|
||||
var b := TestUtils.check(blocking == 0, "HUD labels do not swallow mouse motion")
|
||||
hud.free()
|
||||
return a and b
|
||||
Reference in new issue
Block a user