Fire buttons on press where the browser never delivers a release

Every button in the web build was dead - reported as "Play does nothing".

Diagnosed on the deployed build with a temporary on-screen input probe,
because none of it reproduces here: the suite passes, and pressing Play under
the real gl_compatibility renderer (the same path WebGL2 takes) reaches
Main.tscn fine. The probe reported, from a phone browser:

    #6 touch down @ 362,781
    play rect 296,725 128x128
    hit=YES

Three taps, six events, every one a DOWN and not a single UP - and the button
left drawn in its bright held state, pressed_down stuck true because nothing
ever came to clear it. So touches arrive, their coordinates are correct, and
they land inside the button; the browser simply never delivers the release,
in _gui_input or in raw _input.

IconButton emits `pressed` only on release, deliberately, so that dragging
off a button cancels it. With no release that means it never emits at all -
on every button in the game, not just Play. Which also explains why the
earlier suspects went nowhere: the scene change was never reached, so it was
never the scene change, and hit=YES ruled out coordinate mapping.

fire_on_press defaults to OS.has_feature("web"). There, a press completes the
tap itself and does NOT latch, since nothing is coming to unlatch it. Every
other platform is untouched and keeps drag-off-to-cancel.

The cost, stated plainly: a web tap can no longer be cancelled by sliding off
before lifting, and that applies to destructive buttons too. The alternative -
keeping release-firing and treating a new press as completing the previous
one - preserves cancellation but leaves the very first tap dead, which is
worse.

tests/test_icon_button_press_fires.gd drives the flag directly, since a
headless Linux run cannot BE a web build: one tap emits exactly once, a late
release does not emit again, the button does not latch, and with the flag off
the release still drives the press and dragging off still cancels.

MainMenu keeps the load/start bisection introduced while chasing this - a
failed change_scene_to_packed otherwise leaves the menu up, which looks
exactly like an unpressed button.

Full suite green at 100/100.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-18 18:02:20 +02:00
commit 00455dc6ba
4 changed files with 146 additions and 5 deletions

View file

@ -110,6 +110,24 @@ func _blocked_by_modal() -> bool:
return false
# Fire on the PRESS instead of the release.
#
# A browser on a phone delivers the touch down and then never delivers the
# release. Measured directly on the deployed build: presses arrive with
# correct coordinates, land inside the button, and the button visibly latches
# into its held state - and no release ever follows, in _gui_input or in raw
# _input. A button that only emits on release therefore never emits at all,
# which is exactly the "Play does nothing" report.
#
# The cost is the drag-off-to-cancel affordance below, which is worth much
# less than the button working. Everywhere else keeps it, because everywhere
# else delivers releases.
#
# Settable rather than a constant so a test can exercise both paths without
# needing to be a web build.
@export var fire_on_press: bool = OS.has_feature("web")
func _gui_input(event: InputEvent) -> void:
if _blocked_by_modal():
return
@ -125,6 +143,12 @@ func _handle_press(is_pressed: bool, at_position: Vector2) -> void:
if is_pressed:
pressed_down = true
queue_redraw()
if fire_on_press:
# Nothing is coming to complete this press, so complete it here.
# Not latched: with no release to clear it, the button would
# otherwise stay drawn in its held state forever.
pressed_down = false
pressed.emit()
elif pressed_down:
pressed_down = false
queue_redraw()

View file

@ -94,12 +94,34 @@ func _on_play_pressed() -> void:
_start("res://Main.tscn")
# change_scene_to_file() returns an error and otherwise fails SILENTLY - the
# menu simply stays up, which is indistinguishable from the button not having
# been pressed at all. Reporting it turns "nothing happens" into something a
# player can read back.
# "Nothing happens when I press Play" covers three different faults, and they
# are indistinguishable from the outside: the press never reaching this
# handler, the scene change refusing, or the scene change reporting success
# and not taking effect. Each writes a DIFFERENT line here, so one look at
# the screen says which one it is.
#
# (nothing at all) - the press never got here
# "Loading Main.tscn..." - it got here but the scene never finished
# loading
# "Could not load ..." - the load returned nothing
# "Starting Main.tscn..." - loaded and the change reported OK, yet the
# menu is somehow still up
# "Could not start (...)" - the change refused, with the error number
#
# The scene is also loaded HERE rather than left to change_scene_to_file(),
# which loads internally and reports a single error for both steps. Doing it
# in two makes the failure specific, and takes whatever that call does about
# background loading out of the picture - the web export is built without
# thread support, so anything expecting a loader thread is worth removing
# from the path rather than trusting.
func _start(scene_path: String) -> void:
var result: int = get_tree().change_scene_to_file(scene_path)
status_label.text = "Loading %s..." % scene_path.get_file()
var packed: PackedScene = load(scene_path)
if packed == null:
status_label.text = "Could not load %s" % scene_path.get_file()
return
status_label.text = "Starting %s..." % scene_path.get_file()
var result: int = get_tree().change_scene_to_packed(packed)
if result != OK:
status_label.text = "Could not start (%s, error %d)" % [scene_path.get_file(), result]

