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).
This commit is contained in:
Matteo Benedetto
2026-10-24 17:42:26 +02:00
parent b08ac8c6a1
commit 2c39d6235f
5 changed files with 406 additions and 8 deletions
+73
View File
@@ -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.
+51 -1
View File
@@ -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]
+57 -7
View File
@@ -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
+29
View File
@@ -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
+196
View File
@@ -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)