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.
425 lines
15 KiB
GDScript
425 lines
15 KiB
GDScript
extends Node3D
|
|
const PlayerScript = preload("res://scripts/player.gd")
|
|
const ZombieScript = preload("res://scripts/zombie.gd")
|
|
const BossScript = preload("res://scripts/alien_grunt.gd")
|
|
const BossTeleport = preload("res://scripts/boss_teleport.gd")
|
|
const RoomScript = preload("res://scripts/room.gd")
|
|
const LeaderboardScript = preload("res://scripts/leaderboard.gd")
|
|
const StartScreenScript = preload("res://scripts/start_screen.gd")
|
|
const Profile = preload("res://scripts/player_profile.gd")
|
|
const MUSIC_PATH := "res://assets/audio/music/zombie_industrial_loop.ogg"
|
|
const MUSIC_VOLUME_DB := -11.0
|
|
const TouchControls = preload("res://scripts/touch_controls.gd")
|
|
var player: CharacterBody3D
|
|
var room: Node3D
|
|
var music: AudioStreamPlayer
|
|
var music_started := false
|
|
var touch: Node
|
|
var hud: CanvasLayer
|
|
var navigation: Node
|
|
var score := 0
|
|
var wave := 0
|
|
var alive := 0
|
|
var spawning := false
|
|
var wave_pending := false
|
|
var smoke := false
|
|
var last_window := -1
|
|
var leaderboard: Node
|
|
var start_screen: CanvasLayer
|
|
var profile := {}
|
|
var score_submitted := false
|
|
var boss: CharacterBody3D
|
|
var boss_simulation := false
|
|
var simulation_wave := 0
|
|
var simulation_god := false
|
|
var simulation_finished := false
|
|
var simulation_restarting := false
|
|
var simulation_label: Label
|
|
var simulation_retry: Button
|
|
|
|
func _ready() -> void:
|
|
smoke = "--smoke" in OS.get_cmdline_user_args()
|
|
if not _configure_simulation():
|
|
return
|
|
# A simulation must never read/write the real player's profile or public scores.
|
|
profile = {"name": "SIMULAZIONE", "player_id": "boss-simulation", "best": 0} \
|
|
if boss_simulation else Profile.load_profile()
|
|
room = Node3D.new()
|
|
room.set_script(RoomScript)
|
|
add_child(room)
|
|
player = CharacterBody3D.new()
|
|
player.set_script(PlayerScript)
|
|
player.invulnerable = boss_simulation and simulation_god
|
|
player.position = Vector3(0, 0.1, 0)
|
|
add_child(player)
|
|
var envnode := WorldEnvironment.new()
|
|
var env := Environment.new()
|
|
env.background_mode = Environment.BG_SKY
|
|
var sky_material := PanoramaSkyMaterial.new()
|
|
sky_material.panorama = load("res://assets/textures/sky/space_panorama.png")
|
|
env.sky = Sky.new()
|
|
env.sky.sky_material = sky_material
|
|
# Solo sfondo: illuminazione e riflessi non dipendono dal cielo scuro.
|
|
env.reflected_light_source = Environment.REFLECTION_SOURCE_DISABLED
|
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
|
env.ambient_light_color = Color(0.28, 0.36, 0.48)
|
|
env.ambient_light_energy = 0.12
|
|
# Glow compensates for the missing volumetric fog (unsupported in Compatibility):
|
|
# a high threshold so only the fire core blooms, keeping the amber HUD readable.
|
|
env.glow_enabled = true
|
|
env.glow_intensity = 0.45
|
|
env.glow_strength = 1.0
|
|
env.glow_bloom = 0.05
|
|
env.glow_mix = 0.05
|
|
env.glow_hdr_threshold = 1.3
|
|
env.glow_blend_mode = Environment.GLOW_BLEND_MODE_ADDITIVE
|
|
for level in [2, 3, 4]:
|
|
env.set("glow_levels/%d" % level, 1.0 if level == 3 else 0.4)
|
|
envnode.environment = env
|
|
add_child(envnode)
|
|
var office := Node3D.new()
|
|
office.set_script(preload("res://scripts/office_atmosphere.gd"))
|
|
add_child(office)
|
|
navigation = Node.new()
|
|
navigation.set_script(preload("res://scripts/room_navigation.gd"))
|
|
add_child(navigation)
|
|
var canvas := CanvasLayer.new()
|
|
add_child(canvas)
|
|
hud = CanvasLayer.new()
|
|
hud.set_script(preload("res://scripts/game_hud.gd"))
|
|
add_child(hud)
|
|
var cross := Label.new()
|
|
cross.text = "+"
|
|
cross.add_theme_font_size_override("font_size", 30)
|
|
canvas.add_child(cross)
|
|
cross.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
|
|
_setup_music()
|
|
_setup_touch()
|
|
leaderboard = Node.new()
|
|
leaderboard.set_script(LeaderboardScript)
|
|
leaderboard.player_id = profile["player_id"]
|
|
add_child(leaderboard)
|
|
leaderboard.leaderboard_ready.connect(_on_leaderboard_ready)
|
|
leaderboard.leaderboard_failed.connect(_on_leaderboard_failed)
|
|
leaderboard.score_submitted.connect(_on_score_submitted)
|
|
leaderboard.submit_failed.connect(_on_submit_failed)
|
|
player.died.connect(_on_player_died)
|
|
if boss_simulation:
|
|
leaderboard.disabled = true
|
|
leaderboard.base_url = ""
|
|
_setup_simulation_ui()
|
|
call_deferred("_start_simulation")
|
|
return
|
|
start_screen = CanvasLayer.new()
|
|
start_screen.set_script(StartScreenScript)
|
|
start_screen.player = player
|
|
start_screen.leaderboard = leaderboard
|
|
start_screen.profile = profile
|
|
add_child(start_screen)
|
|
start_screen.started.connect(_on_start_pressed)
|
|
if OS.has_feature("web"):
|
|
if touch != null:
|
|
touch.enabled = false # i tocchi servono alla schermata iniziale
|
|
if smoke:
|
|
get_tree().create_timer(12).timeout.connect(func():
|
|
print("SMOKE: wave=", wave, " alive=", alive, " player_hp=", player.hp)
|
|
get_tree().quit())
|
|
|
|
## Il pulsante GIOCA della schermata iniziale delega qui: sblocca l'audio (policy dei
|
|
## browser: serve un gesto dell'utente) e avvia la prima ondata. I test headless
|
|
## chiamano la stessa funzione con save_profile=false per non toccare il profilo reale.
|
|
func _on_start_pressed(player_name: String) -> void:
|
|
start_game(player_name, true)
|
|
|
|
func start_game(player_name: String, save_profile := true) -> void:
|
|
# 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)
|
|
player.begin_play()
|
|
if touch != null:
|
|
touch.enabled = true
|
|
if start_screen != null:
|
|
start_screen.queue_free()
|
|
start_screen = null
|
|
_start_music()
|
|
_next_wave()
|
|
|
|
## Alla morte si invia UNA volta il punteggio finale con nome e id del giocatore.
|
|
func _on_player_died() -> void:
|
|
if boss_simulation:
|
|
simulation_finished = false
|
|
simulation_retry.hide()
|
|
player.set_death_note("SIMULAZIONE — R / RICOMINCIA per riprovare; nessun punteggio inviato")
|
|
return
|
|
if score_submitted:
|
|
return
|
|
score_submitted = true
|
|
var played_name := str(profile.get("name", ""))
|
|
if int(score) > int(profile.get("best", 0)):
|
|
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...")
|
|
# 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))
|
|
player.set_death_note("PUNTEGGIO %d — %d° su %d partite" % [score, int(info.get("rank", 0)), total])
|
|
leaderboard.fetch()
|
|
|
|
func _on_submit_failed(reason: String) -> void:
|
|
player.set_death_note("punteggio non inviato (%s)" % reason)
|
|
|
|
func _on_leaderboard_ready(rows: Array, total: int, me: Dictionary) -> void:
|
|
if start_screen != null:
|
|
start_screen.show_leaderboard(rows, total, me)
|
|
|
|
func _on_leaderboard_failed(reason: String) -> void:
|
|
if start_screen != null:
|
|
start_screen.show_leaderboard_error(reason)
|
|
|
|
## Nota: qui non c'e' piu' nessuna rete di sicurezza sull'input. La partita parte
|
|
## solo dal pulsante GIOCA della schermata iniziale (segnale `started`): un click o un
|
|
## tocco sullo sfondo del menu non deve avviare nulla.
|
|
|
|
## Controlli on-screen: attivi su browser mobile o touchscreen, forzabili con
|
|
## --touch / --no-touch sulla riga di comando e con ?touch=1 / ?touch=0 sul Web.
|
|
func _setup_touch() -> void:
|
|
var forced := TouchControls.force_mode()
|
|
if not (forced > 0 or (forced == 0 and TouchControls.should_auto_enable())):
|
|
return
|
|
player.touch_mode = true
|
|
# Su un dispositivo touch il pointer lock non serve e su desktop forzato
|
|
# ruberebbe il cursore: nessuna cattura del mouse in modalita' touch.
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
touch = CanvasLayer.new()
|
|
touch.set_script(TouchControls)
|
|
touch.player = player
|
|
# Su desktop forzato con --touch i drag del mouse valgono come tocchi,
|
|
# cosi' la UI e' provabile anche senza dispositivo touch.
|
|
touch.mouse_fallback = forced > 0 and not DisplayServer.is_touchscreen_available()
|
|
add_child(touch)
|
|
|
|
## Colonna sonora: industrial metal strumentale su loop senza giunzione udibile.
|
|
func _setup_music() -> void:
|
|
music = AudioStreamPlayer.new()
|
|
music.name = "Music"
|
|
music.stream = load(MUSIC_PATH)
|
|
music.volume_db = MUSIC_VOLUME_DB
|
|
if music.stream is AudioStreamOggVorbis:
|
|
music.stream.loop = true
|
|
add_child(music)
|
|
music.add_to_group("music")
|
|
# Rete di sicurezza: se il flag di loop mancasse, il brano riparte a fine coda.
|
|
music.finished.connect(_restart_music)
|
|
|
|
func _start_music() -> void:
|
|
if music_started or music == null:
|
|
return
|
|
music_started = true
|
|
music.play()
|
|
|
|
func _restart_music() -> void:
|
|
if music_started and is_instance_valid(music):
|
|
music.play()
|
|
|
|
func _process(_dt: float) -> void:
|
|
if hud:
|
|
hud.update_status(player, wave, score)
|
|
hud.update_boss(boss)
|
|
|
|
static func is_boss_wave(number: int) -> bool:
|
|
return number > 0 and number % 10 == 0
|
|
|
|
func zombies_for_wave(number: int) -> int:
|
|
return 0 if is_boss_wave(number) else 4 + number * 2
|
|
|
|
func _next_wave() -> void:
|
|
wave_pending = false
|
|
if player.hp <= 0 or spawning:
|
|
return
|
|
wave += 1
|
|
spawning = true
|
|
if is_boss_wave(wave):
|
|
_spawn_boss()
|
|
spawning = false
|
|
_check_wave()
|
|
return
|
|
boss = null
|
|
for i in zombies_for_wave(wave):
|
|
if player.hp <= 0:
|
|
break
|
|
_spawn()
|
|
await get_tree().create_timer(0.8).timeout
|
|
spawning = false
|
|
_check_wave()
|
|
|
|
## Sceglie una finestra a caso fra quelle disponibili, evitando di ripetere
|
|
## l'ultima usata: gli zombie entrano da aperture diverse invece che sempre dalle
|
|
## stesse quattro posizioni fisse.
|
|
func _pick_window() -> int:
|
|
var count: int = room.windows.size()
|
|
if count == 0:
|
|
return -1
|
|
if count == 1:
|
|
return 0
|
|
var index: int = randi() % count
|
|
if index == last_window:
|
|
index = (last_window + 1 + randi() % (count - 1)) % count
|
|
return index
|
|
|
|
func _spawn() -> void:
|
|
var chosen := _pick_window()
|
|
if chosen < 0:
|
|
return
|
|
last_window = chosen
|
|
var window: Dictionary = room.windows[chosen]
|
|
var slots: Array = window.slots
|
|
var start: Vector3 = slots[randi() % slots.size()] # slot casuale nella finestra
|
|
var face: Vector3 = window.center
|
|
var inward: Vector3 = window.normal
|
|
var zombie := CharacterBody3D.new()
|
|
zombie.set_script(ZombieScript)
|
|
zombie.player = player
|
|
zombie.navigation = navigation
|
|
zombie.position = start
|
|
add_child(zombie)
|
|
alive += 1
|
|
zombie.killed.connect(func(headshot: bool):
|
|
score += 150 if headshot else 100
|
|
alive -= 1
|
|
_check_wave())
|
|
var tween := zombie.create_tween()
|
|
zombie.entry_tween = tween
|
|
tween.tween_property(zombie, "position", face + Vector3(0, 1.55, 0), 1.2)
|
|
tween.tween_callback(func():
|
|
var glass = window.glass
|
|
if is_instance_valid(glass):
|
|
room.shatter_glass(glass)) # solo la finestra usata
|
|
tween.tween_property(zombie, "position", face + inward * 1.4 + Vector3(0, 0.1, 0), 0.8)
|
|
tween.tween_callback(func(): zombie.entering = false)
|
|
|
|
func _check_wave() -> void:
|
|
if alive == 0 and not spawning and not wave_pending and player.hp > 0:
|
|
wave_pending = true
|
|
if boss_simulation:
|
|
simulation_finished = true
|
|
simulation_label.text = "BOSS SCONFITTO — ondata %d | R o RIPROVA per ricominciare" % wave
|
|
simulation_retry.show()
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
return
|
|
get_tree().create_timer(3).timeout.connect(_next_wave)
|
|
|
|
func _boss_spawn_position() -> Vector3:
|
|
var candidates := [Vector3(0, 0.05, -6), Vector3(6, 0.05, 0),
|
|
Vector3(0, 0.05, 6), Vector3(-6, 0.05, 0), Vector3(6, 0.05, 6),
|
|
Vector3(-6, 0.05, -6), Vector3(6, 0.05, -6), Vector3(-6, 0.05, 6)]
|
|
candidates.shuffle()
|
|
var capsule := CapsuleShape3D.new()
|
|
capsule.radius = 0.5
|
|
capsule.height = 2.5
|
|
for candidate: Vector3 in candidates:
|
|
if candidate.distance_to(player.global_position) < 5:
|
|
continue
|
|
var query := PhysicsShapeQueryParameters3D.new()
|
|
query.shape = capsule
|
|
query.transform = Transform3D(Basis.IDENTITY, candidate + Vector3.UP * 1.25)
|
|
query.collision_mask = 1 | 2 | 4
|
|
if get_world_3d().direct_space_state.intersect_shape(query, 1).is_empty() \
|
|
and navigation.route(candidate, player.global_position).size() > 1:
|
|
return candidate
|
|
# Fixed office layout has clear candidates; never silently spawn inside a prop.
|
|
return Vector3.INF
|
|
|
|
func _spawn_boss() -> void:
|
|
var at := _boss_spawn_position()
|
|
if not at.is_finite():
|
|
push_error("Nessun punto libero per il boss")
|
|
return
|
|
boss = BossScript.new()
|
|
boss.configure(wave)
|
|
boss.player = player
|
|
boss.navigation = navigation
|
|
boss.position = at
|
|
var instance := boss
|
|
alive += 1
|
|
boss.killed.connect(func(_headshot: bool):
|
|
score += instance.reward
|
|
alive -= 1
|
|
_check_wave())
|
|
add_child(boss)
|
|
var portal := BossTeleport.new()
|
|
portal.position = at
|
|
portal.materialized.connect(func():
|
|
if is_instance_valid(instance):
|
|
instance.materialize())
|
|
add_child(portal)
|
|
|
|
func _configure_simulation() -> bool:
|
|
if OS.has_feature("web"):
|
|
return true
|
|
for argument in OS.get_cmdline_user_args():
|
|
if argument.begins_with("--boss-wave="):
|
|
var value := argument.substr(12)
|
|
if not value.is_valid_int() or not is_boss_wave(int(value)):
|
|
push_error("--boss-wave richiede un multiplo positivo di 10: 10, 20, 30...")
|
|
get_tree().quit(2)
|
|
return false
|
|
simulation_wave = int(value)
|
|
boss_simulation = true
|
|
simulation_god = "--boss-god" in OS.get_cmdline_user_args()
|
|
return true
|
|
|
|
func _start_simulation() -> void:
|
|
# Wait for static physics shapes/navigation to be registered before choosing a portal.
|
|
await get_tree().physics_frame
|
|
wave = simulation_wave - 1
|
|
start_game("SIMULAZIONE", false)
|
|
print("BOSS SIMULATION: wave=", simulation_wave, " god=", simulation_god,
|
|
" | public scores/profile writes disabled")
|
|
|
|
func _setup_simulation_ui() -> void:
|
|
var canvas := CanvasLayer.new()
|
|
canvas.layer = 6
|
|
add_child(canvas)
|
|
simulation_label = Label.new()
|
|
simulation_label.text = "SIMULAZIONE BOSS — ONDATA %d%s | nessun punteggio online" % [
|
|
simulation_wave, " | INVULNERABILE" if simulation_god else ""]
|
|
simulation_label.position = Vector2(18, 125)
|
|
simulation_label.add_theme_font_size_override("font_size", 18)
|
|
simulation_label.add_theme_color_override("font_color", Color(0.2, 1, 0.7))
|
|
simulation_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
|
canvas.add_child(simulation_label)
|
|
simulation_retry = Button.new()
|
|
simulation_retry.text = "RIPROVA IL BOSS"
|
|
simulation_retry.position = Vector2(510, 520)
|
|
simulation_retry.size = Vector2(260, 60)
|
|
simulation_retry.pressed.connect(_restart_simulation)
|
|
simulation_retry.hide()
|
|
canvas.add_child(simulation_retry)
|
|
|
|
func _input(event: InputEvent) -> void:
|
|
# Only the completed native simulation has this shortcut. No menu click-to-start.
|
|
if boss_simulation and simulation_finished and event.is_action_pressed("reload"):
|
|
get_viewport().set_input_as_handled()
|
|
_restart_simulation()
|
|
|
|
func _restart_simulation() -> void:
|
|
if simulation_restarting:
|
|
return
|
|
simulation_restarting = true
|
|
get_tree().reload_current_scene()
|