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: servizio pubblico `DEFAULT_API_BASE`, quindi la classifica e' ATTIVA DI ## DEFAULT: non serve nessuna configurazione ne' argomento per leggere e inviare. ## - Override espliciti: `--api=` punta a un altro servizio (test), `--offline` ## (alias `--no-api`) spegne la classifica per le partite di prova. ## - Suite di test (avviate con `--script`): spente, a meno di un `--api=` esplicito, ## cosi' nessun test puo' scrivere 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" ## Servizio pubblico: la classifica funziona senza alcuna configurazione. const DEFAULT_API_BASE := "https://enne2.net/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 solo quando il servizio e' spento apposta (--offline) o in una suite di test. 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=` (test), `--offline`/`--no-api` (spegne), origine ## della pagina sul Web, altrimenti il servizio pubblico. La classifica e' quindi ## abilitata di default e non dipende da configurazione; `--offline` esiste solo per ## le partite di prova che non devono finire nella classifica pubblica. ## `script_run` e' il guard delle suite di test: non e' configurazione utente, la ## passa `_resolve_base()` quando il processo gira con `--script`. static func resolve_base(user_args: PackedStringArray, script_run := false) -> String: for argument in user_args: if argument.begins_with("--api="): return argument.substr(6).trim_suffix("/") for argument in user_args: if argument == "--offline" or argument == "--no-api": return "" 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 if script_run: return "" # suite di test: mai verso il servizio pubblico return DEFAULT_API_BASE ## Le suite di test girano come script (`--script ...`): in quel caso la classifica ## resta spenta, a meno che il test non abbia passato un suo `--api=`. static func _is_script_run() -> bool: for argument in OS.get_cmdline_args(): if argument == "--script" or argument == "-s": return true return false func _resolve_base() -> String: return resolve_base(OS.get_cmdline_user_args(), _is_script_run()) ## 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()