diff --git a/assets/effects/explosion/source_manifest.json b/assets/effects/explosion/source_manifest.json new file mode 100644 index 0000000..aaac002 --- /dev/null +++ b/assets/effects/explosion/source_manifest.json @@ -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" +} diff --git a/assets/effects/explosion/zerogxplode_atlas.png b/assets/effects/explosion/zerogxplode_atlas.png new file mode 100644 index 0000000..3c509ba Binary files /dev/null and b/assets/effects/explosion/zerogxplode_atlas.png differ diff --git a/assets/effects/explosion/zerogxplode_atlas.png.import b/assets/effects/explosion/zerogxplode_atlas.png.import new file mode 100644 index 0000000..ae9f203 --- /dev/null +++ b/assets/effects/explosion/zerogxplode_atlas.png.import @@ -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 diff --git a/docs/GRENADES-WAVES.md b/docs/GRENADES-WAVES.md index ef34375..c312975 100644 --- a/docs/GRENADES-WAVES.md +++ b/docs/GRENADES-WAVES.md @@ -1,22 +1,86 @@ # Grenades and growing waves -- Right mouse button: throw while mouse is captured and player alive. -- 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. +## Rifle grenade: contact detonation -## 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 -``` ---headless --path . --script res://tools/test_grenade.gd ---headless --path . --script res://tools/test_wave_growth.gd +`scripts/grenade_explosion.gd` uses Valve's `sprites/zerogxplode.spr`, the fireball +referenced by Half-Life's `TE_EXPLOSION`. The original SDK uses **15 FPS** and the +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. diff --git a/docs/WEB-DEPLOY.md b/docs/WEB-DEPLOY.md index 44e2d51..261d349 100644 --- a/docs/WEB-DEPLOY.md +++ b/docs/WEB-DEPLOY.md @@ -1,5 +1,27 @@ # 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 Start screen with global leaderboard, player name and score submission; new score API diff --git a/scripts/grenade.gd b/scripts/grenade.gd index 9be8b31..5728e7d 100644 --- a/scripts/grenade.gd +++ b/scripts/grenade.gd @@ -1,6 +1,8 @@ extends RigidBody3D 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 MAX_DAMAGE := 160.0 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 exploded := false var thrower: CharacterBody3D +var previous_position := Vector3.ZERO +var impact_position := Vector3.ZERO func _ready() -> void: name = "Grenade" add_to_group("grenades") + previous_position = global_position collision_layer = 16 collision_mask = 1 | 4 mass = 0.4 continuous_cd = true + contact_monitor = true + max_contacts_reported = 8 + body_entered.connect(_on_body_entered) linear_damp = 0.15 angular_damp = 0.6 var physics := PhysicsMaterial.new() - physics.bounce = 0.45 + physics.bounce = 0.0 physics.friction = 0.65 physics_material_override = physics var collider := CollisionShape3D.new() @@ -39,9 +47,36 @@ func _ready() -> void: func _physics_process(delta: float) -> void: age += delta - if age >= FUSE_SECONDS and not exploded: - exploded = true - call_deferred("_explode") + # Window panes are Area3D on layer 8, not PhysicsBody3D. A swept ray catches + # those too, even if a fast grenade crosses the whole pane in one tick. + 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: freeze = true @@ -49,7 +84,7 @@ func _explode() -> void: collision_mask = 0 hide() var world := get_parent() as Node3D - var point := global_position + var point := impact_position var targets := get_tree().get_nodes_in_group("live_zombies") if is_instance_valid(thrower) and not thrower.death_started: targets.append(thrower) @@ -74,20 +109,13 @@ func _explode() -> void: queue_free() func _effect(world: Node3D, point: Vector3) -> void: - var effect := Node3D.new() - effect.name = "GrenadeExplosion" + var effect := Explosion.new() world.add_child(effect) effect.global_position = point Audio.play(effect, "weapons/explode%d.wav" % randi_range(3, 5), true, -7) - var light := OmniLight3D.new() - 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) + # Secondary embers only: the original fireball is now the main visual. var particles := CPUParticles3D.new() - particles.amount = 60 + particles.amount = 18 particles.lifetime = 0.85 particles.one_shot = true particles.explosiveness = 1 @@ -97,8 +125,8 @@ func _effect(world: Node3D, point: Vector3) -> void: particles.initial_velocity_max = 6 particles.gravity = Vector3(0, -3, 0) var mesh := SphereMesh.new() - mesh.radius = 0.06 - mesh.height = 0.12 + mesh.radius = 0.018 + mesh.height = 0.036 var material := StandardMaterial3D.new() material.albedo_color = Color(1, 0.3, 0.05) material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED @@ -110,4 +138,3 @@ func _effect(world: Node3D, point: Vector3) -> void: particles.color_ramp = colors effect.add_child(particles) particles.emitting = true - world.get_tree().create_timer(4).timeout.connect(effect.queue_free) diff --git a/scripts/grenade_explosion.gd b/scripts/grenade_explosion.gd new file mode 100644 index 0000000..5b859c6 --- /dev/null +++ b/scripts/grenade_explosion.gd @@ -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) diff --git a/scripts/grenade_explosion.gd.uid b/scripts/grenade_explosion.gd.uid new file mode 100644 index 0000000..73f3fbf --- /dev/null +++ b/scripts/grenade_explosion.gd.uid @@ -0,0 +1 @@ +uid://d030pl0ug1qcs diff --git a/tools/extract_explosion_sprite.py b/tools/extract_explosion_sprite.py new file mode 100644 index 0000000..2ec3e31 --- /dev/null +++ b/tools/extract_explosion_sprite.py @@ -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(' 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() diff --git a/tools/shot_grenade.gd.uid b/tools/shot_grenade.gd.uid new file mode 100644 index 0000000..17ca3cf --- /dev/null +++ b/tools/shot_grenade.gd.uid @@ -0,0 +1 @@ +uid://b14ugdkeste0s diff --git a/tools/test_grenade.gd b/tools/test_grenade.gd index ecb2cbb..7b4518e 100644 --- a/tools/test_grenade.gd +++ b/tools/test_grenade.gd @@ -1,70 +1,175 @@ 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: 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() 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")) + var obstacle: Node3D + if layer == 8: + var area := Area3D.new() + area.position = at + 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) 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) + check(grenade != null and player.throw_grenade() == null + and is_equal_approx(player.grenade_cooldown, 15), "lancio e cooldown 15s invariati") + check(grenade.contact_monitor and grenade.max_contacts_reported > 0 + 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] = [] 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")) + var target = load("res://tools/grenade_test_target.gd").new() 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. + body(world, Vector3(1, 1.5, 0), Vector3(0.2, 3, 2)) + await physics_frame + grenade = projectile(world, Vector3(0, 0.3, 0), Vector3.ZERO) 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) + grenade.thrower = player + detonations = 0 + grenade._request_detonation(grenade.global_position) + grenade._request_detonation(grenade.global_position) + await process_frame + await process_frame + check(detonations == 1, "richieste simultanee non duplicano esplosione e danni") + check(targets[0].damage_taken > 0 and targets[1].damage_taken == 0 + and targets[2].damage_taken == 0, "danno, occlusione delle pareti e raggio 5m invariati") + check(player.hp < 10000, "il tiratore vicino subisce ancora danni") + # No fuse: an airborne grenade is not allowed to explode after four seconds. + grenade = projectile(world, Vector3(0, 8, 0), Vector3.ZERO) + grenade.freeze = true + detonations = 0 + await create_timer(4.2).timeout + 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() - assert(main.zombies_for_wave(1) == 6) - assert(main.zombies_for_wave(2) == 8) - assert(main.zombies_for_wave(11) == 26) + check(main.zombies_for_wave(1) == 6 and main.zombies_for_wave(11) == 26, + "regressione crescita ondate oltre 24 zombie") 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() + print(checks, " controlli superati: ", checks - failures, "; falliti: ", failures) + quit(1 if failures else 0)