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
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+46
-19
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user