Files
zombie-fps/scripts/start_screen.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

242 lines
9.7 KiB
GDScript

extends CanvasLayer
## Schermata iniziale (modalita' arcade/kiosk): nome del giocatore, pulsante di avvio
## e classifica globale. Il campo del nome viene mostrato VUOTO a ogni partita, quindi
## a ogni avvio, riavvio o game over viene richiesto un nome nuovo: il profilo
## precedente non viene mai dato per scontato. Il pulsante di avvio e' anche il gesto
## che sblocca l'audio nel browser, quindi la partita parte solo da qui.
signal started(player_name: String)
const Profile = preload("res://scripts/player_profile.gd")
const AMBER := Color(1.0, 0.66, 0.16)
const DIM := Color(0.85, 0.78, 0.6)
const ROWS_DESKTOP := 10
const ROWS_TOUCH := 5
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
var rows_shown := ROWS_DESKTOP
## Con la regola arcade il nome non e' mai bloccato: il campo c'e' sempre. La variabile
## resta per i profili scritti dalle versioni precedenti (arrivano gia' "bloccati").
var name_locked := false
func _ready() -> void:
layer = 4
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()
## Nome con cui si giochera': solo quello scritto nel campo della partita corrente.
func _stored_name() -> String:
if name_field != null:
return name_field.text.strip_edges()
return ""
## Chiamato da main.gd quando si torna al menu dopo il game over: campo liberato,
## cosi' il giocatore della partita successiva deve scrivere il proprio nome.
func reset_for_new_player() -> void:
if name_field != null:
name_field.text = ""
name_field.grab_focus()
_set_status("nome per la nuova partita")
func _build_ui() -> void:
var touch_mode: bool = player != null and player.touch_mode
var viewport := get_viewport().get_visible_rect().size
var width := minf(620.0, viewport.x * 0.92)
var backdrop := ColorRect.new()
backdrop.color = Color(0.01, 0.01, 0.02, 0.92)
backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(backdrop)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
var column := VBoxContainer.new()
column.custom_minimum_size = Vector2(width, 0)
column.add_theme_constant_override("separation", 10 if touch_mode else 14)
center.add_child(column)
var title := _label("Work-Death Balance", 34 if touch_mode else 44, AMBER, HORIZONTAL_ALIGNMENT_CENTER)
column.add_child(title)
column.add_child(_label("MatGames — classifica globale", 15, DIM, HORIZONTAL_ALIGNMENT_CENTER))
# Regola arcade/kiosk: il nome si chiede sempre, campo vuoto all'apertura.
column.add_child(_build_name_row())
var play := Button.new()
play.text = "GIOCA"
play.add_theme_font_size_override("font_size", 30)
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()
style.bg_color = Color(0.03, 0.025, 0.01, 0.9)
style.border_color = Color(1.0, 0.66, 0.16, 0.4)
style.set_border_width_all(1)
style.set_corner_radius_all(4)
style.set_content_margin_all(10)
panel.add_theme_stylebox_override("panel", style)
column.add_child(panel)
var board_column := VBoxContainer.new()
board_column.add_theme_constant_override("separation", 4)
panel.add_child(board_column)
var header := HBoxContainer.new()
board_column.add_child(header)
header.add_child(_label("CLASSIFICA", 17, AMBER, HORIZONTAL_ALIGNMENT_LEFT))
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(spacer)
var refresh := Button.new()
refresh.text = "aggiorna"
refresh.add_theme_font_size_override("font_size", 15)
refresh.pressed.connect(func(): _set_status("aggiornamento...") ; leaderboard.fetch())
header.add_child(refresh)
rows_box = VBoxContainer.new()
rows_box.add_theme_constant_override("separation", 2)
board_column.add_child(rows_box)
me_label = _label("", 15, DIM, HORIZONTAL_ALIGNMENT_LEFT)
board_column.add_child(me_label)
status_label = _label("caricamento classifica...", 14, DIM, HORIZONTAL_ALIGNMENT_LEFT)
board_column.add_child(status_label)
_show_rows([])
## Riga del nome: sempre presente, campo vuoto (nessun nome dato per scontato).
func _build_name_row() -> HBoxContainer:
var name_row := HBoxContainer.new()
name_row.add_theme_constant_override("separation", 8)
name_field = LineEdit.new()
name_field.text = ""
name_field.max_length = Profile.NAME_MAX
name_field.placeholder_text = "il tuo nome"
name_field.virtual_keyboard_enabled = true # necessario su mobile Web
name_field.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_field.add_theme_font_size_override("font_size", 22)
# Invio / tasto "Vai" della tastiera del telefono: conferma il nome, NON avvia
# la partita (prima lo faceva e su mobile partiva la partita per sbaglio).
name_field.text_submitted.connect(func(text): name_field.text = Profile.sanitize_name(text))
name_row.add_child(name_field)
var edit := Button.new()
edit.text = "nome…"
edit.tooltip_text = "tastiera del telefono / finestra del browser"
edit.add_theme_font_size_override("font_size", 18)
edit.pressed.connect(_ask_name)
name_row.add_child(edit)
return name_row
func _label(text: String, size: int, color: Color, align: int) -> Label:
var label := Label.new()
label.text = text
label.horizontal_alignment = align
label.add_theme_color_override("font_color", color)
label.add_theme_color_override("font_shadow_color", Color.BLACK)
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
## Righe della classifica: tre colonne, per restare leggibile senza font monospaziato.
func _show_rows(rows: Array) -> void:
for child in rows_box.get_children():
child.queue_free()
if rows.is_empty():
rows_box.add_child(_label("nessun punteggio registrato: gioca la prima partita", 15, DIM, HORIZONTAL_ALIGNMENT_LEFT))
return
var mine := str(profile.get("player_id", ""))
for i in mini(rows_shown, rows.size()):
var row: Dictionary = rows[i]
var line := HBoxContainer.new()
line.add_theme_constant_override("separation", 6)
var rank := _label("%d." % int(row.get("rank", i + 1)), 17, AMBER, HORIZONTAL_ALIGNMENT_RIGHT)
rank.custom_minimum_size = Vector2(38, 0)
line.add_child(rank)
var is_mine := str(row.get("player_id", "")) == mine and mine != ""
var who := _label(str(row.get("name", "?")), 17, AMBER if is_mine else Color.WHITE,
HORIZONTAL_ALIGNMENT_LEFT)
who.size_flags_horizontal = Control.SIZE_EXPAND_FILL
who.clip_text = true
line.add_child(who)
var score_label := _label(str(int(row.get("score", 0))), 17, AMBER, HORIZONTAL_ALIGNMENT_RIGHT)
score_label.custom_minimum_size = Vector2(80, 0)
line.add_child(score_label)
rows_box.add_child(line)
func show_leaderboard(rows: Array, total: int, me: Dictionary) -> void:
if rows_box == null:
return
_show_rows(rows)
_set_status("%d partite registrate" % total if total > 0 else "nessuna partita registrata")
if me.is_empty():
me_label.text = ""
else:
me_label.text = "il tuo miglior punteggio: %d (%d° posto, %d partite)" % [
int(me.get("best", 0)), int(me.get("rank", 0)), int(me.get("games", 0))]
func show_leaderboard_error(reason: String) -> void:
_set_status("classifica non disponibile (%s)" % reason)
## Su Web la tastiera virtuale di Godot e' sperimentale: in aggiunta al campo di testo
## si offre il prompt nativo del browser, che ha sempre la tastiera del telefono.
func _ask_name() -> void:
if name_field == null:
return
var current := Profile.js_escape(name_field.text)
if OS.has_feature("web") and ClassDB.class_exists("JavaScriptBridge"):
var answer := str(JavaScriptBridge.eval(
"window.prompt('Il tuo nome (max 16 caratteri):', '%s') || ''" % current, true))
if answer.strip_edges() != "":
name_field.text = Profile.sanitize_name(answer)
return
name_field.grab_focus()
## La partita parte SOLO da qui, cioe' dal pulsante GIOCA: un click o un tocco
## altrove (sfondo, campo del nome, tasto Invio della tastiera del telefono) non deve
## avviare nulla.
func _start() -> void:
if name_field == null:
return
var name := name_field.text.strip_edges()
if name == "":
# Su macchina condivisa un punteggio senza nome sarebbe anonimo: non si parte.
_set_status("inserisci il tuo nome per la nuova partita")
return
started.emit(Profile.sanitize_name(name))