Add on-screen touch controls for mobile browsers

- scripts/touch_controls.gd: full-screen layer routing multi-touch by touch
  index: floating joystick on the left half (analog move_* actions), look area
  on the right half, FIRE/GRENADE/RELOAD/JUMP buttons. Shown automatically on
  web_android/web_ios or a touchscreen; overridable with --touch/--no-touch and
  ?touch=1/?touch=0. The root Control uses MOUSE_FILTER_IGNORE on purpose: a
  STOP control swallows the first click and the Web start gate never fires.
- scripts/player.gd: touch_mode disables mouse look and pointer lock; view
  rotation moved to apply_look() shared by mouse and touch; reload and grenade
  are polled now (is_action_just_pressed) like shoot/jump, because an
  InputEventAction synthesised inside another event's dispatch does not reach
  _unhandled_input; start_reload() extracted; get_vector uses an explicit 0.2
  dead zone so the joystick keeps its full travel.
- scripts/main.gd: creates the layer when needed, accepts a screen touch as the
  start gesture (which also unlocks browser audio) and enables the layer only
  afterwards.
- tools/test_touch_controls.gd: 11 assertions (joystick movement, look
  rotation, simultaneous multi-touch, fire hold/release, reload, grenade, jump,
  detection) runnable headless and on a real display.
