Remove the persistent blood-decal system
Strip only the floor/wall blood-splat decals added in 981ebf1, keeping the
older death-spray, spear-wound spurt, and "Blood & Gore" toggle:
- Delete blood_decals.gd + shader, the Gore autoload (gore.gd), and
tests/blood_decals_test.gd
- Drop the Gore autoload and the Blood DP section
- Remove all Gore.splat() call sites (matador.gd, bear.gd, player.gd);
the _blood_burst spray and _spawn_blood spurt stay
- game_shell no longer spawns a per-level decal pool
- Update level_switch test + run_tests.sh accordingly
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmG3mQGVXZUPDVapmdbWZ1
This commit is contained in:
@@ -884,7 +884,6 @@ func _take_hit(hit_dir: Vector3, strength: float) -> void:
|
|||||||
hit_dir.y = 0.0
|
hit_dir.y = 0.0
|
||||||
hit_dir = hit_dir.normalized()
|
hit_dir = hit_dir.normalized()
|
||||||
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
||||||
Gore.splat(global_position, hit_dir, 1.1)
|
|
||||||
_shake(clampf(strength / 15.0, 0.4, 1.0))
|
_shake(clampf(strength / 15.0, 0.4, 1.0))
|
||||||
if _hp <= 0:
|
if _hp <= 0:
|
||||||
_die(hit_dir)
|
_die(hit_dir)
|
||||||
@@ -920,7 +919,6 @@ func _die(hit_dir: Vector3) -> void:
|
|||||||
_hit_area.monitoring = false
|
_hit_area.monitoring = false
|
||||||
velocity = Vector3.ZERO
|
velocity = Vector3.ZERO
|
||||||
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
_blood_burst.burst(global_position + Vector3(0.0, 1.4, 0.0), hit_dir)
|
||||||
Gore.splat(global_position, hit_dir, 1.4)
|
|
||||||
_play(_anim_death)
|
_play(_anim_death)
|
||||||
# No death clip on the rig — topple the beast onto its side (about the hit direction,
|
# No death clip on the rig — topple the beast onto its side (about the hit direction,
|
||||||
# so it falls the way it was struck) and sink it away, then free.
|
# so it falls the way it was struck) and sink it away, then free.
|
||||||
|
|||||||
-194
@@ -1,194 +0,0 @@
|
|||||||
extends MultiMeshInstance3D
|
|
||||||
## A whole level's worth of persistent blood splats rendered in one draw call. Each stain
|
|
||||||
## is a flat quad laid on the surface it hit — floor or a nearby wall — picked from a
|
|
||||||
## procedurally-baked atlas of splat shapes and jittered per instance (random shape, yaw,
|
|
||||||
## size, brightness) so no two read alike.
|
|
||||||
##
|
|
||||||
## The pool is a fixed ring buffer: new splats overwrite the oldest, so the instance count
|
|
||||||
## and fill cost stay bounded no matter how long a fight runs — the property that keeps it
|
|
||||||
## cheap on the web/mobile target. The game shell rebuilds this node into LevelRoot on every
|
|
||||||
## level load, so the stains die with the level (exactly one level's worth, never leaked
|
|
||||||
## into the next). Fire splats through the `Gore` autoload, not this node directly.
|
|
||||||
|
|
||||||
const PhysicsLayers = preload("res://physics_layers.gd")
|
|
||||||
const SHADER_PATH := "res://blood_decals.gdshader"
|
|
||||||
const GROUP := &"blood_decals"
|
|
||||||
|
|
||||||
# Atlas: ATLAS_CELLS distinct splat shapes packed into one row, CELL_PX square each.
|
|
||||||
const ATLAS_CELLS := 8
|
|
||||||
const CELL_PX := 64
|
|
||||||
|
|
||||||
var _rng := RandomNumberGenerator.new()
|
|
||||||
var _mm: MultiMesh
|
|
||||||
var _cap: int = 0
|
|
||||||
var _next: int = 0 # ring-buffer write cursor
|
|
||||||
var _filled: int = 0 # how many ring-buffer slots have ever been stamped, capped at _cap
|
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
|
||||||
add_to_group(GROUP)
|
|
||||||
_rng.randomize()
|
|
||||||
_cap = maxi(int(DP.f("blood_cap")), 0)
|
|
||||||
_build()
|
|
||||||
|
|
||||||
|
|
||||||
func _build() -> void:
|
|
||||||
var quad := QuadMesh.new()
|
|
||||||
quad.size = Vector2.ONE # unit quad; the instance basis carries the real size
|
|
||||||
|
|
||||||
var mat := ShaderMaterial.new()
|
|
||||||
mat.shader = load(SHADER_PATH) as Shader
|
|
||||||
mat.set_shader_parameter("atlas", _bake_atlas())
|
|
||||||
mat.set_shader_parameter("cells", ATLAS_CELLS)
|
|
||||||
material_override = mat
|
|
||||||
|
|
||||||
_mm = MultiMesh.new()
|
|
||||||
_mm.transform_format = MultiMesh.TRANSFORM_3D
|
|
||||||
_mm.use_custom_data = true
|
|
||||||
_mm.mesh = quad
|
|
||||||
_mm.instance_count = _cap
|
|
||||||
# Godot renders only the first `visible_instance_count` instances, so unstained slots simply
|
|
||||||
# aren't drawn — no need to park them off-map. (Parking them via set_instance_transform doesn't
|
|
||||||
# work anyway: MultiMesh's per-instance buffer lives server-side and isn't reliably readable
|
|
||||||
# back through get_instance_transform, so a "hide by moving far away" scheme can't even be
|
|
||||||
# verified, let alone trusted.) Starts at 0 and grows as splats land.
|
|
||||||
_mm.visible_instance_count = 0
|
|
||||||
multimesh = _mm
|
|
||||||
|
|
||||||
|
|
||||||
## Stain the world at `world_pos`: one splat cluster on the floor beneath it, plus a splat
|
|
||||||
## on any wall within `blood_wall_reach`. `dir` is the spray heading (used to seed the wall
|
|
||||||
## fan); `size` is the base quad size in metres.
|
|
||||||
func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void:
|
|
||||||
if not is_instance_valid(_mm) or _cap <= 0:
|
|
||||||
return
|
|
||||||
var space := get_world_3d().direct_space_state
|
|
||||||
if space == null:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Floor directly under the hit.
|
|
||||||
var floor_hit := _ray(space, world_pos + Vector3.UP * 0.5, world_pos + Vector3.DOWN * 4.0)
|
|
||||||
if not floor_hit.is_empty():
|
|
||||||
_stamp_cluster(floor_hit.position, floor_hit.normal, size)
|
|
||||||
|
|
||||||
# Walls near the hit: a fan of outward rays, seeded on the spray heading. Only surfaces
|
|
||||||
# within reach catch blood, so an open-arena hit stains nothing but the floor.
|
|
||||||
var rays := maxi(int(DP.f("blood_wall_rays")), 0)
|
|
||||||
if rays <= 0:
|
|
||||||
return
|
|
||||||
var reach := DP.f("blood_wall_reach")
|
|
||||||
var seed_dir := Vector3(dir.x, 0.0, dir.z)
|
|
||||||
seed_dir = seed_dir.normalized() if seed_dir.length_squared() > 0.01 else Vector3.FORWARD
|
|
||||||
var origin := world_pos + Vector3.UP * 0.6
|
|
||||||
for k in rays:
|
|
||||||
var ang := TAU * (float(k) + 0.5) / float(rays)
|
|
||||||
var out := seed_dir.rotated(Vector3.UP, ang)
|
|
||||||
var wall_hit := _ray(space, origin, origin + out * reach)
|
|
||||||
# Only stain near-vertical surfaces here; the floor is already handled above.
|
|
||||||
if not wall_hit.is_empty() and absf((wall_hit.normal as Vector3).y) < 0.6:
|
|
||||||
_stamp(wall_hit.position, wall_hit.normal, size * 0.85)
|
|
||||||
|
|
||||||
|
|
||||||
func _ray(space: PhysicsDirectSpaceState3D, from: Vector3, to: Vector3) -> Dictionary:
|
|
||||||
var q := PhysicsRayQueryParameters3D.create(
|
|
||||||
from, to, PhysicsLayers.WORLD | PhysicsLayers.CORPSE
|
|
||||||
)
|
|
||||||
q.collide_with_bodies = true
|
|
||||||
return space.intersect_ray(q)
|
|
||||||
|
|
||||||
|
|
||||||
# A main splat plus a scatter of smaller droplets around it, all lying on the same surface.
|
|
||||||
func _stamp_cluster(pos: Vector3, normal: Vector3, size: float) -> void:
|
|
||||||
_stamp(pos, normal, size)
|
|
||||||
var n := normal.normalized()
|
|
||||||
if n.length_squared() < 0.5:
|
|
||||||
n = Vector3.UP
|
|
||||||
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
|
||||||
var tx := up.cross(n).normalized()
|
|
||||||
var ty := n.cross(tx).normalized()
|
|
||||||
var count := maxi(int(DP.f("blood_satellites")), 0)
|
|
||||||
for s in count:
|
|
||||||
var rad := size * _rng.randf_range(0.4, 1.2)
|
|
||||||
var a := _rng.randf() * TAU
|
|
||||||
var off := tx * (cos(a) * rad) + ty * (sin(a) * rad)
|
|
||||||
_stamp(pos + off, n, size * _rng.randf_range(0.25, 0.5))
|
|
||||||
|
|
||||||
|
|
||||||
# Write one splat into the ring buffer, oriented flat on the surface and lifted a hair along
|
|
||||||
# its normal (layered by write index) so coplanar quads don't z-fight the surface or each other.
|
|
||||||
func _stamp(pos: Vector3, normal: Vector3, size: float) -> void:
|
|
||||||
var n := normal.normalized()
|
|
||||||
if n.length_squared() < 0.5:
|
|
||||||
n = Vector3.UP
|
|
||||||
var i := _next
|
|
||||||
_next = (_next + 1) % _cap
|
|
||||||
var eps := 0.015 + float(i) * 0.00015
|
|
||||||
var yaw := _rng.randf() * TAU
|
|
||||||
_mm.set_instance_transform(i, Transform3D(_surface_basis(n, yaw, size), pos + n * eps))
|
|
||||||
var cell := float(_rng.randi_range(0, ATLAS_CELLS - 1))
|
|
||||||
var shade := _rng.randf_range(0.6, 1.0)
|
|
||||||
_mm.set_instance_custom_data(i, Color(cell, shade, 0.0, 0.0))
|
|
||||||
_filled = mini(_filled + 1, _cap)
|
|
||||||
_mm.visible_instance_count = _filled
|
|
||||||
|
|
||||||
|
|
||||||
# Basis for a QuadMesh (face along local +Z) lying flat on a surface with the given normal,
|
|
||||||
# spun by `yaw` about that normal and uniformly scaled to `size`.
|
|
||||||
func _surface_basis(n: Vector3, yaw: float, size: float) -> Basis:
|
|
||||||
var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
|
|
||||||
var x := up.cross(n).normalized()
|
|
||||||
var y := n.cross(x).normalized()
|
|
||||||
var c := cos(yaw)
|
|
||||||
var s := sin(yaw)
|
|
||||||
var b := Basis()
|
|
||||||
b.x = (x * c + y * s) * size
|
|
||||||
b.y = (y * c - x * s) * size
|
|
||||||
b.z = n
|
|
||||||
return b
|
|
||||||
|
|
||||||
|
|
||||||
# ── Atlas baking ──────────────────────────────────────────────────────────────
|
|
||||||
# Draw ATLAS_CELLS irregular blood shapes into one row. Each shape is a metaball field —
|
|
||||||
# a big central blob, a few overlapping lobes and some flung droplets — thresholded to an
|
|
||||||
# organic silhouette. Baked once per pool (once per level load, ~ms) with a fixed seed so
|
|
||||||
# the shape set is deterministic and every level's stains match.
|
|
||||||
|
|
||||||
func _bake_atlas() -> ImageTexture:
|
|
||||||
var img := Image.create(ATLAS_CELLS * CELL_PX, CELL_PX, false, Image.FORMAT_RGBA8)
|
|
||||||
img.fill(Color(0.0, 0.0, 0.0, 0.0))
|
|
||||||
var rng := RandomNumberGenerator.new()
|
|
||||||
rng.seed = hash("bullosseum-blood")
|
|
||||||
for c in ATLAS_CELLS:
|
|
||||||
_draw_splat(img, c * CELL_PX, rng)
|
|
||||||
return ImageTexture.create_from_image(img)
|
|
||||||
|
|
||||||
|
|
||||||
func _draw_splat(img: Image, ox: int, rng: RandomNumberGenerator) -> void:
|
|
||||||
var px := float(CELL_PX)
|
|
||||||
var mid := px * 0.5
|
|
||||||
# blobs are (centre_x, centre_y, radius) — a central mass, lobes, then far droplets.
|
|
||||||
var blobs: Array[Vector3] = []
|
|
||||||
blobs.append(Vector3(mid, mid, px * rng.randf_range(0.22, 0.30)))
|
|
||||||
for i in rng.randi_range(3, 6):
|
|
||||||
var a := rng.randf() * TAU
|
|
||||||
var d := px * rng.randf_range(0.10, 0.34)
|
|
||||||
blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.06, 0.16)))
|
|
||||||
for i in rng.randi_range(2, 5):
|
|
||||||
var a := rng.randf() * TAU
|
|
||||||
var d := px * rng.randf_range(0.30, 0.46)
|
|
||||||
blobs.append(Vector3(mid + cos(a) * d, mid + sin(a) * d, px * rng.randf_range(0.02, 0.05)))
|
|
||||||
|
|
||||||
for y in CELL_PX:
|
|
||||||
for x in CELL_PX:
|
|
||||||
var field := 0.0
|
|
||||||
for b in blobs:
|
|
||||||
var dx := float(x) - b.x
|
|
||||||
var dy := float(y) - b.y
|
|
||||||
field += (b.z * b.z) / (dx * dx + dy * dy + 1.0)
|
|
||||||
var a := smoothstep(0.75, 1.15, field)
|
|
||||||
if a <= 0.004:
|
|
||||||
continue
|
|
||||||
# Denser field (splat interior) reads a touch richer/darker than the thin edges.
|
|
||||||
var t := clampf(field * 0.5, 0.0, 1.0)
|
|
||||||
var col := Color(0.62 - 0.16 * t, 0.05 - 0.03 * t, 0.04 - 0.02 * t, a)
|
|
||||||
img.set_pixel(ox + x, y, col)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cs8fhiasw7q7j
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
shader_type spatial;
|
|
||||||
// Persistent blood splats, drawn as one MultiMesh of flat quads (see blood_decals.gd).
|
|
||||||
// Unshaded and nearest-filtered to sit inside the PS1 look; alpha-blended so overlapping
|
|
||||||
// splats pool darker. depth_draw_never + the small normal offset the pool bakes into each
|
|
||||||
// transform keep the coplanar floor/wall quads from z-fighting the surface they stain.
|
|
||||||
render_mode unshaded, cull_disabled, shadows_disabled, depth_draw_never, blend_mix;
|
|
||||||
|
|
||||||
uniform sampler2D atlas : source_color, filter_nearest;
|
|
||||||
// Number of splat shapes packed side-by-side in the atlas (one row).
|
|
||||||
uniform int cells = 8;
|
|
||||||
|
|
||||||
varying flat float v_cell;
|
|
||||||
varying flat float v_shade;
|
|
||||||
|
|
||||||
void vertex() {
|
|
||||||
// x = which atlas shape this instance uses; y = per-instance brightness (fresh vs old).
|
|
||||||
v_cell = INSTANCE_CUSTOM.x;
|
|
||||||
v_shade = INSTANCE_CUSTOM.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
void fragment() {
|
|
||||||
float u = (UV.x + v_cell) / float(cells);
|
|
||||||
vec4 c = texture(atlas, vec2(u, UV.y));
|
|
||||||
ALBEDO = c.rgb * v_shade;
|
|
||||||
ALPHA = c.a;
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://cj5rmaywacvbj
|
|
||||||
@@ -306,18 +306,6 @@ func _register_all() -> void:
|
|||||||
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
|
# it), so it can't be perma-stunlocked — it gets a window to commit an attack that then
|
||||||
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
|
# rides its hyper-armour. 0 = no poise (stun-locks under sustained fire).
|
||||||
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
|
_reg_f("Bear", "bear_stagger_cd", 1.0, 0.0, 4.0, 0.05)
|
||||||
# ── Blood ─────────────────────────────────────────────────────────────────
|
|
||||||
# Persistent floor/wall splats (blood_decals.gd), fired via the Gore autoload and
|
|
||||||
# gated by the player's Settings.gore. blood_cap is the ring-buffer size: past it,
|
|
||||||
# new splats overwrite the oldest so fill/memory stay bounded. blood_scale is a
|
|
||||||
# global size multiplier over each hit's own size; satellites are extra droplets
|
|
||||||
# scattered per floor cluster; the wall fan casts blood_wall_rays outward and stains
|
|
||||||
# any wall within blood_wall_reach metres of the hit.
|
|
||||||
_reg_f("Blood", "blood_cap", 192.0, 0.0, 512.0, 16.0)
|
|
||||||
_reg_f("Blood", "blood_scale", 1.0, 0.1, 4.0, 0.05)
|
|
||||||
_reg_f("Blood", "blood_satellites", 3.0, 0.0, 8.0, 1.0)
|
|
||||||
_reg_f("Blood", "blood_wall_reach", 2.5, 0.0, 6.0, 0.1)
|
|
||||||
_reg_f("Blood", "blood_wall_rays", 8.0, 0.0, 16.0, 1.0)
|
|
||||||
# ── Sword ─────────────────────────────────────────────────────────────────
|
# ── Sword ─────────────────────────────────────────────────────────────────
|
||||||
# Local seating of the sword in the right hand (drawn / fighting).
|
# Local seating of the sword in the right hand (drawn / fighting).
|
||||||
# The blade model runs along its local +Z, but weapon_bone (like every rig bone)
|
# The blade model runs along its local +Z, but weapon_bone (like every rig bone)
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
extends Node
|
|
||||||
## Stateless façade (autoload `Gore`) for spawning persistent blood splats. Callers just say
|
|
||||||
## Gore.splat(pos, dir, size) at each wound; this routes to the level's BloodDecals pool
|
|
||||||
## (group &"blood_decals"), which the game shell rebuilds into LevelRoot on every load so the
|
|
||||||
## stains die with the level. No-ops when there's no pool (menus, headless tests without a
|
|
||||||
## level) or when the player has turned gore off — so the gate lives in one place.
|
|
||||||
|
|
||||||
|
|
||||||
func splat(world_pos: Vector3, dir: Vector3 = Vector3.ZERO, size: float = 0.6) -> void:
|
|
||||||
if not Settings.gore:
|
|
||||||
return
|
|
||||||
var pool := get_tree().get_first_node_in_group(&"blood_decals")
|
|
||||||
if pool == null:
|
|
||||||
return
|
|
||||||
pool.call(&"splat", world_pos, dir, size * DP.f("blood_scale"))
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://b3dobof8qule3
|
|
||||||
@@ -30,9 +30,6 @@ func load_active_level() -> void:
|
|||||||
var module := level.level_scene.instantiate()
|
var module := level.level_scene.instantiate()
|
||||||
_tag_environment_for_corpses(module)
|
_tag_environment_for_corpses(module)
|
||||||
_level_root.add_child(module)
|
_level_root.add_child(module)
|
||||||
# A fresh blood-splat pool for this level, alongside the module under LevelRoot so it's
|
|
||||||
# torn down with everything else on the next load — stains never leak into the next fight.
|
|
||||||
_level_root.add_child(preload("res://blood_decals.gd").new())
|
|
||||||
|
|
||||||
|
|
||||||
## Ground and walls stay on WORLD so the bull and live matadors collide with them; here
|
## Ground and walls stay on WORLD so the bull and live matadors collide with them; here
|
||||||
|
|||||||
@@ -974,7 +974,6 @@ func _enter_ragdoll(hit_dir: Vector3, bull_speed: float, up_boost: float = 0.0)
|
|||||||
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
|
var throw_dir := (hit_dir + Vector3(0.0, 0.5, 0.0)).normalized()
|
||||||
|
|
||||||
_blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir)
|
_blood_burst.burst(global_position + Vector3(0.0, 0.9, 0.0), hit_dir)
|
||||||
Gore.splat(global_position, hit_dir, 0.55)
|
|
||||||
if _death_player:
|
if _death_player:
|
||||||
_death_player.pitch_scale = randf_range(0.9, 1.1)
|
_death_player.pitch_scale = randf_range(0.9, 1.1)
|
||||||
_death_player.play()
|
_death_player.play()
|
||||||
|
|||||||
@@ -1205,9 +1205,6 @@ func take_sword_hit(cause: String = "", hit_pos: Vector3 = Vector3.ZERO) -> void
|
|||||||
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
|
# bull). Melee gores pass no position and skip the spurt; gore can be turned off.
|
||||||
if cause == "thrown" and Settings.gore:
|
if cause == "thrown" and Settings.gore:
|
||||||
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
|
_spawn_blood(hit_pos if hit_pos != Vector3.ZERO else global_position)
|
||||||
# Any hit — melee or thrown — leaves a lasting stain on the ground the bull bled on.
|
|
||||||
var splat_dir := global_position - hit_pos if hit_pos != Vector3.ZERO else Vector3.ZERO
|
|
||||||
Gore.splat(global_position, splat_dir, 0.5)
|
|
||||||
if _huff_player:
|
if _huff_player:
|
||||||
_huff_player.pitch_scale = randf_range(0.7, 0.9)
|
_huff_player.pitch_scale = randf_range(0.7, 0.9)
|
||||||
_huff_player.play()
|
_huff_player.play()
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ Settings="*res://settings.gd"
|
|||||||
Run="*res://levels/run_state.gd"
|
Run="*res://levels/run_state.gd"
|
||||||
OrientationGuard="*res://orientation_guard.gd"
|
OrientationGuard="*res://orientation_guard.gd"
|
||||||
WebStartGate="*res://web_start_gate.gd"
|
WebStartGate="*res://web_start_gate.gd"
|
||||||
Gore="*res://gore.gd"
|
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ echo ""
|
|||||||
echo "=== Touch control tests (joystick self-heals, never strands) ==="
|
echo "=== Touch control tests (joystick self-heals, never strands) ==="
|
||||||
"$GODOT" --headless --script tests/touch_controls_test.gd
|
"$GODOT" --headless --script tests/touch_controls_test.gd
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== Blood decal tests (floor+wall stamps, ring-buffer cap, gore gate) ==="
|
|
||||||
"$GODOT" --headless --script tests/blood_decals_test.gd
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ==="
|
echo "=== Rigid-skin tests (web mesh conversion keeps all materials) ==="
|
||||||
"$GODOT" --headless --script tests/rigid_skin_test.gd
|
"$GODOT" --headless --script tests/rigid_skin_test.gd
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
extends SceneTree
|
|
||||||
## Persistent blood splats (blood_decals.gd + the Gore autoload). Guards four contracts:
|
|
||||||
## • a splat stamps quads onto the floor and onto a wall within reach of the hit;
|
|
||||||
## • the ring buffer caps the instance count no matter how many hits land;
|
|
||||||
## • Settings.gore = false spawns nothing;
|
|
||||||
## • the pool is a plain node, so it frees with its parent (a level swap clears the stains).
|
|
||||||
## blood_decals.gd references the DP autoload, so it's load()ed at runtime here (not a
|
|
||||||
## top-level preload) — under --script a preload compiles before autoloads register.
|
|
||||||
## Run: godot --headless --script tests/blood_decals_test.gd
|
|
||||||
|
|
||||||
var _pass := 0
|
|
||||||
var _fail := 0
|
|
||||||
|
|
||||||
|
|
||||||
func _init() -> void:
|
|
||||||
_run.call_deferred()
|
|
||||||
|
|
||||||
|
|
||||||
func _ok(c: bool, m: String) -> void:
|
|
||||||
if c:
|
|
||||||
_pass += 1
|
|
||||||
print(" PASS: ", m)
|
|
||||||
else:
|
|
||||||
_fail += 1
|
|
||||||
print(" FAIL: ", m)
|
|
||||||
|
|
||||||
|
|
||||||
# Count instances actually placed on the map. Only the first `visible_instance_count` slots of
|
|
||||||
# the ring buffer are drawn, so that's the live count once the buffer has wrapped.
|
|
||||||
func _live_instances(pool: Node) -> int:
|
|
||||||
var mm: MultiMesh = pool.multimesh
|
|
||||||
if mm == null:
|
|
||||||
return 0
|
|
||||||
return mm.visible_instance_count
|
|
||||||
|
|
||||||
|
|
||||||
func _static_box(size: Vector3, pos: Vector3) -> StaticBody3D:
|
|
||||||
var body := StaticBody3D.new()
|
|
||||||
body.collision_layer = 1 # PhysicsLayers.WORLD
|
|
||||||
body.position = pos
|
|
||||||
var shape := CollisionShape3D.new()
|
|
||||||
var box := BoxShape3D.new()
|
|
||||||
box.size = size
|
|
||||||
shape.shape = box
|
|
||||||
body.add_child(shape)
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
func _run() -> void:
|
|
||||||
var dp: Node = root.get_node("DP")
|
|
||||||
var settings: Node = root.get_node("Settings")
|
|
||||||
var cap := int(dp.f("blood_cap"))
|
|
||||||
|
|
||||||
# A minimal world: a floor plane and one vertical wall, both on WORLD so the splat rays hit.
|
|
||||||
var world := Node3D.new()
|
|
||||||
root.add_child(world)
|
|
||||||
world.add_child(_static_box(Vector3(40, 1, 40), Vector3(0, -0.5, 0))) # floor
|
|
||||||
world.add_child(_static_box(Vector3(1, 6, 40), Vector3(1.4, 3, 0))) # wall at x≈1.4
|
|
||||||
|
|
||||||
var pool: Node = (load("res://blood_decals.gd") as GDScript).new()
|
|
||||||
world.add_child(pool)
|
|
||||||
await create_timer(0.4).timeout
|
|
||||||
|
|
||||||
# Baked atlas exists and is one row of 8 shapes.
|
|
||||||
var atlas: Texture2D = pool.material_override.get_shader_parameter("atlas")
|
|
||||||
_ok(atlas != null and atlas.get_width() == 8 * 64, "splat atlas baked (8 cells)")
|
|
||||||
|
|
||||||
# A hit next to the wall stains both the floor beneath it and the wall.
|
|
||||||
settings.gore = true
|
|
||||||
pool.splat(Vector3(0.8, 0.1, 0.0), Vector3(1, 0, 0), 0.6)
|
|
||||||
var after_one := _live_instances(pool)
|
|
||||||
_ok(after_one > 0, "a splat stamps the floor (got %d quads)" % after_one)
|
|
||||||
# Floor cluster is 1 + satellites; anything beyond that came from the wall fan.
|
|
||||||
var floor_max := 1 + int(dp.f("blood_satellites"))
|
|
||||||
_ok(
|
|
||||||
after_one > floor_max,
|
|
||||||
"a nearby wall also catches blood (%d > floor-only %d)" % [after_one, floor_max]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ring buffer: hammer far past the cap; live count never exceeds it.
|
|
||||||
for i in cap * 2:
|
|
||||||
pool.splat(Vector3(0.0, 0.1, 0.0), Vector3(1, 0, 0), 0.4)
|
|
||||||
_ok(_live_instances(pool) <= cap, "ring buffer holds at the cap (%d)" % cap)
|
|
||||||
|
|
||||||
# Gore off: no new stains. Route through the Gore autoload to prove the gate lives there.
|
|
||||||
var before_off := _live_instances(pool)
|
|
||||||
settings.gore = false
|
|
||||||
root.get_node("Gore").splat(Vector3(5, 0.1, 0.0), Vector3.ZERO, 0.6)
|
|
||||||
_ok(_live_instances(pool) == before_off, "gore off spawns nothing")
|
|
||||||
|
|
||||||
# Lifetime: the pool is a plain node, so freeing its parent (a level swap) clears it.
|
|
||||||
world.queue_free()
|
|
||||||
await create_timer(0.2).timeout
|
|
||||||
_ok(not is_instance_valid(pool), "pool frees with its parent (no leak into next level)")
|
|
||||||
|
|
||||||
print("Results: %d passed, %d failed" % [_pass, _fail])
|
|
||||||
quit(1 if _fail > 0 else 0)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
uid://b8em8poj26a57
|
|
||||||
@@ -53,13 +53,8 @@ func _run() -> void:
|
|||||||
# Shell survives and hosts exactly one level module.
|
# Shell survives and hosts exactly one level module.
|
||||||
var level_root: Node = scene.get_node_or_null("LevelRoot")
|
var level_root: Node = scene.get_node_or_null("LevelRoot")
|
||||||
_ok(level_root != null, "shell has a LevelRoot")
|
_ok(level_root != null, "shell has a LevelRoot")
|
||||||
# LevelRoot holds the module plus the level's blood-splat pool; exactly one of them
|
# LevelRoot holds exactly the one level module.
|
||||||
# is the geometry module (the other is BloodDecals).
|
var module_children := level_root.get_child_count() if level_root != null else 0
|
||||||
var module_children := 0
|
|
||||||
if level_root != null:
|
|
||||||
for child: Node in level_root.get_children():
|
|
||||||
if not child.is_in_group(&"blood_decals"):
|
|
||||||
module_children += 1
|
|
||||||
_ok(module_children == 1, "exactly one level module loaded")
|
_ok(module_children == 1, "exactly one level module loaded")
|
||||||
_ok(not get_nodes_in_group(&"player").is_empty(), "player (shell) present")
|
_ok(not get_nodes_in_group(&"player").is_empty(), "player (shell) present")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user