diff --git a/docs/HIGHSCORES.md b/docs/HIGHSCORES.md index 6860400..ec9d39e 100644 --- a/docs/HIGHSCORES.md +++ b/docs/HIGHSCORES.md @@ -27,11 +27,11 @@ File coinvolti: | `services/scores-api/docker-compose.yml` | compose (volume, limiti, log rotation) | | `tools/nginx-zombie-fps-api.conf` | snippet nginx del reverse proxy | | `scripts/leaderboard.gd` | client HTTP Godot (lettura + invio, retry) | -| `scripts/player_profile.gd` | profilo locale: nome scelto, id giocatore, miglior punteggio | +| `scripts/player_profile.gd` | profilo locale: nome dell'ultima partita, id giocatore, miglior punteggio per nome | | `scripts/start_screen.gd` | menu: nome, pulsante GIOCA, classifica | | `tools/test_scores_api.py` | 42 controlli sul servizio (funzionalità, validazione, limiti) | | `tools/test_leaderboard.gd` + `.sh` | 14 controlli sul client, contro un'API locale usa e getta | -| `tools/test_player_profile.gd` | 26 controlli sul profilo (nome, id, riparazioni, migrazione dopo rinomina) | +| `tools/test_player_profile.gd` | 30 controlli sul profilo (campo del nome sempre libero, id derivato dal nome, record per nome, riparazioni, migrazione dopo rinomina) | | `tools/test_start_screen.gd` | 10 controlli sulle regole del menu | ## 2. Contratto dell'API @@ -148,19 +148,24 @@ sudo nginx -t && sudo systemctl reload nginx # se -t fallisce: ripristinar | Chiave | Contenuto | | --- | --- | -| `zombie_fps_name` | nome scelto | -| `zombie_fps_player_id` | id anonimo, 32 caratteri esadecimali | -| `zombie_fps_best` | miglior punteggio locale | -| `zombie_fps_locked` | `1` se il nome è già stato scelto | +| `zombie_fps_name` | nome dell'ultima partita (solo come chiave del suo record) | +| `zombie_fps_player_id` | id anonimo, 32 caratteri esadecimali, o 16 derivato dal nome | +| `zombie_fps_best` | miglior punteggio del nome salvato | +| `bests` (sezione del file `.cfg`) | miglior punteggio **per nome** | +| `zombie_fps_locked` | sempre `0` in modalità arcade/kiosk | * Sul Web si scrive **anche** in `localStorage` perché è la memoria del browser che sopravvive davvero alla ricarica della pagina (l'IndexedDB di Godot può non essere sincronizzata). Alla lettura i due archivi si fondono: l'id del giocatore resta stabile, così i punteggi vecchi restano collegati. -* **Il nome, una volta scelto, non viene più richiesto**: se `locked` è attivo il menu - mostra `giocatore: Mat` e il campo di testo non esiste proprio. -* Un nome diverso da quello predefinito (`Giocatore`) vale come nome scelto, anche per - i profili salvati dalle versioni precedenti. +* **Modalità arcade/kiosk (`ALWAYS_ASK_NAME`)**: il nome non viene mai dato per scontato. + Il campo del menu è sempre presente e arriva vuoto a ogni avvio, riavvio o game over; + senza nome scritto la partita non parte (nessun punteggio anonimo in classifica). + Il profilo conserva solo il **miglior punteggio per nome**. Dettagli: `docs/KIOSK-ARCADE.md`. +* L'identità della partita nasce dal nome inserito: `player_id_for_name()` è un FNV-1a + deterministico (16 caratteri esadecimali, valido per il server). Stesso nome = stesso id + anche dopo un riavvio del kiosk, nomi diversi = identità diverse. Il backend non cambia. +* Un `locked` arrivato da profili o dal browser di versioni precedenti viene ignorato. * **Riparazione automatica**: `str(null)` in GDScript produce la stringa `"null"`, e una versione precedente l'ha salvata come nome. Alla lettura i valori `null`, `undefined` e vuoti vengono scartati, il nome torna al predefinito e il blocco si @@ -198,9 +203,9 @@ sh tools/test_leaderboard.sh # 14 controlli # profilo e menu (menu: serve un display) $HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64 --headless --path . \ - --script res://tools/test_player_profile.gd # 26 controlli + --script res://tools/test_player_profile.gd # 30 controlli $HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64 --path . \ - --rendering-method gl_compatibility --script res://tools/test_start_screen.gd # 10 controlli + --rendering-method gl_compatibility --script res://tools/test_start_screen.gd # 11 controlli ``` In produzione si verifica a mano, con `curl`: diff --git a/docs/KIOSK-ARCADE.md b/docs/KIOSK-ARCADE.md new file mode 100644 index 0000000..a4274f6 --- /dev/null +++ b/docs/KIOSK-ARCADE.md @@ -0,0 +1,115 @@ +# Modalità arcade/kiosk (macchina condivisa) e build Linux + +## 1. Regola di flusso + +Il gioco è installato su un computer condiviso. Il profilo precedente **non viene mai +dato per scontato**: + +* il campo del nome del menu è **sempre presente e vuoto** a ogni avvio e a ogni + riavvio; +* alla morte (game over) il profilo viene **sbloccato** e il nome liberato, così al + ritorno al menu — `R` o pulsante `RICOMINCIA` — viene richiesto il nome della nuova + partita; +* senza nome scritto la partita **non parte** (`GIOCA` non emette `started`), perché un + punteggio senza nome sarebbe anonimo in classifica. + +Il `name lock` delle versioni precedenti (`zombie_fps_locked = 1` → menu con +`giocatore: Mat` e nessun campo) è ignorato: `Profile.is_name_locked()` restituisce +`false` con `ALWAYS_ASK_NAME`. I profili vecchi e i valori di `localStorage` arrivano +ancora letti, ma solo come chiave del miglior punteggio. + +**Identità** (backend invariato, API pubblica così com'è): `player_id` nasce dal nome +inserito, `player_id_for_name()` = FNV-1a 32 bit con due semi → 16 caratteri +esadecimali, accettato dal server (`8–64 [A-Za-z0-9_-]`). Stesso nome = stessa persona +anche dopo un riavvio; nome diverso = identità diversa. Il record è tenuto **per nome** +(`bests` nel file `.cfg`). + +Permalink del flusso: `start_screen.gd::_start()` → `main.gd::start_game()` (id e best +del nome, `leaderboard.player_id`, `player.begin_play()`) → morte → `main.gd::_on_player_died()` +(`record_best` con il nome della partita appena conclusa, `unlock_name`, salvataggio, +`reset_for_new_player()`) → reload → menu con campo vuoto. + +## 1b. Cursore del mouse + +Bug risolto: `player.gd::_ready()` catturava il pointer (`MOUSE_MODE_CAPTURED`) alla +costruzione del giocatore, cioè **prima** del menu, quindi il cursore era già bloccato +sulla schermata iniziale e i pulsanti non erano cliccabili. + +* `player.gd::_ready()` imposta **sempre** `MOUSE_MODE_VISIBLE`. +* La cattura avviene solo in `player.gd::begin_play()`, chiamata da + `main.gd::start_game()` quando si preme GIOCA. +* Nuovo flag `player.playing`: `Esc` (pausa) e il click sul Web catturano il cursore + solo durante la partita, mai nel menu. +* In modalità touch il cursore non viene catturato (serve ai controlli on-screen). +* Alla morte `_die()` lo riporta visibile, quindi la schermata di morte è cliccabile. + +### File toccati + +| File | Modifica | +| --- | --- | +| `scripts/player_profile.gd` | `ALWAYS_ASK_NAME`, `player_id_for_name()`, `fnv1a32()`, `best_for_name()`, `record_best()`, `unlock_name()`; nome non presupposto, record per nome | +| `scripts/start_screen.gd` | campo del nome sempre mostrato e vuoto, `reset_for_new_player()`, `GIOCA` bloccato a campo vuoto | +| `scripts/main.gd` | `start_game()` deriva id e record dal nome e chiama `player.begin_play()`; `_on_player_died()` sblocca il profilo e invia il nome della partita conclusa | +| `scripts/player.gd` | cursore visibile al menu, `begin_play()` con la cattura, flag `playing` | +| `tools/test_player_profile.gd` | 30 controlli (campo libero, id derivato, record per nome, game over) | +| `tools/test_start_screen.gd` | 15 controlli (campo sempre richiesto, partenza bloccata a campo vuoto, campo liberato dopo il game over, cursore libero al menu e catturato solo in partita) | + +## 2. Verifiche eseguite + +```bash +G="$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64" +"$G" --headless --path . --script res://tools/test_player_profile.gd # 30/30 PASS +"$G" --path . --rendering-method gl_compatibility --script res://tools/test_start_screen.gd # 15/15 PASS +# suite precedenti, riuscite dopo la modifica: +"$G" --headless --path . --script res://tools/test_boss.gd # 50/50 +"$G" --headless --path . --script res://tools/test_rebalance_branding.gd # 14/14 +"$G" --headless --path . --script res://tools/test_spawn_windows.gd +"$G" --headless --path . --script res://tools/test_wave_growth.gd +"$G" --headless --path . --script res://tools/test_gameplay_improvements.gd +python3 tools/test_scores_api.py # 42/42 +``` + +Nessuna modifica al backend: nessun cambiamento a nginx, Docker, API o database. +La release Web pubblicata (`https://enne2.net/pub/zombie-fps/`) resta quella precedente +finché non viene riesportata e rimpiantata con questa regola. + +## 3. Build Linux portabile + +`export_presets.cfg` → `preset.1` (name `Linux`, platform `Linux`): + +```ini +export_filter="all_resources" +exclude_filter="addons/*,tools/*,docs/*,build/*,assets/**/*.mdl,assets/half_life_manifest.json" +export_path="build/linux/work_death_balance.x86_64" +binary_format/embed_pck=true +binary_format/architecture="x86_64" +``` + +* **PCK incorporato**: sul kiosk serve un solo file (`work_death_balance.x86_64`, + 80.517.080 byte = template 73.470.264 + PCK 7.046.808), verificato eseguendolo da + una cartella vuota (`--headless -- --smoke` → `SMOKE: wave=0 alive=0 player_hp=100.0`). +* **Dipendenze dinamica minime**: `libc`, `libm`, `librt`, `libpthread`, `libdl` + + `ld-linux-x86-64.so.2`. Nessuna `libstdc++` (già linkata staticamente nel template). +* **Baseline glibc del template ufficiale 4.7.1**: simboli richiesti fino a + `GLIBC_2.28` (`readelf -V`) → il binario gira su glibc ≥ 2.28 (distro dal 2018 in poi). + È il miglior risultato ottenibile senza ricompilare l'engine. +* **musl statico scartato**: un binario completamente statico non carica le librerie + grafiche, perché i driver OpenGL/Vulkan linkano glibc (funziona solo headless). Non è + adatto a un gioco con finestra. +* Runtime richiesto sul kiosk (non linkato, caricato a runtime): driver OpenGL + (Compatibility) o Vulkan (Forward+, renderer nativo attuale), X11 **o** Wayland, + ALSA o PulseAudio. Sono `dlopen`, quindi non rompono la portabilità del binario. +* Attenzione: `#` dentro `export_presets.cfg` non è un commento per il parser → una riga + commentata dopo `binary_format/embed_pck=true` ha fatto esportare il PCK separato. + +### Avvio sul kiosk + +La build nativa non ha un'origine di pagina, quindi la classifica va indicata +esplicitamente (è la stessa API pubblica già in uso): + +```bash +./work_death_balance.x86_64 --api=https://enne2.net/api/zombie-fps/ +``` + +Senza `--api=` la classifica è disattiva di proposito (una build di prova non deve +poter scrivere alla classifica pubblica). diff --git a/export_presets.cfg b/export_presets.cfg index 9d9e9e6..743fc14 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -28,3 +28,29 @@ html/canvas_resize_policy=2 html/focus_canvas_on_start=true html/experimental_virtual_keyboard=true progressive_web_app/enabled=false + +[preset.1] +name="Linux" +platform="Linux" +runnable=true +advanced_options=false +dedicated_server=false +custom_features="" +export_filter="all_resources" +include_filter="" +exclude_filter="addons/*,tools/*,docs/*,build/*,assets/**/*.mdl,assets/half_life_manifest.json" +export_path="build/linux/work_death_balance.x86_64" +patches=PackedStringArray() +encrypt_pck=false +encrypt_directory=false +script_export_mode=2 + +[preset.1.options] +custom_template/release="" +custom_template/debug="" +debug/export_console_wrapper=1 +binary_format/embed_pck=true +binary_format/architecture="x86_64" +texture_format/s3tc_bptc=true +texture_format/etc2_astc=false +ssh_remote_deploy/enabled=false diff --git a/scripts/main.gd b/scripts/main.gd index 52e6ebd..9c8f8ba 100644 --- a/scripts/main.gd +++ b/scripts/main.gd @@ -132,11 +132,17 @@ 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 + # Modalita' arcade/kiosk: l'identita' nasce dal nome scritto in questo momento, + # non dal profilo precedente. Stesso nome = stesso id (quindi stessa persona anche + # dopo un riavvio); nome diverso = identita' diversa. + profile["name"] = Profile.sanitize_name(player_name) + profile["player_id"] = Profile.player_id_for_name(profile["name"]) + profile["best"] = Profile.best_for_name(profile, profile["name"]) + if leaderboard != null: + leaderboard.player_id = profile["player_id"] if save_profile and not boss_simulation: Profile.save_profile(profile) - if not player.touch_mode: - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + player.begin_play() if touch != null: touch.enabled = true if start_screen != null: @@ -155,11 +161,19 @@ func _on_player_died() -> void: if score_submitted: return score_submitted = true + var played_name := str(profile.get("name", "")) if int(score) > int(profile.get("best", 0)): - profile["best"] = score + Profile.record_best(profile, played_name, int(score)) + # Game over: profilo sbloccato e nome liberato. Al ritorno al menu (R o pulsante + # Ricomincia) il campo e' vuoto e viene richiesto il nome della partita successiva. + Profile.unlock_name(profile) + if not boss_simulation: Profile.save_profile(profile) + if start_screen != null: + start_screen.reset_for_new_player() player.set_death_note("invio del punteggio in corso...") - leaderboard.submit(profile["name"], score) + # Il nome della partita appena conclusa, non quello liberato per la partita nuova. + leaderboard.submit(played_name, score) func _on_score_submitted(info: Dictionary) -> void: var total := int(info.get("total", 0)) diff --git a/scripts/player.gd b/scripts/player.gd index fcfcda4..cc83873 100644 --- a/scripts/player.gd +++ b/scripts/player.gd @@ -34,6 +34,9 @@ var death_overlay: ColorRect ## True quando la partita e' guidata dai controlli on-screen (touch): il mouse ## emulato dai tocchi non deve muovere la visuale ne' sparare al posto del dito. var touch_mode := false +## Vero solo dopo l'inizio della partita: al menu il cursore deve restare libero, +## altrimenti la schermata iniziale non e' cliccabile. +var playing := false const Blood = preload("res://scripts/blood_effects.gd") const Audio = preload("res://scripts/hl_audio.gd") @@ -80,12 +83,21 @@ func _ready() -> void: flash.omni_range = 7 flash.light_energy = 0 camera.add_child(flash) - if DisplayServer.get_name() != "headless" and not OS.has_feature("web"): - Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + # Al menu il cursore deve essere visibile e libero: la cattura avviene solo quando + # la partita inizia (begin_play(), chiamata da main.gd::start_game()). + Input.mouse_mode = Input.MOUSE_MODE_VISIBLE var restart_button: Button var death_note: Label +## Inizio partita: qui, e solo qui, il pointer viene catturato. In modalita' touch il +## cursore serve ai controlli on-screen e il mirino e' emulato dal dito, quindi non +## viene mai catturato. +func begin_play() -> void: + playing = true + if not touch_mode and DisplayServer.get_name() != "headless": + Input.mouse_mode = Input.MOUSE_MODE_CAPTURED + ## 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: @@ -102,11 +114,11 @@ func _input(event: InputEvent) -> void: get_tree().call_deferred("reload_current_scene") func _unhandled_input(event: InputEvent) -> void: - if OS.has_feature("web") and not touch_mode and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started: + if playing and OS.has_feature("web") and not touch_mode and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if event is InputEventMouseMotion and not touch_mode and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: apply_look(event.relative) - if event.is_action_pressed("pause") and not death_started: + if playing and event.is_action_pressed("pause") and not death_started: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED ## Ricarica: tempi invariati, riusata da tastiera (R) e dal pulsante on-screen. diff --git a/scripts/player_profile.gd b/scripts/player_profile.gd index 08e32d8..20002cf 100644 --- a/scripts/player_profile.gd +++ b/scripts/player_profile.gd @@ -3,7 +3,9 @@ extends RefCounted ## 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. +## 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" @@ -12,11 +14,13 @@ 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": DEFAULT_NAME, "player_id": "", "best": 0, "locked": false} + 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 @@ -28,19 +32,24 @@ static func load_profile(path := PATH, legacy_path := "") -> Dictionary: 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", DEFAULT_NAME)) + 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 DEFAULT_NAME + 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)) - data["locked"] = bool(config.get_value("player", "locked", false)) and is_stored_name_usable(raw_name) + 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) - 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. + # 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 @@ -49,22 +58,71 @@ 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 raw_name := text_of(data.get("name")) - var name := sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME + 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", int(data.get("best", 0))) + 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(int(data.get("best", 0)))) - _web_set(KEY_LOCKED, "1" if locked else "0") + _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. @@ -72,11 +130,11 @@ 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'. +## Regola arcade/kiosk: il nome non e' mai bloccato, si richiede a ogni partita. 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 + 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: @@ -186,10 +244,12 @@ static func apply_web_json(data: Dictionary, raw: String) -> Dictionary: 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"] = DEFAULT_NAME + data["name"] = "" data["locked"] = false var player_id := text_of(stored.get("i")) if is_valid_player_id(player_id): @@ -197,8 +257,9 @@ static func apply_web_json(data: Dictionary, raw: String) -> Dictionary: 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 + # 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 diff --git a/scripts/start_screen.gd b/scripts/start_screen.gd index 6063871..8fef053 100644 --- a/scripts/start_screen.gd +++ b/scripts/start_screen.gd @@ -1,7 +1,9 @@ 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. +## Schermata iniziale (modalita' arcade/kiosk): nome del giocatore, pulsante di avvio +## e classifica globale. Il campo del nome viene mostrato VUOTO a ogni partita, quindi +## a ogni avvio, riavvio o game over viene richiesto un nome nuovo: il profilo +## precedente non viene mai dato per scontato. 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") @@ -18,7 +20,8 @@ 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. +## Con la regola arcade il nome non e' mai bloccato: il campo c'e' sempre. La variabile +## resta per i profili scritti dalle versioni precedenti (arrivano gia' "bloccati"). var name_locked := false func _ready() -> void: @@ -29,9 +32,19 @@ func _ready() -> void: if leaderboard != null: leaderboard.fetch() -## Nome con cui si giochera': quello memorizzato se e' bloccato, altrimenti il campo. +## Nome con cui si giochera': solo quello scritto nel campo della partita corrente. func _stored_name() -> String: - return Profile.sanitize_name(str(profile.get("name", Profile.DEFAULT_NAME))) + if name_field != null: + return name_field.text.strip_edges() + return "" + +## Chiamato da main.gd quando si torna al menu dopo il game over: campo liberato, +## cosi' il giocatore della partita successiva deve scrivere il proprio nome. +func reset_for_new_player() -> void: + if name_field != null: + name_field.text = "" + name_field.grab_focus() + _set_status("nome per la nuova partita") func _build_ui() -> void: var touch_mode: bool = player != null and player.touch_mode @@ -53,11 +66,8 @@ func _build_ui() -> void: 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()) + # Regola arcade/kiosk: il nome si chiede sempre, campo vuoto all'apertura. + column.add_child(_build_name_row()) var play := Button.new() play.text = "GIOCA" @@ -98,12 +108,12 @@ func _build_ui() -> void: board_column.add_child(status_label) _show_rows([]) -## Riga del nome, mostrata solo finche' il nome non e' stato scelto. +## Riga del nome: sempre presente, campo vuoto (nessun nome dato per scontato). 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.text = "" name_field.max_length = Profile.NAME_MAX name_field.placeholder_text = "il tuo nome" name_field.virtual_keyboard_enabled = true # necessario su mobile Web @@ -177,7 +187,7 @@ func show_leaderboard_error(reason: String) -> void: ## 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: + if name_field == null: return var current := Profile.js_escape(name_field.text) if OS.has_feature("web") and ClassDB.class_exists("JavaScriptBridge"): @@ -192,7 +202,11 @@ func _ask_name() -> void: ## 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()) + if name_field == null: return - started.emit(Profile.sanitize_name(name_field.text)) + var name := name_field.text.strip_edges() + if name == "": + # Su macchina condivisa un punteggio senza nome sarebbe anonimo: non si parte. + _set_status("inserisci il tuo nome per la nuova partita") + return + started.emit(Profile.sanitize_name(name)) diff --git a/tools/test_player_profile.gd b/tools/test_player_profile.gd index ad512c5..a7c2df8 100644 --- a/tools/test_player_profile.gd +++ b/tools/test_player_profile.gd @@ -1,6 +1,7 @@ extends SceneTree -## Verifica le regole del profilo locale (nome, id stabile, blocco del nome) su un file -## temporaneo, senza toccare il profilo reale del gioco. +## Verifica le regole del profilo locale in modalita' arcade/kiosk (nome mai dato per +## scontato, id derivato dal nome, miglior punteggio per nome) su un file temporaneo, +## senza toccare il profilo reale del gioco. ## Avviare con: --headless --path . --script res://tools/test_player_profile.gd const Profile = preload("res://scripts/player_profile.gd") const TEST_PATH := "user://test_profile_check.cfg" @@ -22,19 +23,25 @@ func check(label: String, condition: bool, detail: String = "") -> void: func run_test() -> void: DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH)) - # 1) file assente: valori predefiniti, id generato, nome non bloccato + # 1) file assente: nessun nome e nessun id dati per scontato var fresh := Profile.load_profile(TEST_PATH) - check("profilo nuovo: nome predefinito e nome non bloccato", - fresh["name"] == Profile.DEFAULT_NAME and fresh["locked"] == false, + check("profilo nuovo: campo del nome libero (nessun nome presunto)", + fresh["name"] == "" and fresh["locked"] == false, "nome=%s bloccato=%s" % [str(fresh["name"]), str(fresh["locked"])]) - check("profilo nuovo: id del giocatore generato (32 esadecimali)", - fresh["player_id"].length() == 32 and Profile.is_valid_player_id(fresh["player_id"]), - "id=%s" % str(fresh["player_id"])) + check("profilo nuovo: nessun id assegnato prima del nome inserito", + str(fresh["player_id"]) == "", "id=%s" % str(fresh["player_id"])) - # 2) l'id resta lo stesso a ogni lettura (i punteggi vecchi restano collegati) - var again := Profile.load_profile(TEST_PATH) - check("l'id del giocatore e' stabile tra una lettura e l'altra", - again["player_id"] == fresh["player_id"], "%s vs %s" % [str(again["player_id"]), str(fresh["player_id"])]) + # 2) identita' derivata dal nome della partita: deterministica e valida per il server + var id_a := Profile.player_id_for_name("Mat") + var id_b := Profile.player_id_for_name("Mat") + check("l'id derivato dal nome e' stabile e accettato dal server", + id_a == id_b and id_a.length() == 16 and Profile.is_valid_player_id(id_a), + "id=%s vs %s" % [id_a, id_b]) + check("nomi diversi = identita' diverse", + Profile.player_id_for_name("Lu") != id_a, "%s vs %s" % [str(Profile.player_id_for_name("Lu")), id_a]) + check("nome vuoto: id di ripiego generato e valido", + Profile.is_valid_player_id(Profile.player_id_for_name("")), + "id=%s" % Profile.player_id_for_name("")) # 3) nome scelto e punteggio migliore salvati e riletti var saved := fresh.duplicate() @@ -45,26 +52,34 @@ func run_test() -> void: check("nome e miglior punteggio sopravvivono alla riscrittura", reloaded["name"] == "Mat" and int(reloaded["best"]) == 300, "nome=%s best=%s" % [str(reloaded["name"]), str(reloaded["best"])]) - check("un nome scelto blocca il nome (non si richiede piu')", - reloaded["locked"] == true, "bloccato=%s" % str(reloaded["locked"])) + check("regola arcade: il nome scelto NON blocca, viene richiesto di nuovo", + reloaded["locked"] == false, "bloccato=%s" % str(reloaded["locked"])) + check("il miglior punteggio e' registrato per nome (record per persona)", + Profile.best_for_name(reloaded, "Mat") == 300 and Profile.best_for_name(reloaded, "Lu") == 0, + "mat=%s lu=%s" % [str(Profile.best_for_name(reloaded, "Mat")), str(Profile.best_for_name(reloaded, "Lu"))]) + check("game over: profilo sbloccato e nome liberato per la partita nuova", + Profile.unlock_name(reloaded)["locked"] == false and str(reloaded["name"]) == "", + "nome=%s bloccato=%s" % [str(reloaded["name"]), str(reloaded["locked"])]) - # 4) il nome scelto e' persistito anche nel file, non solo in memoria + # 4) nome, record per nome e sblocco sono scritti nel file, non solo in memoria var config := ConfigFile.new() var wrote := config.load(TEST_PATH) == OK - check("il blocco del nome e' scritto nel file del profilo", - wrote and bool(config.get_value("player", "locked", false)), - "letto=%s" % str(wrote)) + var stored_bests: Variant = config.get_value("player", "bests", {}) + check("il file conserva il record del nome e il nome non bloccato", + wrote and bool(config.get_value("player", "locked", true)) == false + and stored_bests is Dictionary and int(stored_bests.get("Mat", 0)) == 300, + "letto=%s bests=%s" % [str(wrote), str(stored_bests)]) - # 5) profili vecchi (senza il campo locked) con nome personalizzato risultano bloccati - check("profilo vecchio con nome personalizzato: nome considerato scelto", - Profile.is_name_locked({"name": "Vecchio", "player_id": "x"}) == true) - check("profilo vecchio con nome predefinito: nome non considerato scelto", + # 5) i profili delle versioni precedenti arrivano "bloccati": la regola arcade li ignora + check("profilo vecchio con nome personalizzato: comunque non bloccato", + Profile.is_name_locked({"name": "Vecchio", "player_id": "x", "locked": true}) == false) + check("profilo nuovo: il nome non e' bloccato", Profile.is_name_locked({"name": Profile.DEFAULT_NAME, "player_id": "x"}) == false) # 6) pulizia del nome secondo le regole del server check("il nome viene ripulito da apici, emoji e simboli", Profile.sanitize_name(" Ma't 😀 ") == "Mat b", Profile.sanitize_name(" Ma't 😀 ")) - check("il nome vuoto diventa quello predefinito", + check("il nome vuoto resta vuoto per il campo, predefinito solo lato server", Profile.sanitize_name(" ") == Profile.DEFAULT_NAME, Profile.sanitize_name(" ")) check("il nome e' limitato a 16 caratteri", Profile.sanitize_name("abcdefghijklmnopqrstuvwxyz").length() == Profile.NAME_MAX, @@ -101,13 +116,14 @@ func run_test() -> void: and from_browser["player_id"] == "abcdef0123456789", "nome=%s bloccato=%s" % [str(from_browser["name"]), str(from_browser["locked"])]) Profile.apply_web_json(from_browser, '{"n":"Mat","i":"024a956d532357c426ceb08ff86124e1","b":"800","l":"1"}') - check("i valori del browser vengono applicati al profilo", - from_browser["name"] == "Mat" and int(from_browser["best"]) == 800 and from_browser["locked"] == true + check("i valori del browser vengono applicati, il nome resta libero (regola arcade)", + from_browser["name"] == "Mat" and int(from_browser["best"]) == 800 and from_browser["locked"] == false and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1", - "nome=%s best=%s" % [str(from_browser["name"]), str(from_browser["best"])]) + "nome=%s best=%s bloccato=%s" % [str(from_browser["name"]), str(from_browser["best"]), + str(from_browser["locked"])]) Profile.apply_web_json(from_browser, '{"n":"null","i":"corto","b":"molto","l":"1"}') - check("valori non validi dal browser: nome riparato, id e punteggio intatti", - from_browser["name"] == Profile.DEFAULT_NAME and from_browser["locked"] == false + check("valori non validi dal browser: campo del nome liberato, id e punteggio intatti", + from_browser["name"] == "" and from_browser["locked"] == false and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1" and int(from_browser["best"]) == 800, "nome=%s id=%s best=%s bloccato=%s" % [str(from_browser["name"]), @@ -125,15 +141,15 @@ func run_test() -> void: junk.set_value("player", "locked", true) junk.save(junk_path) var repaired := Profile.load_profile(junk_path) - check("un profilo con nome \"null\" viene riparato (nome predefinito, sbloccato)", - repaired["name"] == Profile.DEFAULT_NAME and repaired["locked"] == false + check("un profilo con nome \"null\" viene riparato (campo libero, sbloccato)", + repaired["name"] == "" and repaired["locked"] == false and int(repaired["best"]) == 500, "nome=%s bloccato=%s best=%s" % [str(repaired["name"]), str(repaired["locked"]), str(repaired["best"])]) var junk_after := ConfigFile.new() junk_after.load(junk_path) check("la riparazione viene riscritta nel file", - str(junk_after.get_value("player", "name", "")) == Profile.DEFAULT_NAME + str(junk_after.get_value("player", "name", "x")) == "" and bool(junk_after.get_value("player", "locked", true)) == false, "%s / %s" % [str(junk_after.get_value("player", "name", "")), str(junk_after.get_value("player", "locked", true))]) @@ -147,9 +163,9 @@ func run_test() -> void: "best": 850, "locked": true}, legacy_path) var old_hash := FileAccess.get_sha256(legacy_path) var migrated := Profile.load_profile(migrated_path, legacy_path) - check("rinomina: nome, id, record e blocco migrati senza perdite", + check("rinomina: nome, id e record migrati senza perdite (nome libero)", migrated.name == "Legacy" and migrated.player_id == "abcdef0123456789" - and migrated.best == 850 and migrated.locked) + and int(migrated.best) == 850 and migrated.locked == false) check("rinomina: il vecchio file rimane byte-identico", FileAccess.get_sha256(legacy_path) == old_hash) check("rinomina: il profilo viene scritto nella nuova posizione", diff --git a/tools/test_start_screen.gd b/tools/test_start_screen.gd index 04498dd..82b793c 100644 --- a/tools/test_start_screen.gd +++ b/tools/test_start_screen.gd @@ -1,8 +1,9 @@ extends SceneTree -## Verifica le regole della schermata iniziale: +## Verifica le regole della schermata iniziale in modalita' arcade/kiosk: ## - la partita parte SOLO dal pulsante GIOCA: click o tocco sullo sfondo, oppure ## Invio/"Vai" nel campo del nome, non devono avviare nulla; -## - il nome scelto una volta non viene piu' richiesto. +## - il campo del nome c'e' sempre, arriva vuoto e senza nome la partita non parte; +## - al ritorno al menu dopo il game over il campo viene liberato per il nuovo giocatore. ## Avviare con: --headless --path . --script res://tools/test_start_screen.gd ## (con display, per gli eventi di puntamento: --rendering-method gl_compatibility) const Profile = preload("res://scripts/player_profile.gd") @@ -28,7 +29,7 @@ func check(label: String, condition: bool, detail: String = "") -> void: func run_test() -> void: await test_world_menu() - await test_locked_name() + await test_name_always_asked() await test_unlocked_name() print("%d controlli superati, %d falliti" % [checks - failures, failures]) quit(1 if failures > 0 else 0) @@ -54,6 +55,20 @@ func test_world_menu() -> void: gioca != null and not gioca.get_global_rect().has_point(outside), "pulsante=%s" % str(gioca)) + ## Bug risoluto: al menu il cursore deve essere visibile e libero, la cattura + ## avviene solo all'inizio della partita (begin_play()). + check("al menu il giocatore non e' ancora in partita (playing=false)", + world.player.playing == false, "playing=%s" % str(world.player.playing)) + check("al menu il cursore e' visibile e libero (schermata cliccabile)", + Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "mode=%s" % str(Input.mouse_mode)) + var esc := InputEventKey.new() + esc.physical_keycode = KEY_ESCAPE + esc.pressed = true + Input.parse_input_event(esc) + await process_frame + check("Esc al menu non cattura il cursore (solo durante la partita)", + Input.mouse_mode == Input.MOUSE_MODE_VISIBLE, "mode=%s" % str(Input.mouse_mode)) + _click(outside) await process_frame await process_frame @@ -81,6 +96,11 @@ func test_world_menu() -> void: await process_frame check("il pulsante GIOCA avvia la partita", start_events == 1 and world.start_screen == null, "avvii=%d schermata=%s" % [start_events, "presente" if world.start_screen != null else "via"]) + check("all'inizio della partita il giocatore e' in partita e il cursore viene catturato", + world.player.playing == true and (DisplayServer.get_name() == "headless" + or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED), + "playing=%s mode=%s display=%s" % [str(world.player.playing), str(Input.mouse_mode), + DisplayServer.get_name()]) # Regressione: senza --api= questa e' una build desktop di prova e non deve poter # inviare punteggi alla classifica pubblica (ci sono finite righe "Test"). check("senza --api= la build di prova non invia punteggi alla classifica pubblica", @@ -93,7 +113,7 @@ func _on_started(player_name: String) -> void: emitted = player_name start_events += 1 -## --- nome bloccato e non ------------------------------------------------------ +## --- nome sempre richiesto (macchina condivisa) ------------------------------ func menu_with(profile: Dictionary) -> CanvasLayer: var layer := CanvasLayer.new() @@ -103,26 +123,34 @@ func menu_with(profile: Dictionary) -> CanvasLayer: layer.started.connect(_on_started) return layer -func test_locked_name() -> void: +func test_name_always_asked() -> void: emitted = "" start_events = 0 + # Un profilo scritto da una versione precedente arriva con il nome gia' scelto e + # bloccato: in modalita' arcade il campo deve esserci comunque, vuoto. var menu := menu_with({"name": "Mat", "player_id": "abcdef0123456789", "best": 300, "locked": true}) await process_frame - var label_text := "" - for label in menu.find_children("*", "Label", true, false): - if str(label.text).begins_with("giocatore:"): - label_text = str(label.text) - check("con nome scelto il campo non viene richiesto", - menu.get("name_field") == null and menu.get("name_locked") == true, - "campo=%s bloccato=%s" % [str(menu.get("name_field")), str(menu.get("name_locked"))]) - check("il nome scelto e' mostrato al giocatore", label_text == "giocatore: Mat", label_text) + var field: LineEdit = menu.get("name_field") + var field_text := "" if field == null else str(field.text) + check("regola arcade: campo del nome presente e vuoto anche con profilo precedente", + field != null and menu.get("name_locked") == false and field_text == "", + "campo=%s testo=%s bloccato=%s" % [str(field), field_text, str(menu.get("name_locked"))]) var gioca: Button = null for button in menu.find_children("*", "Button", true, false): if button.text == "GIOCA": gioca = button if gioca != null: gioca.pressed.emit() - check("la partita usa il nome memorizzato", emitted == "Mat", "nome=%s" % emitted) + check("senza nome scritto la partita non parte (nessun punteggio anonimo)", + start_events == 0 and emitted == "", "avvii=%d nome=%s" % [start_events, emitted]) + if field != null and gioca != null: + field.text = "Neo" + gioca.pressed.emit() + check("con il nome scritto la partita parte con quel nome", emitted == "Neo", "nome=%s" % emitted) + if field != null: + menu.reset_for_new_player() + check("game over: campo liberato per il giocatore successivo", str(field.text) == "", + "testo=%s" % str(field.text)) menu.queue_free() await process_frame