- docs/TOUCH-CONTROLS.md: detection rules, input model, verification.
This commit is contained in:
enne2
2026-10-10 23:52:27 +02:00
parent 956a15a525
commit 6b2779223f
8 changed files with 619 additions and 26 deletions
+172
View File
@@ -0,0 +1,172 @@
extends SceneTree
## Verifica i controlli on-screen e il loro rilevamento automatico.
##
## --touch forza la UI touch e prova joystick, area di puntamento e pulsanti
## --no-touch verifica che su un contesto non mobile/touch la UI NON compaia
##
## Gli eventi sono iniettati con Input.parse_input_event come fa il browser
## (DisplayServerWeb invia InputEventScreenTouch/Drag), quindi passa la stessa
## pipeline di multitouch usata su dispositivo reale.
##
## Nota sull'ambiente: in --headless la finestra del DisplayServer e' 64x36,
## quindi la stretch transform (canvas_items) riscala le coordinate iniettate e
## una posizione di (200,500) arriva al Control come (4000,9720). In quel caso il
## test pilota direttamente i gestori in spazio viewport; con un display reale
## usa la pipeline eventi completa. La logica verificata e' la stessa.
const TouchScript = preload("res://scripts/touch_controls.gd")
var _direct := false
var world: Node
var touch: Node
var player: Node
var _log: Array[String] = []
func _initialize() -> void:
call_deferred("run_test")
func _touch(index: int, pos: Vector2, pressed: bool) -> void:
if _direct:
if pressed:
touch._press(index, pos)
else:
touch._release(index)
return
var ev := InputEventScreenTouch.new()
ev.index = index
ev.position = pos
ev.pressed = pressed
Input.parse_input_event(ev)
func _drag(index: int, pos: Vector2, relative: Vector2) -> void:
if _direct:
touch._drag(index, pos, relative)
return
var ev := InputEventScreenDrag.new()
ev.index = index
ev.position = pos
ev.relative = relative
Input.parse_input_event(ev)
func _steps(count: int) -> void:
for i in count:
await physics_frame
func _frame() -> void:
await process_frame
func run_test() -> void:
var args := OS.get_cmdline_user_args()
world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
player = world.player
if "--no-touch" in args:
await _frame()
assert(world.touch == null)
assert(not player.touch_mode)
print("PASS: nessuna UI touch quando il contesto non e' mobile ne' touchscreen")
world.queue_free()
await _frame()
quit()
return
await _frame()
# --- presenza e costruzione della UI ---
assert(world.touch != null)
assert(player.touch_mode)
touch = world.touch
_direct = DisplayServer.get_name() == "headless"
assert(touch._buttons.size() == 4)
for role in [TouchScript.Role.FIRE, TouchScript.Role.GRENADE, TouchScript.Role.RELOAD, TouchScript.Role.JUMP]:
assert(touch._buttons.has(role))
print("PASS: UI touch creata con joystick, area di puntamento e 4 pulsanti%s" % (" [modalita' diretta: headless]" if _direct else " [pipeline eventi: display reale]"))
# --- joystick: trascinamento nella meta' sinistra -> movimento ---
var start_pos := Vector2(200, 500)
var before: Vector3 = player.global_position
_touch(0, start_pos, true)
await _frame()
_drag(0, start_pos + Vector2(140, -120), Vector2(140, -120))
await _frame()
assert(touch.move_vector.length() > 0.5)
await _steps(12)
var moved: Vector3 = player.global_position
assert(moved.x > before.x + 0.05) # verso destra (asse +X)
assert(moved.z < before.z - 0.05) # verso avanti (asse -Z)
print("PASS: joystick muovi -> delta (%.2f, %.2f) con vettore %.2f" % [moved.x - before.x, moved.z - before.z, touch.move_vector.length()])
# --- area di puntamento: trascinamento nella meta' destra -> rotazione ---
var yaw_before: float = player.rotation.y
_touch(1, Vector2(900, 300), true)
await _frame()
_drag(1, Vector2(960, 320), Vector2(60, 20))
await _frame()
assert(player.rotation.y < yaw_before - 0.001) # trascinare a destra ruota a destra
assert(absf(player.camera.rotation.x) <= 1.4)
print("PASS: area di puntamento -> rotazione visuale (yaw %.3f -> %.3f)" % [yaw_before, player.rotation.y])
# --- multitouch: i due tocchi coesistono, il rilascio di uno non ferma l'altro ---
assert(touch._touches.values().has(TouchScript.Role.JOY))
assert(touch._touches.values().has(TouchScript.Role.LOOK))
_touch(1, Vector2(960, 320), false)
await _frame()
assert(touch._touches.values().has(TouchScript.Role.JOY)) # il movimento resta attivo
print("PASS: multitouch simultaneo (movimento + puntamento) con indici separati")
_touch(0, start_pos + Vector2(140, -120), false)
await _frame()
assert(touch.move_vector == Vector2.ZERO)
await _steps(6)
assert(absf(player.velocity.x) < 0.01 and absf(player.velocity.z) < 0.01)
print("PASS: rilascio del joystick -> vettore azzerato e giocatore fermo")
# --- pulsante FUOCO: tenere premuto spara in automatico ---
player.ammo = 30
player.cooldown = 0.0
var fire_pos: Vector2 = touch._buttons[TouchScript.Role.FIRE].center
_touch(0, fire_pos, true)
await _steps(10)
assert(player.ammo < 30)
var after_fire: int = player.ammo
_touch(0, fire_pos, false)
await _steps(10)
assert(player.ammo == after_fire) # rilasciato: non spara piu'
print("PASS: FUOCO tiene premuto -> 30 -> %d colpi, si ferma al rilascio" % after_fire)
# --- pulsante RICARICA ---
_touch(0, touch._buttons[TouchScript.Role.RELOAD].center, true)
await _steps(2) # l'edge dell'azione e' visto dal _physics_process
_touch(0, touch._buttons[TouchScript.Role.RELOAD].center, false)
assert(player.reloading)
await create_timer(1.8).timeout
assert(player.ammo == 30 and not player.reloading)
print("PASS: RICARICA -> caricatore pieno (30)")
# --- pulsante GRANATA ---
assert(player.grenade_cooldown <= 0)
_touch(0, touch._buttons[TouchScript.Role.GRENADE].center, true)
await _steps(2)
_touch(0, touch._buttons[TouchScript.Role.GRENADE].center, false)
assert(player.grenade_cooldown > 0)
assert(get_nodes_in_group("grenades").size() >= 1)
print("PASS: GRANATA -> lancio eseguito (cooldown %.0f s)" % player.grenade_cooldown)
player.grenade_cooldown = 0.0
# --- pulsante SALTO ---
await _steps(10)
assert(player.is_on_floor())
_touch(0, touch._buttons[TouchScript.Role.JUMP].center, true)
await _steps(2)
_touch(0, touch._buttons[TouchScript.Role.JUMP].center, false)
await _steps(2)
assert(player.velocity.y > 0.5)
print("PASS: SALTO -> velocita' verticale %.1f m/s" % player.velocity.y)
# --- rilevamento: su questo contesto (desktop headless) la UI non e' automatica ---
assert(not TouchScript.should_auto_enable())
print("PASS: TouchControls.should_auto_enable() = false su desktop -> la UI compare solo se serve")
print("PASS: controlli touch completi")
world.queue_free()
await _frame()
quit()