Classifica globale: API dei punteggi, schermata iniziale e nome memorizzato
- 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.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
extends Node
|
||||
## Client della classifica remota.
|
||||
##
|
||||
## - Web: stessa origine della pagina (window.location.origin) + /api/zombie-fps, cosi'
|
||||
## il browser non ha bisogno di CORS.
|
||||
## - Desktop: nessuna classifica, a meno di --api=http://... (serve ai test). Cosi' una
|
||||
## partita di prova non finisce MAI nella classifica pubblica.
|
||||
## - Due nodi HTTPRequest separati: Godot non ammette due richieste insieme sullo
|
||||
## stesso nodo, e lettura e invio possono sovrapporsi (l'invio avviene alla morte).
|
||||
## - Un errore di rete non deve mai rompere il gioco: si segnala e basta.
|
||||
signal leaderboard_ready(rows: Array, total: int, me: Dictionary)
|
||||
signal leaderboard_failed(reason: String)
|
||||
signal score_submitted(info: Dictionary)
|
||||
signal submit_failed(reason: String)
|
||||
|
||||
const API_PATH := "/api/zombie-fps"
|
||||
const TOP_LIMIT := 10
|
||||
const RETRY_DELAY := 6.0
|
||||
const RETRY_LIMIT := 3
|
||||
const TIMEOUT := 12.0
|
||||
const DISABLED_REASON := "classifica non attiva in questa build"
|
||||
|
||||
var player_id := ""
|
||||
var base_url := ""
|
||||
## Vero quando non c'e' nessun servizio da usare (build desktop senza --api=).
|
||||
var disabled := false
|
||||
|
||||
var _get_request: HTTPRequest
|
||||
var _post_request: HTTPRequest
|
||||
var _pending := {} # invio in corso: nome, punteggio, submission_id
|
||||
var _retries := 0
|
||||
var _retry_timer: Timer
|
||||
var _fetching := false
|
||||
var _fetch_again := false
|
||||
|
||||
func _ready() -> void:
|
||||
name = "Leaderboard"
|
||||
base_url = _resolve_base()
|
||||
disabled = base_url == ""
|
||||
_get_request = HTTPRequest.new()
|
||||
_get_request.timeout = TIMEOUT
|
||||
_get_request.body_size_limit = 262144
|
||||
add_child(_get_request)
|
||||
_get_request.request_completed.connect(_on_get_completed)
|
||||
_post_request = HTTPRequest.new()
|
||||
_post_request.timeout = TIMEOUT
|
||||
_post_request.body_size_limit = 65536
|
||||
add_child(_post_request)
|
||||
_post_request.request_completed.connect(_on_post_completed)
|
||||
_retry_timer = Timer.new()
|
||||
_retry_timer.one_shot = true
|
||||
add_child(_retry_timer)
|
||||
_retry_timer.timeout.connect(_send_pending)
|
||||
|
||||
## Ordine di priorita': --api=..., poi l'origine della pagina sul Web. Senza nessuno
|
||||
## dei due non si usa NIENTE: una build desktop di prova non deve poter inviare
|
||||
## punteggi alla classifica pubblica (e' successo: righe "Test" in produzione).
|
||||
func _resolve_base() -> String:
|
||||
for argument in OS.get_cmdline_user_args():
|
||||
if argument.begins_with("--api="):
|
||||
return argument.substr(6).trim_suffix("/")
|
||||
if OS.has_feature("web") and ClassDB.class_exists("JavaScriptBridge"):
|
||||
var origin := str(JavaScriptBridge.eval("window.location.origin", true))
|
||||
if origin.begins_with("http"):
|
||||
return origin.trim_suffix("/") + API_PATH
|
||||
push_warning("Classifica disattivata: usa --api=http://host:porta per indicare il servizio.")
|
||||
return ""
|
||||
|
||||
## Godot non ammette due richieste insieme sullo stesso nodo: una lettura chiesta
|
||||
## mentre la precedente e' in volo viene rimandata a fine risposta invece di fallire
|
||||
## (capita davvero: il menu legge all'avvio e il pulsante "aggiorna" puo' essere premuto).
|
||||
func fetch() -> void:
|
||||
if _get_request == null:
|
||||
return
|
||||
if disabled:
|
||||
leaderboard_failed.emit(DISABLED_REASON)
|
||||
return
|
||||
if _fetching:
|
||||
_fetch_again = true
|
||||
return
|
||||
_fetching = true
|
||||
var url := "%s/scores?limit=%d" % [base_url, TOP_LIMIT]
|
||||
if player_id != "":
|
||||
url += "&player=" + player_id.uri_encode()
|
||||
var error := _get_request.request(url, PackedStringArray(["Accept: application/json"]))
|
||||
if error != OK:
|
||||
_fetching = false
|
||||
leaderboard_failed.emit("richiesta non avviata")
|
||||
|
||||
func submit(player_name: String, score: int) -> void:
|
||||
if not _pending.is_empty():
|
||||
return # un invio alla volta: il secondo verrebbe scartato dal server
|
||||
if disabled:
|
||||
submit_failed.emit(DISABLED_REASON)
|
||||
return
|
||||
_pending = {
|
||||
"name": player_name,
|
||||
"score": score,
|
||||
"player_id": player_id,
|
||||
"submission_id": _new_submission_id(),
|
||||
}
|
||||
_retries = 0
|
||||
_send_pending()
|
||||
|
||||
func _send_pending() -> void:
|
||||
if _pending.is_empty() or _post_request == null:
|
||||
return
|
||||
var body := JSON.stringify(_pending)
|
||||
var error := _post_request.request(base_url + "/scores",
|
||||
PackedStringArray(["Content-Type: application/json", "Accept: application/json"]),
|
||||
HTTPClient.METHOD_POST, body)
|
||||
if error != OK:
|
||||
_fail_pending("invio non avviato")
|
||||
|
||||
## Retry con lo stesso submission_id: il server lo riconosce e non duplica la riga.
|
||||
func _retry(reason: String) -> void:
|
||||
if _retries >= RETRY_LIMIT:
|
||||
_fail_pending(reason)
|
||||
return
|
||||
_retries += 1
|
||||
_retry_timer.start(RETRY_DELAY * _retries)
|
||||
|
||||
func _fail_pending(reason: String) -> void:
|
||||
_pending = {}
|
||||
submit_failed.emit(reason)
|
||||
|
||||
func _on_get_completed(result: int, status: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
_fetching = false
|
||||
if _fetch_again: # la lettura rimandata parte adesso
|
||||
_fetch_again = false
|
||||
fetch()
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
leaderboard_failed.emit("rete non disponibile")
|
||||
return
|
||||
if status != 200:
|
||||
leaderboard_failed.emit("risposta %d" % status)
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("top"):
|
||||
leaderboard_failed.emit("risposta non valida")
|
||||
return
|
||||
leaderboard_ready.emit(parsed.get("top", []), int(parsed.get("total", 0)),
|
||||
parsed.get("me", {}) if typeof(parsed.get("me")) == TYPE_DICTIONARY else {})
|
||||
|
||||
func _on_post_completed(result: int, status: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
if _pending.is_empty():
|
||||
return
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
_retry("rete non disponibile")
|
||||
return
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if status == 429 or status >= 500:
|
||||
_retry("server occupato")
|
||||
return
|
||||
if status != 200 and status != 201:
|
||||
var detail := "rifiutato"
|
||||
if typeof(parsed) == TYPE_DICTIONARY and parsed.has("error"):
|
||||
detail = str(parsed["error"])
|
||||
_fail_pending(detail)
|
||||
return
|
||||
if typeof(parsed) != TYPE_DICTIONARY:
|
||||
_fail_pending("risposta non valida")
|
||||
return
|
||||
var info: Dictionary = parsed.duplicate()
|
||||
info["submitted"] = _pending.get("score", 0)
|
||||
_pending = {}
|
||||
score_submitted.emit(info)
|
||||
|
||||
func _new_submission_id() -> String:
|
||||
return preload("res://scripts/player_profile.gd").new_submission_id()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dqu86xp3orxdv
|
||||
+74
-32
@@ -2,6 +2,9 @@ extends Node3D
|
||||
const PlayerScript = preload("res://scripts/player.gd")
|
||||
const ZombieScript = preload("res://scripts/zombie.gd")
|
||||
const RoomScript = preload("res://scripts/room.gd")
|
||||
const LeaderboardScript = preload("res://scripts/leaderboard.gd")
|
||||
const StartScreenScript = preload("res://scripts/start_screen.gd")
|
||||
const Profile = preload("res://scripts/player_profile.gd")
|
||||
const MUSIC_PATH := "res://assets/audio/music/zombie_industrial_loop.ogg"
|
||||
const MUSIC_VOLUME_DB := -11.0
|
||||
const TouchControls = preload("res://scripts/touch_controls.gd")
|
||||
@@ -18,12 +21,15 @@ var alive := 0
|
||||
var spawning := false
|
||||
var wave_pending := false
|
||||
var smoke := false
|
||||
var awaiting_web_start := false
|
||||
var start_label: Label
|
||||
var last_window := -1
|
||||
var leaderboard: Node
|
||||
var start_screen: CanvasLayer
|
||||
var profile := {}
|
||||
var score_submitted := false
|
||||
|
||||
func _ready() -> void:
|
||||
smoke = "--smoke" in OS.get_cmdline_user_args()
|
||||
profile = Profile.load_profile()
|
||||
room = Node3D.new()
|
||||
room.set_script(RoomScript)
|
||||
add_child(room)
|
||||
@@ -74,44 +80,80 @@ func _ready() -> void:
|
||||
cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||||
_setup_music()
|
||||
_setup_touch()
|
||||
leaderboard = Node.new()
|
||||
leaderboard.set_script(LeaderboardScript)
|
||||
leaderboard.player_id = profile["player_id"]
|
||||
add_child(leaderboard)
|
||||
leaderboard.leaderboard_ready.connect(_on_leaderboard_ready)
|
||||
leaderboard.leaderboard_failed.connect(_on_leaderboard_failed)
|
||||
leaderboard.score_submitted.connect(_on_score_submitted)
|
||||
leaderboard.submit_failed.connect(_on_submit_failed)
|
||||
player.died.connect(_on_player_died)
|
||||
start_screen = CanvasLayer.new()
|
||||
start_screen.set_script(StartScreenScript)
|
||||
start_screen.player = player
|
||||
start_screen.leaderboard = leaderboard
|
||||
start_screen.profile = profile
|
||||
add_child(start_screen)
|
||||
start_screen.started.connect(_on_start_pressed)
|
||||
if OS.has_feature("web"):
|
||||
awaiting_web_start = true
|
||||
if touch != null:
|
||||
touch.enabled = false # il primo tocco/click avvia la partita
|
||||
start_label = Label.new()
|
||||
var howto := "trascina a sinistra: muovi · destra: guarda · pulsanti: fuoco/granata/ricarica/salto" if player.touch_mode else "WASD: movimento · clic sinistro: spara · destro: granata · R: ricarica"
|
||||
start_label.text = ("TOCCA PER INIZIARE" if player.touch_mode else "CLICCA PER INIZIARE") + "\n" + howto
|
||||
start_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
start_label.add_theme_font_size_override("font_size", 24)
|
||||
start_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
canvas.add_child(start_label)
|
||||
start_label.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||||
else:
|
||||
_start_music()
|
||||
_next_wave()
|
||||
touch.enabled = false # i tocchi servono alla schermata iniziale
|
||||
if smoke:
|
||||
get_tree().create_timer(12).timeout.connect(func():
|
||||
print("SMOKE: wave=", wave, " alive=", alive, " player_hp=", player.hp)
|
||||
get_tree().quit())
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not awaiting_web_start:
|
||||
## Il pulsante GIOCA della schermata iniziale delega qui: sblocca l'audio (policy dei
|
||||
## browser: serve un gesto dell'utente) e avvia la prima ondata. I test headless
|
||||
## chiamano la stessa funzione con save_profile=false per non toccare il profilo reale.
|
||||
func _on_start_pressed(player_name: String) -> void:
|
||||
start_game(player_name, true)
|
||||
|
||||
func start_game(player_name: String, save_profile := true) -> void:
|
||||
profile["name"] = player_name
|
||||
if save_profile:
|
||||
Profile.save_profile(profile)
|
||||
if not player.touch_mode:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
if touch != null:
|
||||
touch.enabled = true
|
||||
if start_screen != null:
|
||||
start_screen.queue_free()
|
||||
start_screen = null
|
||||
_start_music()
|
||||
_next_wave()
|
||||
|
||||
## Alla morte si invia UNA volta il punteggio finale con nome e id del giocatore.
|
||||
func _on_player_died() -> void:
|
||||
if score_submitted:
|
||||
return
|
||||
var started := false
|
||||
if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
|
||||
started = true
|
||||
elif event is InputEventScreenTouch and event.pressed:
|
||||
started = true # primo tocco su mobile
|
||||
if started:
|
||||
awaiting_web_start = false
|
||||
start_label.queue_free()
|
||||
if not player.touch_mode:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
if touch != null:
|
||||
touch.enabled = true
|
||||
# Il browser concede l'audio solo dopo un gesto dell'utente: qui il click/tocco.
|
||||
_start_music()
|
||||
_next_wave()
|
||||
score_submitted = true
|
||||
if int(score) > int(profile.get("best", 0)):
|
||||
profile["best"] = score
|
||||
Profile.save_profile(profile)
|
||||
player.set_death_note("invio del punteggio in corso...")
|
||||
leaderboard.submit(profile["name"], score)
|
||||
|
||||
func _on_score_submitted(info: Dictionary) -> void:
|
||||
var total := int(info.get("total", 0))
|
||||
player.set_death_note("PUNTEGGIO %d — %d° su %d partite" % [score, int(info.get("rank", 0)), total])
|
||||
leaderboard.fetch()
|
||||
|
||||
func _on_submit_failed(reason: String) -> void:
|
||||
player.set_death_note("punteggio non inviato (%s)" % reason)
|
||||
|
||||
func _on_leaderboard_ready(rows: Array, total: int, me: Dictionary) -> void:
|
||||
if start_screen != null:
|
||||
start_screen.show_leaderboard(rows, total, me)
|
||||
|
||||
func _on_leaderboard_failed(reason: String) -> void:
|
||||
if start_screen != null:
|
||||
start_screen.show_leaderboard_error(reason)
|
||||
|
||||
## Nota: qui non c'e' piu' nessuna rete di sicurezza sull'input. La partita parte
|
||||
## solo dal pulsante GIOCA della schermata iniziale (segnale `started`): un click o un
|
||||
## tocco sullo sfondo del menu non deve avviare nulla.
|
||||
|
||||
## Controlli on-screen: attivi su browser mobile o touchscreen, forzabili con
|
||||
## --touch / --no-touch sulla riga di comando e con ?touch=1 / ?touch=0 sul Web.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
extends CharacterBody3D
|
||||
signal died
|
||||
var camera: Camera3D
|
||||
var damage_pivot: Node3D
|
||||
var damage_feedback: CanvasLayer
|
||||
@@ -81,6 +82,14 @@ func _ready() -> void:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
var restart_button: Button
|
||||
var death_note: Label
|
||||
|
||||
## Riga sotto il messaggio di morte: punteggio inviato, posizione in classifica o
|
||||
## motivo del fallimento. Richiamata da main.gd a conclusione dell'invio.
|
||||
func set_death_note(text: String) -> void:
|
||||
if is_instance_valid(death_note):
|
||||
death_note.text = text
|
||||
death_note.modulate.a = 1.0
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
# Touch nativo: funziona anche con emulate_mouse_from_touch disattivato.
|
||||
@@ -248,6 +257,20 @@ func _die() -> void:
|
||||
layer.add_child(message)
|
||||
message.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||||
tween.tween_property(message, "modulate:a", 1.0, 0.5).set_delay(2.2)
|
||||
death_note = Label.new()
|
||||
death_note.text = ""
|
||||
death_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
death_note.add_theme_font_size_override("font_size", 22)
|
||||
death_note.add_theme_color_override("font_color", Color(1.0, 0.66, 0.16))
|
||||
death_note.modulate.a = 0
|
||||
layer.add_child(death_note)
|
||||
death_note.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
||||
death_note.offset_top = 60
|
||||
death_note.offset_bottom = 100
|
||||
death_note.offset_left = -420
|
||||
death_note.offset_right = 420
|
||||
tween.tween_property(death_note, "modulate:a", 1.0, 0.5).set_delay(2.6)
|
||||
died.emit()
|
||||
if touch_mode:
|
||||
var restart := Button.new()
|
||||
restart_button = restart
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
extends RefCounted
|
||||
## Profilo locale del giocatore: nome scelto, id persistente e ultimo punteggio.
|
||||
## Su desktop vive in user://, sul Web si scrive ANCHE in window.localStorage, perche'
|
||||
## e' la memoria del browser che sopravvive davvero alla ricarica della pagina (la
|
||||
## IndexedDB di Godot puo' non essere sincronizzata). Chiavi: zombie_fps_*.
|
||||
## Il nome, una volta scelto, resta bloccato: non viene piu' richiesto.
|
||||
const PATH := "user://zombie_fps_profile.cfg"
|
||||
const DEFAULT_NAME := "Giocatore"
|
||||
const NAME_MAX := 16
|
||||
const KEY_NAME := "zombie_fps_name"
|
||||
const KEY_PLAYER_ID := "zombie_fps_player_id"
|
||||
const KEY_BEST := "zombie_fps_best"
|
||||
const KEY_LOCKED := "zombie_fps_locked"
|
||||
|
||||
## Legge il profilo (o i valori predefiniti) e lo riscrive, cosi' il primo avvio
|
||||
## lascia sempre un id stabile e le chiavi del browser allineate.
|
||||
static func load_profile(path := PATH) -> Dictionary:
|
||||
var data := {"name": DEFAULT_NAME, "player_id": "", "best": 0, "locked": false}
|
||||
var config := ConfigFile.new()
|
||||
if config.load(path) == OK:
|
||||
var raw_name := text_of(config.get_value("player", "name", DEFAULT_NAME))
|
||||
# Una versione precedente poteva salvare la stringa "null": in quel caso il
|
||||
# nome va considerato non scelto e il blocco rimosso (riparazione automatica).
|
||||
data["name"] = sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME
|
||||
data["player_id"] = str(config.get_value("player", "player_id", ""))
|
||||
data["best"] = int(config.get_value("player", "best", 0))
|
||||
data["locked"] = bool(config.get_value("player", "locked", false)) and is_stored_name_usable(raw_name)
|
||||
if path == PATH:
|
||||
_web_merge(data)
|
||||
if data["player_id"] == "":
|
||||
data["player_id"] = new_player_id()
|
||||
# Vale anche per i profili scritti prima di questa regola: un nome diverso da
|
||||
# quello predefinito significa che il giocatore l'ha scelto di proposito.
|
||||
data["locked"] = is_name_locked(data)
|
||||
save_profile(data, path)
|
||||
return data
|
||||
|
||||
static func save_profile(data: Dictionary, path := PATH) -> void:
|
||||
var raw_name := text_of(data.get("name"))
|
||||
var name := sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME
|
||||
var locked := is_name_locked(data)
|
||||
data["name"] = name
|
||||
data["locked"] = locked
|
||||
var config := ConfigFile.new()
|
||||
config.set_value("player", "name", name)
|
||||
config.set_value("player", "player_id", str(data.get("player_id", "")))
|
||||
config.set_value("player", "best", int(data.get("best", 0)))
|
||||
config.set_value("player", "locked", locked)
|
||||
config.save(path)
|
||||
if path == PATH:
|
||||
_web_set(KEY_NAME, name)
|
||||
_web_set(KEY_PLAYER_ID, str(data.get("player_id", "")))
|
||||
_web_set(KEY_BEST, str(int(data.get("best", 0))))
|
||||
_web_set(KEY_LOCKED, "1" if locked else "0")
|
||||
|
||||
## Vero se il testo e' un nome memorizzato utilizzabile. Le versioni precedenti
|
||||
## potevano salvare la stringa "null" (`str(null)` in GDScript): va scartata.
|
||||
static func is_stored_name_usable(value: Variant) -> bool:
|
||||
var text := text_of(value).strip_edges()
|
||||
return text != "" and text != "null" and text != "undefined"
|
||||
|
||||
## Il nome e' bloccato quando il giocatore l'ha scelto: non si richiede piu'.
|
||||
static func is_name_locked(data: Dictionary) -> bool:
|
||||
if bool(data.get("locked", false)):
|
||||
return true
|
||||
return sanitize_name(str(data.get("name", DEFAULT_NAME))) != DEFAULT_NAME
|
||||
|
||||
## Id persistente del giocatore: 32 caratteri esadecimali casuali.
|
||||
static func new_player_id() -> String:
|
||||
var bytes := random_bytes(16)
|
||||
var text := ""
|
||||
for byte in bytes:
|
||||
text += "%02x" % byte
|
||||
return text
|
||||
|
||||
## Uuid v4 per identificare UNA partita: il server lo usa per non duplicare i retry.
|
||||
static func new_submission_id() -> String:
|
||||
var bytes := random_bytes(16)
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40 # versione 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80 # variante
|
||||
var text := ""
|
||||
for byte in bytes:
|
||||
text += "%02x" % byte
|
||||
return "%s-%s-%s-%s-%s" % [text.substr(0, 8), text.substr(8, 4), text.substr(12, 4),
|
||||
text.substr(16, 4), text.substr(20, 12)]
|
||||
|
||||
## Byte casuali da Crypto, con ripiego sul generatore della scena se non disponibile.
|
||||
static func random_bytes(count: int) -> PackedByteArray:
|
||||
if ClassDB.class_exists("Crypto"):
|
||||
var crypto := Crypto.new()
|
||||
var bytes := crypto.generate_random_bytes(count)
|
||||
if bytes.size() == count:
|
||||
return bytes
|
||||
var rng := RandomNumberGenerator.new()
|
||||
rng.randomize()
|
||||
var fallback := PackedByteArray()
|
||||
for i in count:
|
||||
fallback.append(rng.randi_range(0, 255))
|
||||
return fallback
|
||||
|
||||
## Id del giocatore secondo le stesse regole del server (8-64 caratteri sicuri).
|
||||
static func is_valid_player_id(text: String) -> bool:
|
||||
if text.length() < 8 or text.length() > 64:
|
||||
return false
|
||||
for character in text:
|
||||
var allowed := character.is_valid_int() or character == "-" or character == "_"
|
||||
allowed = allowed or (character.to_lower() != character.to_upper())
|
||||
if not allowed:
|
||||
return false
|
||||
return true
|
||||
|
||||
## Pulizia del nome secondo le stesse regole del server: 1-16 caratteri utili,
|
||||
## niente apici/emoji (il server risponderebbe 400 e il punteggio andrebbe perso).
|
||||
static func sanitize_name(raw: String) -> String:
|
||||
var cleaned := ""
|
||||
for character in raw.strip_edges():
|
||||
if cleaned.length() >= NAME_MAX:
|
||||
break
|
||||
if character == "\n" or character == "\t" or character == "\r":
|
||||
character = " "
|
||||
var allowed := character == " " or character == "-" or character == "."
|
||||
allowed = allowed or character.is_valid_int()
|
||||
allowed = allowed or (character.to_lower() != character.to_upper())
|
||||
if allowed:
|
||||
cleaned += character
|
||||
cleaned = cleaned.strip_edges()
|
||||
while cleaned.contains(" "):
|
||||
cleaned = cleaned.replace(" ", " ")
|
||||
if cleaned.strip_edges() == "":
|
||||
cleaned = DEFAULT_NAME
|
||||
return cleaned
|
||||
|
||||
## Testo pronto per un letterale JavaScript fra apici singoli.
|
||||
static func js_escape(text: String) -> String:
|
||||
var out := ""
|
||||
for character in text:
|
||||
if character == "\\":
|
||||
out += "\\\\"
|
||||
elif character == "'":
|
||||
out += "\\'"
|
||||
elif character == "\n" or character == "\r" or character == "\t":
|
||||
out += " "
|
||||
else:
|
||||
out += character
|
||||
return out
|
||||
|
||||
## --- memoria del browser (solo sul Web) -------------------------------------
|
||||
|
||||
static func _web_eval(code: String) -> String:
|
||||
if not OS.has_feature("web"):
|
||||
return ""
|
||||
if not ClassDB.class_exists("JavaScriptBridge"):
|
||||
return ""
|
||||
return str(JavaScriptBridge.eval(code, true))
|
||||
|
||||
static func _web_merge(data: Dictionary) -> void:
|
||||
var raw := _web_eval(
|
||||
"(function(){try{return JSON.stringify({n:window.localStorage.getItem('%s')||''," % KEY_NAME
|
||||
+ "i:window.localStorage.getItem('%s')||''," % KEY_PLAYER_ID
|
||||
+ "b:window.localStorage.getItem('%s')||''," % KEY_BEST
|
||||
+ "l:window.localStorage.getItem('%s')||''});}catch(e){return '';}})()" % KEY_LOCKED)
|
||||
if raw == "" or raw == "<null>":
|
||||
return
|
||||
apply_web_json(data, raw)
|
||||
|
||||
## Applica i valori letti dal browser al profilo. E' una funzione PURA (riceve il
|
||||
## JSON come testo) cosi' si puo' verificare senza browser: attenzione, in GDScript
|
||||
## `str(null)` vale "null", quindi i valori assenti vanno scartati, non convertiti.
|
||||
static func apply_web_json(data: Dictionary, raw: String) -> Dictionary:
|
||||
var parsed: Variant = JSON.parse_string(raw)
|
||||
if not (parsed is Dictionary):
|
||||
return data
|
||||
var stored: Dictionary = parsed
|
||||
var raw_name := text_of(stored.get("n"))
|
||||
if raw_name != "":
|
||||
if is_stored_name_usable(raw_name):
|
||||
data["name"] = sanitize_name(raw_name)
|
||||
else:
|
||||
data["name"] = DEFAULT_NAME
|
||||
data["locked"] = false
|
||||
var player_id := text_of(stored.get("i"))
|
||||
if is_valid_player_id(player_id):
|
||||
data["player_id"] = player_id
|
||||
var best := text_of(stored.get("b"))
|
||||
if best.is_valid_int():
|
||||
data["best"] = int(best)
|
||||
if text_of(stored.get("l")) == "1" and is_stored_name_usable(raw_name):
|
||||
data["locked"] = true
|
||||
return data
|
||||
|
||||
## Testo di un valore che puo' arrivare dal browser o da un file: null e numeri
|
||||
## diventano stringhe sensate (in GDScript `str(null)` produrrebbe "null").
|
||||
static func text_of(value: Variant) -> String:
|
||||
if value == null:
|
||||
return ""
|
||||
if value is String:
|
||||
return str(value).strip_edges()
|
||||
return str(value)
|
||||
|
||||
static func _web_set(key: String, value: String) -> void:
|
||||
_web_eval("(function(){try{window.localStorage.setItem('%s','%s');return '1';}catch(e){return '0';}})()"
|
||||
% [js_escape(key), js_escape(value)])
|
||||
|
||||
## Cancella le chiavi del browser: serve ai test, non al gioco.
|
||||
static func web_clear() -> void:
|
||||
for key in [KEY_NAME, KEY_PLAYER_ID, KEY_BEST, KEY_LOCKED]:
|
||||
_web_eval("(function(){try{window.localStorage.removeItem('%s');return '1';}catch(e){return '0';}})()" % key)
|
||||
@@ -0,0 +1 @@
|
||||
uid://2o00kx51f760
|
||||
@@ -0,0 +1,198 @@
|
||||
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))
|
||||
@@ -0,0 +1 @@
|
||||
uid://djsrmu6t131d
|
||||
Reference in New Issue
Block a user