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:
+19
-5
@@ -132,11 +132,17 @@ func _on_start_pressed(player_name: String) -> void:
|
||||
start_game(player_name, true)
|
||||
|
||||
func start_game(player_name: String, save_profile := true) -> void:
|
||||
profile["name"] = player_name
|
||||
# Modalita' arcade/kiosk: l'identita' nasce dal nome scritto in questo momento,
|
||||
# non dal profilo precedente. Stesso nome = stesso id (quindi stessa persona anche
|
||||
# dopo un riavvio); nome diverso = identita' diversa.
|
||||
profile["name"] = Profile.sanitize_name(player_name)
|
||||
profile["player_id"] = Profile.player_id_for_name(profile["name"])
|
||||
profile["best"] = Profile.best_for_name(profile, profile["name"])
|
||||
if leaderboard != null:
|
||||
leaderboard.player_id = profile["player_id"]
|
||||
if save_profile and not boss_simulation:
|
||||
Profile.save_profile(profile)
|
||||
if not player.touch_mode:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
player.begin_play()
|
||||
if touch != null:
|
||||
touch.enabled = true
|
||||
if start_screen != null:
|
||||
@@ -155,11 +161,19 @@ func _on_player_died() -> void:
|
||||
if score_submitted:
|
||||
return
|
||||
score_submitted = true
|
||||
var played_name := str(profile.get("name", ""))
|
||||
if int(score) > int(profile.get("best", 0)):
|
||||
profile["best"] = score
|
||||
Profile.record_best(profile, played_name, int(score))
|
||||
# Game over: profilo sbloccato e nome liberato. Al ritorno al menu (R o pulsante
|
||||
# Ricomincia) il campo e' vuoto e viene richiesto il nome della partita successiva.
|
||||
Profile.unlock_name(profile)
|
||||
if not boss_simulation:
|
||||
Profile.save_profile(profile)
|
||||
if start_screen != null:
|
||||
start_screen.reset_for_new_player()
|
||||
player.set_death_note("invio del punteggio in corso...")
|
||||
leaderboard.submit(profile["name"], score)
|
||||
# Il nome della partita appena conclusa, non quello liberato per la partita nuova.
|
||||
leaderboard.submit(played_name, score)
|
||||
|
||||
func _on_score_submitted(info: Dictionary) -> void:
|
||||
var total := int(info.get("total", 0))
|
||||
|
||||
+16
-4
@@ -34,6 +34,9 @@ var death_overlay: ColorRect
|
||||
## True quando la partita e' guidata dai controlli on-screen (touch): il mouse
|
||||
## emulato dai tocchi non deve muovere la visuale ne' sparare al posto del dito.
|
||||
var touch_mode := false
|
||||
## Vero solo dopo l'inizio della partita: al menu il cursore deve restare libero,
|
||||
## altrimenti la schermata iniziale non e' cliccabile.
|
||||
var playing := false
|
||||
const Blood = preload("res://scripts/blood_effects.gd")
|
||||
const Audio = preload("res://scripts/hl_audio.gd")
|
||||
|
||||
@@ -80,12 +83,21 @@ func _ready() -> void:
|
||||
flash.omni_range = 7
|
||||
flash.light_energy = 0
|
||||
camera.add_child(flash)
|
||||
if DisplayServer.get_name() != "headless" and not OS.has_feature("web"):
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
# Al menu il cursore deve essere visibile e libero: la cattura avviene solo quando
|
||||
# la partita inizia (begin_play(), chiamata da main.gd::start_game()).
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
||||
|
||||
var restart_button: Button
|
||||
var death_note: Label
|
||||
|
||||
## Inizio partita: qui, e solo qui, il pointer viene catturato. In modalita' touch il
|
||||
## cursore serve ai controlli on-screen e il mirino e' emulato dal dito, quindi non
|
||||
## viene mai catturato.
|
||||
func begin_play() -> void:
|
||||
playing = true
|
||||
if not touch_mode and DisplayServer.get_name() != "headless":
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
## Riga sotto il messaggio di morte: punteggio inviato, posizione in classifica o
|
||||
## motivo del fallimento. Richiamata da main.gd a conclusione dell'invio.
|
||||
func set_death_note(text: String) -> void:
|
||||
@@ -102,11 +114,11 @@ func _input(event: InputEvent) -> void:
|
||||
get_tree().call_deferred("reload_current_scene")
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if OS.has_feature("web") and not touch_mode and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started:
|
||||
if playing and OS.has_feature("web") and not touch_mode and event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT and not death_started:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
|
||||
if event is InputEventMouseMotion and not touch_mode and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
|
||||
apply_look(event.relative)
|
||||
if event.is_action_pressed("pause") and not death_started:
|
||||
if playing and event.is_action_pressed("pause") and not death_started:
|
||||
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED
|
||||
|
||||
## Ricarica: tempi invariati, riusata da tastiera (R) e dal pulsante on-screen.
|
||||
|
||||
+82
-21
@@ -3,7 +3,9 @@ extends RefCounted
|
||||
## Su desktop vive in user://, sul Web si scrive ANCHE in window.localStorage, perche'
|
||||
## e' la memoria del browser che sopravvive davvero alla ricarica della pagina (la
|
||||
## IndexedDB di Godot puo' non essere sincronizzata). Chiavi: zombie_fps_*.
|
||||
## Il nome, una volta scelto, resta bloccato: non viene piu' richiesto.
|
||||
## Regola arcade/kiosk (macchina condivisa): il nome non viene mai dato per scontato.
|
||||
## A ogni avvio, riavvio o game over il menu chiede un nome nuovo. Il profilo conserva
|
||||
## il miglior punteggio PER NOME; il nome non e' piu' bloccato.
|
||||
const PATH := "user://zombie_fps_profile.cfg"
|
||||
const LEGACY_PROJECT_NAME := "Zombie Room FPS"
|
||||
const DEFAULT_NAME := "Giocatore"
|
||||
@@ -12,11 +14,13 @@ const KEY_NAME := "zombie_fps_name"
|
||||
const KEY_PLAYER_ID := "zombie_fps_player_id"
|
||||
const KEY_BEST := "zombie_fps_best"
|
||||
const KEY_LOCKED := "zombie_fps_locked"
|
||||
## Regola arcade/kiosk: il nome viene richiesto a ogni partita, mai bloccato.
|
||||
const ALWAYS_ASK_NAME := true
|
||||
|
||||
## Legge il profilo (o i valori predefiniti) e lo riscrive, cosi' il primo avvio
|
||||
## lascia sempre un id stabile e le chiavi del browser allineate.
|
||||
static func load_profile(path := PATH, legacy_path := "") -> Dictionary:
|
||||
var data := {"name": DEFAULT_NAME, "player_id": "", "best": 0, "locked": false}
|
||||
var data := {"name": "", "player_id": "", "best": 0, "bests": {}, "locked": false}
|
||||
var config := ConfigFile.new()
|
||||
var loaded := config.load(path)
|
||||
# Renaming application/config/name moves native user://. Import the old file
|
||||
@@ -28,19 +32,24 @@ static func load_profile(path := PATH, legacy_path := "") -> Dictionary:
|
||||
if legacy_path != "" and legacy_path != ProjectSettings.globalize_path(path):
|
||||
loaded = config.load(legacy_path)
|
||||
if loaded == OK:
|
||||
var raw_name := text_of(config.get_value("player", "name", DEFAULT_NAME))
|
||||
var raw_name := text_of(config.get_value("player", "name", ""))
|
||||
# Una versione precedente poteva salvare la stringa "null": in quel caso il
|
||||
# nome va considerato non scelto e il blocco rimosso (riparazione automatica).
|
||||
data["name"] = sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME
|
||||
data["name"] = sanitize_name(raw_name) if is_stored_name_usable(raw_name) else ""
|
||||
data["player_id"] = str(config.get_value("player", "player_id", ""))
|
||||
data["best"] = int(config.get_value("player", "best", 0))
|
||||
data["locked"] = bool(config.get_value("player", "locked", false)) and is_stored_name_usable(raw_name)
|
||||
var stored_bests: Variant = config.get_value("player", "bests", {})
|
||||
if stored_bests is Dictionary:
|
||||
data["bests"] = stored_bests
|
||||
else:
|
||||
data["bests"] = {}
|
||||
# Il nome memorizzato serve solo come chiave del suo miglior punteggio.
|
||||
if data["name"] != "" and int(data["bests"].get(data["name"], 0)) > int(data["best"]):
|
||||
data["best"] = int(data["bests"].get(data["name"], 0))
|
||||
if path == PATH:
|
||||
_web_merge(data)
|
||||
if data["player_id"] == "":
|
||||
data["player_id"] = new_player_id()
|
||||
# Vale anche per i profili scritti prima di questa regola: un nome diverso da
|
||||
# quello predefinito significa che il giocatore l'ha scelto di proposito.
|
||||
# Regola arcade/kiosk: mai un nome o un id dati per scontati. L'identita' nasce
|
||||
# dal nome inserito, quindi il profilo non assegna mai un id da solo.
|
||||
data["locked"] = is_name_locked(data)
|
||||
save_profile(data, path)
|
||||
return data
|
||||
@@ -49,22 +58,71 @@ static func legacy_profile_path() -> String:
|
||||
return OS.get_user_data_dir().get_base_dir().path_join(LEGACY_PROJECT_NAME).path_join(PATH.get_file())
|
||||
|
||||
static func save_profile(data: Dictionary, path := PATH) -> void:
|
||||
var raw_name := text_of(data.get("name"))
|
||||
var name := sanitize_name(raw_name) if is_stored_name_usable(raw_name) else DEFAULT_NAME
|
||||
var name := sanitize_name(text_of(data.get("name"))) if is_stored_name_usable(text_of(data.get("name"))) else ""
|
||||
var locked := is_name_locked(data)
|
||||
data["name"] = name
|
||||
data["locked"] = locked
|
||||
var bests := _bests(data)
|
||||
var best := int(data.get("best", 0))
|
||||
if name != "":
|
||||
bests[name] = maxi(int(bests.get(name, 0)), best)
|
||||
best = int(bests[name])
|
||||
data["bests"] = bests
|
||||
data["best"] = best
|
||||
var config := ConfigFile.new()
|
||||
config.set_value("player", "name", name)
|
||||
config.set_value("player", "player_id", str(data.get("player_id", "")))
|
||||
config.set_value("player", "best", int(data.get("best", 0)))
|
||||
config.set_value("player", "best", best)
|
||||
config.set_value("player", "bests", bests)
|
||||
config.set_value("player", "locked", locked)
|
||||
config.save(path)
|
||||
if path == PATH:
|
||||
_web_set(KEY_NAME, name)
|
||||
_web_set(KEY_PLAYER_ID, str(data.get("player_id", "")))
|
||||
_web_set(KEY_BEST, str(int(data.get("best", 0))))
|
||||
_web_set(KEY_LOCKED, "1" if locked else "0")
|
||||
_web_set(KEY_BEST, str(best))
|
||||
_web_set(KEY_LOCKED, "0")
|
||||
|
||||
## Mappa nome -> miglior punteggio. E' la sola memoria per persona: il nome stesso
|
||||
## non viene mai usato per decidere se mostrarlo al giocatore.
|
||||
static func _bests(data: Dictionary) -> Dictionary:
|
||||
var stored: Variant = data.get("bests", {})
|
||||
return stored if stored is Dictionary else {}
|
||||
|
||||
## Miglior punteggio del NOME inserito (non del profilo in generale).
|
||||
static func best_for_name(data: Dictionary, name: String) -> int:
|
||||
var key := sanitize_name(name)
|
||||
return int(_bests(data).get(key, 0))
|
||||
|
||||
## Registra un punteggio per il nome della partita appena conclusa.
|
||||
static func record_best(data: Dictionary, name: String, score: int) -> int:
|
||||
var key := sanitize_name(name)
|
||||
var bests := _bests(data)
|
||||
bests[key] = maxi(int(bests.get(key, 0)), score)
|
||||
data["bests"] = bests
|
||||
data["best"] = int(bests[key])
|
||||
return int(data["best"])
|
||||
|
||||
## Game over: profilo sbloccato e nome liberato, cosi' il menu chiede un nome nuovo.
|
||||
static func unlock_name(data: Dictionary) -> Dictionary:
|
||||
data["locked"] = false
|
||||
data["name"] = ""
|
||||
return data
|
||||
|
||||
## Identita' del giocatore derivata dal nome inserito: stessa persona = stesso id
|
||||
## anche dopo un riavvio del kiosk, nomi diversi = identita' diverse. FNV-1a a 32 bit
|
||||
## con due semi, 16 caratteri esadecimali (regole del server: 8-64 caratteri sicuri).
|
||||
static func player_id_for_name(name: String) -> String:
|
||||
var clean := sanitize_name(name)
|
||||
if clean == DEFAULT_NAME or clean == "":
|
||||
return new_player_id()
|
||||
return "%08x%08x" % [fnv1a32(clean, 0x811C9DC5), fnv1a32(clean, 0x01000193)]
|
||||
|
||||
static func fnv1a32(text: String, seed: int) -> int:
|
||||
var hash := seed & 0xFFFFFFFF
|
||||
for i in text.length():
|
||||
hash = (hash ^ int(text.unicode_at(i))) & 0xFFFFFFFF
|
||||
hash = (hash * 16777619) & 0xFFFFFFFF
|
||||
return hash
|
||||
|
||||
## Vero se il testo e' un nome memorizzato utilizzabile. Le versioni precedenti
|
||||
## potevano salvare la stringa "null" (`str(null)` in GDScript): va scartata.
|
||||
@@ -72,11 +130,11 @@ static func is_stored_name_usable(value: Variant) -> bool:
|
||||
var text := text_of(value).strip_edges()
|
||||
return text != "" and text != "null" and text != "undefined"
|
||||
|
||||
## Il nome e' bloccato quando il giocatore l'ha scelto: non si richiede piu'.
|
||||
## Regola arcade/kiosk: il nome non e' mai bloccato, si richiede a ogni partita.
|
||||
static func is_name_locked(data: Dictionary) -> bool:
|
||||
if bool(data.get("locked", false)):
|
||||
return true
|
||||
return sanitize_name(str(data.get("name", DEFAULT_NAME))) != DEFAULT_NAME
|
||||
if ALWAYS_ASK_NAME:
|
||||
return false
|
||||
return bool(data.get("locked", false))
|
||||
|
||||
## Id persistente del giocatore: 32 caratteri esadecimali casuali.
|
||||
static func new_player_id() -> String:
|
||||
@@ -186,10 +244,12 @@ static func apply_web_json(data: Dictionary, raw: String) -> Dictionary:
|
||||
var stored: Dictionary = parsed
|
||||
var raw_name := text_of(stored.get("n"))
|
||||
if raw_name != "":
|
||||
# Solo la chiave del miglior punteggio: il nome letto dal browser non viene
|
||||
# proposto al giocatore e non blocca piu' niente.
|
||||
if is_stored_name_usable(raw_name):
|
||||
data["name"] = sanitize_name(raw_name)
|
||||
else:
|
||||
data["name"] = DEFAULT_NAME
|
||||
data["name"] = ""
|
||||
data["locked"] = false
|
||||
var player_id := text_of(stored.get("i"))
|
||||
if is_valid_player_id(player_id):
|
||||
@@ -197,8 +257,9 @@ static func apply_web_json(data: Dictionary, raw: String) -> Dictionary:
|
||||
var best := text_of(stored.get("b"))
|
||||
if best.is_valid_int():
|
||||
data["best"] = int(best)
|
||||
if text_of(stored.get("l")) == "1" and is_stored_name_usable(raw_name):
|
||||
data["locked"] = true
|
||||
# Un valore "locked" arrivato dal browser (profili di versioni precedenti) viene
|
||||
# ignorato: con la regola arcade il nome si richiede comunque.
|
||||
data["locked"] = is_name_locked(data)
|
||||
return data
|
||||
|
||||
## Testo di un valore che puo' arrivare dal browser o da un file: null e numeri
|
||||
|
||||
+31
-17
@@ -1,7 +1,9 @@
|
||||
extends CanvasLayer
|
||||
## Schermata iniziale: nome del giocatore, pulsante di avvio e classifica globale
|
||||
## (tutte le partite). Il pulsante di avvio e' anche il gesto che sblocca l'audio
|
||||
## nel browser, quindi la partita parte solo da qui.
|
||||
## Schermata iniziale (modalita' arcade/kiosk): nome del giocatore, pulsante di avvio
|
||||
## e classifica globale. Il campo del nome viene mostrato VUOTO a ogni partita, quindi
|
||||
## a ogni avvio, riavvio o game over viene richiesto un nome nuovo: il profilo
|
||||
## precedente non viene mai dato per scontato. Il pulsante di avvio e' anche il gesto
|
||||
## che sblocca l'audio nel browser, quindi la partita parte solo da qui.
|
||||
signal started(player_name: String)
|
||||
|
||||
const Profile = preload("res://scripts/player_profile.gd")
|
||||
@@ -18,7 +20,8 @@ var rows_box: VBoxContainer
|
||||
var status_label: Label
|
||||
var me_label: Label
|
||||
var rows_shown := ROWS_DESKTOP
|
||||
## Vero quando il nome e' gia' stato scelto: niente campo di testo, solo il saluto.
|
||||
## Con la regola arcade il nome non e' mai bloccato: il campo c'e' sempre. La variabile
|
||||
## resta per i profili scritti dalle versioni precedenti (arrivano gia' "bloccati").
|
||||
var name_locked := false
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -29,9 +32,19 @@ func _ready() -> void:
|
||||
if leaderboard != null:
|
||||
leaderboard.fetch()
|
||||
|
||||
## Nome con cui si giochera': quello memorizzato se e' bloccato, altrimenti il campo.
|
||||
## Nome con cui si giochera': solo quello scritto nel campo della partita corrente.
|
||||
func _stored_name() -> String:
|
||||
return Profile.sanitize_name(str(profile.get("name", Profile.DEFAULT_NAME)))
|
||||
if name_field != null:
|
||||
return name_field.text.strip_edges()
|
||||
return ""
|
||||
|
||||
## Chiamato da main.gd quando si torna al menu dopo il game over: campo liberato,
|
||||
## cosi' il giocatore della partita successiva deve scrivere il proprio nome.
|
||||
func reset_for_new_player() -> void:
|
||||
if name_field != null:
|
||||
name_field.text = ""
|
||||
name_field.grab_focus()
|
||||
_set_status("nome per la nuova partita")
|
||||
|
||||
func _build_ui() -> void:
|
||||
var touch_mode: bool = player != null and player.touch_mode
|
||||
@@ -53,11 +66,8 @@ func _build_ui() -> void:
|
||||
column.add_child(title)
|
||||
column.add_child(_label("MatGames — classifica globale", 15, DIM, HORIZONTAL_ALIGNMENT_CENTER))
|
||||
|
||||
if name_locked:
|
||||
# Nome gia' scelto: non si richiede piu' (e nessun campo da toccare per sbaglio).
|
||||
column.add_child(_label("giocatore: %s" % _stored_name(), 22, AMBER, HORIZONTAL_ALIGNMENT_CENTER))
|
||||
else:
|
||||
column.add_child(_build_name_row())
|
||||
# Regola arcade/kiosk: il nome si chiede sempre, campo vuoto all'apertura.
|
||||
column.add_child(_build_name_row())
|
||||
|
||||
var play := Button.new()
|
||||
play.text = "GIOCA"
|
||||
@@ -98,12 +108,12 @@ func _build_ui() -> void:
|
||||
board_column.add_child(status_label)
|
||||
_show_rows([])
|
||||
|
||||
## Riga del nome, mostrata solo finche' il nome non e' stato scelto.
|
||||
## Riga del nome: sempre presente, campo vuoto (nessun nome dato per scontato).
|
||||
func _build_name_row() -> HBoxContainer:
|
||||
var name_row := HBoxContainer.new()
|
||||
name_row.add_theme_constant_override("separation", 8)
|
||||
name_field = LineEdit.new()
|
||||
name_field.text = str(profile.get("name", Profile.DEFAULT_NAME))
|
||||
name_field.text = ""
|
||||
name_field.max_length = Profile.NAME_MAX
|
||||
name_field.placeholder_text = "il tuo nome"
|
||||
name_field.virtual_keyboard_enabled = true # necessario su mobile Web
|
||||
@@ -177,7 +187,7 @@ func show_leaderboard_error(reason: String) -> void:
|
||||
## Su Web la tastiera virtuale di Godot e' sperimentale: in aggiunta al campo di testo
|
||||
## si offre il prompt nativo del browser, che ha sempre la tastiera del telefono.
|
||||
func _ask_name() -> void:
|
||||
if name_locked or name_field == null:
|
||||
if name_field == null:
|
||||
return
|
||||
var current := Profile.js_escape(name_field.text)
|
||||
if OS.has_feature("web") and ClassDB.class_exists("JavaScriptBridge"):
|
||||
@@ -192,7 +202,11 @@ func _ask_name() -> void:
|
||||
## altrove (sfondo, campo del nome, tasto Invio della tastiera del telefono) non deve
|
||||
## avviare nulla.
|
||||
func _start() -> void:
|
||||
if name_locked or name_field == null:
|
||||
started.emit(_stored_name())
|
||||
if name_field == null:
|
||||
return
|
||||
started.emit(Profile.sanitize_name(name_field.text))
|
||||
var name := name_field.text.strip_edges()
|
||||
if name == "":
|
||||
# Su macchina condivisa un punteggio senza nome sarebbe anonimo: non si parte.
|
||||
_set_status("inserisci il tuo nome per la nuova partita")
|
||||
return
|
||||
started.emit(Profile.sanitize_name(name))
|
||||
|
||||
Reference in New Issue
Block a user