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)