Detonate rifle grenades on impact with original Half-Life fireball
This commit is contained in:
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://d030pl0ug1qcs
|
||||||
@@ -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))
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b14ugdkeste0s
|
||||||
+153
-48
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user