Files
zombie-fps/scripts/player.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

291 lines
11 KiB
GDScript

extends CharacterBody3D
signal died
var camera: Camera3D
var damage_pivot: Node3D
var damage_feedback: CanvasLayer
const DamageFeedback = preload("res://scripts/damage_feedback.gd")
const MAX_HP := 100.0
var hp := MAX_HP
var _regen_elapsed := 0.0
## Discreto: +1 HP per secondo di gioco, mai oltre il massimo o dopo morte.
func _regenerate_health(dt: float) -> void:
if hp <= 0 or death_started or hp >= MAX_HP:
_regen_elapsed = 0.0
return
_regen_elapsed += dt
while _regen_elapsed >= 1.0:
_regen_elapsed -= 1.0
hp = minf(MAX_HP, hp + 1.0)
var ammo := 30
var cooldown := 0.0
var grenade_cooldown := 0.0
const GRENADE_COOLDOWN := 15.0
const Grenade = preload("res://scripts/grenade.gd")
var reloading := false
var gun: Node3D
var flash: OmniLight3D
var weapon_anim: AnimationPlayer
var step_timer := 0.0
var death_started := false
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
const Blood = preload("res://scripts/blood_effects.gd")
const Audio = preload("res://scripts/hl_audio.gd")
func _ready() -> void:
collision_layer = 2
collision_mask = 1 | 4
var cs := CollisionShape3D.new()
var cap := CapsuleShape3D.new()
cap.height = 1.8
cap.radius = 0.35
cs.shape = cap
cs.position.y = 0.9
add_child(cs)
camera = Camera3D.new()
camera.position.y = 1.65
camera.current = true
camera.fov = 80
damage_pivot = Node3D.new()
damage_pivot.name = "DamagePivot"
add_child(damage_pivot)
damage_pivot.add_child(camera)
damage_feedback = CanvasLayer.new()
damage_feedback.set_script(DamageFeedback)
damage_feedback.pivot = damage_pivot
add_child(damage_feedback)
gun = Node3D.new()
camera.add_child(gun)
var viewmodel: Node3D = load("res://assets/weapons/v_9mmar.scn").instantiate()
gun.add_child(viewmodel)
for area in viewmodel.find_children("*", "Area3D", true, false):
area.collision_layer = 0
area.collision_mask = 0
for mesh in viewmodel.find_children("*", "MeshInstance3D", true, false):
mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
mesh.layers = 2
weapon_anim = viewmodel.get_node("AnimationPlayer")
weapon_anim.animation_finished.connect(func(_name: StringName):
if not reloading and not death_started:
weapon_anim.play("idle1"))
weapon_anim.play("deploy")
flash = OmniLight3D.new()
flash.position = Vector3(0.28, -0.2, -1.1)
flash.light_color = Color(1, 0.7, 0.25)
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
var restart_button: Button
var death_note: Label
## 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:
if is_instance_valid(death_note):
death_note.text = text
death_note.modulate.a = 1.0
func _input(event: InputEvent) -> void:
# Touch nativo: funziona anche con emulate_mouse_from_touch disattivato.
if event is InputEventScreenTouch and event.pressed and is_instance_valid(restart_button):
if not restart_button.disabled and restart_button.get_global_rect().has_point(event.position):
restart_button.disabled = true
get_viewport().set_input_as_handled()
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:
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:
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.
func start_reload() -> void:
if reloading or death_started or hp <= 0 or ammo >= 30:
return
reloading = true
weapon_anim.play("reload")
Audio.play(self, "items/cliprelease1.wav")
get_tree().create_timer(0.8).timeout.connect(func():
if not death_started:
Audio.play(self, "items/clipinsert1.wav"))
get_tree().create_timer(1.57).timeout.connect(func():
if not death_started:
ammo = 30
reloading = false
weapon_anim.play("idle1"))
## Azioni a pressione singola lette a polling: stesso percorso per tastiera,
## joypad e pulsanti on-screen. Gli InputEventAction sintetici, generati dentro
## il dispatch di un altro evento (come fa un tocco), non arrivano affidabilmente
## a _unhandled_input; lo stato delle azioni invece si'.
func _handle_actions() -> void:
if Input.is_action_just_pressed("reload") and not reloading and ammo < 30:
start_reload()
if Input.is_action_just_pressed("grenade") and not death_started and (touch_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED):
throw_grenade()
## Rotazione della visuale. Unico punto in cui vivono sensibilita' e limiti di
## inclinazione, condiviso da mouse e controlli touch.
func apply_look(relative: Vector2) -> void:
if hp <= 0 or death_started:
return
rotate_y(-relative.x * 0.0025)
camera.rotation.x = clampf(camera.rotation.x - relative.y * 0.0025, -1.4, 1.4)
func _physics_process(dt: float) -> void:
cooldown -= dt
grenade_cooldown = maxf(0, grenade_cooldown - dt)
flash.light_energy = maxf(0, flash.light_energy - dt * 60)
gun.position.z = lerpf(gun.position.z, 0, dt * 15)
if hp <= 0:
if Input.is_action_just_pressed("reload"):
get_tree().reload_current_scene() # R o pulsante Ricarica a partita finita
return
_regenerate_health(dt)
if ammo <= 0 and not reloading:
start_reload()
_handle_actions()
# Deadzone esplicita piccola (0.2): con la 0.5 delle azioni la corsa utile del
# joystick touch si dimezzerebbe, mentre per tastiera/joypad nulla cambia.
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back", 0.2)
var direction := transform.basis * Vector3(input.x, 0, input.y)
velocity.x = direction.x * 5
velocity.z = direction.z * 5
velocity.y -= 20 * dt
if is_on_floor() and Input.is_action_just_pressed("jump"):
velocity.y = 7
move_and_slide()
step_timer -= dt
if is_on_floor() and direction.length() > 0.1 and step_timer <= 0:
step_timer = 0.42
Audio.play(self, "player/pl_metal%d.wav" % randi_range(1, 4), false, -18)
if Input.is_action_just_pressed("shoot") and ammo <= 0 and not reloading:
Audio.play(self, "weapons/dryfire1.wav")
if Input.is_action_pressed("shoot") and cooldown <= 0 and ammo > 0 and not reloading and (touch_mode or Input.mouse_mode == Input.MOUSE_MODE_CAPTURED):
shoot()
func shoot() -> void:
if ammo <= 0 or reloading or hp <= 0:
return
weapon_anim.stop()
weapon_anim.play("shoot")
Audio.play(self, "weapons/hks%d.wav" % randi_range(1, 3), false, -13)
ammo -= 1
cooldown = 0.13
flash.light_energy = 3
gun.position.z = 0.06
var start := camera.global_position
var query := PhysicsRayQueryParameters3D.create(start, start - camera.global_basis.z * 80, 1 | 4)
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if hit and hit.collider.has_method("take_damage"):
Blood.impact(get_parent(), hit.position, -camera.global_basis.z)
hit.collider.take_damage(35, hit.position.y)
if ammo <= 0:
start_reload()
func throw_grenade() -> RigidBody3D:
if death_started or hp <= 0 or grenade_cooldown > 0:
return null
var world := get_parent() as Node3D
var forward := -camera.global_basis.z
var start := camera.global_position
var destination := start + forward * 0.55
# Do not spawn through the wall when the camera is close to it.
var query := PhysicsRayQueryParameters3D.create(start, destination, 1)
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if not hit.is_empty():
destination = start + forward * maxf(0, start.distance_to(hit.position) - 0.16)
var grenade := RigidBody3D.new()
grenade.set_script(Grenade)
grenade.thrower = self
grenade.position = world.to_local(destination)
world.add_child(grenade)
grenade.linear_velocity = forward * 12 + Vector3.UP * 3 + velocity
grenade.angular_velocity = Vector3(5, 3, 2)
grenade_cooldown = GRENADE_COOLDOWN
return grenade
func take_damage(amount: float) -> void:
if death_started or amount <= 0:
return
hp = maxf(0, hp - amount)
if hp <= 0:
_die()
else:
damage_feedback.hit(amount)
func _die() -> void:
death_started = true
damage_feedback.stop()
velocity = Vector3.ZERO
reloading = false
gun.hide()
flash.light_energy = 0
weapon_anim.stop()
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
# Original Half-Life player pain sample used as the death event vocal.
Audio.play(self, "player/pl_pain7.wav", false, -8)
Audio.play(self, "fvox/flatline.wav", false, -9)
var layer := CanvasLayer.new()
layer.layer = 5
add_child(layer)
death_overlay = ColorRect.new()
death_overlay.color = Color(0, 0, 0, 0)
death_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
layer.add_child(death_overlay)
death_overlay.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var tween := create_tween()
tween.set_parallel(true)
tween.tween_property(camera, "position:y", 0.22, 0.9).set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_IN)
tween.tween_property(camera, "rotation:z", deg_to_rad(-75), 0.9)
tween.tween_property(camera, "rotation:x", 0.08, 0.9)
tween.tween_property(death_overlay, "color:a", 1.0, 1.8).set_delay(0.7)
var message := Label.new()
message.text = "SEI MORTO" if touch_mode else "SEI MORTO\nPremi R per ricominciare"
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.add_theme_font_size_override("font_size", 36)
message.modulate.a = 0
layer.add_child(message)
message.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
tween.tween_property(message, "modulate:a", 1.0, 0.5).set_delay(2.2)
death_note = Label.new()
death_note.text = ""
death_note.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
death_note.add_theme_font_size_override("font_size", 22)
death_note.add_theme_color_override("font_color", Color(1.0, 0.66, 0.16))
death_note.modulate.a = 0
layer.add_child(death_note)
death_note.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
death_note.offset_top = 60
death_note.offset_bottom = 100
death_note.offset_left = -420
death_note.offset_right = 420
tween.tween_property(death_note, "modulate:a", 1.0, 0.5).set_delay(2.6)
died.emit()
if touch_mode:
var restart := Button.new()
restart_button = restart
restart.name = "RestartButton"
restart.text = "RICOMINCIA"
restart.add_theme_font_size_override("font_size", 28)
layer.add_child(restart)
restart.set_anchors_and_offsets_preset(Control.PRESET_CENTER)
restart.offset_left = -150
restart.offset_right = 150
restart.offset_top = 90
restart.offset_bottom = 162
restart.disabled = true
get_tree().create_timer(0.6).timeout.connect(func(): restart.disabled = false)
restart.pressed.connect(func():
restart.disabled = true
get_tree().reload_current_scene())