extends SceneTree ## Guardrail for the on-screen joystick's self-healing. The reported bug is a stick that ## "disappears / goes non-responsive": a touchend dropped during a resize/orientation flip (or ## an app backgrounding) leaves _stick_active stuck true, so the ghost hint hides and no new ## stick can start — and the bull keeps coasting on the held move actions. touch_controls.gd ## now resets on relayout + focus-out, prunes orphaned touches, and lets the latest left-zone ## touch re-acquire the stick. This drives synthetic touch events and asserts the stick never ## stays stranded and never leaves movement held. ## Run: godot --headless --script res://tests/touch_controls_test.gd const _MOVE := [&"move_forward", &"move_back", &"move_left", &"move_right"] var _pass := 0 var _fail := 0 var _tc: CanvasLayer func _init() -> void: _run.call_deferred() func _ok(c: bool, m: String) -> void: if c: _pass += 1 print(" PASS: ", m) else: _fail += 1 print(" FAIL: ", m) func _press(index: int, pos: Vector2) -> void: var e := InputEventScreenTouch.new() e.index = index e.position = pos e.pressed = true _tc._input(e) func _release(index: int, pos: Vector2) -> void: var e := InputEventScreenTouch.new() e.index = index e.position = pos e.pressed = false _tc._input(e) func _drag(index: int, pos: Vector2) -> void: var e := InputEventScreenDrag.new() e.index = index e.position = pos _tc._input(e) func _any_move_held() -> bool: for a: StringName in _MOVE: if Input.is_action_pressed(a): return true return false # A point safely inside the left-hand joystick zone (independent of viewport size), plus the # same point pushed a full radius "up" so the stick engages move_forward. func _stick_origin() -> Vector2: return _tc._stick_zone.position + _tc._stick_zone.size * Vector2(0.25, 0.5) func _run() -> void: var dp: Node = root.get_node("/root/DP") var controls: Node = root.get_node("/root/Controls") dp.call("set_value", "force_touch_controls", true) _ok(controls.call("use_touch_ui"), "force flag makes use_touch_ui() true (touch UI live)") # Headless boots a 64x64 root viewport, which collapses every UI rect on top of each other. # Give it a real phone-ish landscape size so the joystick zone / buttons lay out sanely. root.size = Vector2i(1152, 648) _tc = (load("res://touch_controls.gd") as Script).new() root.add_child(_tc) await process_frame # _ready builds _surface and runs the first _relayout _tc._relayout() # ensure the layout reflects the size set above var o := _stick_origin() var up := o + Vector2(0.0, -_tc._stick_radius) # (a) A resize mid-drag (dropped touchend) must not strand the stick or keep the bull moving. _tc._reset_touch_state() _press(0, o) _drag(0, up) _ok(_tc._stick_active and _any_move_held(), "drag engages the stick and holds movement") _tc._relayout() # the resize/orientation flip that used to swallow the release _ok(not _tc._stick_active, "a relayout mid-drag stands the stick down (not stranded)") _ok(not _any_move_held(), "movement is released on relayout (bull doesn't coast)") # (b) App backgrounded / focus lost — same self-heal via the notification hook. _tc._reset_touch_state() _press(0, o) _drag(0, up) _tc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_OUT) _ok(not _tc._stick_active and not _any_move_held(), "focus-out clears the stick and movement") # (c) A stranded stick self-heals: with finger 0's release lost, a fresh finger 1 in the zone # re-acquires the stick; finger 0's late release must NOT kill finger 1's stick. _tc._reset_touch_state() _press(0, o) # finger 0 owns the stick _press(1, o + Vector2(20, 10)) # finger 1 lands while 0 is still (wrongly) held → takes over _ok(_tc._stick_active and _tc._stick_index == 1, "latest left-zone touch re-acquires the stick") _release(0, o) # the stranded finger finally reports up _ok(_tc._stick_active and _tc._stick_index == 1, "a superseded finger's release doesn't end the stick") _release(1, o) _ok(not _tc._stick_active, "releasing the owning finger ends the stick") # (d) Watchdog: a touch that vanishes from tracking (no release event at all) is pruned. _tc._reset_touch_state() _press(0, o) _tc._touches.erase(0) # simulate the finger silently disappearing _tc._process(0.016) # prune runs each frame _ok(not _tc._stick_active and not _any_move_held(), "watchdog prunes a vanished touch and frees the stick") _finish() func _finish() -> void: print("Results: %d passed, %d failed" % [_pass, _fail]) quit(1 if _fail > 0 else 0)