Add classic HUD, auto reload, furniture pathfinding and death flatline

This commit is contained in:
enne2
2026-10-11 00:58:28 +02:00
parent d6d25fabaa
commit 8a0c441af6
14 changed files with 438 additions and 19 deletions
+87
View File
@@ -0,0 +1,87 @@
extends CanvasLayer
## Classic amber HUD, kept above touch sticks/buttons rather than over them.
const AMBER := Color(1.0, 0.66, 0.16)
const DANGER := Color(1.0, 0.22, 0.12)
var health_value: Label
var ammo_value: Label
var ammo_state: Label
var wave_value: Label
var grenade_value: Label
var help: Label
var root: Control
func _ready() -> void:
layer = 2
root = Control.new()
root.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
root.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(root)
var health := _panel("Health", false)
_label(health, "+ VITA", 16, Vector2(16, 10))
health_value = _label(health, "100", 42, Vector2(16, 27))
var ammo := _panel("Ammo", true)
_label(ammo, "CARICATORE", 16, Vector2(16, 10))
ammo_value = _label(ammo, "30 / 30", 36, Vector2(16, 30))
ammo_state = _label(ammo, "", 14, Vector2(16, 77))
wave_value = _label(root, "", 18, Vector2.ZERO)
wave_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
wave_value.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
wave_value.offset_left = -160
wave_value.offset_right = 160
wave_value.offset_top = 15
wave_value.offset_bottom = 45
grenade_value = _label(root, "", 15, Vector2.ZERO)
grenade_value.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
grenade_value.set_anchors_and_offsets_preset(Control.PRESET_CENTER_TOP)
grenade_value.offset_left = -160
grenade_value.offset_right = 160
grenade_value.offset_top = 44
grenade_value.offset_bottom = 66
help = _label(root, "WASD: muovi | mouse: mira | R: ricarica | destro: granata", 15, Vector2.ZERO)
help.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
help.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM)
help.offset_left = -420
help.offset_right = 420
help.offset_top = -30
help.offset_bottom = -8
func _panel(label: String, right: bool) -> Panel:
var panel := Panel.new()
panel.name = label
panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
var style := StyleBoxFlat.new()
style.bg_color = Color(0.025, 0.02, 0.008, 0.8)
style.border_color = Color(1.0, 0.66, 0.16, 0.45)
style.set_border_width_all(1)
style.set_corner_radius_all(4)
panel.add_theme_stylebox_override("panel", style)
root.add_child(panel)
panel.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT if right else Control.PRESET_TOP_LEFT)
panel.offset_left = -224 if right else 16
panel.offset_right = -16 if right else 224
panel.offset_top = 16
panel.offset_bottom = 116
return panel
func _label(parent: Control, text: String, size: int, pos: Vector2) -> Label:
var label := Label.new()
label.text = text
label.position = pos
label.mouse_filter = Control.MOUSE_FILTER_IGNORE
label.add_theme_color_override("font_color", AMBER)
label.add_theme_color_override("font_shadow_color", Color.BLACK)
label.add_theme_constant_override("shadow_offset_x", 1)
label.add_theme_constant_override("shadow_offset_y", 1)
label.add_theme_font_size_override("font_size", size)
parent.add_child(label)
return label
func update_status(player: Node, wave: int, score: int) -> void:
health_value.text = str(maxi(0, ceili(player.hp)))
health_value.add_theme_color_override("font_color", DANGER if player.hp <= 25 else AMBER)
ammo_value.text = "%02d / 30" % player.ammo
ammo_value.add_theme_color_override("font_color", DANGER if player.ammo <= 5 else AMBER)
ammo_state.text = "RICARICA..." if player.reloading else ""
wave_value.text = "ONDATA %d | PUNTI %d" % [wave, score]
grenade_value.text = "GRANATA: " + ("PRONTA" if player.grenade_cooldown <= 0 else "%ds" % ceili(player.grenade_cooldown))
help.visible = not player.touch_mode
+1
View File
@@ -0,0 +1 @@
uid://cxxu6fixvsjbs
+10 -13
View File
@@ -10,7 +10,8 @@ var room: Node3D
var music: AudioStreamPlayer
var music_started := false
var touch: Node
var hud: Label
var hud: CanvasLayer
var navigation: Node
var score := 0
var wave := 0
var alive := 0
@@ -46,12 +47,14 @@ func _ready() -> void:
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 = Label.new()
hud.position = Vector2(24, 20)
hud.add_theme_font_size_override("font_size", 24)
canvas.add_child(hud)
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)
@@ -141,14 +144,7 @@ func _restart_music() -> void:
func _process(_dt: float) -> void:
if hud:
hud.text = "VITA %d MUNIZIONI %d/30 ONDATA %d PUNTI %d\nWASD · mouse · sinistro: spara · destro: granata · R: ricarica · spazio: salto · Esc: mouse" % [player.hp, player.ammo, wave, score]
hud.text += "\nGRANATA: " + ("PRONTA" if player.grenade_cooldown <= 0 else "%d s" % ceili(player.grenade_cooldown))
if player.reloading:
hud.text += "\nRICARICA..."
if player.touch_mode:
hud.text += "\ntrascina a sinistra: muovi · a destra: guarda"
if player.hp <= 0:
hud.text += "\nSEI MORTO — tocca RICOMINCIA" if player.touch_mode else "\nSEI MORTO — premi R per ricominciare"
hud.update_status(player, wave, score)
func zombies_for_wave(number: int) -> int:
return 4 + number * 2
@@ -173,6 +169,7 @@ func _spawn(index: int) -> void:
var zombie := CharacterBody3D.new()
zombie.set_script(ZombieScript)
zombie.player = player
zombie.navigation = navigation
zombie.position = centers[index] - inward * 1.2
add_child(zombie)
alive += 1
+7
View File
@@ -100,6 +100,8 @@ func _unhandled_input(event: InputEvent) -> void:
## 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")
@@ -140,6 +142,8 @@ func _physics_process(dt: float) -> void:
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.
@@ -176,6 +180,8 @@ func shoot() -> void:
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:
@@ -219,6 +225,7 @@ func _die() -> void:
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)
+73
View File
@@ -0,0 +1,73 @@
extends Node
## Shared planar A* map for static office collisions; no runtime navmesh bake.
const CELL := 0.5
const CLEARANCE := 0.60 # capsule radius .35 + conservative cell/edge margin
var grid := AStarGrid2D.new()
var obstacles: Array[Rect2] = []
func _ready() -> void:
name = "RoomNavigation"
grid.region = Rect2i(-17, -17, 35, 35)
grid.cell_size = Vector2(CELL, CELL)
grid.diagonal_mode = AStarGrid2D.DIAGONAL_MODE_ONLY_IF_NO_OBSTACLES
grid.default_compute_heuristic = AStarGrid2D.HEURISTIC_OCTILE
grid.default_estimate_heuristic = AStarGrid2D.HEURISTIC_OCTILE
grid.update() # Must precede solid flags; update clears them.
for prop in get_tree().get_nodes_in_group("office_props"):
for collider in prop.get_children():
if not collider is CollisionShape3D or not collider.shape is BoxShape3D:
continue
var half: Vector3 = collider.shape.size * 0.5
var minimum := Vector2(INF, INF)
var maximum := Vector2(-INF, -INF)
for x in [-half.x, half.x]:
for z in [-half.z, half.z]:
var p: Vector3 = collider.global_transform * Vector3(x, 0, z)
minimum = minimum.min(Vector2(p.x, p.z))
maximum = maximum.max(Vector2(p.x, p.z))
obstacles.append(Rect2(minimum, maximum - minimum).grow(CLEARANCE))
for x in range(-17, 18):
for y in range(-17, 18):
var id := Vector2i(x, y)
var p := Vector2(id) * CELL
for rect in obstacles:
if rect.has_point(p):
grid.set_point_solid(id)
break
func _free_id(point: Vector3) -> Vector2i:
var id := Vector2i(roundi(point.x / CELL), roundi(point.z / CELL))
id.x = clampi(id.x, -17, 17)
id.y = clampi(id.y, -17, 17)
if not grid.is_point_solid(id):
return id
var best := id
var distance := INF
for x in range(-17, 18):
for y in range(-17, 18):
var candidate := Vector2i(x, y)
if not grid.is_point_solid(candidate):
var d := Vector2(candidate).distance_squared_to(Vector2(point.x, point.z) / CELL)
if d < distance:
best = candidate
distance = d
return best
func route(from: Vector3, to: Vector3) -> PackedVector3Array:
var result := PackedVector3Array()
for p in grid.get_point_path(_free_id(from), _free_id(to)):
result.append(Vector3(p.x, 0, p.y))
return result
func direct_clear(from: Vector3, to: Vector3) -> bool:
var a := Vector2(from.x, from.z)
var b := Vector2(to.x, to.z)
var count := maxi(1, ceili(a.distance_to(b) / 0.15))
for i in range(count + 1):
var p := a.lerp(b, float(i) / count)
if absf(p.x) > 8.6 or absf(p.y) > 8.6:
return false
for obstacle in obstacles:
if obstacle.has_point(p):
return false
return true
+1
View File
@@ -0,0 +1 @@
uid://dkb8hopidwlrs
+45 -3
View File
@@ -1,6 +1,12 @@
extends CharacterBody3D
signal killed(headshot: bool)
var player: CharacterBody3D
var navigation: Node
var _path := PackedVector3Array()
var _path_index := 0
var _repath := 0.0
var _direct := false
var _nav_started := false
var hp := 100.0
var speed := 2.0
var cooldown := 0.0
@@ -30,6 +36,7 @@ func _ready() -> void:
for area in model.find_children("*", "Area3D", true, false):
area.collision_layer = 0
speed = randf_range(1.6, 2.8)
_repath = randf_range(0, 0.5)
anim.play("walk")
Audio.play(self, "zombie/zo_alert%d0.wav" % randi_range(1, 3), true, -15)
@@ -46,8 +53,10 @@ func _physics_process(dt: float) -> void:
var distance := dir.length()
if distance > 0.01:
rotation.y = atan2(-dir.x, -dir.z)
var walking := distance > 1.7
var motion := dir.normalized() * speed if walking else Vector3.ZERO
var walking := distance > 1.7 or not _attack_clear()
var motion := _pursuit_direction(dt) * speed if walking else Vector3.ZERO
if motion.length_squared() > 0.01:
rotation.y = atan2(-motion.x, -motion.z)
velocity.x = motion.x
velocity.z = motion.z
velocity.y = -1.0 if is_on_floor() else velocity.y - 20.0 * dt
@@ -60,8 +69,41 @@ func _physics_process(dt: float) -> void:
Audio.play(self, "zombie/zo_attack%d.wav" % randi_range(1, 2), true, -15)
get_tree().create_timer(0.5).timeout.connect(_attack)
func _pursuit_direction(dt: float) -> Vector3:
var target := player.global_position
_repath -= dt
if navigation != null and (_repath <= 0 or not _nav_started):
_nav_started = true
_repath = 0.55 + float(get_instance_id() % 7) * 0.02
_direct = navigation.direct_clear(global_position, target)
if not _direct:
_path = navigation.route(global_position, target)
_path_index = 0
if _path.size() > 1 and navigation.direct_clear(global_position, _path[1]):
_path_index = 1
if navigation == null or _direct:
var dir := target - global_position
dir.y = 0
return dir.normalized()
while _path_index < _path.size():
var dir := _path[_path_index] - global_position
dir.y = 0
if dir.length() > 0.22:
return dir.normalized()
_path_index += 1
return Vector3.ZERO
func _attack_clear() -> bool:
# Check below desk-top height: a chest-high ray can pass over a desk
# even though the bodies cannot approach each other through it.
var start := global_position + Vector3.UP * 0.6
var end := player.global_position + Vector3.UP * 0.6
var query := PhysicsRayQueryParameters3D.create(start, end, 1)
query.exclude = [get_rid(), player.get_rid()]
return get_world_3d().direct_space_state.intersect_ray(query).is_empty()
func _attack() -> void:
if not dead and is_instance_valid(player) and player.hp > 0 and global_position.distance_to(player.global_position) < 2.3:
if not dead and is_instance_valid(player) and player.hp > 0 and global_position.distance_to(player.global_position) < 2.3 and _attack_clear():
player.take_damage(12)
Audio.play(self, "zombie/claw_strike%d.wav" % randi_range(1, 3), true, -12)