Files
zombie-fps/scripts/main.gd
T
enne2 ae32ad9615 Classifica globale: API dei punteggi, schermata iniziale e nome memorizzato
- services/scores-api: microservizio (solo stdlib) con SQLite WAL, idempotenza per
  submission_id, limiti di frequenza e validazione; immagine Docker non-root.
- nginx: reverse proxy su /api/zombie-fps/ (stessa origine del gioco, niente CORS).
- scripts/leaderboard.gd: client HTTP con retry idempotente; su desktop la classifica
  resta disattivata senza --api=, cosi' i test non sporcano la classifica pubblica.
- scripts/player_profile.gd: nome, id anonimo e miglior punteggio in user:// e in
  window.localStorage; il nome scelto non viene piu' richiesto; riparazione dei
  profili con la stringa "null".
- scripts/start_screen.gd: menu con nome, GIOCA e classifica; la partita parte solo
  dal pulsante (via la rete di sicurezza sull'input, Invio nel campo non avvia).
- scripts/main.gd, player.gd: avvio esplicito, segnale died, nota dell'invio.
- tools: test_scores_api.py (42), test_leaderboard (14), test_player_profile (21),
  test_start_screen (10); suite esistenti adeguate all'avvio manuale.
- docs/HIGHSCORES.md + docs/WEB-DEPLOY.md aggiornati.
2026-10-13 23:26:00 +02:00

269 lines
9.2 KiB
GDScript

extends Node3D
const PlayerScript = preload("res://scripts/player.gd")
const ZombieScript = preload("res://scripts/zombie.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
func _ready() -> void:
smoke = "--smoke" in OS.get_cmdline_user_args()
profile = Profile.load_profile()
room = Node3D.new()
room.set_script(RoomScript)
add_child(room)
player = CharacterBody3D.new()
player.set_script(PlayerScript)
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)
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:
profile["name"] = player_name
if save_profile:
Profile.save_profile(profile)
if not player.touch_mode:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
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 score_submitted:
return
score_submitted = true
if int(score) > int(profile.get("best", 0)):
profile["best"] = score
Profile.save_profile(profile)
player.set_death_note("invio del punteggio in corso...")
leaderboard.submit(profile["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)
func zombies_for_wave(number: int) -> int:
return 4 + number * 2
func _next_wave() -> void:
wave_pending = false
if player.hp <= 0 or spawning:
return
wave += 1
spawning = true
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
get_tree().create_timer(3).timeout.connect(_next_wave)