Let the speed fader have the click it is under

Clicking the fader did nothing on a desktop: it fired the guns instead.
Unhandled input reaches the last nodes in the tree first, and the fader's
mouse handling lived in GameController - the script on Main, the ROOT, so
it was served last of all. MouseCombatController is a child of it and marks
every left press handled to fire, so the press never arrived.

The fader asks for its own mouse events now, from the UI layer, which sits
after the combat controllers: the control the pointer is actually on gets
the press ahead of the guns behind it. The handlers themselves are the same
wrappers #118 wrote.

The test for #118 could not see any of this - it handed the event straight
to Main._unhandled_input, which is precisely the delivery the real game
never makes, so it passed against the bug it was written for. It pushes
through the viewport now, with mouse aiming on, and checks the guns stay
quiet. Confirmed it fails without the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QR2Mdk42ghEpeyXPe4UFyn
This commit is contained in:
Loïc Wisniewski 2026-09-12 19:06:14 +02:00
commit a43fae5e28
3 changed files with 78 additions and 18 deletions

View file

@ -383,14 +383,14 @@ func _unhandled_input(event: InputEvent) -> void:
_handle_key(event)
elif event is InputEventMouseMotion:
_note_keyboard()
if speed_fader != null:
speed_fader.handle_mouse_motion(event)
elif event is InputEventMouseButton:
_note_keyboard()
# #118: the fader gets first refusal, so dragging the boost does not
# also pan or zoom the world behind it.
if speed_fader != null and speed_fader.handle_mouse_button(event):
return
# The speed fader used to be driven from here, and could not work:
# MouseCombatController is dispatched before this node (it is a
# child, and this is the root) and marks every left press handled to
# fire the guns, so the press never arrived. It listens for its own
# mouse events now - see SpeedFader._unhandled_input - and claims
# them from the UI layer, ahead of the combat controllers.
if not event.pressed:
return
# Wheel zoom, the desktop equivalent of the pinch.

View file

@ -10,16 +10,19 @@ extends SceneTree
# The boost was unreachable on a desktop and the formation could not be
# rearranged at all.
#
# Driven through the REAL routing in each case (GameController for the
# fader, BuildController for the editor) rather than by calling the controls
# directly, because the routing is what was missing - a test that called
# handle_mouse_button() itself would have passed against both reports.
# Driven through the REAL routing in each case - the viewport itself for the
# fader, BuildController for the editor - rather than by calling the
# controls directly, because the routing is what was missing: a test that
# called handle_mouse_button() itself would have passed against both
# reports. It did, in fact, and hid a second one: the fader was dispatched
# from Main, which is the LAST node to see an unhandled press, so
# MouseCombatController fired the guns with it and the knob never moved.
#
# Covers:
# A. #118 - a click on the fader takes hold of it, dragging moves the
# boost, and releasing lets go
# B. the fader claims the press, so dragging the boost does not also zoom
# the world behind it
# B. the press stops at the fader: not the camera behind it, and not the
# guns, which claim every other left press on a desktop
# C. #121 - a click grabs a formation slot, moving drags it, and the
# formation really changes
# D. releasing drops the slot, and moving afterwards does not keep dragging
@ -67,6 +70,31 @@ func _fail(message: String) -> bool:
return true
# Pushed through the viewport, not handed to a node's own _unhandled_input:
# WHICH node sees a press first is the thing under test. #118 shipped a fader
# that only worked when a test called it directly - MouseCombatController
# claims every left press to fire the guns, and it is dispatched well before
# Main's own handler, so on a real desktop the click never arrived.
# `true` keeps the coordinates in viewport space, which is the space the
# controls are laid out in.
func _push(event: InputEvent) -> void:
root.push_input(event, true)
func _push_click(at: Vector2, pressed: bool) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.pressed = pressed
event.position = at
_push(event)
func _push_move(to: Vector2) -> void:
var event: InputEventMouseMotion = InputEventMouseMotion.new()
event.position = to
_push(event)
func _click(at: Vector2, pressed: bool, target: Node) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
@ -112,22 +140,29 @@ func _process(delta: float) -> bool:
return _fail("the boost has no range to move through")
# --- A. click, drag, release -----------------------------
# With mouse aiming live, which is what a desktop always has:
# the guns are the other thing a left press means, and they used
# to take every one of them.
var mouse_combat: Node = scene.get_node("MouseCombatController")
mouse_combat.pointer_mode = mouse_combat.PointerMode.ON
var top: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5,
fader.track_margin + 2.0)
var bottom: Vector2 = fader.global_position + Vector2(fader.size.x * 0.5,
fader.size.y - fader.track_margin - 2.0)
_click(bottom, true, scene)
_push_click(bottom, true)
if not fader.is_active():
return _fail("a click on the fader did not take hold of it")
if mouse_combat.firing:
return _fail("the click that grabbed the fader also fired the fleet's guns")
var at_bottom: float = game_state.fleet_speed_multiplier
_move(top, scene)
_push_move(top)
var at_top: float = game_state.fleet_speed_multiplier
if at_top <= at_bottom:
return _fail("dragging the fader from bottom to top left the boost at %.2f" % at_top)
_click(top, false, scene)
_push_click(top, false)
if fader.is_active():
return _fail("the fader is still held after the button came up")
_move(bottom, scene)
_push_move(bottom)
if not is_equal_approx(game_state.fleet_speed_multiplier, at_top):
return _fail("the fader kept following the pointer after release")
print("fader: %.2fx at the bottom, %.2fx at the top, released cleanly" \
@ -135,10 +170,13 @@ func _process(delta: float) -> bool:
# --- B. it claims the press ------------------------------
var zoom_before: float = scene.get_node("Camera2D").zoom.x
_click(bottom, true, scene)
_click(bottom, false, scene)
_push_click(bottom, true)
_push_click(bottom, false)
if not is_equal_approx(scene.get_node("Camera2D").zoom.x, zoom_before):
return _fail("pressing the fader also moved the camera behind it")
if mouse_combat.firing:
return _fail("the fader's press reached the guns behind it")
mouse_combat.pointer_mode = mouse_combat.PointerMode.AUTO
print("the press stops at the fader; the world behind it is untouched")
# --- C/D/E. the formation editor -------------------------

View file

@ -101,6 +101,28 @@ func handle_mouse_motion(event: InputEventMouseMotion) -> bool:
return true
# ...and listening for those events HERE rather than being handed them by
# GameController, which is what #118 did.
#
# Unhandled input reaches the last nodes in the tree first, and Main - where
# that dispatch lives - is the root, so it is served last of all.
# MouseCombatController is a child of it and claims every left press to fire
# the fleet's guns, which meant a click on the fader shot at the fader
# instead of taking hold of it: the knob never moved on a desktop. The UI
# layer sits after the combat controllers, so asking for the event here puts
# the control the player is actually pointing at ahead of the guns behind it.
#
# Touch still comes through GameController: nothing competes for a finger,
# and the joystick/fire stick ordering it arbitrates is real.
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if handle_mouse_button(event):
get_viewport().set_input_as_handled()
elif event is InputEventMouseMotion:
if handle_mouse_motion(event):
get_viewport().set_input_as_handled()
func handle_touch(event: InputEventScreenTouch) -> bool:
return _press(event.pressed, event.position, event.index)