Files
zombie-fps/tools/test_gamepad.gd
T
Matteo Benedetto 2c39d6235f 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).
2026-10-24 17:42:26 +02:00

197 lines
7.2 KiB
GDScript

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)