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.
|
`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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -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