Detonate rifle grenades on impact with original Half-Life fireball

This commit is contained in:
enne2
2026-10-15 14:20:07 +02:00
parent ae32ad9615
commit 5761e160c1
12 changed files with 581 additions and 81 deletions
@@ -0,0 +1,79 @@
{
"source": "Half-Life/valve/sprites/zerogxplode.spr",
"source_sha256": "c0fe1514c05b1d05691afcf3e76d7c928c8f7720125260eaa6ac276cfdf986ce",
"destination": "assets/effects/explosion/zerogxplode_atlas.png",
"destination_sha256": "cb4268e353c0aaee86a125a999b4ab1d219355849f736b5c529d3d9d3ce7eba6",
"format": "IDSP v2 / SPR_ADDITIVE",
"frame_size": [
64,
64
],
"frame_count": 15,
"columns": 5,
"rows": 3,
"gutter": 2,
"origins": [
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
],
[
-32,
32
]
],
"fps": 15,
"fps_source": "https://github.com/ValveSoftware/halflife/blob/master/dlls/ggrenade.cpp"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bk2hf3q27fngd"
path="res://.godot/imported/zerogxplode_atlas.png-383c34cafb9515203129be8c899d1310.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/effects/explosion/zerogxplode_atlas.png"
dest_files=["res://.godot/imported/zerogxplode_atlas.png-383c34cafb9515203129be8c899d1310.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+78 -14
View File
@@ -1,22 +1,86 @@
# Grenades and growing waves # Grenades and growing waves
- Right mouse button: throw while mouse is captured and player alive. ## Rifle grenade: contact detonation
- Cooldown: 15 seconds from launch, displayed in HUD; cannot be bypassed by repeated clicks.
- Physical RigidBody3D: sphere radius 0.12m, mass0.4kg, bounce0.45, friction0.65, gravity, CCD; launch velocity forward12m/s + up3m/s + player velocity. Thrower excluded from projectile collision but not blast damage.
- Fuse: four physics seconds from spawn; one deferred detonation.
- Blast radius5m, maximum damage160 diminishing linearly with distance; world collision ray blocks damage through walls. Includes thrower if alive. CPU particles/flash and original Half-Life explode3–5.wav.
- Original Valve grenade.mdl contains two embedded textures; copied unchanged and exported to native grenade.scn for Web. Provenance in assets/half_life_manifest.json; extraction script tools/extract_grenade_assets.py.
- Wave size: 4 + 2*wave (6,8,10,...), no old24 limit. Next wave three seconds after all enemies die and spawning completes. Scheduling guard prevents duplicate wave timers. Corpses remain.
## Tests - Right mouse button / touch GRENADE: launch while alive (mouse captured on desktop).
- Cooldown: **15 seconds from launch**, displayed in HUD; repeated clicks cannot bypass it.
- Physical `RigidBody3D`: sphere radius 0.12 m, mass 0.4 kg, **no bounce**, friction 0.65,
gravity and continuous collision detection (CCD). Velocity: forward 12 m/s + up 3 m/s
+ player velocity. The thrower is excluded from projectile collision, not blast damage.
- **First contact detonates immediately**: ground, walls, office props and enemies.
`contact_monitor=true`, `max_contacts_reported=8`; `_integrate_forces()` captures the
physics position, `body_entered` is a fallback. One synchronous `exploded` guard and
one deferred `_explode()` prevent duplicate effects/damage and scene changes during
physics callbacks. Damage rays start at the sphere centre, on the free side of the wall.
- Window panes are `Area3D`, layer 8, not physical bodies: a swept ray between physics
positions detects these separately, including fast crossings of thin glass. This does
not add environmental destruction (the existing zombie-entry glass shatter is unchanged).
- **No 4-second fuse anymore**. A lost projectile is deleted after 15 seconds without
an explosion, avoiding orphan nodes or delayed airborne blasts.
- Blast radius **5 m**, maximum damage **160**, linear falloff; a world-mask ray prevents
damage through walls. The living thrower is still included.
Godot4.7.1: ## Original Half-Life explosion
``` `scripts/grenade_explosion.gd` uses Valve's `sprites/zerogxplode.spr`, the fireball
--headless --path . --script res://tools/test_grenade.gd referenced by Half-Life's `TE_EXPLOSION`. The original SDK uses **15 FPS** and the
--headless --path . --script res://tools/test_wave_growth.gd sprite has **15 frames, 64×64 each**: one second, no looping.
`tools/extract_explosion_sprite.py` reads the local Steam installation without changing
it, validates IDSP v2 / additive format, and converts the original RGB palette/frames to
`assets/effects/explosion/zerogxplode_atlas.png`. Source/output SHA256, frame origins,
format and timing are recorded in the adjacent `source_manifest.json`.
- PNG atlas **340×204**, 5×3 frames with 2-pixel black gutters; UVs crop the gutters to
avoid bleeding between frames under linear filtering.
- Camera-facing 3.2 m quad, **additive and unshaded**, normal depth testing (not visible
through walls), no shadows. Original RGB is preserved: black contributes zero under
additive blending; do not treat the last palette index as alpha-test transparency.
- Fireball raised 0.8 m above the physical blast centre to reduce floor clipping. Damage
stays at the actual impact; visual position does not change blast distance or occlusion.
- Orange flash fades over 0.3 s, with no shadow-casting light; 18 small CPU embers are
secondary to the fireball. Original `weapons/explode3–5.wav` unchanged. The effect node
remains alive 4 s for audio, while the sprite disappears after its single playback.
- Plain PNG/QuadMesh/StandardMaterial3D and CPU particles: compatible with the Web
Compatibility renderer; no native SPR importer or GPU-only effects at runtime.
Other original assets: `grenade.mdl` (two embedded textures), native `grenade.scn` for
Web and explosion WAVs; provenance `assets/half_life_manifest.json`, extraction
`tools/extract_grenade_assets.py`.
## Growing waves
Wave size: **4 + 2 × wave** (6, 8, 10, ...), no old cap of 24. Next wave three seconds
after all enemies die and spawning completes. Scheduling guard prevents duplicate
wave timers. Corpses remain.
## Tests and visual verification
Godot **4.7.1**, not the older binary on PATH:
```bash
G="$HOME/.local/opt/godot-4.7.1/Godot_v4.7.1-stable_linux.x86_64"
"$G" --headless --path . --script res://tools/test_grenade.gd
"$G" --headless --path . --script res://tools/test_wave_growth.gd
DISPLAY=:0 "$G" --display-driver x11 --rendering-method gl_compatibility --path . \
--script res://tools/shot_grenade.gd
``` ```
Grenade test PASS: movement/gravity, fuse3.997s, actual15s cooldown, visible/blocked/out-of-radius damage, self-damage and formula26 enemies for wave11. Wave test PASS: six zombies cleared, single next wave spawns eight, six corpses retained; an existing shutdown resource warning remains in the wave test, not a gameplay error. `test_grenade`: **24 checks**, real floor/desk/thin-wall/enemy/glass collisions, rapid
crossings at 80 m/s, single detonation, enemy damage, motion/gravity/thrower exclusion,
real 15-second cooldown, blast LOS/range/self-damage, absence of a 4-second fuse,
non-explosive cleanup, additive billboard atlas and one-shot frame timing, wave growth.
Published Web release4e181f0fe5d02cc9 at https://enne2.net/pub/zombie-fps/; gzip+immutable cache retained. Browser right-click test showed HUD countdown10s after throw. Backup prior root HTML: /home/debian/zombie-fps-backups/index-before-grenade.html. Previous releases kept; no nginx changes. Visual harness captures original frames 0/3/7/11/14 **after an actual floor impact** in
the office (`/tmp/zombie-grenade-frame-*.png`). Desktop tests use no public scores API.
The start-screen GUI regression needs a real display, not the headless dummy driver.
Sources:
- https://github.com/ValveSoftware/halflife/blob/master/dlls/ggrenade.cpp (`ShootContact`,
`ExplodeTouch`, `TE_EXPLOSION`, frame rate 15).
- https://github.com/ValveSoftware/halflife/blob/master/dlls/weapons.cpp (fireball precache).
- https://github.com/ValveSoftware/halflife/blob/master/utils/sprgen/sprgen.c (SPR format).
- https://docs.godotengine.org/en/stable/classes/class_rigidbody3d.html (contact reporting).
Historical first grenade release: `4e181f0fe5d02cc9` used the old 4-second fuse and a
particle-only explosion. It is superseded; current behavior is described above.
+22
View File
@@ -1,5 +1,27 @@
# Web build and compressed deployment # Web build and compressed deployment
## Contact grenade / original Half-Life fireball: add7baaef2b022ef
First-impact detonation (floor, props, walls, enemies and glass), no old timed fuse;
original `zerogxplode.spr` converted to a portable additive PNG atlas (15 frames,
15 FPS), see `docs/GRENADES-WAVES.md`. Damage, blast radius and cooldown unchanged.
- PCK 4,420,396 bytes; total gzip payload 12,866,911 bytes.
- SHA256 verified on all 14 uploaded files; HTTPS-decoded PCK byte-identical to local.
- Atomic root swap; backup `/home/debian/zombie-fps-backups/index-before-impact-add7baaef2b022ef.html`.
- No nginx/API changes, no release deletion; previous `a961c96175bc644e` and older
`209fa6b154fea8df` retained (three release directories after this deploy).
- 24 grenade checks passed headless and with the real Compatibility renderer;
all 15 Godot suites plus the 42-check API suite passed in their appropriate drivers.
`test_start_screen` needs a real display (headless dummy misses its GUI click).
- Captured five original fireball frames after a real floor impact in the office.
Exported Web build booted in Firefox/WebGL2 and touch grenade launch worked on
loopback (no production score submissions). Loopback has no score API, so its
HTTP 404/501 and JSON parse errors on death are expected test-environment limitations.
- Live root, PCK, WASM and API health verified; versioned WASM retains correct MIME
and immutable/Vary headers. Browser production check stays at the menu to avoid
recording test games in the global leaderboard.
## Global high scores release: a961c96175bc644e ## Global high scores release: a961c96175bc644e
Start screen with global leaderboard, player name and score submission; new score API Start screen with global leaderboard, player name and score submission; new score API
+46 -19
View File
@@ -1,6 +1,8 @@
extends RigidBody3D extends RigidBody3D
signal detonated signal detonated
const FUSE_SECONDS := 4.0 # No timed fuse: the rifle grenade detonates on its first physical contact.
const MAX_LIFETIME := 15.0
const Explosion = preload("res://scripts/grenade_explosion.gd")
const BLAST_RADIUS := 5.0 const BLAST_RADIUS := 5.0
const MAX_DAMAGE := 160.0 const MAX_DAMAGE := 160.0
const Audio = preload("res://scripts/hl_audio.gd") const Audio = preload("res://scripts/hl_audio.gd")
@@ -8,18 +10,24 @@ const Blood = preload("res://scripts/blood_effects.gd")
var age := 0.0 var age := 0.0
var exploded := false var exploded := false
var thrower: CharacterBody3D var thrower: CharacterBody3D
var previous_position := Vector3.ZERO
var impact_position := Vector3.ZERO
func _ready() -> void: func _ready() -> void:
name = "Grenade" name = "Grenade"
add_to_group("grenades") add_to_group("grenades")
previous_position = global_position
collision_layer = 16 collision_layer = 16
collision_mask = 1 | 4 collision_mask = 1 | 4
mass = 0.4 mass = 0.4
continuous_cd = true continuous_cd = true
contact_monitor = true
max_contacts_reported = 8
body_entered.connect(_on_body_entered)
linear_damp = 0.15 linear_damp = 0.15
angular_damp = 0.6 angular_damp = 0.6
var physics := PhysicsMaterial.new() var physics := PhysicsMaterial.new()
physics.bounce = 0.45 physics.bounce = 0.0
physics.friction = 0.65 physics.friction = 0.65
physics_material_override = physics physics_material_override = physics
var collider := CollisionShape3D.new() var collider := CollisionShape3D.new()
@@ -39,9 +47,36 @@ func _ready() -> void:
func _physics_process(delta: float) -> void: func _physics_process(delta: float) -> void:
age += delta age += delta
if age >= FUSE_SECONDS and not exploded: # Window panes are Area3D on layer 8, not PhysicsBody3D. A swept ray catches
exploded = true # those too, even if a fast grenade crosses the whole pane in one tick.
call_deferred("_explode") if not exploded and not previous_position.is_equal_approx(global_position):
var query := PhysicsRayQueryParameters3D.create(previous_position, global_position, 8)
query.collide_with_areas = true
query.collide_with_bodies = false
var hit := get_world_3d().direct_space_state.intersect_ray(query)
if not hit.is_empty():
_request_detonation(hit.position + hit.normal * 0.13)
previous_position = global_position
# Safety cleanup only: a projectile lost outside the map must not explode in air.
if age >= MAX_LIFETIME and not exploded:
queue_free()
func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
if not exploded and state.get_contact_count() > 0:
# Save the physics position before deferring scene/shape changes. The sphere
# centre stays on the free side of the surface, also for damage LOS rays.
_request_detonation(state.transform.origin)
func _on_body_entered(body: Node) -> void:
if body != thrower:
_request_detonation(global_position)
func _request_detonation(point: Vector3) -> void:
if exploded:
return
exploded = true
impact_position = point
call_deferred("_explode")
func _explode() -> void: func _explode() -> void:
freeze = true freeze = true
@@ -49,7 +84,7 @@ func _explode() -> void:
collision_mask = 0 collision_mask = 0
hide() hide()
var world := get_parent() as Node3D var world := get_parent() as Node3D
var point := global_position var point := impact_position
var targets := get_tree().get_nodes_in_group("live_zombies") var targets := get_tree().get_nodes_in_group("live_zombies")
if is_instance_valid(thrower) and not thrower.death_started: if is_instance_valid(thrower) and not thrower.death_started:
targets.append(thrower) targets.append(thrower)
@@ -74,20 +109,13 @@ func _explode() -> void:
queue_free() queue_free()
func _effect(world: Node3D, point: Vector3) -> void: func _effect(world: Node3D, point: Vector3) -> void:
var effect := Node3D.new() var effect := Explosion.new()
effect.name = "GrenadeExplosion"
world.add_child(effect) world.add_child(effect)
effect.global_position = point effect.global_position = point
Audio.play(effect, "weapons/explode%d.wav" % randi_range(3, 5), true, -7) Audio.play(effect, "weapons/explode%d.wav" % randi_range(3, 5), true, -7)
var light := OmniLight3D.new() # Secondary embers only: the original fireball is now the main visual.
light.light_color = Color(1, 0.45, 0.08)
light.light_energy = 8
light.omni_range = 10
effect.add_child(light)
var tween := effect.create_tween()
tween.tween_property(light, "light_energy", 0.0, 0.3)
var particles := CPUParticles3D.new() var particles := CPUParticles3D.new()
particles.amount = 60 particles.amount = 18
particles.lifetime = 0.85 particles.lifetime = 0.85
particles.one_shot = true particles.one_shot = true
particles.explosiveness = 1 particles.explosiveness = 1
@@ -97,8 +125,8 @@ func _effect(world: Node3D, point: Vector3) -> void:
particles.initial_velocity_max = 6 particles.initial_velocity_max = 6
particles.gravity = Vector3(0, -3, 0) particles.gravity = Vector3(0, -3, 0)
var mesh := SphereMesh.new() var mesh := SphereMesh.new()
mesh.radius = 0.06 mesh.radius = 0.018
mesh.height = 0.12 mesh.height = 0.036
var material := StandardMaterial3D.new() var material := StandardMaterial3D.new()
material.albedo_color = Color(1, 0.3, 0.05) material.albedo_color = Color(1, 0.3, 0.05)
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
@@ -110,4 +138,3 @@ func _effect(world: Node3D, point: Vector3) -> void:
particles.color_ramp = colors particles.color_ramp = colors
effect.add_child(particles) effect.add_child(particles)
particles.emitting = true particles.emitting = true
world.get_tree().create_timer(4).timeout.connect(effect.queue_free)
+61
View File
@@ -0,0 +1,61 @@
extends Node3D
## Original Half-Life fireball, 15 frames at 15 FPS, additive camera-facing quad.
## A PNG atlas avoids the native GoldSrc importer in Web exports.
const ATLAS = preload("res://assets/effects/explosion/zerogxplode_atlas.png")
const FRAME_COUNT := 15
const FPS := 15.0
const DURATION := FRAME_COUNT / FPS
const SIZE := 3.2
var elapsed := 0.0
var frame_index := 0
var fireball: MeshInstance3D
var material: StandardMaterial3D
func _ready() -> void:
name = "GrenadeExplosion"
material = StandardMaterial3D.new()
material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
material.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
material.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
material.cull_mode = BaseMaterial3D.CULL_DISABLED
material.albedo_texture = ATLAS
material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_LINEAR
material.texture_repeat = false
# Two-pixel black gutters prevent adjacent frames bleeding under filtering.
material.uv1_scale = Vector3(64.0 / 340.0, 64.0 / 204.0, 1)
fireball = MeshInstance3D.new()
fireball.name = "Fireball"
fireball.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
var quad := QuadMesh.new()
quad.size = Vector2(SIZE, SIZE)
quad.material = material
fireball.mesh = quad
# Raise the fireball, but leave the damage origin at the physical impact.
fireball.position.y = 0.8
add_child(fireball)
_set_frame(0)
var light := OmniLight3D.new()
light.name = "Flash"
light.light_color = Color(1, 0.45, 0.08)
light.light_energy = 8
light.omni_range = 10
light.shadow_enabled = false
add_child(light)
create_tween().tween_property(light, "light_energy", 0.0, 0.3)
# Keep the node alive for the original explosion sound to finish.
get_tree().create_timer(4).timeout.connect(queue_free)
func _process(delta: float) -> void:
elapsed += delta
if elapsed >= DURATION:
fireball.hide()
set_process(false)
return
_set_frame(mini(int(elapsed * FPS), FRAME_COUNT - 1))
func _set_frame(index: int) -> void:
frame_index = index
material.uv1_offset = Vector3(
(float(index % 5) * 68 + 2) / 340.0,
(float(index / 5) * 68 + 2) / 204.0, 0)
+1
View File
@@ -0,0 +1 @@
uid://d030pl0ug1qcs
+66
View File
@@ -0,0 +1,66 @@
"""Convert the original GoldSrc additive explosion into a portable PNG atlas.
Read-only on the Half-Life installation. No native SPR importer needed at runtime.
"""
import argparse
import hashlib
import json
import struct
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
def extract(source: Path, destination: Path) -> dict:
raw = source.read_bytes()
ident, version, orientation, fmt, radius, width, height, count, beam, sync = (
struct.unpack_from('<4siiifiiifi', raw)
)
assert (ident, version, fmt) == (b'IDSP', 2, 1), 'Expected GoldSrc additive SPR v2'
palette_count = struct.unpack_from('<H', raw, 40)[0]
assert 1 <= palette_count <= 256 and count == 15 and (width, height) == (64, 64)
palette = raw[42:42 + palette_count * 3]
offset = 42 + len(palette)
columns, rows, gutter = 5, 3, 2
tile_w, tile_h = width + 2 * gutter, height + 2 * gutter
atlas = Image.new('RGB', (columns * tile_w, rows * tile_h))
origins = []
for index in range(count):
kind, x, y, w, h = struct.unpack_from('<5i', raw, offset)
assert kind == 0 and (w, h) == (width, height), 'Unsupported frame/group'
offset += 20
pixels = raw[offset:offset + w * h]
assert len(pixels) == w * h and max(pixels) < palette_count
frame = Image.frombytes('P', (w, h), pixels)
frame.putpalette(palette)
# Preserve RGB: black contributes zero under additive blending, NOT alphatest.
atlas.paste(frame.convert('RGB'),
((index % columns) * tile_w + gutter, (index // columns) * tile_h + gutter))
origins.append([x, y])
offset += w * h
assert offset == len(raw), 'Trailing or truncated sprite data'
destination.mkdir(parents=True, exist_ok=True)
output = destination / 'zerogxplode_atlas.png'
atlas.save(output, optimize=True)
manifest = {
'source': 'Half-Life/valve/sprites/zerogxplode.spr',
'source_sha256': hashlib.sha256(raw).hexdigest(),
'destination': str(output.relative_to(ROOT)),
'destination_sha256': hashlib.sha256(output.read_bytes()).hexdigest(),
'format': 'IDSP v2 / SPR_ADDITIVE', 'frame_size': [width, height],
'frame_count': count, 'columns': columns, 'rows': rows, 'gutter': gutter,
'origins': origins, 'fps': 15,
'fps_source': 'https://github.com/ValveSoftware/halflife/blob/master/dlls/ggrenade.cpp',
}
(destination / 'source_manifest.json').write_text(json.dumps(manifest, indent=2) + '\n')
return manifest
if __name__ == '__main__':
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--source', type=Path, default=Path.home() /
'.steam/steam/steamapps/common/Half-Life/valve/sprites/zerogxplode.spr')
args = parser.parse_args()
print(json.dumps(extract(args.source, ROOT / 'assets/effects/explosion'), indent=2))
+34
View File
@@ -0,0 +1,34 @@
extends SceneTree
## Capture the real impact effect in the Compatibility renderer, no remote score calls.
func _initialize() -> void:
call_deferred("run_test")
func run_test() -> void:
root.size = Vector2i(1280, 720)
var world = load("res://scenes/main.tscn").instantiate()
root.add_child(world)
world.spawning = true
world.start_screen.hide()
world.player.set_physics_process(false)
world.player.position = Vector3(0, 0.1, 4)
world.player.camera.look_at(Vector3(0, 1, -1))
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await create_timer(0.3).timeout
var grenade = load("res://scripts/grenade.gd").new()
grenade.position = Vector3(0, 0.6, -1)
world.add_child(grenade)
grenade.linear_velocity = Vector3(0, -4, 0)
await grenade.detonated
await process_frame
var effect = world.get_node("GrenadeExplosion")
effect.set_process(false)
for index in [0, 3, 7, 11, 14]:
effect.fireball.show()
effect._set_frame(index)
await process_frame
await RenderingServer.frame_post_draw
root.get_texture().get_image().save_png("/tmp/zombie-grenade-frame-%02d.png" % index)
print("SHOT: original sprite frames 0/3/7/11/14 at real floor impact")
world.queue_free()
await process_frame
quit()
+1
View File
@@ -0,0 +1 @@
uid://b14ugdkeste0s
+153 -48
View File
@@ -1,70 +1,175 @@
extends SceneTree extends SceneTree
var detonation_time := -1 ## Real physics contacts + blast regression + original sprite playback, no public API.
const Grenade = preload("res://scripts/grenade.gd")
const Explosion = preload("res://scripts/grenade_explosion.gd")
var checks := 0
var failures := 0
var detonations := 0
var impact_time := 0
func _initialize() -> void: func _initialize() -> void:
call_deferred("run_test") call_deferred("run_test")
func run_test() -> void:
func check(ok: bool, label: String) -> void:
checks += 1
if not ok:
failures += 1
print("PASS: " if ok else "FAIL: ", label)
func body(world: Node3D, at: Vector3, size: Vector3, layer: int = 1) -> StaticBody3D:
var result := StaticBody3D.new()
result.position = at
result.collision_layer = layer
result.collision_mask = 16
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
shape.shape = box
result.add_child(shape)
world.add_child(result)
return result
func projectile(world: Node3D, at: Vector3, speed: Vector3) -> RigidBody3D:
var grenade := Grenade.new()
grenade.position = at
world.add_child(grenade)
grenade.linear_velocity = speed
grenade.detonated.connect(func():
detonations += 1
impact_time = Time.get_ticks_msec())
return grenade
func contact_case(label: String, at: Vector3, size: Vector3, layer: int,
start: Vector3, speed: Vector3) -> void:
var world := Node3D.new() var world := Node3D.new()
root.add_child(world) root.add_child(world)
var room := Node3D.new() var obstacle: Node3D
room.set_script(load("res://scripts/room.gd")) if layer == 8:
world.add_child(room) var area := Area3D.new()
var player := CharacterBody3D.new() area.position = at
player.set_script(load("res://scripts/player.gd")) area.collision_layer = 8
area.collision_mask = 0
var shape := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = size
shape.shape = box
area.add_child(shape)
world.add_child(area)
obstacle = area
else:
obstacle = body(world, at, size, layer)
if layer == 4:
obstacle.set_script(load("res://tools/grenade_test_target.gd"))
obstacle.add_to_group("live_zombies")
await physics_frame
detonations = 0
var started := Time.get_ticks_msec()
var grenade := projectile(world, start, speed)
for i in range(120):
await physics_frame
if not is_instance_valid(grenade):
break
check(not is_instance_valid(grenade) and detonations == 1,
label + ": esplode una sola volta al contatto")
check(impact_time - started < 1500 and detonations == 1,
label + ": nessuna attesa della vecchia miccia")
if layer == 4:
check(obstacle.get("damage_taken") > 0, "nemico colpito: riceve il danno esplosivo")
world.queue_free()
await process_frame
func run_test() -> void:
await contact_case("pavimento", Vector3(0, -0.1, 0), Vector3(20, 0.2, 20), 1,
Vector3(0, 1.2, 0), Vector3(0, -5, 0))
await contact_case("scrivania", Vector3(0, 0.7, -2), Vector3(2, 1.4, 1), 1,
Vector3(0, 1.2, 0), Vector3(0, 0, -12))
await contact_case("parete sottile / CCD", Vector3(0, 1.2, -2), Vector3(4, 4, 0.05), 1,
Vector3(0, 1.2, 0), Vector3(0, 0, -80))
await contact_case("nemico", Vector3(0, 0.9, -2), Vector3(0.7, 1.8, 0.7), 4,
Vector3(0, 1.2, 0), Vector3(0, 0, -12))
await contact_case("vetro Area3D / attraversamento rapido", Vector3(0, 1.2, -2),
Vector3(4, 4, 0.03), 8, Vector3(0, 1.2, 0), Vector3(0, 0, -80))
var world := Node3D.new()
root.add_child(world)
body(world, Vector3(0, -0.1, 0), Vector3(30, 0.2, 30))
var player = load("res://scripts/player.gd").new()
player.position = Vector3(0, 0.1, 4) player.position = Vector3(0, 0.1, 4)
world.add_child(player) world.add_child(player)
player.hp = 10000 player.hp = 10000
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
await physics_frame await physics_frame
var grenade: RigidBody3D = player.throw_grenade() 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 started := Time.get_ticks_msec()
var start := grenade.position check(grenade != null and player.throw_grenade() == null
await create_timer(0.5).timeout and is_equal_approx(player.grenade_cooldown, 15), "lancio e cooldown 15s invariati")
assert(grenade.position.distance_to(start) > 1) check(grenade.contact_monitor and grenade.max_contacts_reported > 0
assert(grenade.linear_velocity.y < 3) and grenade.continuous_cd and grenade.physics_material_override.bounce == 0,
"contatti abilitati, CCD e nessun rimbalzo")
var start := grenade.global_position
await create_timer(0.15).timeout
check(is_instance_valid(grenade) and grenade.position.distance_to(start) > 1
and grenade.linear_velocity.y < 3, "ignora il tiratore; moto e gravità invariati")
# Dispose flight before isolating blast LOS/range (no reliance on random bounces).
if is_instance_valid(grenade):
grenade.queue_free()
await process_frame
var targets: Array[Node3D] = [] var targets: Array[Node3D] = []
for pos in [Vector3(0, 0, -2), Vector3(2, 0, 0), Vector3(0, 0, -7)]: for pos in [Vector3(0, 0, -2), Vector3(2, 0, 0), Vector3(0, 0, -7)]:
var target := Node3D.new() var target = load("res://tools/grenade_test_target.gd").new()
target.set_script(load("res://tools/grenade_test_target.gd"))
target.position = pos target.position = pos
world.add_child(target) world.add_child(target)
targets.append(target) targets.append(target)
var wall := StaticBody3D.new() body(world, Vector3(1, 1.5, 0), Vector3(0.2, 3, 2))
wall.position = Vector3(1, 1.5, 0) await physics_frame
var shape := CollisionShape3D.new() grenade = projectile(world, Vector3(0, 0.3, 0), Vector3.ZERO)
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.freeze = true
grenade.global_position = Vector3(0, 0.3, 0) grenade.thrower = player
await create_timer(3.15).timeout detonations = 0
assert(is_instance_valid(grenade) and not grenade.exploded) grenade._request_detonation(grenade.global_position)
await create_timer(0.6).timeout grenade._request_detonation(grenade.global_position)
assert(detonation_time >= 0) await process_frame
var seconds := (detonation_time - started) / 1000.0 await process_frame
assert(seconds >= 3.8 and seconds <= 4.3) check(detonations == 1, "richieste simultanee non duplicano esplosione e danni")
assert(targets[0].damage_taken > 0) check(targets[0].damage_taken > 0 and targets[1].damage_taken == 0
assert(targets[1].damage_taken == 0) # behind a wall and targets[2].damage_taken == 0, "danno, occlusione delle pareti e raggio 5m invariati")
assert(targets[2].damage_taken == 0) # beyond blast radius check(player.hp < 10000, "il tiratore vicino subisce ancora danni")
assert(player.hp < 10000) # No fuse: an airborne grenade is not allowed to explode after four seconds.
assert(player.throw_grenade() == null) grenade = projectile(world, Vector3(0, 8, 0), Vector3.ZERO)
# Wait until the real cooldown expires, without changing the game timer. grenade.freeze = true
await create_timer(player.grenade_cooldown + 0.1).timeout detonations = 0
assert(player.grenade_cooldown == 0) await create_timer(4.2).timeout
assert(player.throw_grenade() != null) check(is_instance_valid(grenade) and not grenade.exploded and detonations == 0,
"nessuna detonazione in aria dopo 4s")
grenade.age = Grenade.MAX_LIFETIME
await physics_frame
await process_frame
check(not is_instance_valid(grenade) and detonations == 0,
"proiettile perso: cleanup senza esplosione")
var effect := Explosion.new()
world.add_child(effect)
effect.set_process(false)
check(effect.ATLAS.get_size() == Vector2(340, 204)
and effect.material.blend_mode == BaseMaterial3D.BLEND_MODE_ADD
and effect.material.billboard_mode == BaseMaterial3D.BILLBOARD_ENABLED,
"sprite originale: atlas con gutter, blending additivo, billboard")
effect._process(14.1 / Explosion.FPS)
check(effect.frame_index == 14 and effect.fireball.visible
and is_equal_approx(effect.material.uv1_offset.y, 138.0 / 204.0),
"15 fotogrammi a 15 FPS: ultimo frame e UV corretti")
effect._process(0.1)
check(not effect.fireball.visible, "animazione one-shot: sparisce dopo un secondo")
# Wait for the real countdown; never reset or accelerate the player's timer.
await create_timer(player.grenade_cooldown + 0.15).timeout
check(Time.get_ticks_msec() - started >= 15000
and player.grenade_cooldown == 0 and player.throw_grenade() != null,
"nuovo lancio disponibile dopo il cooldown")
var main = load("res://scripts/main.gd").new() var main = load("res://scripts/main.gd").new()
assert(main.zombies_for_wave(1) == 6) check(main.zombies_for_wave(1) == 6 and main.zombies_for_wave(11) == 26,
assert(main.zombies_for_wave(2) == 8) "regressione crescita ondate oltre 24 zombie")
assert(main.zombies_for_wave(11) == 26)
main.free() main.free()
print("PASS: thrown physics/gravity, 4s fuse (", seconds, "), 15s cooldown, blast LOS/range/self-damage, wave growth beyond 24")
world.queue_free() world.queue_free()
await process_frame await process_frame
quit() print(checks, " controlli superati: ", checks - failures, "; falliti: ", failures)
quit(1 if failures else 0)