- 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).
242 lines
9.7 KiB
GDScript
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))
|