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:
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://ox7reh4673n5
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://divewqhrr6nq5
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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.')
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bx3iw0exfo25k
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://duk4s0b075ckw
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dboym2kmns672
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://h20uv8uhbnfo
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://btk5c8aa0wlwy
|
||||
Reference in New Issue
Block a user