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,65 @@
|
||||
# Touch controls (mobile browser)
|
||||
|
||||
On-screen controls for phones and tablets, shown automatically when needed.
|
||||
|
||||
## When they appear
|
||||
|
||||
`scripts/touch_controls.gd` decides, in this order:
|
||||
|
||||
1. `OS.has_feature("web_android") or OS.has_feature("web_ios")` — browser on a phone/tablet.
|
||||
This is the documented way: in a Web export `OS.has_feature("mobile")` is **false**, and the
|
||||
`web_*` tags are the only ones that describe the browser's host OS
|
||||
([feature tags](https://docs.godotengine.org/en/stable/tutorials/export/feature_tags.html)).
|
||||
2. `DisplayServer.is_touchscreen_available()` — native mobile, or a desktop/laptop touchscreen.
|
||||
Careful: `display_server_web.cpp` returns true here also when `emulate_touch_from_mouse` is on,
|
||||
so it is never used alone.
|
||||
3. Overrides: `--touch` / `--no-touch` on the command line, and `?touch=1` / `?touch=0` on the Web
|
||||
URL. With `--touch` on a desktop the mouse drags are treated as touches, so the UI can be tried
|
||||
without a touch device.
|
||||
|
||||
When touch mode is on, `main.gd` sets `player.touch_mode`, no pointer lock is requested and
|
||||
`Input.set_emulate_mouse_from_touch(false)` is applied: the emulated mouse would otherwise move the
|
||||
view twice and fire the weapon on any touch.
|
||||
|
||||
## Layout and input model
|
||||
|
||||
One full-screen `Control` routes multi-touch by **touch index** (`InputEventScreenTouch` /
|
||||
`InputEventScreenDrag`), which is what the browser sends (`display_server_web.cpp` feeds exactly
|
||||
these events into `parse_input_event`); emulated mouse events are not used, because mouse emulation
|
||||
carries a single pointer and would break simultaneous aiming and firing.
|
||||
|
||||
- left half: floating joystick (its base recenters where the finger lands), feeds the analog
|
||||
`move_*` actions;
|
||||
- right half: look area, drag rotates the view through `player.apply_look()`;
|
||||
- bottom right: FIRE (hold to keep shooting), GRENADE, RELOAD, JUMP.
|
||||
|
||||
Movement uses the existing InputMap actions, so keyboard, gamepad and touch share one path. Reload,
|
||||
grenade and jump are read by polling (`Input.is_action_just_pressed`) instead of by event handling:
|
||||
an `InputEventAction` synthesised inside the dispatch of another event (which is what a touch looks
|
||||
like) does not reliably reach `_unhandled_input`. Note that such a press is seen by
|
||||
`_physics_process` one physics frame later.
|
||||
|
||||
`Input.get_vector(..., 0.2)` uses an explicit small dead zone: the 0.5 dead zone configured for the
|
||||
actions would waste half of the joystick travel (keyboard input is digital, so nothing changes for
|
||||
it).
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
godot --headless --path . --script res://tools/test_touch_controls.gd -- --touch
|
||||
godot --headless --path . --script res://tools/test_touch_controls.gd -- --no-touch
|
||||
DISPLAY=:0 godot --display-driver x11 --rendering-driver opengl3 --path . \
|
||||
--script res://tools/test_touch_controls.gd -- --touch
|
||||
```
|
||||
|
||||
The first and third run the same assertions (joystick movement, look rotation, simultaneous
|
||||
multi-touch, fire hold/release, reload, grenade, jump); the third goes through the real event
|
||||
pipeline. In `--headless` the DisplayServer window is 64x36, so the `canvas_items` stretch transform
|
||||
rescales injected coordinates (a (200,500) touch arrives as (4000,9720)); the test detects this and
|
||||
drives the handlers directly in viewport space instead.
|
||||
|
||||
## Web build notes
|
||||
|
||||
- The first tap also unlocks audio in the browser, so `main.gd` accepts either a mouse press or a
|
||||
screen touch as the start gesture and enables the touch layer only after it.
|
||||
- The HTML shell already ships `user-scalable=no`; no extra page-level CSS is required.
|
||||
+39
-3
@@ -4,10 +4,12 @@ const ZombieScript = preload("res://scripts/zombie.gd")
|
||||
const RoomScript = preload("res://scripts/room.gd")
|
||||
const MUSIC_PATH := "res://assets/audio/music/zombie_industrial_loop.ogg"
|
||||
const MUSIC_VOLUME_DB := -11.0
|
||||
const TouchControls = preload("res://scripts/touch_controls.gd")
|
||||
var player: CharacterBody3D
|
||||
var room: Node3D
|
||||
var music: AudioStreamPlayer
|
||||
var music_started := false
|
||||
var touch: Node
|
||||
var hud: Label
|
||||
var score := 0
|
||||
var wave := 0
|
||||
@@ -54,10 +56,14 @@ func _ready() -> void:
|
||||
canvas.add_child(cross)
|
||||
cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||||
_setup_music()
|
||||
_setup_touch()
|
||||
if OS.has_feature("web"):
|
||||
awaiting_web_start = true
|
||||
if touch != null:
|
||||
touch.enabled = false # il primo tocco/click avvia la partita
|
||||
start_label = Label.new()
|
||||
start_label.text = "CLICCA PER INIZIARE\nWASD: movimento · clic sinistro: spara · destro: granata · R: ricarica"
|
||||
var howto := "trascina a sinistra: muovi · destra: guarda · pulsanti: fuoco/granata/ricarica/salto" if player.touch_mode else "WASD: movimento · clic sinistro: spara · destro: granata · R: ricarica"
|
||||
start_label.text = ("TOCCA PER INIZIARE" if player.touch_mode else "CLICCA PER INIZIARE") + "\n" + howto
|
||||
start_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
start_label.add_theme_font_size_override("font_size", 24)
|
||||
start_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
@@ -72,14 +78,42 @@ func _ready() -> void:
|
||||
get_tree().quit())
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if awaiting_web_start and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
if not awaiting_web_start:
|
||||
return
|
||||
var started := false
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
started = true
|
||||
elif event is InputEventScreenTouch and event.pressed:
|
||||
started = true # primo tocco su mobile
|
||||
if started:
|
||||
awaiting_web_start = false
|
||||
start_label.queue_free()
|
||||
if not player.touch_mode:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
# Il browser concede l'audio solo dopo un gesto dell'utente: qui il click.
|
||||
if touch != null:
|
||||
touch.enabled = true
|
||||
# Il browser concede l'audio solo dopo un gesto dell'utente: qui il click/tocco.
|
||||
_start_music()
|
||||
_next_wave()
|
||||
|
||||
## Controlli on-screen: attivi su browser mobile o touchscreen, forzabili con
|
||||
## --touch / --no-touch sulla riga di comando e con ?touch=1 / ?touch=0 sul Web.
|
||||
func _setup_touch() -> void:
|
||||
var forced := TouchControls.force_mode()
|
||||
if not (forced > 0 or (forced == 0 and TouchControls.should_auto_enable())):
|
||||
return
|
||||
player.touch_mode = true
|
||||
# Su un dispositivo touch il pointer lock non serve e su desktop forzato
|
||||
# ruberebbe il cursore: nessuna cattura del mouse in modalita' touch.
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
touch = CanvasLayer.new()
|
||||
touch.set_script(TouchControls)
|
||||
touch.player = player
|
||||
# Su desktop forzato con --touch i drag del mouse valgono come tocchi,
|
||||
# cosi' la UI e' provabile anche senza dispositivo touch.
|
||||
touch.mouse_fallback = forced > 0 and not DisplayServer.is_touchscreen_available()
|
||||
add_child(touch)
|
||||
|
||||
## Colonna sonora: industrial metal strumentale su loop senza giunzione udibile.
|
||||
func _setup_music() -> void:
|
||||
music = AudioStreamPlayer.new()
|
||||
@@ -109,6 +143,8 @@ func _process(_dt: float) -> void:
|
||||
hud.text += "\nGRANATA: " + ("PRONTA" if player.grenade_cooldown <= 0 else "%d s" % ceili(player.grenade_cooldown))
|
||||
if player.reloading:
|
||||
hud.text += "\nRICARICA..."
|
||||
if player.touch_mode:
|
||||
hud.text += "\ntrascina a sinistra: muovi · a destra: guarda"
|
||||
if player.hp <= 0:
|
||||
hud.text += "\nSEI MORTO — premi R per ricominciare"
|
||||
|
||||
|
||||
+34
-12
@@ -16,6 +16,9 @@ var weapon_anim: AnimationPlayer
|
||||
var step_timer := 0.0
|
||||
var death_started := false
|
||||
var death_overlay: ColorRect
|
||||
## True quando la partita e' guidata dai controlli on-screen (touch): il mouse
|
||||
## emulato dai tocchi non deve muovere la visuale ne' sparare al posto del dito.
|
||||
var touch_mode := false
|
||||
const Blood = preload("res://scripts/blood_effects.gd")
|
||||
const Audio = preload("res://scripts/hl_audio.gd")
|
||||
|
||||
@@ -66,19 +69,15 @@ func _ready() -> void:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if OS.has_feature("web") and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started:
|
||||
if OS.has_feature("web") and not touch_mode and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and hp > 0:
|
||||
rotate_y(-event.relative.x * 0.0025)
|
||||
camera.rotation.x = clampf(camera.rotation.x - event.relative.y * 0.0025, -1.4, 1.4)
|
||||
if event is InputEventMouseMotion and not touch_mode and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
apply_look(event.relative)
|
||||
if event.is_action_pressed("pause") and not death_started:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED
|
||||
if event.is_action_pressed("grenade") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED and not death_started:
|
||||
throw_grenade()
|
||||
if event.is_action_pressed("reload"):
|
||||
if hp <= 0:
|
||||
get_tree().reload_current_scene()
|
||||
elif not reloading and ammo < 30:
|
||||
|
||||
## Ricarica: tempi invariati, riusata da tastiera (R) e dal pulsante on-screen.
|
||||
func start_reload() -> void:
|
||||
reloading = true
|
||||
weapon_anim.play("reload")
|
||||
Audio.play(self, "items/cliprelease1.wav")
|
||||
@@ -91,14 +90,37 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
reloading = false
|
||||
weapon_anim.play("idle1"))
|
||||
|
||||
## Azioni a pressione singola lette a polling: stesso percorso per tastiera,
|
||||
## joypad e pulsanti on-screen. Gli InputEventAction sintetici, generati dentro
|
||||
## il dispatch di un altro evento (come fa un tocco), non arrivano affidabilmente
|
||||
## a _unhandled_input; lo stato delle azioni invece si'.
|
||||
func _handle_actions() -> void:
|
||||
if Input.is_action_just_pressed("reload") and not reloading and ammo < 30:
|
||||
start_reload()
|
||||
if Input.is_action_just_pressed("grenade") and not death_started and (touch_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED):
|
||||
throw_grenade()
|
||||
|
||||
## Rotazione della visuale. Unico punto in cui vivono sensibilita' e limiti di
|
||||
## inclinazione, condiviso da mouse e controlli touch.
|
||||
func apply_look(relative: Vector2) -> void:
|
||||
if hp <= 0 or death_started:
|
||||
return
|
||||
rotate_y(-relative.x * 0.0025)
|
||||
camera.rotation.x = clampf(camera.rotation.x - relative.y * 0.0025, -1.4, 1.4)
|
||||
|
||||
func _physics_process(dt: float) -> void:
|
||||
cooldown -= dt
|
||||
grenade_cooldown = maxf(0, grenade_cooldown - dt)
|
||||
flash.light_energy = maxf(0, flash.light_energy - dt * 60)
|
||||
gun.position.z = lerpf(gun.position.z, 0, dt * 15)
|
||||
if hp <= 0:
|
||||
if Input.is_action_just_pressed("reload"):
|
||||
get_tree().reload_current_scene() # R o pulsante Ricarica a partita finita
|
||||
return
|
||||
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
|
||||
_handle_actions()
|
||||
# Deadzone esplicita piccola (0.2): con la 0.5 delle azioni la corsa utile del
|
||||
# joystick touch si dimezzerebbe, mentre per tastiera/joypad nulla cambia.
|
||||
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back", 0.2)
|
||||
var direction := transform.basis * Vector3(input.x, 0, input.y)
|
||||
velocity.x = direction.x * 5
|
||||
velocity.z = direction.z * 5
|
||||
@@ -112,7 +134,7 @@ func _physics_process(dt: float) -> void:
|
||||
Audio.play(self, "player/pl_metal%d.wav" % randi_range(1, 4), false, -18)
|
||||
if Input.is_action_just_pressed("shoot") and ammo <= 0 and not reloading:
|
||||
Audio.play(self, "weapons/dryfire1.wav")
|
||||
if Input.is_action_pressed("shoot") and cooldown <= 0 and ammo > 0 and not reloading and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
if Input.is_action_pressed("shoot") and cooldown <= 0 and ammo > 0 and not reloading and (touch_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED):
|
||||
shoot()
|
||||
|
||||
func shoot() -> void:
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
extends CanvasLayer
|
||||
## Controlli on-screen per browser mobile e dispositivi touchscreen.
|
||||
##
|
||||
## Un solo Control a schermo intero smista il multi-touch per indice di tocco:
|
||||
## - meta' sinistra -> joystick di movimento flottante (alimenta le azioni move_*)
|
||||
## - meta' destra -> area di puntamento (trascina per ruotare la visuale)
|
||||
## - cerchi in basso a destra -> fuoco (tenere premuto), granata, ricarica, salto
|
||||
##
|
||||
## Non dipende dall'emulazione mouse<->touch: l'emulazione copre un solo
|
||||
## puntatore e romperebbe il multi-touch (es. fuoco + movimento insieme).
|
||||
## Vedi docs/TOUCH-CONTROLS.md.
|
||||
|
||||
const DEAD_ZONE := 10.0 # px: sotto questa soglia il vettore e' nullo
|
||||
const JOY_RADIUS := 92.0
|
||||
const KNOB_RADIUS := 40.0
|
||||
const BTN_RADIUS := 46.0
|
||||
const BTN_FIRE_RADIUS := 62.0
|
||||
const LOOK_SENSITIVITY := 1.7 # moltiplicatore del trascinamento nell'area di puntamento
|
||||
const EDGE := 24.0
|
||||
|
||||
const COLOR_BASE := Color(1, 1, 1, 0.09)
|
||||
const COLOR_RING := Color(1, 1, 1, 0.34)
|
||||
const COLOR_KNOB := Color(0.82, 0.88, 1.0, 0.42)
|
||||
const COLOR_BTN := Color(1, 1, 1, 0.13)
|
||||
const COLOR_ACTIVE := Color(1, 0.55, 0.15, 0.55)
|
||||
const COLOR_TEXT := Color(1, 1, 1, 0.88)
|
||||
|
||||
## Giocatore controllato: riceve i delta di visuale. Impostato da main.gd.
|
||||
var player: Node
|
||||
## Modalita' mouse: trattare i drag del mouse come tocchi (solo test su desktop).
|
||||
var mouse_fallback := false
|
||||
## Finche' e' false i tocchi non vengono consumati: serve al gate di avvio Web,
|
||||
## dove il primo tocco deve arrivare a main.gd per sbloccare audio e partita.
|
||||
var enabled := true
|
||||
|
||||
enum Role { JOY, LOOK, FIRE, GRENADE, RELOAD, JUMP }
|
||||
|
||||
const MOUSE_INDEX := -1
|
||||
const MOVE_ACTIONS := ["move_left", "move_right", "move_forward", "move_back"]
|
||||
|
||||
var move_vector := Vector2.ZERO
|
||||
var _touches := {} # index -> Role
|
||||
var _active := {} # Role -> bool (per il disegno)
|
||||
var _joy_origin := Vector2.ZERO
|
||||
var _joy_knob := Vector2.ZERO
|
||||
var _buttons := {} # Role -> {center: Vector2, radius: float, label: String}
|
||||
var _size := Vector2(1280, 720)
|
||||
|
||||
func _ready() -> void:
|
||||
layer = 3
|
||||
# I tocchi non devono essere tradotti in eventi mouse: la posizione di fuoco
|
||||
# e' gestita solo dall'area di puntamento, altrimenti la visuale si muove due volte.
|
||||
Input.set_emulate_mouse_from_touch(false)
|
||||
var root := Control.new()
|
||||
root.name = "TouchRoot"
|
||||
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
# IGNORE, non STOP: un Control a schermo intero che cattura i click
|
||||
# impedirebbe al gate di avvio Web (in _unhandled_input) di vedere il primo
|
||||
# tocco. Node._input riceve comunque tutti gli eventi e il consumo avviene
|
||||
# esplicitamente con set_input_as_handled() sugli eventi gestiti.
|
||||
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
# I metodi draw_* valgono solo durante il disegno: il segnale draw e' il punto giusto.
|
||||
root.draw.connect(_on_draw)
|
||||
add_child(root)
|
||||
_root = root
|
||||
var vp := get_viewport().get_visible_rect().size
|
||||
_size = root.size if root.size.x > 1.0 else vp
|
||||
root.resized.connect(_on_resized)
|
||||
_layout()
|
||||
root.queue_redraw()
|
||||
|
||||
var _root: Control
|
||||
|
||||
func _on_resized() -> void:
|
||||
_size = _root.size if _root.size.x > 1.0 else get_viewport().get_visible_rect().size
|
||||
_layout()
|
||||
_root.queue_redraw()
|
||||
|
||||
func _on_draw() -> void:
|
||||
var font: Font = _root.get_theme_default_font()
|
||||
var fs := 15
|
||||
# joystick
|
||||
var base := _joy_base()
|
||||
_root.draw_circle(base, JOY_RADIUS, COLOR_BASE)
|
||||
_root.draw_arc(base, JOY_RADIUS, 0, TAU, 64, COLOR_RING, 2.0)
|
||||
_root.draw_circle(base + _joy_knob, KNOB_RADIUS, COLOR_KNOB)
|
||||
_root.draw_arc(base + _joy_knob, KNOB_RADIUS, 0, TAU, 48, COLOR_RING, 1.5)
|
||||
_draw_label(font, fs, "MUOVI", base + Vector2(-JOY_RADIUS + 6, JOY_RADIUS + 22), COLOR_TEXT)
|
||||
# pulsanti
|
||||
for role in _buttons:
|
||||
var b: Dictionary = _buttons[role]
|
||||
var c: Vector2 = b.center
|
||||
var r: float = b.radius
|
||||
_root.draw_circle(c, r, COLOR_ACTIVE if _active.get(role, false) else COLOR_BTN)
|
||||
_root.draw_arc(c, r, 0, TAU, 48, COLOR_RING, 2.0)
|
||||
var text: String = b.label
|
||||
var w := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs).x
|
||||
_draw_label(font, fs, text, c - Vector2(w * 0.5, -fs * 0.35), COLOR_TEXT)
|
||||
|
||||
func _draw_label(font: Font, fs: int, text: String, pos: Vector2, color: Color) -> void:
|
||||
_root.draw_string(font, pos, text, HORIZONTAL_ALIGNMENT_LEFT, -1, fs, color)
|
||||
|
||||
## Riposiziona pulsanti e base del joystick in base alla dimensione della finestra.
|
||||
func _layout() -> void:
|
||||
var w := _size.x
|
||||
var h := _size.y
|
||||
var fire := Vector2(w - EDGE - BTN_FIRE_RADIUS, h - EDGE - BTN_FIRE_RADIUS)
|
||||
var gap := BTN_FIRE_RADIUS + BTN_RADIUS - 6.0
|
||||
_buttons = {
|
||||
Role.FIRE: {"center": fire, "radius": BTN_FIRE_RADIUS, "label": "FUOCO"},
|
||||
Role.GRENADE: {"center": fire + Vector2(0, -gap), "radius": BTN_RADIUS, "label": "GRANATA"},
|
||||
Role.RELOAD: {"center": fire + Vector2(-gap, 0), "radius": BTN_RADIUS, "label": "Ricarica"},
|
||||
Role.JUMP: {"center": fire + Vector2(-gap, -gap), "radius": BTN_RADIUS, "label": "Salto"},
|
||||
}
|
||||
|
||||
func _joy_base() -> Vector2:
|
||||
if _touches.values().has(Role.JOY):
|
||||
return _joy_origin
|
||||
return Vector2(EDGE + JOY_RADIUS, _size.y - EDGE - JOY_RADIUS)
|
||||
|
||||
# ---------------------------------------------------------------- input
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not enabled:
|
||||
return
|
||||
var index := -2
|
||||
var pressed := false
|
||||
var pos := Vector2.ZERO
|
||||
var relative := Vector2.ZERO
|
||||
var is_press := false
|
||||
var is_release := false
|
||||
var is_drag := false
|
||||
if event is InputEventScreenTouch:
|
||||
index = event.index
|
||||
pos = event.position
|
||||
is_press = event.pressed
|
||||
is_release = not event.pressed
|
||||
elif event is InputEventScreenDrag:
|
||||
index = event.index
|
||||
pos = event.position
|
||||
relative = event.relative
|
||||
is_drag = true
|
||||
elif mouse_fallback and event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
index = MOUSE_INDEX
|
||||
pos = event.position
|
||||
is_press = event.pressed
|
||||
is_release = not event.pressed
|
||||
elif mouse_fallback and event is InputEventMouseMotion and _touches.has(MOUSE_INDEX):
|
||||
index = MOUSE_INDEX
|
||||
pos = event.position
|
||||
relative = event.relative
|
||||
is_drag = true
|
||||
else:
|
||||
return
|
||||
if is_press:
|
||||
_press(index, pos)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif is_release:
|
||||
_release(index)
|
||||
get_viewport().set_input_as_handled()
|
||||
elif is_drag:
|
||||
_drag(index, pos, relative)
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func _press(index: int, pos: Vector2) -> void:
|
||||
if _touches.has(index):
|
||||
return
|
||||
var role := _hit_test(pos)
|
||||
if role != Role.JOY and _touches.values().has(role):
|
||||
return # un solo dito per ruolo
|
||||
if role == Role.JOY and not _in_left_half(pos):
|
||||
return
|
||||
_touches[index] = role
|
||||
_active[role] = true
|
||||
match role:
|
||||
Role.JOY:
|
||||
_joy_origin = pos
|
||||
_joy_knob = Vector2.ZERO
|
||||
_apply_move()
|
||||
Role.LOOK:
|
||||
pass
|
||||
Role.FIRE:
|
||||
Input.action_press("shoot")
|
||||
Role.GRENADE:
|
||||
_send_action("grenade")
|
||||
Role.RELOAD:
|
||||
_send_action("reload")
|
||||
Role.JUMP:
|
||||
_send_action("jump")
|
||||
_root.queue_redraw()
|
||||
|
||||
func _release(index: int) -> void:
|
||||
if not _touches.has(index):
|
||||
return
|
||||
var role: Role = _touches[index]
|
||||
_touches.erase(index)
|
||||
_active.erase(role)
|
||||
match role:
|
||||
Role.JOY:
|
||||
_joy_knob = Vector2.ZERO
|
||||
move_vector = Vector2.ZERO
|
||||
for action in MOVE_ACTIONS:
|
||||
Input.action_release(action)
|
||||
Role.FIRE:
|
||||
Input.action_release("shoot")
|
||||
_root.queue_redraw()
|
||||
|
||||
func _drag(index: int, pos: Vector2, relative: Vector2) -> void:
|
||||
if not _touches.has(index):
|
||||
# un drag iniziato fuori dai Control (raro su Web) viene adottato come visuale
|
||||
if not _in_left_half(pos):
|
||||
_touches[index] = Role.LOOK
|
||||
_active[Role.LOOK] = true
|
||||
else:
|
||||
return
|
||||
var role: Role = _touches[index]
|
||||
match role:
|
||||
Role.JOY:
|
||||
_joy_knob = (pos - _joy_origin).limit_length(JOY_RADIUS)
|
||||
_apply_move()
|
||||
_root.queue_redraw()
|
||||
Role.LOOK:
|
||||
if player and player.has_method("apply_look"):
|
||||
player.apply_look(relative * LOOK_SENSITIVITY)
|
||||
|
||||
## Traduce il vettore del joystick nelle azioni di movimento (analogiche).
|
||||
func _apply_move() -> void:
|
||||
var v := _joy_knob / JOY_RADIUS
|
||||
if v.length() * JOY_RADIUS < DEAD_ZONE:
|
||||
v = Vector2.ZERO
|
||||
move_vector = v.limit_length(1.0)
|
||||
_set_action("move_left", -move_vector.x)
|
||||
_set_action("move_right", move_vector.x)
|
||||
_set_action("move_forward", -move_vector.y)
|
||||
_set_action("move_back", move_vector.y)
|
||||
|
||||
func _set_action(action: String, strength: float) -> void:
|
||||
if strength > 0.01:
|
||||
Input.action_press(action, minf(1.0, strength))
|
||||
else:
|
||||
Input.action_release(action)
|
||||
|
||||
## Azioni a pressione singola (granata, ricarica, salto). Il giocatore le legge
|
||||
## a polling: premere lo stato dell'azione e rilasciarlo qualche frame dopo
|
||||
## evita sia l'evento sintetico perso sia il vedere premuto e rilasciato
|
||||
## nello stesso frame (is_action_just_pressed).
|
||||
func _send_action(action: String) -> void:
|
||||
Input.action_press(action)
|
||||
get_tree().create_timer(0.08).timeout.connect(func():
|
||||
Input.action_release(action))
|
||||
|
||||
func _in_left_half(pos: Vector2) -> bool:
|
||||
return pos.x < _size.x * 0.5
|
||||
|
||||
## Ordine di priorita': pulsanti, poi meta' sinistra (movimento), poi visuale.
|
||||
func _hit_test(pos: Vector2) -> Role:
|
||||
var best := Role.LOOK
|
||||
var best_dist := INF
|
||||
for role in _buttons:
|
||||
var b: Dictionary = _buttons[role]
|
||||
var d: float = pos.distance_to(b.center)
|
||||
if d <= b.radius + 6.0 and d < best_dist:
|
||||
best = role
|
||||
best_dist = d
|
||||
if best != Role.LOOK:
|
||||
return best
|
||||
if _in_left_half(pos):
|
||||
return Role.JOY
|
||||
return Role.LOOK
|
||||
|
||||
# ------------------------------------------------- rilevamento del contesto
|
||||
## True se il gioco gira su un dispositivo che merita i controlli on-screen.
|
||||
## Documentazione: in un export Web OS.has_feature("mobile") e' false, valgono
|
||||
## i tag web_android / web_ios.
|
||||
static func should_auto_enable() -> bool:
|
||||
if OS.has_feature("web_android") or OS.has_feature("web_ios"):
|
||||
return true
|
||||
if DisplayServer.is_touchscreen_available():
|
||||
return true
|
||||
return false
|
||||
|
||||
## Forzatura per test: 1 = attivo, -1 = disattivo, 0 = automatico.
|
||||
## Riga di comando: --touch / --no-touch. Sul Web anche ?touch=1 / ?touch=0.
|
||||
static func force_mode() -> int:
|
||||
var args := OS.get_cmdline_user_args()
|
||||
if "--touch" in args:
|
||||
return 1
|
||||
if "--no-touch" in args:
|
||||
return -1
|
||||
if OS.has_feature("web"):
|
||||
var query := str(JavaScriptBridge.eval("window.location.search", true))
|
||||
if query.contains("touch=1"):
|
||||
return 1
|
||||
if query.contains("touch=0"):
|
||||
return -1
|
||||
return 0
|
||||
@@ -0,0 +1 @@
|
||||
uid://d7sobr8u06j6
|
||||
@@ -0,0 +1 @@
|
||||
uid://djveiber70c6k
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4dtupqef7ys3
|
||||
Reference in New Issue
Block a user