Add industrial metal soundtrack with seamless loop

- assets/audio/music/zombie_industrial_loop.ogg: 57.60 s Ogg Vorbis loop
  generated locally with YuE2-3B on the Tesla V100 (score written for this
  track; vocals removed with HTDemucs). The loop is spliced at the track's
  structural repeat with a 0.6 s crossfade: measured seam dip <= 1.6 dB.
- scripts/main.gd: "Music" AudioStreamPlayer at -11 dB, started at boot on
  desktop and on the start click on Web (browser autoplay policy). It forces
  stream.loop at runtime because the Web staging step re-imports the OGG with
  loop=false.
- tools/test_music.gd: soundtrack test (loop, length, volume, playback,
  still playing after player death).
- docs/MUSIC.md: source, loop construction, verification, licence.
- docs/WEB-DEPLOY.md: release ab6054111f5919a8 notes.

Initial commit of the project.
This commit is contained in:
enne2
2026-10-10 23:11:13 +02:00
commit 956a15a525
199 changed files with 4563 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
"""Run with sudo on bastion after upload. Backup, validate, reload, rollback on failure."""
from pathlib import Path
import subprocess,sys,uuid,shutil
source=Path(sys.argv[1])
config=Path('/etc/nginx/conf.d/enne2.conf')
snippet=Path('/etc/nginx/snippets/zombie-fps.conf')
backup=Path('/etc/nginx/zombie-fps-backup-'+uuid.uuid4().hex[:12])
backup.mkdir(mode=0o700)
shutil.copy2(config,backup/'enne2.conf')
had_snippet=snippet.exists()
if had_snippet: shutil.copy2(snippet,backup/'zombie-fps.conf')
old=config.read_text()
include=' include /etc/nginx/snippets/zombie-fps.conf;\n'
anchor=' location /pub/ {'
assert old.count(anchor)==1
new=old if include in old else old.replace(anchor,include+'\n'+anchor,1)
shutil.copyfile(source,snippet)
snippet.chmod(0o644)
config.write_text(new)
try:
subprocess.run(['/usr/sbin/nginx','-t'],check=True)
subprocess.run(['systemctl','reload','nginx'],check=True)
except Exception:
shutil.copy2(backup/'enne2.conf',config)
if had_snippet: shutil.copy2(backup/'zombie-fps.conf',snippet)
else: snippet.unlink(missing_ok=True)
subprocess.run(['/usr/sbin/nginx','-t'],check=True)
subprocess.run(['systemctl','reload','nginx'],check=True)
raise
print('Nginx validated and reloaded; protected backup:',backup)
+23
View File
@@ -0,0 +1,23 @@
extends SceneTree
## Persist imported assets as native Godot scenes, independent of import extension at runtime.
func _initialize() -> void:
for name in ["v_9mmar", "w_9mmar", "p_9mmar"]:
var scene: PackedScene = load("res://assets/weapons/" + name + ".mdl")
assert(scene != null)
assert(ResourceSaver.save(scene, "res://assets/weapons/" + name + ".scn") == OK)
var node := scene.instantiate()
var textures := 0
for mesh in node.find_children("*", "MeshInstance3D", true, false):
for i in mesh.mesh.get_surface_count():
var material = mesh.mesh.surface_get_material(i)
assert(material is StandardMaterial3D and material.albedo_texture != null)
textures += 1
assert(textures > 0)
if name == "v_9mmar":
var animation: AnimationPlayer = node.get_node("AnimationPlayer")
for expected in ["deploy", "reload", "shoot", "idle1"]:
assert(animation.has_animation(expected))
assert(animation.get_animation(expected).get_track_count() > 0)
print("EXPORT VERIFIED ", name, " textured surfaces=", textures)
node.free()
quit()
+1
View File
@@ -0,0 +1 @@
uid://ox7reh4673n5
+17
View File
@@ -0,0 +1,17 @@
extends SceneTree
func _initialize() -> void:
for path in ["res://assets/weapons/v_9mmar", "res://assets/zombies/zombie_textured", "res://assets/weapons/grenade"]:
var scene: PackedScene = load(path + ".mdl")
assert(scene != null)
assert(ResourceSaver.save(scene, path + ".scn") == OK)
var node := scene.instantiate()
var textured := 0
for mesh in node.find_children("*", "MeshInstance3D", true, false):
for i in mesh.mesh.get_surface_count():
var material = mesh.mesh.surface_get_material(i)
assert(material is StandardMaterial3D and material.albedo_texture != null)
textured += 1
assert(textured > 0)
print("NATIVE SCENE VERIFIED ", path, " surfaces=", textured)
node.free()
quit()
+1
View File
@@ -0,0 +1 @@
uid://divewqhrr6nq5
+21
View File
@@ -0,0 +1,21 @@
"""Copy original grenade assets; verify and append provenance manifest."""
from pathlib import Path
import shutil,hashlib,json,struct,wave
root=Path(__file__).resolve().parents[1]
source=Path.home()/'.steam/steam/steamapps/common/Half-Life/valve'
manifest=root/'assets/half_life_manifest.json'
rows=json.loads(manifest.read_text())
for relative in ['models/grenade.mdl']+['sound/weapons/explode%d.wav'%i for i in [3,4,5]]:
p=source/relative
dest=root/'assets'/('weapons/'+p.name if relative.startswith('models/') else 'audio/weapons/'+p.name)
shutil.copyfile(p,dest)
raw=p.read_bytes(); assert raw==dest.read_bytes()
row={'source':relative,'destination':str(dest.relative_to(root)),'sha256':hashlib.sha256(raw).hexdigest()}
if p.suffix=='.mdl':
assert raw[:4]==b'IDST' and struct.unpack_from('<i',raw,4)[0]==10
row['textures']=struct.unpack_from('<i',raw,180)[0]
else:
with wave.open(str(dest)) as w: assert w.getcomptype()=='NONE'
rows=[x for x in rows if x['destination']!=row['destination']]+[row]
print(relative,row['sha256'])
manifest.write_text(json.dumps(rows,indent=2)+'\n')
+40
View File
@@ -0,0 +1,40 @@
"""Extract local Half-Life assets; never alters the Steam installation."""
import pathlib, struct
from PIL import Image
PROJECT = pathlib.Path(__file__).resolve().parents[1]
SOURCE = pathlib.Path.home()/'.steam/steam/steamapps/common/Half-Life/valve'
out = PROJECT/'assets/textures'
out.mkdir(parents=True, exist_ok=True)
body = bytearray((SOURCE/'models/zombie.mdl').read_bytes())
tex = bytearray((SOURCE/'models/zombiet.mdl').read_bytes())
base = len(body)
count, records, data, refs, families, skins = struct.unpack_from('<6i', tex, 180)
for i in range(count):
pos = records + i*80
name, flags, w, h, pixel = struct.unpack_from('<64s4i', tex, pos)
image = Image.frombytes('P', (w,h), bytes(tex[pixel:pixel+w*h]))
image.putpalette(bytes(tex[pixel+w*h:pixel+w*h+768]))
image.convert('RGB').save(out/('zombie_%02d.png'%i))
struct.pack_into('<i', tex, pos+76, pixel+base)
struct.pack_into('<6i', body, 180, count, records+base, data+base, refs, families, skins+base)
body.extend(tex)
struct.pack_into('<i', body, 72, len(body))
(PROJECT/'assets/zombies/zombie_textured.mdl').write_bytes(body)
wad = (SOURCE/'halflife.wad').read_bytes()
n, directory = struct.unpack_from('<ii',wad,4)
wanted = {'LAB1_W3':'wall.png','C1A0_LABFLR':'floor.png','C1A0_C1':'ceiling.png'}
found = set()
for i in range(n):
offset, _, _, typ, compression, _, _, raw = struct.unpack_from('<iiiBBBB16s',wad,directory+32*i)
name = raw.split(b'\0')[0].decode().upper()
if name not in wanted or compression:
continue
_, w,h, a,b,c,d = struct.unpack_from('<16s6I',wad,offset)
palette_offset = offset+d+(w//8)*(h//8)+2
image = Image.frombytes('P',(w,h),wad[offset+a:offset+a+w*h])
image.putpalette(wad[palette_offset:palette_offset+768])
image.convert('RGB').save(out/wanted[name])
found.add(name)
print(name,w,h)
assert found == set(wanted), ('Missing WAD textures',set(wanted)-found)
print('Zombie textures:',count)
+28
View File
@@ -0,0 +1,28 @@
"""Copy original local Valve assets, validate WAV/MDL, record SHA256 provenance."""
import pathlib, shutil, struct, wave, hashlib, json
project = pathlib.Path(__file__).resolve().parents[1]
source = pathlib.Path.home()/'.steam/steam/steamapps/common/Half-Life/valve'
files = ['models/v_9mmar.mdl','models/w_9mmar.mdl','models/p_9mmar.mdl']
files += ['sound/weapons/'+n+'.wav' for n in ['hks1','hks2','hks3','dryfire1']]
files += ['sound/items/'+n+'.wav' for n in ['clipinsert1','cliprelease1','guncock1']]
files += ['sound/zombie/'+p.name for p in sorted((source/'sound/zombie').glob('*.wav'))]
files += ['sound/player/pl_metal%d.wav'%i for i in range(1,5)]
files += ['sound/debris/bustglass%d.wav'%i for i in range(1,4)]
manifest=[]
for relative in files:
src=source/relative
dst=project/'assets'/('weapons/'+src.name if relative.startswith('models/') else 'audio/'+relative.removeprefix('sound/'))
dst.parent.mkdir(parents=True,exist_ok=True)
shutil.copyfile(src,dst)
row={'source':relative,'destination':str(dst.relative_to(project)),'sha256':hashlib.sha256(src.read_bytes()).hexdigest()}
assert src.read_bytes()==dst.read_bytes()
if src.suffix=='.wav':
with wave.open(str(dst)) as w:
assert w.getcomptype()=='NONE'
row.update(channels=w.getnchannels(),bits=w.getsampwidth()*8,rate=w.getframerate(),duration=w.getnframes()/w.getframerate())
else:
d=dst.read_bytes(); assert d[:4]==b'IDST' and struct.unpack_from('<i',d,4)[0]==10
row.update(textures=struct.unpack_from('<i',d,180)[0],sequence_groups=struct.unpack_from('<i',d,172)[0])
manifest.append(row)
(project/'assets/half_life_manifest.json').write_text(json.dumps(manifest,indent=2)+'\n')
print('Validated and copied',len(manifest),'original assets; SHA256 manifest written.')
+15
View File
@@ -0,0 +1,15 @@
"""Deterministic original blood decal mask (not a Valve asset)."""
from PIL import Image, ImageDraw, ImageFilter
from pathlib import Path
import random
rng=random.Random(41)
im=Image.new('RGBA',(512,512))
d=ImageDraw.Draw(im)
for i in range(120):
x=rng.gauss(256,70); y=rng.gauss(256,65)
radius=rng.uniform(4,48) if i<55 else rng.uniform(2,8)
d.ellipse((x-radius,y-radius,x+radius,y+radius),fill=(100+rng.randrange(30),5,8,rng.randrange(155,240)))
im=im.filter(ImageFilter.GaussianBlur(0.8))
p=Path(__file__).resolve().parents[1]/'assets/effects/blood.png'
p.parent.mkdir(parents=True,exist_ok=True)
im.save(p)
+6
View File
@@ -0,0 +1,6 @@
extends Node3D
var damage_taken := 0.0
func _ready() -> void:
add_to_group("live_zombies")
func take_damage(amount: float, _hit_y: float) -> void:
damage_taken += amount
+1
View File
@@ -0,0 +1 @@
uid://bx3iw0exfo25k
+39
View File
@@ -0,0 +1,39 @@
# Scope is limited to this game. Versioned resources are never overwritten.
location ^~ /pub/zombie-fps/releases/ {
alias /home/debian/public_html/zombie-fps/releases/;
index index.html;
autoindex off;
types {
text/html html;
application/javascript js;
application/wasm wasm;
application/octet-stream pck;
image/png png;
}
default_type application/octet-stream;
gzip_static on;
gzip_vary on;
gzip_proxied any;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
}
location ^~ /pub/zombie-fps/ {
alias /home/debian/public_html/zombie-fps/;
index index.html;
autoindex off;
types {
text/html html;
application/javascript js;
application/wasm wasm;
application/octet-stream pck;
image/png png;
}
default_type application/octet-stream;
gzip_static on;
gzip_vary on;
gzip_proxied any;
add_header Cache-Control "no-cache" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
}
+33
View File
@@ -0,0 +1,33 @@
"""Immutable content-addressed Web releases with offline gzip compression."""
from pathlib import Path
import hashlib, gzip, json, shutil, os
root=Path(__file__).resolve().parents[1]
source=root/'build/web'
output=root/'build/web-deploy'
files=sorted(p for p in source.iterdir() if p.is_file() and not p.name.endswith(('.gz','.br')))
identity=hashlib.sha256()
for p in files:
identity.update(p.name.encode()+b'\0'+p.read_bytes())
version=identity.hexdigest()[:16]
if output.exists(): shutil.rmtree(output)
release=output/'releases'/version
release.mkdir(parents=True)
report=[]
for p in files:
destination=release/p.name
shutil.copyfile(p,destination)
raw=p.read_bytes()
if p.suffix in ['.wasm','.pck','.js','.html']:
compressed=gzip.compress(raw,compresslevel=9,mtime=0)
assert gzip.decompress(compressed)==raw
z=destination.with_name(destination.name+'.gz')
z.write_bytes(compressed)
os.utime(z,(destination.stat().st_mtime,destination.stat().st_mtime))
report.append({'file':p.name,'original':len(raw),'gzip':len(compressed),'sha256':hashlib.sha256(raw).hexdigest()})
html=(source/'index.html').read_text()
html=html.replace('<head>','<head>\n\t\t<base href="/pub/zombie-fps/releases/'+version+'/">',1)
(output/'index.html').write_text(html)
(root/'build/web-compression-report.json').write_text(json.dumps({'version':version,'files':report},indent=2)+'\n')
print('Release:',version)
for r in report: print(r['file'],r['original'],'->',r['gzip'])
print('Total:',sum(r['original'] for r in report),'->',sum(r['gzip'] for r in report))
+15
View File
@@ -0,0 +1,15 @@
"""Build a GDExtension-free staging project; leaves desktop import plugin intact."""
from pathlib import Path
import shutil
root = Path(__file__).resolve().parents[1]
stage = root/'build/web-stage'
if stage.exists():
shutil.rmtree(stage)
stage.mkdir(parents=True)
for directory in ['scripts','scenes','assets']:
shutil.copytree(root/directory,stage/directory,ignore=shutil.ignore_patterns('*.mdl','*.import','*.uid','half_life_manifest.json','p_9mmar.scn','w_9mmar.scn'))
config=(root/'project.godot').read_text().replace('enabled=PackedStringArray("res://addons/goldsrc/plugin.cfg")','enabled=PackedStringArray()')
config=config.replace('renderer/rendering_method="forward_plus"','renderer/rendering_method="gl_compatibility"')
(stage/'project.godot').write_text(config)
shutil.copyfile(root/'export_presets.cfg',stage/'export_presets.cfg')
print(stage)
+52
View File
@@ -0,0 +1,52 @@
extends SceneTree
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var world := Node3D.new()
root.add_child(world)
var room := Node3D.new()
room.set_script(load("res://scripts/room.gd"))
world.add_child(room)
var light := OmniLight3D.new()
light.position = Vector3(0, 4, 0)
light.omni_range = 24
light.light_energy = 4
world.add_child(light)
var player := CharacterBody3D.new()
player.set_script(load("res://scripts/player.gd"))
player.position.y = 0.1
world.add_child(player)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await create_timer(0.2).timeout
player.camera.rotation.x = 0.12
var aim: Vector3 = player.camera.rotation
player.take_damage(12)
assert(player.hp == 88)
assert(player.damage_feedback.blood_left > 0)
assert(player.damage_feedback.splats.size() == 7)
await create_timer(0.06).timeout
assert(player.damage_pivot.position.length() > 0.0001)
assert(player.camera.rotation.is_equal_approx(aim))
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-player-hit.png")
# Multiple hits reuse a bounded pool; no camera offset accumulates.
player.take_damage(12)
player.take_damage(12)
assert(player.damage_feedback.intensity <= 1)
assert(player.damage_feedback.splats.size() == 7)
await create_timer(1.35).timeout
assert(player.damage_pivot.transform.is_equal_approx(Transform3D.IDENTITY))
assert(player.camera.rotation.is_equal_approx(aim))
assert(player.damage_feedback.blood_left == 0)
player.take_damage(12)
player.take_damage(1000)
assert(not player.damage_feedback.active)
assert(player.damage_pivot.transform.is_equal_approx(Transform3D.IDENTITY))
await create_timer(3).timeout
assert(player.camera.position.y < 0.25)
assert(player.death_overlay.color.a > 0.99)
print("PASS: damage shake, screen blood, aim preserved, repeated hits bounded, camera reset, death fade intact")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://duk4s0b075ckw
+52
View File
@@ -0,0 +1,52 @@
extends SceneTree
var world: Node3D
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await create_timer(2.4).timeout
var zombie: Node3D
for child in world.get_children():
if child.get_script() == world.ZombieScript:
zombie = child
break
assert(zombie != null)
world.player.hp = 10000
zombie.position = Vector3(0, 0, -3)
if zombie.entry_tween:
zombie.entry_tween.kill()
zombie.entering = false
var point := zombie.global_position + Vector3(0, 1.2, 0)
preload("res://scripts/blood_effects.gd").impact(world, point, Vector3(0, 0, -1))
zombie.take_damage(1000, point.y)
assert(zombie.dead and zombie.collision_layer == 0)
await create_timer(0.2).timeout
assert(get_nodes_in_group("blood_stains").size() == 1)
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-blood.png")
await create_timer(4.3).timeout
assert(is_instance_valid(zombie) and zombie.is_inside_tree())
assert(absf(zombie.position.y) < 0.01)
assert(get_nodes_in_group("zombie_corpses").has(zombie))
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-corpse.png")
world.player.take_damage(20000)
world.player.take_damage(12)
assert(world.player.death_started and world.player.hp == 0)
var ammo: int = world.player.ammo
world.player.shoot()
assert(world.player.ammo == ammo)
await create_timer(3).timeout
assert(world.player.camera.position.y < 0.25)
assert(world.player.death_overlay.color.a > 0.99)
if DisplayServer.get_name() != "headless":
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-death-fade.png")
print("PASS: persistent corpse, blood decal, single death, blocked shooting, camera fall, black fade")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://dboym2kmns672
+70
View File
@@ -0,0 +1,70 @@
extends SceneTree
var detonation_time := -1
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var world := Node3D.new()
root.add_child(world)
var room := Node3D.new()
room.set_script(load("res://scripts/room.gd"))
world.add_child(room)
var player := CharacterBody3D.new()
player.set_script(load("res://scripts/player.gd"))
player.position = Vector3(0, 0.1, 4)
world.add_child(player)
player.hp = 10000
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await physics_frame
var grenade: RigidBody3D = player.throw_grenade()
assert(grenade != null)
assert(player.throw_grenade() == null)
assert(is_equal_approx(player.grenade_cooldown, 15))
assert(grenade.physics_material_override.bounce > 0)
grenade.detonated.connect(func(): detonation_time = Time.get_ticks_msec())
var started := Time.get_ticks_msec()
var start := grenade.position
await create_timer(0.5).timeout
assert(grenade.position.distance_to(start) > 1)
assert(grenade.linear_velocity.y < 3)
var targets: Array[Node3D] = []
for pos in [Vector3(0, 0, -2), Vector3(2, 0, 0), Vector3(0, 0, -7)]:
var target := Node3D.new()
target.set_script(load("res://tools/grenade_test_target.gd"))
target.position = pos
world.add_child(target)
targets.append(target)
var wall := StaticBody3D.new()
wall.position = Vector3(1, 1.5, 0)
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = Vector3(0.2, 3, 2)
shape.shape = box
wall.add_child(shape)
world.add_child(wall)
# Isolate explosion coverage from the projectile's random rotation/bounces.
grenade.freeze = true
grenade.global_position = Vector3(0, 0.3, 0)
await create_timer(3.15).timeout
assert(is_instance_valid(grenade) and not grenade.exploded)
await create_timer(0.6).timeout
assert(detonation_time >= 0)
var seconds := (detonation_time - started) / 1000.0
assert(seconds >= 3.8 and seconds <= 4.3)
assert(targets[0].damage_taken > 0)
assert(targets[1].damage_taken == 0) # behind a wall
assert(targets[2].damage_taken == 0) # beyond blast radius
assert(player.hp < 10000)
assert(player.throw_grenade() == null)
# Wait until the real cooldown expires, without changing the game timer.
await create_timer(player.grenade_cooldown + 0.1).timeout
assert(player.grenade_cooldown == 0)
assert(player.throw_grenade() != null)
var main = load("res://scripts/main.gd").new()
assert(main.zombies_for_wave(1) == 6)
assert(main.zombies_for_wave(2) == 8)
assert(main.zombies_for_wave(11) == 26)
main.free()
print("PASS: thrown physics/gravity, 4s fuse (", seconds, "), 15s cooldown, blast LOS/range/self-damage, wave growth beyond 24")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://h20uv8uhbnfo
+37
View File
@@ -0,0 +1,37 @@
extends SceneTree
## Verifica la colonna sonora in loop: nodo presente, stream OGG con loop attivo,
## volume da sottofondo, durata attesa e riproduzione avviata.
const EXPECTED_LENGTH := 57.6
const EXPECTED_VOLUME_DB := -11.0
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
await process_frame
var players := get_nodes_in_group("music")
assert(players.size() == 1)
var music: AudioStreamPlayer = players[0]
assert(music.name == "Music")
assert(music.stream is AudioStreamOggVorbis)
assert(music.stream.loop)
assert(absf(music.volume_db - EXPECTED_VOLUME_DB) < 0.001)
var length: float = music.stream.get_length()
assert(absf(length - EXPECTED_LENGTH) < 0.3)
assert(music.playing)
var first := music.get_playback_position()
await create_timer(0.5).timeout
assert(music.playing)
var second := music.get_playback_position()
print("PASS: Music loop=%s durata=%.2fs volume=%.1fdB posizione %.2f -> %.2f | driver audio: %s"
% [music.stream.loop, length, music.volume_db, first, second, AudioServer.get_driver_name()])
# La musica deve restare in riproduzione anche a giocatore morto.
world.player.take_damage(9999)
await create_timer(0.3).timeout
assert(music.playing)
print("PASS: musica attiva anche dopo la morte del giocatore")
world.queue_free()
await process_frame
quit()
+24
View File
@@ -0,0 +1,24 @@
extends SceneTree
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
world.player.hp = 100000
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await create_timer(5.2).timeout
assert(world.wave == 1 and world.alive == 6 and not world.spawning)
for zombie in get_nodes_in_group("live_zombies"):
zombie.take_damage(1000, zombie.global_position.y + 0.9)
assert(world.alive == 0 and world.wave_pending)
world._check_wave()
world._check_wave() # no duplicate next-wave scheduling
await create_timer(3.2).timeout
assert(world.wave == 2)
await create_timer(6.4).timeout
assert(world.wave == 2 and world.alive == 8 and not world.spawning)
assert(get_nodes_in_group("zombie_corpses").size() == 6)
print("PASS: wave1=6, clear -> wave2=8, single transition, corpses retained")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://btk5c8aa0wlwy