View file

@ -0,0 +1,94 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_icon_button_press_fires.gd
# A browser on a phone delivers the touch DOWN and never delivers the
# release. Measured on the deployed web build: presses arrived with correct
# coordinates, landed inside the button, the button latched into its held
# state, and no release ever followed. IconButton emits `pressed` only on
# release, so on the web nothing ever fired - the "Play does nothing" bug.
#
# fire_on_press exists for exactly that platform. This test does not care
# whether THIS run is a web build; it exercises both settings directly, which
# is the only way a headless Linux run can cover the web path at all.
#
# Covers:
# A. with fire_on_press, a press alone emits - and emits ONCE even if a
# release does arrive after it
# B. with fire_on_press, the button does not stay latched in its held
# state (nothing is coming to clear it)
# C. without it, the release still drives the press, and dragging off
# still cancels - the behaviour every other platform keeps
# D. the default follows OS.has_feature("web")
var presses: int = 0
func _initialize() -> void:
var button_script: GDScript = load("res://IconButton.gd")
# --- D. the default is the platform rule --------------------------
var default_button: Control = button_script.new()
default_button.size = Vector2(120, 120)
if default_button.fire_on_press != OS.has_feature("web"):
return _fail("fire_on_press defaults to %s, expected OS.has_feature('web') = %s" \
% [default_button.fire_on_press, OS.has_feature("web")])
default_button.free()
print("fire_on_press defaults to the web feature flag (%s here)" % OS.has_feature("web"))
# --- A + B. the web path ------------------------------------------
var web_button: Control = button_script.new()
web_button.size = Vector2(120, 120)
web_button.fire_on_press = true
web_button.pressed.connect(_count)
root.add_child(web_button)
presses = 0
web_button._handle_press(true, Vector2(60, 60))
if presses != 1:
return _fail("a press alone emitted %d times with fire_on_press, expected 1" % presses)
if web_button.pressed_down:
return _fail("the button latched held with fire_on_press - nothing will ever clear it")
# A release may still arrive on a platform that sends one; it must not
# produce a second press for the same tap.
web_button._handle_press(false, Vector2(60, 60))
if presses != 1:
return _fail("a release after a fired press emitted again (%d total)" % presses)
print("fire_on_press: one tap emits exactly once and does not latch")
# --- C. every other platform --------------------------------------
var native_button: Control = button_script.new()
native_button.size = Vector2(120, 120)
native_button.fire_on_press = false
native_button.pressed.connect(_count)
root.add_child(native_button)
presses = 0
native_button._handle_press(true, Vector2(60, 60))
if presses != 0:
return _fail("a press emitted without a release while fire_on_press was off")
if not native_button.pressed_down:
return _fail("the button did not latch held while waiting for its release")
native_button._handle_press(false, Vector2(60, 60))
if presses != 1:
return _fail("the release did not emit (%d)" % presses)
# Dragging off before lifting still cancels.
presses = 0
native_button._handle_press(true, Vector2(60, 60))
native_button._handle_press(false, Vector2(400, 400))
if presses != 0:
return _fail("releasing outside the button still counted as a press")
print("release path unchanged: release fires, dragging off cancels")
print("TEST_ICON_BUTTON_PRESS_FIRES: PASS")
quit(0)
func _count() -> void:
presses += 1
func _fail(message: String) -> void:
print("TEST_ICON_BUTTON_PRESS_FIRES: FAIL (%s)" % message)
quit(1)

View file

@ -0,0 +1 @@
uid://1icvlueb00ab