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_*. ## Regola arcade/kiosk (macchina condivisa): il nome non viene mai dato per scontato. ## A ogni avvio, riavvio o game over il menu chiede un nome nuovo. Il profilo conserva ## il miglior punteggio PER NOME; il nome non e' piu' bloccato. const PATH := "user://zombie_fps_profile.cfg" const LEGACY_PROJECT_NAME := "Zombie Room FPS" 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" ## Regola arcade/kiosk: il nome viene richiesto a ogni partita, mai bloccato. const ALWAYS_ASK_NAME := true ## 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, legacy_path := "") -> Dictionary: var data := {"name": "", "player_id": "", "best": 0, "bests": {}, "locked": false} var config := ConfigFile.new() var loaded := config.load(path) # Renaming application/config/name moves native user://. Import the old file # only when the new one does not exist; never change/delete the source profile. # Explicit legacy_path enables isolated migration tests using temporary files. if loaded != OK and not FileAccess.file_exists(path): if legacy_path == "" and path == PATH and not OS.has_feature("web"): legacy_path = legacy_profile_path() if legacy_path != "" and legacy_path != ProjectSettings.globalize_path(path): loaded = config.load(legacy_path) if loaded == OK: var raw_name := text_of(config.get_value("player", "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 "" data["player_id"] = str(config.get_value("player", "player_id", "")) data["best"] = int(config.get_value("player", "best", 0)) var stored_bests: Variant = config.get_value("player", "bests", {}) if stored_bests is Dictionary: data["bests"] = stored_bests else: data["bests"] = {} # Il nome memorizzato serve solo come chiave del suo miglior punteggio. if data["name"] != "" and int(data["bests"].get(data["name"], 0)) > int(data["best"]): data["best"] = int(data["bests"].get(data["name"], 0)) if path == PATH: _web_merge(data) # Regola arcade/kiosk: mai un nome o un id dati per scontati. L'identita' nasce # dal nome inserito, quindi il profilo non assegna mai un id da solo. data["locked"] = is_name_locked(data) save_profile(data, path) return data static func legacy_profile_path() -> String: return OS.get_user_data_dir().get_base_dir().path_join(LEGACY_PROJECT_NAME).path_join(PATH.get_file()) static func save_profile(data: Dictionary, path := PATH) -> void: var name := sanitize_name(text_of(data.get("name"))) if is_stored_name_usable(text_of(data.get("name"))) else "" var locked := is_name_locked(data) data["name"] = name data["locked"] = locked var bests := _bests(data) var best := int(data.get("best", 0)) if name != "": bests[name] = maxi(int(bests.get(name, 0)), best) best = int(bests[name]) data["bests"] = bests data["best"] = best 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", best) config.set_value("player", "bests", bests) 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(best)) _web_set(KEY_LOCKED, "0") ## Mappa nome -> miglior punteggio. E' la sola memoria per persona: il nome stesso ## non viene mai usato per decidere se mostrarlo al giocatore. static func _bests(data: Dictionary) -> Dictionary: var stored: Variant = data.get("bests", {}) return stored if stored is Dictionary else {} ## Miglior punteggio del NOME inserito (non del profilo in generale). static func best_for_name(data: Dictionary, name: String) -> int: var key := sanitize_name(name) return int(_bests(data).get(key, 0)) ## Registra un punteggio per il nome della partita appena conclusa. static func record_best(data: Dictionary, name: String, score: int) -> int: var key := sanitize_name(name) var bests := _bests(data) bests[key] = maxi(int(bests.get(key, 0)), score) data["bests"] = bests data["best"] = int(bests[key]) return int(data["best"]) ## Game over: profilo sbloccato e nome liberato, cosi' il menu chiede un nome nuovo. static func unlock_name(data: Dictionary) -> Dictionary: data["locked"] = false data["name"] = "" return data ## Identita' del giocatore derivata dal nome inserito: stessa persona = stesso id ## anche dopo un riavvio del kiosk, nomi diversi = identita' diverse. FNV-1a a 32 bit ## con due semi, 16 caratteri esadecimali (regole del server: 8-64 caratteri sicuri). static func player_id_for_name(name: String) -> String: var clean := sanitize_name(name) if clean == DEFAULT_NAME or clean == "": return new_player_id() return "%08x%08x" % [fnv1a32(clean, 0x811C9DC5), fnv1a32(clean, 0x01000193)] static func fnv1a32(text: String, seed: int) -> int: var hash := seed & 0xFFFFFFFF for i in text.length(): hash = (hash ^ int(text.unicode_at(i))) & 0xFFFFFFFF hash = (hash * 16777619) & 0xFFFFFFFF return hash ## 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" ## Regola arcade/kiosk: il nome non e' mai bloccato, si richiede a ogni partita. static func is_name_locked(data: Dictionary) -> bool: if ALWAYS_ASK_NAME: return false return bool(data.get("locked", false)) ## 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 != "": # Solo la chiave del miglior punteggio: il nome letto dal browser non viene # proposto al giocatore e non blocca piu' niente. if is_stored_name_usable(raw_name): data["name"] = sanitize_name(raw_name) else: data["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) # Un valore "locked" arrivato dal browser (profili di versioni precedenti) viene # ignorato: con la regola arcade il nome si richiede comunque. data["locked"] = is_name_locked(data) 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)