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 == "": 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)