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.
This commit is contained in:
enne2
2026-10-24 12:50:03 +02:00
parent 21a1d5bf79
commit cb14fd5400
9 changed files with 397 additions and 106 deletions
+17 -12
View File
@@ -27,11 +27,11 @@ File coinvolti:
| `services/scores-api/docker-compose.yml` | compose (volume, limiti, log rotation) | | `services/scores-api/docker-compose.yml` | compose (volume, limiti, log rotation) |
| `tools/nginx-zombie-fps-api.conf` | snippet nginx del reverse proxy | | `tools/nginx-zombie-fps-api.conf` | snippet nginx del reverse proxy |
| `scripts/leaderboard.gd` | client HTTP Godot (lettura + invio, retry) | | `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 | | `scripts/start_screen.gd` | menu: nome, pulsante GIOCA, classifica |
| `tools/test_scores_api.py` | 42 controlli sul servizio (funzionalità, validazione, limiti) | | `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_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 | | `tools/test_start_screen.gd` | 10 controlli sulle regole del menu |
## 2. Contratto dell'API ## 2. Contratto dell'API
@@ -148,19 +148,24 @@ sudo nginx -t && sudo systemctl reload nginx # se -t fallisce: ripristinar
| Chiave | Contenuto | | Chiave | Contenuto |
| --- | --- | | --- | --- |
| `zombie_fps_name` | nome scelto | | `zombie_fps_name` | nome dell'ultima partita (solo come chiave del suo record) |
| `zombie_fps_player_id` | id anonimo, 32 caratteri esadecimali | | `zombie_fps_player_id` | id anonimo, 32 caratteri esadecimali, o 16 derivato dal nome |
| `zombie_fps_best` | miglior punteggio locale | | `zombie_fps_best` | miglior punteggio del nome salvato |
| `zombie_fps_locked` | `1` se il nome è già stato scelto | | `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 * 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 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 sincronizzata). Alla lettura i due archivi si fondono: l'id del giocatore resta
stabile, così i punteggi vecchi restano collegati. stabile, così i punteggi vecchi restano collegati.
* **Il nome, una volta scelto, non viene più richiesto**: se `locked` è attivo il menu * **Modalità arcade/kiosk (`ALWAYS_ASK_NAME`)**: il nome non viene mai dato per scontato.
mostra `giocatore: Mat` e il campo di testo non esiste proprio. Il campo del menu è sempre presente e arriva vuoto a ogni avvio, riavvio o game over;
* Un nome diverso da quello predefinito (`Giocatore`) vale come nome scelto, anche per senza nome scritto la partita non parte (nessun punteggio anonimo in classifica).
i profili salvati dalle versioni precedenti. 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 * **Riparazione automatica**: `str(null)` in GDScript produce la stringa `"null"`, e
una versione precedente l'ha salvata come nome. Alla lettura i valori `null`, 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 `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) # profilo e menu (menu: serve un display)
$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64 --headless --path . \ $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 . \ $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`: In produzione si verifica a mano, con `curl`:
+115
View File
@@ -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).
+26
View File
@@ -28,3 +28,29 @@ html/canvas_resize_policy=2
html/focus_canvas_on_start=true html/focus_canvas_on_start=true
html/experimental_virtual_keyboard=true html/experimental_virtual_keyboard=true
progressive_web_app/enabled=false 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
+19 -5
View File
@@ -132,11 +132,17 @@ func _on_start_pressed(player_name: String) -> void:
start_game(player_name, true) start_game(player_name, true)
func start_game(player_name: String, save_profile := true) -> void: 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: if save_profile and not boss_simulation:
Profile.save_profile(profile) Profile.save_profile(profile)
if not player.touch_mode: player.begin_play()
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
if touch != null: if touch != null:
touch.enabled = true touch.enabled = true
if start_screen != null: if start_screen != null:
@@ -155,11 +161,19 @@ func _on_player_died() -> void:
if score_submitted: if score_submitted:
return return
score_submitted = true score_submitted = true
var played_name := str(profile.get("name", ""))
if int(score) > int(profile.get("best", 0)): 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) Profile.save_profile(profile)
if start_screen != null:
start_screen.reset_for_new_player()
player.set_death_note("invio del punteggio in corso...") 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: func _on_score_submitted(info: Dictionary) -> void:
var total := int(info.get("total", 0)) var total := int(info.get("total", 0))
+16 -4
View File
@@ -34,6 +34,9 @@ var death_overlay: ColorRect
## True quando la partita e' guidata dai controlli on-screen (touch): il mouse ## 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. ## emulato dai tocchi non deve muovere la visuale ne' sparare al posto del dito.
var touch_mode := false 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 Blood = preload("res://scripts/blood_effects.gd")
const Audio = preload("res://scripts/hl_audio.gd") const Audio = preload("res://scripts/hl_audio.gd")
@@ -80,12 +83,21 @@ func _ready() -> void:
flash.omni_range = 7 flash.omni_range = 7
flash.light_energy = 0 flash.light_energy = 0
camera.add_child(flash) camera.add_child(flash)
if DisplayServer.get_name() != "headless" and not OS.has_feature("web"): # Al menu il cursore deve essere visibile e libero: la cattura avviene solo quando
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # la partita inizia (begin_play(), chiamata da main.gd::start_game()).
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
var restart_button: Button var restart_button: Button
var death_note: Label 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 ## Riga sotto il messaggio di morte: punteggio inviato, posizione in classifica o
## motivo del fallimento. Richiamata da main.gd a conclusione dell'invio. ## motivo del fallimento. Richiamata da main.gd a conclusione dell'invio.
func set_death_note(text: String) -> void: func set_death_note(text: String) -> void:
@@ -102,11 +114,11 @@ func _input(event: InputEvent) -> void:
get_tree().call_deferred("reload_current_scene") get_tree().call_deferred("reload_current_scene")
func _unhandled_input(event: InputEvent) -> void: 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 Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
if event is InputEventMouseMotion and not touch_mode and 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) 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 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. ## Ricarica: tempi invariati, riusata da tastiera (R) e dal pulsante on-screen.
+82 -21
View File
@@ -3,7 +3,9 @@ extends RefCounted
## Su desktop vive in user://, sul Web si scrive ANCHE in window.localStorage, perche' ## 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 ## e' la memoria del browser che sopravvive davvero alla ricarica della pagina (la
## IndexedDB di Godot puo' non essere sincronizzata). Chiavi: zombie_fps_*. ## 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 PATH := "user://zombie_fps_profile.cfg"
const LEGACY_PROJECT_NAME := "Zombie Room FPS" const LEGACY_PROJECT_NAME := "Zombie Room FPS"
const DEFAULT_NAME := "Giocatore" const DEFAULT_NAME := "Giocatore"
@@ -12,11 +14,13 @@ const KEY_NAME := "zombie_fps_name"
const KEY_PLAYER_ID := "zombie_fps_player_id" const KEY_PLAYER_ID := "zombie_fps_player_id"
const KEY_BEST := "zombie_fps_best" const KEY_BEST := "zombie_fps_best"
const KEY_LOCKED := "zombie_fps_locked" 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 ## 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. ## lascia sempre un id stabile e le chiavi del browser allineate.
static func load_profile(path := PATH, legacy_path := "") -> Dictionary: 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 config := ConfigFile.new()
var loaded := config.load(path) var loaded := config.load(path)
# Renaming application/config/name moves native user://. Import the old file # 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): if legacy_path != "" and legacy_path != ProjectSettings.globalize_path(path):
loaded = config.load(legacy_path) loaded = config.load(legacy_path)
if loaded == OK: 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 # Una versione precedente poteva salvare la stringa "null": in quel caso il
# nome va considerato non scelto e il blocco rimosso (riparazione automatica). # 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["player_id"] = str(config.get_value("player", "player_id", ""))
data["best"] = int(config.get_value("player", "best", 0)) 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: if path == PATH:
_web_merge(data) _web_merge(data)
if data["player_id"] == "": # Regola arcade/kiosk: mai un nome o un id dati per scontati. L'identita' nasce
data["player_id"] = new_player_id() # dal nome inserito, quindi il profilo non assegna mai un id da solo.
# 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) data["locked"] = is_name_locked(data)
save_profile(data, path) save_profile(data, path)
return data 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()) 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: static func save_profile(data: Dictionary, path := PATH) -> void:
var raw_name := text_of(data.get("name")) var name := sanitize_name(text_of(data.get("name"))) if is_stored_name_usable(text_of(data.get("name"))) else ""
var name := sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME
var locked := is_name_locked(data) var locked := is_name_locked(data)
data["name"] = name data["name"] = name
data["locked"] = locked 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() var config := ConfigFile.new()
config.set_value("player", "name", name) config.set_value("player", "name", name)
config.set_value("player", "player_id", str(data.get("player_id", ""))) 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.set_value("player", "locked", locked)
config.save(path) config.save(path)
if path == PATH: if path == PATH:
_web_set(KEY_NAME, name) _web_set(KEY_NAME, name)
_web_set(KEY_PLAYER_ID, str(data.get("player_id", ""))) _web_set(KEY_PLAYER_ID, str(data.get("player_id", "")))
_web_set(KEY_BEST, str(int(data.get("best", 0)))) _web_set(KEY_BEST, str(best))
_web_set(KEY_LOCKED, "1" if locked else "0") _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 ## Vero se il testo e' un nome memorizzato utilizzabile. Le versioni precedenti
## potevano salvare la stringa "null" (`str(null)` in GDScript): va scartata. ## 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() var text := text_of(value).strip_edges()
return text != "" and text != "null" and text != "undefined" 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: static func is_name_locked(data: Dictionary) -> bool:
if bool(data.get("locked", false)): if ALWAYS_ASK_NAME:
return true return false
return sanitize_name(str(data.get("name", DEFAULT_NAME))) != DEFAULT_NAME return bool(data.get("locked", false))
## Id persistente del giocatore: 32 caratteri esadecimali casuali. ## Id persistente del giocatore: 32 caratteri esadecimali casuali.
static func new_player_id() -> String: 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 stored: Dictionary = parsed
var raw_name := text_of(stored.get("n")) var raw_name := text_of(stored.get("n"))
if raw_name != "": 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): if is_stored_name_usable(raw_name):
data["name"] = sanitize_name(raw_name) data["name"] = sanitize_name(raw_name)
else: else:
data["name"] = DEFAULT_NAME data["name"] = ""
data["locked"] = false data["locked"] = false
var player_id := text_of(stored.get("i")) var player_id := text_of(stored.get("i"))
if is_valid_player_id(player_id): 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")) var best := text_of(stored.get("b"))
if best.is_valid_int(): if best.is_valid_int():
data["best"] = int(best) data["best"] = int(best)
if text_of(stored.get("l")) == "1" and is_stored_name_usable(raw_name): # Un valore "locked" arrivato dal browser (profili di versioni precedenti) viene
data["locked"] = true # ignorato: con la regola arcade il nome si richiede comunque.
data["locked"] = is_name_locked(data)
return data return data
## Testo di un valore che puo' arrivare dal browser o da un file: null e numeri ## Testo di un valore che puo' arrivare dal browser o da un file: null e numeri
+30 -16
View File
@@ -1,7 +1,9 @@
extends CanvasLayer extends CanvasLayer
## Schermata iniziale: nome del giocatore, pulsante di avvio e classifica globale ## Schermata iniziale (modalita' arcade/kiosk): nome del giocatore, pulsante di avvio
## (tutte le partite). Il pulsante di avvio e' anche il gesto che sblocca l'audio ## e classifica globale. Il campo del nome viene mostrato VUOTO a ogni partita, quindi
## nel browser, quindi la partita parte solo da qui. ## 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) signal started(player_name: String)
const Profile = preload("res://scripts/player_profile.gd") const Profile = preload("res://scripts/player_profile.gd")
@@ -18,7 +20,8 @@ var rows_box: VBoxContainer
var status_label: Label var status_label: Label
var me_label: Label var me_label: Label
var rows_shown := ROWS_DESKTOP 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 var name_locked := false
func _ready() -> void: func _ready() -> void:
@@ -29,9 +32,19 @@ func _ready() -> void:
if leaderboard != null: if leaderboard != null:
leaderboard.fetch() 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: 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: func _build_ui() -> void:
var touch_mode: bool = player != null and player.touch_mode var touch_mode: bool = player != null and player.touch_mode
@@ -53,10 +66,7 @@ func _build_ui() -> void:
column.add_child(title) column.add_child(title)
column.add_child(_label("MatGames — classifica globale", 15, DIM, HORIZONTAL_ALIGNMENT_CENTER)) column.add_child(_label("MatGames — classifica globale", 15, DIM, HORIZONTAL_ALIGNMENT_CENTER))
if name_locked: # Regola arcade/kiosk: il nome si chiede sempre, campo vuoto all'apertura.
# 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()) column.add_child(_build_name_row())
var play := Button.new() var play := Button.new()
@@ -98,12 +108,12 @@ func _build_ui() -> void:
board_column.add_child(status_label) board_column.add_child(status_label)
_show_rows([]) _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: func _build_name_row() -> HBoxContainer:
var name_row := HBoxContainer.new() var name_row := HBoxContainer.new()
name_row.add_theme_constant_override("separation", 8) name_row.add_theme_constant_override("separation", 8)
name_field = LineEdit.new() 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.max_length = Profile.NAME_MAX
name_field.placeholder_text = "il tuo nome" name_field.placeholder_text = "il tuo nome"
name_field.virtual_keyboard_enabled = true # necessario su mobile Web 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 ## 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. ## si offre il prompt nativo del browser, che ha sempre la tastiera del telefono.
func _ask_name() -> void: func _ask_name() -> void:
if name_locked or name_field == null: if name_field == null:
return return
var current := Profile.js_escape(name_field.text) var current := Profile.js_escape(name_field.text)
if OS.has_feature("web") and ClassDB.class_exists("JavaScriptBridge"): 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 ## altrove (sfondo, campo del nome, tasto Invio della tastiera del telefono) non deve
## avviare nulla. ## avviare nulla.
func _start() -> void: func _start() -> void:
if name_locked or name_field == null: if name_field == null:
started.emit(_stored_name())
return 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))
+49 -33
View File
@@ -1,6 +1,7 @@
extends SceneTree extends SceneTree
## Verifica le regole del profilo locale (nome, id stabile, blocco del nome) su un file ## Verifica le regole del profilo locale in modalita' arcade/kiosk (nome mai dato per
## temporaneo, senza toccare il profilo reale del gioco. ## 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 ## Avviare con: --headless --path . --script res://tools/test_player_profile.gd
const Profile = preload("res://scripts/player_profile.gd") const Profile = preload("res://scripts/player_profile.gd")
const TEST_PATH := "user://test_profile_check.cfg" 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: func run_test() -> void:
DirAccess.remove_absolute(ProjectSettings.globalize_path(TEST_PATH)) 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) var fresh := Profile.load_profile(TEST_PATH)
check("profilo nuovo: nome predefinito e nome non bloccato", check("profilo nuovo: campo del nome libero (nessun nome presunto)",
fresh["name"] == Profile.DEFAULT_NAME and fresh["locked"] == false, fresh["name"] == "" and fresh["locked"] == false,
"nome=%s bloccato=%s" % [str(fresh["name"]), str(fresh["locked"])]) "nome=%s bloccato=%s" % [str(fresh["name"]), str(fresh["locked"])])
check("profilo nuovo: id del giocatore generato (32 esadecimali)", check("profilo nuovo: nessun id assegnato prima del nome inserito",
fresh["player_id"].length() == 32 and Profile.is_valid_player_id(fresh["player_id"]), str(fresh["player_id"]) == "", "id=%s" % str(fresh["player_id"]))
"id=%s" % str(fresh["player_id"]))
# 2) l'id resta lo stesso a ogni lettura (i punteggi vecchi restano collegati) # 2) identita' derivata dal nome della partita: deterministica e valida per il server
var again := Profile.load_profile(TEST_PATH) var id_a := Profile.player_id_for_name("Mat")
check("l'id del giocatore e' stabile tra una lettura e l'altra", var id_b := Profile.player_id_for_name("Mat")
again["player_id"] == fresh["player_id"], "%s vs %s" % [str(again["player_id"]), str(fresh["player_id"])]) 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 # 3) nome scelto e punteggio migliore salvati e riletti
var saved := fresh.duplicate() var saved := fresh.duplicate()
@@ -45,26 +52,34 @@ func run_test() -> void:
check("nome e miglior punteggio sopravvivono alla riscrittura", check("nome e miglior punteggio sopravvivono alla riscrittura",
reloaded["name"] == "Mat" and int(reloaded["best"]) == 300, reloaded["name"] == "Mat" and int(reloaded["best"]) == 300,
"nome=%s best=%s" % [str(reloaded["name"]), str(reloaded["best"])]) "nome=%s best=%s" % [str(reloaded["name"]), str(reloaded["best"])])
check("un nome scelto blocca il nome (non si richiede piu')", check("regola arcade: il nome scelto NON blocca, viene richiesto di nuovo",
reloaded["locked"] == true, "bloccato=%s" % str(reloaded["locked"])) 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 config := ConfigFile.new()
var wrote := config.load(TEST_PATH) == OK var wrote := config.load(TEST_PATH) == OK
check("il blocco del nome e' scritto nel file del profilo", var stored_bests: Variant = config.get_value("player", "bests", {})
wrote and bool(config.get_value("player", "locked", false)), check("il file conserva il record del nome e il nome non bloccato",
"letto=%s" % str(wrote)) 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 # 5) i profili delle versioni precedenti arrivano "bloccati": la regola arcade li ignora
check("profilo vecchio con nome personalizzato: nome considerato scelto", check("profilo vecchio con nome personalizzato: comunque non bloccato",
Profile.is_name_locked({"name": "Vecchio", "player_id": "x"}) == true) Profile.is_name_locked({"name": "Vecchio", "player_id": "x", "locked": true}) == false)
check("profilo vecchio con nome predefinito: nome non considerato scelto", check("profilo nuovo: il nome non e' bloccato",
Profile.is_name_locked({"name": Profile.DEFAULT_NAME, "player_id": "x"}) == false) Profile.is_name_locked({"name": Profile.DEFAULT_NAME, "player_id": "x"}) == false)
# 6) pulizia del nome secondo le regole del server # 6) pulizia del nome secondo le regole del server
check("il nome viene ripulito da apici, emoji e simboli", check("il nome viene ripulito da apici, emoji e simboli",
Profile.sanitize_name(" Ma't 😀 <b> ") == "Mat b", Profile.sanitize_name(" Ma't 😀 <b> ")) Profile.sanitize_name(" Ma't 😀 <b> ") == "Mat b", Profile.sanitize_name(" Ma't 😀 <b> "))
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(" ")) Profile.sanitize_name(" ") == Profile.DEFAULT_NAME, Profile.sanitize_name(" "))
check("il nome e' limitato a 16 caratteri", check("il nome e' limitato a 16 caratteri",
Profile.sanitize_name("abcdefghijklmnopqrstuvwxyz").length() == Profile.NAME_MAX, Profile.sanitize_name("abcdefghijklmnopqrstuvwxyz").length() == Profile.NAME_MAX,
@@ -101,13 +116,14 @@ func run_test() -> void:
and from_browser["player_id"] == "abcdef0123456789", and from_browser["player_id"] == "abcdef0123456789",
"nome=%s bloccato=%s" % [str(from_browser["name"]), str(from_browser["locked"])]) "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"}') Profile.apply_web_json(from_browser, '{"n":"Mat","i":"024a956d532357c426ceb08ff86124e1","b":"800","l":"1"}')
check("i valori del browser vengono applicati al profilo", 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"] == true from_browser["name"] == "Mat" and int(from_browser["best"]) == 800 and from_browser["locked"] == false
and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1", 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"}') 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", check("valori non validi dal browser: campo del nome liberato, id e punteggio intatti",
from_browser["name"] == Profile.DEFAULT_NAME and from_browser["locked"] == false from_browser["name"] == "" and from_browser["locked"] == false
and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1" and from_browser["player_id"] == "024a956d532357c426ceb08ff86124e1"
and int(from_browser["best"]) == 800, and int(from_browser["best"]) == 800,
"nome=%s id=%s best=%s bloccato=%s" % [str(from_browser["name"]), "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.set_value("player", "locked", true)
junk.save(junk_path) junk.save(junk_path)
var repaired := Profile.load_profile(junk_path) var repaired := Profile.load_profile(junk_path)
check("un profilo con nome \"null\" viene riparato (nome predefinito, sbloccato)", check("un profilo con nome \"null\" viene riparato (campo libero, sbloccato)",
repaired["name"] == Profile.DEFAULT_NAME and repaired["locked"] == false repaired["name"] == "" and repaired["locked"] == false
and int(repaired["best"]) == 500, and int(repaired["best"]) == 500,
"nome=%s bloccato=%s best=%s" % [str(repaired["name"]), str(repaired["locked"]), "nome=%s bloccato=%s best=%s" % [str(repaired["name"]), str(repaired["locked"]),
str(repaired["best"])]) str(repaired["best"])])
var junk_after := ConfigFile.new() var junk_after := ConfigFile.new()
junk_after.load(junk_path) junk_after.load(junk_path)
check("la riparazione viene riscritta nel file", 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, and bool(junk_after.get_value("player", "locked", true)) == false,
"%s / %s" % [str(junk_after.get_value("player", "name", "")), "%s / %s" % [str(junk_after.get_value("player", "name", "")),
str(junk_after.get_value("player", "locked", true))]) str(junk_after.get_value("player", "locked", true))])
@@ -147,9 +163,9 @@ func run_test() -> void:
"best": 850, "locked": true}, legacy_path) "best": 850, "locked": true}, legacy_path)
var old_hash := FileAccess.get_sha256(legacy_path) var old_hash := FileAccess.get_sha256(legacy_path)
var migrated := Profile.load_profile(migrated_path, 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" 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", check("rinomina: il vecchio file rimane byte-identico",
FileAccess.get_sha256(legacy_path) == old_hash) FileAccess.get_sha256(legacy_path) == old_hash)
check("rinomina: il profilo viene scritto nella nuova posizione", check("rinomina: il profilo viene scritto nella nuova posizione",
+42 -14
View File
@@ -1,8 +1,9 @@
extends SceneTree 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 ## - la partita parte SOLO dal pulsante GIOCA: click o tocco sullo sfondo, oppure
## Invio/"Vai" nel campo del nome, non devono avviare nulla; ## 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 ## Avviare con: --headless --path . --script res://tools/test_start_screen.gd
## (con display, per gli eventi di puntamento: --rendering-method gl_compatibility) ## (con display, per gli eventi di puntamento: --rendering-method gl_compatibility)
const Profile = preload("res://scripts/player_profile.gd") 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: func run_test() -> void:
await test_world_menu() await test_world_menu()
await test_locked_name() await test_name_always_asked()
await test_unlocked_name() await test_unlocked_name()
print("%d controlli superati, %d falliti" % [checks - failures, failures]) print("%d controlli superati, %d falliti" % [checks - failures, failures])
quit(1 if failures > 0 else 0) 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), gioca != null and not gioca.get_global_rect().has_point(outside),
"pulsante=%s" % str(gioca)) "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) _click(outside)
await process_frame await process_frame
await process_frame await process_frame
@@ -81,6 +96,11 @@ func test_world_menu() -> void:
await process_frame await process_frame
check("il pulsante GIOCA avvia la partita", start_events == 1 and world.start_screen == null, 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"]) "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 # Regressione: senza --api= questa e' una build desktop di prova e non deve poter
# inviare punteggi alla classifica pubblica (ci sono finite righe "Test"). # inviare punteggi alla classifica pubblica (ci sono finite righe "Test").
check("senza --api= la build di prova non invia punteggi alla classifica pubblica", 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 emitted = player_name
start_events += 1 start_events += 1
## --- nome bloccato e non ------------------------------------------------------ ## --- nome sempre richiesto (macchina condivisa) ------------------------------
func menu_with(profile: Dictionary) -> CanvasLayer: func menu_with(profile: Dictionary) -> CanvasLayer:
var layer := CanvasLayer.new() var layer := CanvasLayer.new()
@@ -103,26 +123,34 @@ func menu_with(profile: Dictionary) -> CanvasLayer:
layer.started.connect(_on_started) layer.started.connect(_on_started)
return layer return layer
func test_locked_name() -> void: func test_name_always_asked() -> void:
emitted = "" emitted = ""
start_events = 0 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}) var menu := menu_with({"name": "Mat", "player_id": "abcdef0123456789", "best": 300, "locked": true})
await process_frame await process_frame
var label_text := "" var field: LineEdit = menu.get("name_field")
for label in menu.find_children("*", "Label", true, false): var field_text := "" if field == null else str(field.text)
if str(label.text).begins_with("giocatore:"): check("regola arcade: campo del nome presente e vuoto anche con profilo precedente",
label_text = str(label.text) field != null and menu.get("name_locked") == false and field_text == "",
check("con nome scelto il campo non viene richiesto", "campo=%s testo=%s bloccato=%s" % [str(field), field_text, str(menu.get("name_locked"))])
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 gioca: Button = null var gioca: Button = null
for button in menu.find_children("*", "Button", true, false): for button in menu.find_children("*", "Button", true, false):
if button.text == "GIOCA": if button.text == "GIOCA":
gioca = button gioca = button
if gioca != null: if gioca != null:
gioca.pressed.emit() 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() menu.queue_free()
await process_frame await process_frame