Files
zombie-fps/scripts/player_profile.gd
T
enne2 cb14fd5400 Arcade/kiosk name flow, free cursor in menu and portable Linux export
Every game over unlocks the profile and frees the name, so the menu always asks
for a new player name: the field is empty at every boot, restart or game over and
the game does not start without a name (no anonymous scores). The player identity
is derived from the entered name (deterministic FNV-1a, 16 hex chars valid for the
public API) and the profile keeps the best score per name. Backend untouched.

Mouse: player._ready() captured the pointer before the menu existed, so the start
screen was not clickable. The cursor now always starts visible; the capture happens
only in player.begin_play(), called by start_game(). New `playing` flag gates Esc
and the Web click capture to the actual match.

Linux export preset: single-file x86_64 binary with the PCK embedded, no
libstdc++ dependency, official 4.7.1 template (glibc >= 2.28). Notes and the
export_presets.cfg `#`-is-not-a-comment trap documented in docs/KIOSK-ARCADE.md.

Tests: profile 30/30, start screen 15/15 (cursor free at menu, captured only in
game), boss 50/50, rebalance 14/14, touch 14 PASS, API 42/42, all other suites pass.
2026-10-24 12:50:03 +02:00

282 lines
12 KiB
GDScript

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