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
+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
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)