diff --git a/docs/TOUCH-CONTROLS.md b/docs/TOUCH-CONTROLS.md new file mode 100644 index 0000000..327e728 --- /dev/null +++ b/docs/TOUCH-CONTROLS.md @@ -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. diff --git a/scripts/main.gd b/scripts/main.gd index 503f71e..14aa5df 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -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() - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED - # Il browser concede l'audio solo dopo un gesto dell'utente: qui il click. + if not player.touch_mode: + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + 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" diff --git a/scripts/player.gd b/scripts/player.gd index 794b034..ca7aab4 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -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,30 +69,44 @@ 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: + +## 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") + get_tree().create_timer(0.8).timeout.connect(func(): + if not death_started: + Audio.play(self, "items/clipinsert1.wav")) + get_tree().create_timer(1.57).timeout.connect(func(): + if not death_started: + ammo = 30 + 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() - if event.is_action_pressed("reload"): - if hp <= 0: - get_tree().reload_current_scene() - elif not reloading and ammo < 30: - reloading = true - weapon_anim.play("reload") - Audio.play(self, "items/cliprelease1.wav") - get_tree().create_timer(0.8).timeout.connect(func(): - if not death_started: - Audio.play(self, "items/clipinsert1.wav")) - get_tree().create_timer(1.57).timeout.connect(func(): - if not death_started: - ammo = 30 - reloading = false - weapon_anim.play("idle1")) + +## 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 @@ -97,8 +114,13 @@ func _physics_process(dt: float) -> void: 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: diff --git a/scripts/touch_controls.gd b/scripts/touch_controls.gd new file mode 100644 index 0000000..c1b56c5 --- /dev/null +++ b/scripts/touch_controls.gd @@ -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 diff --git a/scripts/touch_controls.gd.uid b/scripts/touch_controls.gd.uid new file mode 100644 index 0000000..3c19ca0 --- /dev/null +++ b/scripts/touch_controls.gd.uid @@ -0,0 +1 @@ +uid://d7sobr8u06j6 diff --git a/tools/test_music.gd.uid b/tools/test_music.gd.uid new file mode 100644 index 0000000..e4c18e0 --- /dev/null +++ b/tools/test_music.gd.uid @@ -0,0 +1 @@ +uid://djveiber70c6k diff --git a/tools/test_touch_controls.gd b/tools/test_touch_controls.gd new file mode 100644 index 0000000..1de510c --- /dev/null +++ b/tools/test_touch_controls.gd @@ -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() diff --git a/tools/test_touch_controls.gd.uid b/tools/test_touch_controls.gd.uid new file mode 100644 index 0000000..7115182 --- /dev/null +++ b/tools/test_touch_controls.gd.uid @@ -0,0 +1 @@ +uid://b4dtupqef7ys3