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
+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