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:
@@ -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()
|
||||
Reference in New Issue
Block a user