From 2c39d6235f5216b7c4fe48fac1850b91d814c4cf Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Sat, 24 Oct 2026 17:42:26 +0200 Subject: [PATCH] Add gamepad support: stick look, trigger fire, pad menu confirm - project.godot: joypad events on the ten existing actions (left stick move, A jump, right trigger/RB fire, X reload, B/LB grenade, Start pause, Back fullscreen), four new look_* actions on the right stick (deadzone 0.2) and ui_accept/ui_cancel redefined with the pad buttons A/B while keeping Enter/KP Enter/Space/Esc: the 4.7.1 engine maps no pad button on those. - scripts/player.gd: gamepad_mode turns on by itself on the first pad input; _can_aim() replaces the "mouse captured" gate of shoot and grenade (the pad never captures the pointer, same trap already fixed for touch); _handle_gamepad_look() converts the right stick into equivalent relative mouse pixels (900 px/s at full deflection, m^1.8 response) and still goes through apply_look(); restart accepts A/X and the death text follows the device in use. - scripts/start_screen.gd: pad hint shown only when a controller is connected, refreshed on Input.joy_connection_changed. - tools/test_gamepad.gd: 33 checks on map structure, keyboard preservation, real event pipeline, gamepad_mode and analog look. - docs/GAMEPAD.md: mapping, code map, verification, known limit (kiosk name entry still needs a keyboard: on-screen keyboard is the next step). Verified: test_gamepad 33/33, test_fullscreen_shortcut 8/8, test_touch_controls 14/14 (--touch), test_grenade 24/24, test_boss 50/50, test_rebalance_branding 14/14, test_player_profile 30/30. test_start_screen keeps its 2 pre-existing headless failures (identical on HEAD). --- docs/GAMEPAD.md | 73 +++++++++++++++ project.godot | 52 ++++++++++- scripts/player.gd | 64 +++++++++++-- scripts/start_screen.gd | 29 ++++++ tools/test_gamepad.gd | 196 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 docs/GAMEPAD.md create mode 100644 tools/test_gamepad.gd diff --git a/docs/GAMEPAD.md b/docs/GAMEPAD.md new file mode 100644 index 0000000..7a35d57 --- /dev/null +++ b/docs/GAMEPAD.md @@ -0,0 +1,73 @@ +# Gamepad / controller + +Supporto ai controller implementato il 2026-10-24. Il gameplay non è stato +riscritto: tutte le azioni passano già dalla Input Map, quindi il lavoro è stato +mappare i binding, aggiungere il ramo analogico della visuale e aprire i gate che +prima pretendevano il mouse catturato. + +## Mappatura (layout SDL/Xbox) + +| Azione | Tastiera/mouse | Gamepad | +| --- | --- | --- | +| `move_forward` / `move_back` | W / S, frecce | stick sinistro Y (asse 1) | +| `move_left` / `move_right` | A / D, frecce | stick sinistro X (asse 0) | +| `jump` | Spazio | A (pulsante 0) | +| `shoot` | tasto sinistro del mouse | grilletto destro (asse 5) · RB (10) | +| `reload` | R | X (pulsante 2) | +| `grenade` | tasto destro del mouse | B (1) · LB (9) | +| `pause` | Esc | Start (6) | +| `toggle_fullscreen` | Alt+F | Back/Select (4) | +| `look_*` (nuove) | movimento del mouse | stick destro (assi 2 e 3) | + +Tutti i binding joypad hanno `device = -1`, quindi funzionano con qualunque pad +collegato, non solo con il primo. + +## Dove sta il codice + +- `project.godot` → `[input]`: eventi `InputEventJoypadButton` / `InputEventJoypadMotion` + sulle azioni esistenti, quattro nuove azioni `look_left/right/up/down` (deadzone 0.2) + e la ridefinizione di `ui_accept`/`ui_cancel`. +- `scripts/player.gd`: + - `gamepad_mode`: si accende da solo al primo pulsante/stick usato (`_is_gamepad_activity`); + - `_can_aim()` sostituisce il gate `touch_mode or mouse_mode == CAPTURED` di sparo e + granata: senza, col pad non si sarebbe potuto né sparare né lanciare; + - `_handle_gamepad_look(dt)`: converte lo stick destro in "pixel relativi + equivalenti" (900 px/s a fondo corsa, curva `m^1.8`) e li passa ad `apply_look()`, + che resta l'unico punto con sensibilità e limiti di inclinazione; + - a partita finita ricomincia la partita anche con A/X, e il messaggio di morte + cambia testo in base al dispositivo in uso. +- `scripts/start_screen.gd`: riga di aiuto che compare **solo** se c'è un pad + collegato (aggiornata in hotplug via `Input.joy_connection_changed`). + +## Perché `ui_accept` va ridefinito a mano + +Nel motore 4.7.1 `ui_accept` è mappato su Invio, Invio del tastierino e Spazio, e +`ui_cancel` solo su Esc: **nessun pulsante di gamepad**. Con il pad si poteva +navigare fra i controlli (le `ui_up/down/left/right` hanno già dpad e stick) ma non +confermare. La ridefinizione in `project.godot` rimette anche i tasti di tastiera: +senza, Invio e Spazio avrebbero smesso di funzionare. Il test lo controlla. + +## Verifica + +```bash +godot --headless --path . --script res://tools/test_gamepad.gd +``` + +33 controlli: struttura della mappa, conservazione dei tasti su `ui_accept`/`ui_cancel`, +pipeline eventi reale (`Input.parse_input_event` con un pad su device 0), accensione di +`gamepad_mode`, rotazione della visuale con lo stick destro e limiti di inclinazione. + +Con un pad vero conviene rifare la stessa prova a schermo reale +(`--rendering-method gl_compatibility`) perché il puntatore e il focus non sono +verificabili in headless. Suite esistenti riverificate: `test_fullscreen_shortcut`, +`test_touch_controls -- --touch`, `test_grenade`, `test_boss`, `test_rebalance_branding`, +`test_player_profile` tutte verdi. `test_start_screen` ha 2 FAIL preesistenti +(pulsante GIOCA in headless), identici su HEAD senza queste modifiche. + +## Limite noto: il nome del giocatore + +La regola arcade/kiosk chiede il nome a ogni avvio, e il campo è un `LineEdit` con +`virtual_keyboard_enabled`: la tastiera su schermo del motore però esiste solo su +Android/iOS/Web. Con **solo** il pad, su desktop non c'è modo di scrivere il nome. +Fase successiva: tastiera on-screen navigabile col dpad (griglia A-Z), oppure scelta +del nome da una lista di profili recenti. diff --git a/project.godot b/project.godot index 0d32bcb..f88fd2f 100644 --- a/project.godot +++ b/project.godot @@ -44,54 +44,104 @@ move_forward={ "deadzone": 0.5, "events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) , Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194320,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":-1.0,"script":null) ] } move_back={ "deadzone": 0.5, "events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) , Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194322,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":1,"axis_value":1.0,"script":null) ] } move_left={ "deadzone": 0.5, "events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":-1.0,"script":null) ] } move_right={ "deadzone": 0.5, "events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":0,"axis_value":1.0,"script":null) ] } jump={ "deadzone": 0.5, "events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null) ] } shoot={ "deadzone": 0.5, "events": [Object(InputEventMouseButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":true,"double_click":false,"script":null) +, Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":5,"axis_value":1.0,"script":null) +, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":10,"pressure":0.0,"pressed":false,"script":null) ] } reload={ "deadzone": 0.5, "events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":114,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":2,"pressure":0.0,"pressed":false,"script":null) ] } pause={ "deadzone": 0.5, "events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194305,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":6,"pressure":0.0,"pressed":false,"script":null) ] } toggle_fullscreen={ "deadzone": 0.5, "events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":true,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":70,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":4,"pressure":0.0,"pressed":false,"script":null) ] } grenade={ "deadzone": 0.5, -"events": [Object(InputEventMouseButton, "button_index":2)] +"events": [Object(InputEventMouseButton, "button_index":2), Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null), Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":9,"pressure":0.0,"pressed":false,"script":null)] +} + +look_left={ +"deadzone": 0.2, +"events": [Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":-1.0,"script":null) +] +} + +look_right={ +"deadzone": 0.2, +"events": [Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":2,"axis_value":1.0,"script":null) +] +} + +look_up={ +"deadzone": 0.2, +"events": [Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":-1.0,"script":null) +] +} + +look_down={ +"deadzone": 0.2, +"events": [Object(InputEventJoypadMotion, "resource_local_to_scene":false,"resource_name":"","device":-1,"axis":3,"axis_value":1.0,"script":null) +] +} + +ui_accept={ +"deadzone": 0.5, +"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194310,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null) +] +} + +ui_cancel={ +"deadzone": 0.5, +"events": [Object(InputEventKey, "resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventJoypadButton, "resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":1,"pressure":0.0,"pressed":false,"script":null) +] } [rendering] diff --git a/scripts/player.gd b/scripts/player.gd index cc83873..94d0f9b 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -34,6 +34,18 @@ 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 +## True quando l'ultimo input utile arriva da un gamepad. Il pad non cattura il +## puntatore, quindi serve un flag esplicito: senza, sparo e granata resterebbero +## bloccati dal gate "mouse catturato" (la stessa trappola gia' corretta per il touch). +var gamepad_mode := false +## Punti-equivalenti di mouse al secondo con lo stick destro a fondo corsa: 900 px/s +## per gli 0.0025 rad/px di apply_look danno 2.25 rad/s (~129 gradi/s). +const GAMEPAD_LOOK_SPEED := 900.0 +## Risposta dello stick: con 1.8 i piccoli spostamenti restano fini per mirare e il +## fondo corsa continua a girare veloce. +const GAMEPAD_LOOK_EXPONENT := 1.8 +## Oltre questa ampiezza un movimento di stick conta come "sto usando il pad". +const GAMEPAD_ACTIVITY := 0.3 ## Vero solo dopo l'inizio della partita: al menu il cursore deve restare libero, ## altrimenti la schermata iniziale non e' cliccabile. var playing := false @@ -95,7 +107,7 @@ var death_note: Label ## viene mai catturato. func begin_play() -> void: playing = true - if not touch_mode and DisplayServer.get_name() != "headless": + if not touch_mode and not gamepad_mode and DisplayServer.get_name() != "headless": Input.mouse_mode = Input.MOUSE_MODE_CAPTURED ## Riga sotto il messaggio di morte: punteggio inviato, posizione in classifica o @@ -113,12 +125,29 @@ func _input(event: InputEvent) -> void: get_viewport().set_input_as_handled() get_tree().call_deferred("reload_current_scene") +## Riconosce l'attivita' del pad (pulsante premuto o stick fuori dalla zona morta): +## cosi' il gioco passa da mouse a gamepad senza nessuna configurazione. +func _is_gamepad_activity(event: InputEvent) -> bool: + if event is InputEventJoypadButton: + return event.pressed + if event is InputEventJoypadMotion: + return absf(event.axis_value) > GAMEPAD_ACTIVITY + return false + +## Il giocatore puo' mirare/sparare quando il puntatore e' catturato, oppure con touch +## o gamepad, che non catturano nulla. +func _can_aim() -> bool: + return touch_mode or gamepad_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED + func _unhandled_input(event: InputEvent) -> void: + if _is_gamepad_activity(event): + gamepad_mode = true if playing and 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 not touch_mode and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: apply_look(event.relative) - if playing and event.is_action_pressed("pause") and not death_started: + # Con il pad il puntatore non viene catturato: il toggle del mouse non ha senso. + if playing and event.is_action_pressed("pause") and not death_started and not gamepad_mode: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED ## Ricarica: tempi invariati, riusata da tastiera (R) e dal pulsante on-screen. @@ -144,7 +173,7 @@ func start_reload() -> void: 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): + if Input.is_action_just_pressed("grenade") and not death_started and _can_aim(): throw_grenade() ## Rotazione della visuale. Unico punto in cui vivono sensibilita' e limiti di @@ -155,19 +184,38 @@ func apply_look(relative: Vector2) -> void: rotate_y(-relative.x * 0.0025) camera.rotation.x = clampf(camera.rotation.x - relative.y * 0.0025, -1.4, 1.4) +## Visuale analogica: il mouse arriva come InputEventMouseMotion relativo, lo stick +## destro come azioni. Qui lo stick diventa "pixel relativi equivalenti" e passa ad +## apply_look(), che resta l'unico posto con sensibilita' e limiti di inclinazione. +## La curva viene applicata sulla sola ampiezza, quindi la direzione resta esatta. +func _handle_gamepad_look(dt: float) -> void: + if not gamepad_mode or not playing or touch_mode: + return + if hp <= 0 or death_started: + return + # Deadzone esplicita 0.2, la stessa del movimento. + var look := Input.get_vector("look_left", "look_right", "look_up", "look_down", 0.2) + if look == Vector2.ZERO: + return + var magnitude := clampf(look.length(), 0.0, 1.0) + var shaped := look.normalized() * pow(magnitude, GAMEPAD_LOOK_EXPONENT) + apply_look(shaped * GAMEPAD_LOOK_SPEED * dt) + 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 + # R (tastiera) oppure X/A del pad: stessi tempi, nessun menu da navigare. + if Input.is_action_just_pressed("reload") or Input.is_action_just_pressed("jump"): + get_tree().reload_current_scene() # R, X/A del pad o pulsante Ricarica a partita finita return _regenerate_health(dt) if ammo <= 0 and not reloading: start_reload() _handle_actions() + _handle_gamepad_look(dt) # 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) @@ -184,7 +232,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 (touch_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED): + if Input.is_action_pressed("shoot") and cooldown <= 0 and ammo > 0 and not reloading and _can_aim(): shoot() func shoot() -> void: @@ -264,7 +312,9 @@ func _die() -> void: tween.tween_property(camera, "rotation:x", 0.08, 0.9) tween.tween_property(death_overlay, "color:a", 1.0, 1.8).set_delay(0.7) var message := Label.new() - message.text = "SEI MORTO" if touch_mode else "SEI MORTO\nPremi R per ricominciare" + message.text = "SEI MORTO" if touch_mode else ( + "SEI MORTO\nPremi A o X per ricominciare" if gamepad_mode + else "SEI MORTO\nPremi R per ricominciare") message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER message.add_theme_font_size_override("font_size", 36) message.modulate.a = 0 diff --git a/scripts/start_screen.gd b/scripts/start_screen.gd index 8fef053..c67c065 100644 --- a/scripts/start_screen.gd +++ b/scripts/start_screen.gd @@ -16,6 +16,10 @@ var player: Node var leaderboard: Node var profile := {} var name_field: LineEdit +var play_button: Button +## Riga di aiuto che compare solo quando c'e' un gamepad collegato: con mouse e +## tastiera la schermata resta identica a prima. +var pad_hint: Label var rows_box: VBoxContainer var status_label: Label var me_label: Label @@ -29,6 +33,9 @@ func _ready() -> void: name_locked = Profile.is_name_locked(profile) rows_shown = ROWS_TOUCH if (player != null and player.touch_mode) else ROWS_DESKTOP _build_ui() + # Hotplug: la riga di aiuto del pad compare/scompare senza riavviare il gioco. + Input.joy_connection_changed.connect(_on_joy_connection_changed) + _refresh_pad_hint() if leaderboard != null: leaderboard.fetch() @@ -75,6 +82,13 @@ func _build_ui() -> void: play.custom_minimum_size = Vector2(0, 56) play.pressed.connect(_start) column.add_child(play) + play_button = play + + # Aiuto per il pad: visibile solo se un controller e' collegato. + pad_hint = _label("", 13, DIM, HORIZONTAL_ALIGNMENT_CENTER) + pad_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + pad_hint.visible = false + column.add_child(pad_hint) var panel := PanelContainer.new() var style := StyleBoxFlat.new() @@ -140,6 +154,21 @@ func _label(text: String, size: int, color: Color, align: int) -> Label: label.add_theme_font_size_override("font_size", size) return label +func _on_joy_connection_changed(_device: int, _connected: bool) -> void: + _refresh_pad_hint() + +## Testo e visibilita' dell'aiuto gamepad. Il nome resta da inserire: con il solo pad +## serve una tastiera su schermo (fase successiva), qui si documentano i comandi. +func _refresh_pad_hint() -> void: + if pad_hint == null: + return + var pads := Input.get_connected_joypads() + pad_hint.visible = not pads.is_empty() + if not pads.is_empty(): + pad_hint.text = "PAD — stick sx muovi · stick dx visuale · RT spara · X ricarica · B/LB granata · A salta · Start pausa · Back schermo intero" + if play_button != null and not play_button.has_focus() and name_field != null and not name_field.has_focus(): + play_button.grab_focus() + func _set_status(text: String) -> void: if status_label != null: status_label.text = text diff --git a/tools/test_gamepad.gd b/tools/test_gamepad.gd new file mode 100644 index 0000000..a60ba38 --- /dev/null +++ b/tools/test_gamepad.gd @@ -0,0 +1,196 @@ +extends SceneTree +## Verifica il supporto gamepad: +## - ogni azione di gioco ha almeno un binding joypad, con i tasti/gli assi attesi; +## - gli assi di movimento sono sullo stick sinistro, la visuale sullo stick destro; +## - ui_accept/ui_cancel accettano A/B del pad SENZA perdere Invio/Spazio/Esc; +## - un pad reale (device != -1) attiva le azioni mappate con device -1; +## - l'attivita' del pad accende player.gamepad_mode, il puntatore non viene +## catturato e la visuale gira con lo stick destro passando da apply_look(). +## +## Avviare con: --headless --path . --script res://tools/test_gamepad.gd +## Con un display reale: --rendering-method gl_compatibility + +## azione -> eventi joypad attesi come stringhe leggibili (il formato del controllo) +const EXPECTED := { + "move_forward": "axis1-1.0", + "move_back": "axis1+1.0", + "move_left": "axis0-1.0", + "move_right": "axis0+1.0", + "jump": "btn0", + "reload": "btn2", + "pause": "btn6", + "toggle_fullscreen": "btn4", + "grenade": "btn1+btn9", + "shoot": "axis5+1.0+btn10", + "look_left": "axis2-1.0", + "look_right": "axis2+1.0", + "look_up": "axis3-1.0", + "look_down": "axis3+1.0", +} + +var world: Node3D +var failures := 0 +var checks := 0 + +func _initialize() -> void: + call_deferred("run_test") + +func check(label: String, condition: bool, detail: String = "") -> void: + checks += 1 + if condition: + print("PASS: " + label) + else: + failures += 1 + print("FAIL: %s %s" % [label, detail]) + +## Rappresentazione compatta degli eventi joypad di un'azione, ordinata. +func _joypad_signature(action: String) -> String: + var parts: Array[String] = [] + for event in InputMap.action_get_events(action): + if event is InputEventJoypadButton: + parts.append("btn%d" % event.button_index) + elif event is InputEventJoypadMotion: + parts.append("axis%d%+.1f" % [event.axis, event.axis_value]) + parts.sort() + return "+".join(parts) + +func _has_key(action: String, keycode: int) -> bool: + for event in InputMap.action_get_events(action): + if event is InputEventKey and (event.keycode == keycode or event.physical_keycode == keycode): + return true + return false + +func _joypad_devices(action: String) -> Array[int]: + var devices: Array[int] = [] + for event in InputMap.action_get_events(action): + if event is InputEventJoypadButton or event is InputEventJoypadMotion: + devices.append(event.device) + return devices + +## Pressione/rilascio di un pulsante del pad, come un controller vero. +func _pad_button(index: int, pressed: bool, device := 0) -> void: + var event := InputEventJoypadButton.new() + event.device = device + event.button_index = index + event.pressed = pressed + Input.parse_input_event(event) + Input.flush_buffered_events() + +## Movimento di un asse del pad (valore fra -1 e 1). +func _pad_axis(axis: int, value: float, device := 0) -> void: + var event := InputEventJoypadMotion.new() + event.device = device + event.axis = axis + event.axis_value = value + Input.parse_input_event(event) + Input.flush_buffered_events() + +func _frames(count: int) -> void: + for i in count: + await process_frame + +func _steps(count: int) -> void: + for i in count: + await physics_frame + +func run_test() -> void: + # --- mappa dei comandi --- + for action: String in EXPECTED: + check("\"%s\" ha i binding joypad attesi" % action, + _joypad_signature(action) == EXPECTED[action], + "trovato \"%s\" atteso \"%s\"" % [_joypad_signature(action), EXPECTED[action]]) + + var all_any := true + var offenders: Array[String] = [] + for action: String in EXPECTED: + for device in _joypad_devices(action): + if device != -1: + all_any = false + offenders.append("%s(device %d)" % [action, device]) + check("i binding joypad accettano qualunque pad (device -1)", all_any, str(offenders)) + + # ui_*: il motore 4.7.1 non mappa alcun pulsante su ui_accept/ui_cancel, quindi + # vanno aggiunti a mano tenendo i tasti di tastiera gia' presenti. + check("ui_accept accetta il pulsante A del pad", + _joypad_signature("ui_accept") == "btn0", _joypad_signature("ui_accept")) + check("ui_accept conserva Invio", _has_key("ui_accept", KEY_ENTER)) + check("ui_accept conserva Invio del tastierino", _has_key("ui_accept", KEY_KP_ENTER)) + check("ui_accept conserva Spazio", _has_key("ui_accept", KEY_SPACE)) + check("ui_cancel accetta il pulsante B del pad", + _joypad_signature("ui_cancel") == "btn1", _joypad_signature("ui_cancel")) + check("ui_cancel conserva Esc", _has_key("ui_cancel", KEY_ESCAPE)) + + # --- pipeline eventi: un pad qualsiasi (device 0) deve attivare le azioni --- + _pad_button(JOY_BUTTON_A, true) + await _frames(1) + check("A del pad attiva l'azione \"jump\"", Input.is_action_pressed("jump")) + check("A del pad attiva ui_accept", Input.is_action_pressed("ui_accept")) + _pad_button(JOY_BUTTON_A, false) + await _frames(1) + check("il rilascio di A spegne \"jump\"", not Input.is_action_pressed("jump")) + + _pad_axis(JOY_AXIS_LEFT_Y, -1.0) + await _frames(1) + check("stick sinistro in alto attiva \"move_forward\"", + Input.is_action_pressed("move_forward")) + _pad_axis(JOY_AXIS_LEFT_Y, 0.0) + await _frames(1) + + _pad_axis(JOY_AXIS_RIGHT_X, 1.0) + await _frames(1) + check("stick destro a destra attiva \"look_right\"", Input.is_action_pressed("look_right")) + _pad_axis(JOY_AXIS_RIGHT_X, 0.0) + await _frames(1) + + # --- mondo di gioco: gamepad_mode e visuale analogica --- + world = load("res://scenes/main.tscn").instantiate() + root.add_child(world) + await _frames(2) + var player: Node = world.player + var screen = world.start_screen + var gioca: Button = null + if screen != null: + for button in screen.find_children("*", "Button", true, false): + if button.text == "GIOCA": + gioca = button + if screen != null and screen.get("name_field") != null: + screen.name_field.text = "PadTest" + if gioca != null: + gioca.pressed.emit() + await _frames(2) + check("la partita e' iniziata", player.playing == true, "playing=%s" % str(player.playing)) + check("senza attivita' di pad gamepad_mode resta falso", player.gamepad_mode == false) + + _pad_button(JOY_BUTTON_A, true) + _pad_button(JOY_BUTTON_A, false) + await _frames(1) + check("un pulsante del pad accende player.gamepad_mode", player.gamepad_mode == true) + + var yaw_before: float = player.rotation.y + _pad_axis(JOY_AXIS_RIGHT_X, 1.0) + await _steps(12) + _pad_axis(JOY_AXIS_RIGHT_X, 0.0) + await _steps(2) + check("lo stick destro fa ruotare la visuale verso destra", + player.rotation.y < yaw_before - 0.01, + "prima=%.3f dopo=%.3f" % [yaw_before, player.rotation.y]) + + var pitch_before: float = player.camera.rotation.x + _pad_axis(JOY_AXIS_RIGHT_Y, 1.0) + await _steps(8) + _pad_axis(JOY_AXIS_RIGHT_Y, 0.0) + await _steps(2) + check("lo stick destro in basso abbassa l'inclinazione della camera", + player.camera.rotation.x < pitch_before, + "prima=%.3f dopo=%.3f" % [pitch_before, player.camera.rotation.x]) + check("l'inclinazione resta dentro i limiti di apply_look", + player.camera.rotation.x >= -1.4 - 0.001, "pitch=%.3f" % player.camera.rotation.x) + + # Il pad non deve catturare il puntatore (in headless non e' verificabile): + # quello che conta e' che il gate di sparo/granata non dipenda dal mouse. + check("con gamepad_mode il gate di mira e' aperto anche senza mouse catturato", + player._can_aim() == true and player.touch_mode == false, + "can_aim=%s touch=%s" % [str(player._can_aim()), str(player.touch_mode)]) + + print("%d controlli superati, %d falliti" % [checks - failures, failures]) + quit(1 if failures > 0 else 0)