- services/scores-api: microservizio (solo stdlib) con SQLite WAL, idempotenza per submission_id, limiti di frequenza e validazione; immagine Docker non-root. - nginx: reverse proxy su /api/zombie-fps/ (stessa origine del gioco, niente CORS). - scripts/leaderboard.gd: client HTTP con retry idempotente; su desktop la classifica resta disattivata senza --api=, cosi' i test non sporcano la classifica pubblica. - scripts/player_profile.gd: nome, id anonimo e miglior punteggio in user:// e in window.localStorage; il nome scelto non viene piu' richiesto; riparazione dei profili con la stringa "null". - scripts/start_screen.gd: menu con nome, GIOCA e classifica; la partita parte solo dal pulsante (via la rete di sicurezza sull'input, Invio nel campo non avvia). - scripts/main.gd, player.gd: avvio esplicito, segnale died, nota dell'invio. - tools: test_scores_api.py (42), test_leaderboard (14), test_player_profile (21), test_start_screen (10); suite esistenti adeguate all'avvio manuale. - docs/HIGHSCORES.md + docs/WEB-DEPLOY.md aggiornati.
199 lines
7.8 KiB
GDScript
199 lines
7.8 KiB
GDScript
extends CanvasLayer
|
|
## Schermata iniziale: nome del giocatore, pulsante di avvio e classifica globale
|
|
## (tutte le partite). 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 rows_box: VBoxContainer
|
|
var status_label: Label
|
|
var me_label: Label
|
|
var rows_shown := ROWS_DESKTOP
|
|
## Vero quando il nome e' gia' stato scelto: niente campo di testo, solo il saluto.
|
|
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()
|
|
if leaderboard != null:
|
|
leaderboard.fetch()
|
|
|
|
## Nome con cui si giochera': quello memorizzato se e' bloccato, altrimenti il campo.
|
|
func _stored_name() -> String:
|
|
return Profile.sanitize_name(str(profile.get("name", Profile.DEFAULT_NAME)))
|
|
|
|
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("ZOMBIE ROOM FPS", 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))
|
|
|
|
if name_locked:
|
|
# Nome gia' scelto: non si richiede piu' (e nessun campo da toccare per sbaglio).
|
|
column.add_child(_label("giocatore: %s" % _stored_name(), 22, AMBER, HORIZONTAL_ALIGNMENT_CENTER))
|
|
else:
|
|
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)
|
|
|
|
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, mostrata solo finche' il nome non e' stato scelto.
|
|
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 = str(profile.get("name", Profile.DEFAULT_NAME))
|
|
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 _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_locked or 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_locked or name_field == null:
|
|
started.emit(_stored_name())
|
|
return
|
|
started.emit(Profile.sanitize_name(name_field.text))
|