Add classic HUD, auto reload, furniture pathfinding and death flatline
This commit is contained in:
Executable
BIN
Binary file not shown.
@@ -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
|
||||
@@ -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,8 +3,8 @@
|
||||
`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.
|
||||
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;
|
||||
props can obstruct arbitrary paths when the player hides behind them.
|
||||
Zombies now route around furniture using the shared XZ grid described in
|
||||
`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
|
||||
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.
|
||||
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
|
||||
FPS guarantee. Keep testing arbitrary zombie routes around the new obstacles.
|
||||
FPS guarantee. Physical furniture-pursuit tests are in `test_gameplay_improvements.gd`.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 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
|
||||
|
||||
See `docs/OFFICE-ATMOSPHERE.md`: two desks, three chairs, two desk lamps and
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxxu6fixvsjbs
|
||||
+10
-13
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkb8hopidwlrs
|
||||
+45
-3
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dttbumb5iq0ti
|
||||
Reference in New Issue
Block a user