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:
+17
-12
@@ -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`:
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
+31
-17
@@ -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,11 +66,8 @@ 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(_build_name_row())
|
||||||
column.add_child(_label("giocatore: %s" % _stored_name(), 22, AMBER, HORIZONTAL_ALIGNMENT_CENTER))
|
|
||||||
else:
|
|
||||||
column.add_child(_build_name_row())
|
|
||||||
|
|
||||||
var play := Button.new()
|
var play := Button.new()
|
||||||
play.text = "GIOCA"
|
play.text = "GIOCA"
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user