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
BIN
View File
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://dpd2tbv3u062y"
path="res://.godot/imported/flatline.wav-f115126661c17b8f714387eac4171e94.sample"
[deps]
source_file="res://assets/audio/fvox/flatline.wav"
dest_files=["res://.godot/imported/flatline.wav-f115126661c17b8f714387eac4171e94.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2
+61
View File
@@ -0,0 +1,61 @@
# Classic HUD, automatic reload, furniture navigation, death audio
## HUD
`scripts/game_hud.gd` replaces the debug-style text block with translucent black
panels and amber classic-FPS typography. Health and magazine numbers are large,
low health (<=25) and low ammo (<=5) turn red. Reload status is explicit; wave,
score and grenade cooldown are compact at top center. Panels are above touch
controls; every HUD Control ignores mouse events so the Web start gesture works.
Desktop instructions are hidden in touch mode. `30 / 30` means loaded/max magazine,
not a fictional reserve-ammo counter (reloads remain unlimited).
## Automatic reload
The last shot triggers `start_reload()` immediately. An empty magazine detected in
physics also triggers it. Guarded against duplicate requests, full magazines and
death. Existing duration1.57s and reload sounds remain; no refill after death.
Manual R and touch reload are unchanged.
## Shared grid navigation on the room's XZ floor
`scripts/room_navigation.gd` builds a shared35x35 AStarGrid2D with0.5m cells.
Obstacles come from `office_props` box collisions transformed into world-space
XZ rectangles (including rotated chairs). Rectangles are expanded0.60m to protect
the zombie capsule radius0.35m plus grid/edge margin. Diagonals are allowed only
when both adjacent cells are free. Grid setup calls update BEFORE solid flags,
since update clears them.
Targets/start cells inside expanded obstacles are snapped to the nearest free
cell. Each zombie caches direct visibility or an A* route, refreshing approximately
every0.55–0.67s with staggered intervals rather than every physics frame. Clear
routes use direct pursuit; blocked routes follow waypoints. Entry window tween,
animations, gravity, attack range and corpse behavior remain unchanged.
Melee also checks a world-geometry ray below desk-top height: chest-high rays can
pass over tables even when the actors cannot physically approach each other.
This map is for static furniture: moved/new props require rebuilding it. Existing
body collisions still separate zombies, but this is not a full crowd avoidance
solver. No mobile FPS guarantee is made. The algorithm uses the existing simple
flat room and does not require runtime navmesh baking or native extensions.
Reference: https://docs.godotengine.org/en/stable/classes/class_astargrid2d.html
## Death sound
Copied the ORIGINAL local Half-Life `valve/sound/fvox/flatline.wav` to
`assets/audio/fvox/flatline.wav`, no source modification. SHA256:
`6fc00ca83a1310b8ff373a77ff895c5396c35416c00a974a0fa55b3fb14bd772`.
Duration3.994s, mono11025Hz. Played once at-9dB on death alongside the existing
player vocal; nonpositional so it remains audible through the camera fall.
## Verification
`tools/test_gameplay_improvements.gd`: HUD values/colors/touch hints, immediate
last-shot reload, duplicate-request guard, physical pursuits around both desks,
chairs (including rotated chair) and barrel, blocked melee through desk,
idempotent flatline and no refill after death. Passed headless and with real
PulseAudio output. All eight prior test suites pass. Touch screenshot of new HUD
inspected via test_office_atmosphere --screenshot --touch.
Existing test-shutdown resource-in-use warnings remain. Device touch feel and
smartphone performance still need real-device validation.
+3 -3
View File
@@ -3,8 +3,8 @@
`scripts/office_atmosphere.gd` adds two desks, three chairs and a metal fire barrel. `scripts/office_atmosphere.gd` adds two desks, three chairs and a metal fire barrel.
Props use low-poly primitives with simple static collisions on world layer 1. Props use low-poly primitives with simple static collisions on world layer 1.
Positions avoid the four cardinal entry corridors and the central combat space. Positions avoid the four cardinal entry corridors and the central combat space.
The zombies still use their existing direct pursuit, not a new pathfinding system; Zombies now route around furniture using the shared XZ grid described in
props can obstruct arbitrary paths when the player hides behind them. `GAMEPLAY-IMPROVEMENTS.md`; static collision bounds feed the navigation map.
The central ceiling OmniLight was removed. Ambient energy is 0.12 with a cool blue The central ceiling OmniLight was removed. Ambient energy is 0.12 with a cool blue
color, preserving silhouette readability rather than total darkness. Two warm desk color, preserving silhouette readability rather than total darkness. Two warm desk
@@ -40,4 +40,4 @@ Three screenshots `/tmp/zombie-office-{0,1,2}.png` inspected, including the barr
All seven prior suites pass: health/sky, music, grenade, waves, death, damage, touch. All seven prior suites pass: health/sky, music, grenade, waves, death, damage, touch.
Existing resource-in-use warnings at test shutdown remain. Actual smartphone Existing resource-in-use warnings at test shutdown remain. Actual smartphone
performance has not been measured; this is a small light/particle budget, not an performance has not been measured; this is a small light/particle budget, not an
FPS guarantee. Keep testing arbitrary zombie routes around the new obstacles. FPS guarantee. Physical furniture-pursuit tests are in `test_gameplay_improvements.gd`.
+8
View File
@@ -1,5 +1,13 @@
# Web build and compressed deployment # Web build and compressed deployment
## Gameplay improvements release: 7ebf03fe3c88be15
See `docs/GAMEPLAY-IMPROVEMENTS.md`: amber HUD, automatic reload, shared furniture
pathfinding and original Half-Life flatline. PCK 3,972,620 bytes; gzip payload total
12,424,176 bytes. Previous releases retained; root backup `index-before-gameplay.html`.
Serial staging import workaround retained.
## Office atmosphere release: fd65770c62e811fe ## Office atmosphere release: fd65770c62e811fe
See `docs/OFFICE-ATMOSPHERE.md`: two desks, three chairs, two desk lamps and See `docs/OFFICE-ATMOSPHERE.md`: two desks, three chairs, two desk lamps and
+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: AudioStreamPlayer
var music_started := false var music_started := false
var touch: Node var touch: Node
var hud: Label var hud: CanvasLayer
var navigation: Node
var score := 0 var score := 0
var wave := 0 var wave := 0
var alive := 0 var alive := 0
@@ -46,12 +47,14 @@ func _ready() -> void:
var office := Node3D.new() var office := Node3D.new()
office.set_script(preload("res://scripts/office_atmosphere.gd")) office.set_script(preload("res://scripts/office_atmosphere.gd"))
add_child(office) add_child(office)
navigation = Node.new()
navigation.set_script(preload("res://scripts/room_navigation.gd"))
add_child(navigation)
var canvas := CanvasLayer.new() var canvas := CanvasLayer.new()
add_child(canvas) add_child(canvas)
hud = Label.new() hud = CanvasLayer.new()
hud.position = Vector2(24, 20) hud.set_script(preload("res://scripts/game_hud.gd"))
hud.add_theme_font_size_override("font_size", 24) add_child(hud)
canvas.add_child(hud)
var cross := Label.new() var cross := Label.new()
cross.text = "+" cross.text = "+"
cross.add_theme_font_size_override("font_size", 30) cross.add_theme_font_size_override("font_size", 30)
@@ -141,14 +144,7 @@ func _restart_music() -> void:
func _process(_dt: float) -> void: func _process(_dt: float) -> void:
if hud: 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.update_status(player, 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"
func zombies_for_wave(number: int) -> int: func zombies_for_wave(number: int) -> int:
return 4 + number * 2 return 4 + number * 2
@@ -173,6 +169,7 @@ func _spawn(index: int) -> void:
var zombie := CharacterBody3D.new() var zombie := CharacterBody3D.new()
zombie.set_script(ZombieScript) zombie.set_script(ZombieScript)
zombie.player = player zombie.player = player
zombie.navigation = navigation
zombie.position = centers[index] - inward * 1.2 zombie.position = centers[index] - inward * 1.2
add_child(zombie) add_child(zombie)
alive += 1 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. ## Ricarica: tempi invariati, riusata da tastiera (R) e dal pulsante on-screen.
func start_reload() -> void: func start_reload() -> void:
if reloading or death_started or hp <= 0 or ammo >= 30:
return
reloading = true reloading = true
weapon_anim.play("reload") weapon_anim.play("reload")
Audio.play(self, "items/cliprelease1.wav") 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 get_tree().reload_current_scene() # R o pulsante Ricarica a partita finita
return return
_regenerate_health(dt) _regenerate_health(dt)
if ammo <= 0 and not reloading:
start_reload()
_handle_actions() _handle_actions()
# Deadzone esplicita piccola (0.2): con la 0.5 delle azioni la corsa utile del # 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. # 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"): if hit and hit.collider.has_method("take_damage"):
Blood.impact(get_parent(), hit.position, -camera.global_basis.z) Blood.impact(get_parent(), hit.position, -camera.global_basis.z)
hit.collider.take_damage(35, hit.position.y) hit.collider.take_damage(35, hit.position.y)
if ammo <= 0:
start_reload()
func throw_grenade() -> RigidBody3D: func throw_grenade() -> RigidBody3D:
if death_started or hp <= 0 or grenade_cooldown > 0: if death_started or hp <= 0 or grenade_cooldown > 0:
@@ -219,6 +225,7 @@ func _die() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
# Original Half-Life player pain sample used as the death event vocal. # Original Half-Life player pain sample used as the death event vocal.
Audio.play(self, "player/pl_pain7.wav", false, -8) Audio.play(self, "player/pl_pain7.wav", false, -8)
Audio.play(self, "fvox/flatline.wav", false, -9)
var layer := CanvasLayer.new() var layer := CanvasLayer.new()
layer.layer = 5 layer.layer = 5
add_child(layer) 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 extends CharacterBody3D
signal killed(headshot: bool) signal killed(headshot: bool)
var player: CharacterBody3D 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 hp := 100.0
var speed := 2.0 var speed := 2.0
var cooldown := 0.0 var cooldown := 0.0
@@ -30,6 +36,7 @@ func _ready() -> void:
for area in model.find_children("*", "Area3D", true, false): for area in model.find_children("*", "Area3D", true, false):
area.collision_layer = 0 area.collision_layer = 0
speed = randf_range(1.6, 2.8) speed = randf_range(1.6, 2.8)
_repath = randf_range(0, 0.5)
anim.play("walk") anim.play("walk")
Audio.play(self, "zombie/zo_alert%d0.wav" % randi_range(1, 3), true, -15) 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() var distance := dir.length()
if distance > 0.01: if distance > 0.01:
rotation.y = atan2(-dir.x, -dir.z) rotation.y = atan2(-dir.x, -dir.z)
var walking := distance > 1.7 var walking := distance > 1.7 or not _attack_clear()
var motion := dir.normalized() * speed if walking else Vector3.ZERO 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.x = motion.x
velocity.z = motion.z velocity.z = motion.z
velocity.y = -1.0 if is_on_floor() else velocity.y - 20.0 * dt 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) Audio.play(self, "zombie/zo_attack%d.wav" % randi_range(1, 2), true, -15)
get_tree().create_timer(0.5).timeout.connect(_attack) 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: 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) player.take_damage(12)
Audio.play(self, "zombie/claw_strike%d.wav" % randi_range(1, 3), true, -12) Audio.play(self, "zombie/claw_strike%d.wav" % randi_range(1, 3), true, -12)
+117
View File
@@ -0,0 +1,117 @@
extends SceneTree
const Zombie = preload("res://scripts/zombie.gd")
var world: Node3D
var player: Node3D
func _initialize() -> void:
call_deferred("run_test")
func _freeze_others(actor: Node = null) -> void:
for zombie in get_nodes_in_group("live_zombies"):
if zombie != actor:
zombie.set_physics_process(false)
func run_test() -> void:
world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
player = world.player
player.set_physics_process(false)
player.touch_mode = true
_freeze_others()
player.hp = 20
player.ammo = 3
world.hud.update_status(player, 4, 1200)
assert(world.hud.health_value.text == "20")
assert(world.hud.ammo_value.text == "03 / 30")
assert(world.hud.health_value.get_theme_color("font_color") == world.hud.DANGER)
assert(not world.hud.help.visible)
print("PASS: HUD numeri, vita bassa, caricatore e modalita' touch")
player.hp = 100
player.ammo = 1
player.shoot()
assert(player.ammo == 0 and player.reloading)
var release_count := 0
for speaker in player.get_children():
if speaker is AudioStreamPlayer and speaker.stream.resource_path.ends_with("cliprelease1.wav"):
release_count += 1
player.start_reload() # must not add another reload
var repeated_count := 0
for speaker in player.get_children():
if speaker is AudioStreamPlayer and speaker.stream.resource_path.ends_with("cliprelease1.wav"):
repeated_count += 1
assert(repeated_count == release_count)
await create_timer(1.7).timeout
assert(player.ammo == 30 and not player.reloading)
print("PASS: ultimo colpo avvia auto-reload e completa a 30")
# Shared grid route: actual physics movement, not just returned waypoints.
Engine.time_scale = 4
var cases := [
[Vector3(-5, 0.1, -6.5), Vector3(-5, 0.1, 0)],
[Vector3(5, 0.1, 1.5), Vector3(5, 0.1, 7.5)],
[Vector3(-6, 0.1, 4), Vector3(-2, 0.1, 4)],
[Vector3(6.8, 0.1, -6), Vector3(6.8, 0.1, -2)],
]
player.hp = 10000
for positions in cases:
player.position = positions[1]
var actor := CharacterBody3D.new()
actor.set_script(Zombie)
actor.player = player
actor.navigation = world.navigation
actor.position = positions[0]
world.add_child(actor)
actor.entering = false
actor.speed = 3
var reached := false
for i in 420:
_freeze_others(actor)
await physics_frame
var delta: Vector3 = actor.position - player.position
delta.y = 0
if delta.length() < 1.8:
reached = true
break
assert(reached, "Zombie bloccato: %s -> %s, finale %s" % [positions[0], positions[1], actor.position])
actor.queue_free()
await process_frame
print("PASS: inseguimento fisico attorno a entrambe le scrivanie, sedie e bidone")
Engine.time_scale = 1
# A melee ray across the left desk must not hit through it.
player.position = Vector3(-5, 0.1, -3.2)
var actor := CharacterBody3D.new()
actor.set_script(Zombie)
actor.player = player
actor.position = Vector3(-5, 0.1, -4.8)
world.add_child(actor)
actor.entering = false
actor.set_physics_process(false)
await physics_frame
await physics_frame
assert(not actor._attack_clear())
var hp_before: float = player.hp
actor._attack()
assert(player.hp == hp_before)
print("PASS: nessun attacco attraverso la scrivania")
_freeze_others()
player.hp = 100
player.ammo = 1
player.shoot()
assert(player.reloading)
player.take_damage(1000)
var flatlines := 0
for speaker in player.get_children():
if speaker is AudioStreamPlayer and speaker.stream.resource_path.ends_with("flatline.wav"):
flatlines += 1
assert(flatlines == 1)
player.take_damage(10)
flatlines = 0
for speaker in player.get_children():
if speaker is AudioStreamPlayer and speaker.stream.resource_path.ends_with("flatline.wav"):
flatlines += 1
assert(flatlines == 1)
await create_timer(1.8).timeout
assert(player.hp == 0 and player.ammo == 0 and not player.reloading)
print("PASS: flatline una volta e auto-reload annullato dalla morte")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://dttbumb5iq0ti