Add blood decals, gore, mobile HUD, web start gate + touch/perf tests

Remove tools/fstest.html scratch page used to probe browser
fullscreen/orientation APIs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EznnY8rH2dXhtono1kwsXg
This commit is contained in:
2026-09-04 14:07:57 +03:00
parent 01d6ecf475
commit 981ebf1910
32 changed files with 1536 additions and 113 deletions
+97
View File
@@ -0,0 +1,97 @@
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
View File
@@ -0,0 +1 @@
uid://b8em8poj26a57
+8 -1
View File
@@ -53,7 +53,14 @@ func _run() -> void:
# Shell survives and hosts exactly one level module.
var level_root: Node = scene.get_node_or_null("LevelRoot")
_ok(level_root != null, "shell has a LevelRoot")
_ok(level_root != null and level_root.get_child_count() == 1, "exactly one level module loaded")
# LevelRoot holds the module plus the level's blood-splat pool; exactly one of them
# is the geometry module (the other is BloodDecals).
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(not get_nodes_in_group(&"player").is_empty(), "player (shell) present")
# The leak we are guarding against: no arena geometry may exist in the bear level.
+201
View File
@@ -0,0 +1,201 @@
extends SceneTree
## Micro-benchmark for the on-hit ragdoll spike.
##
## On mobile the profiler reads SCRIPT-bound (main-thread GDScript, one thread) exactly when the
## bull's attack connects and a matador is hit. This times the pieces of that synchronous path so
## we can see which op eats the milliseconds instead of guessing:
## • spawn — _matador.instantiate() + add_child (_ready builds the 21-bone ragdoll rig)
## • sim start — PhysicalBoneSimulator3D.physical_bones_start_simulation() (Jolt makes bodies)
## • blood burst — the CPUParticles3D one-shot fired on death
## • full hit — apply_ability_hit() end to end (what the player actually triggers)
## • MASS hit — hitting N matadors in ONE frame (what a slam / roll does — the real spike)
##
## Run: godot --headless --script res://tests/ragdoll_perf_test.gd
## It PRINTS per-op avg/worst ms and names the dominant cost; it also fails (exit 1) if a single
## hit or a mass-hit frame blows a generous budget, so it doubles as a regression guard.
# Loaded at runtime (not preload): a preload here would compile matador.gd at this entry
# script's parse time, before the autoloads (DP / Controls) register as global identifiers,
# so matador.gd's `Controls.rigid_skin_enabled()` would fail to resolve. load() in _run runs
# after the tree — and its autoloads — are up.
var _matador: PackedScene = null
const N := 8 # matadors sampled per op
const MASS := 6 # matadors hit in one frame (a slam catching a cluster)
const SINGLE_HIT_BUDGET_MS := 12.0
const MASS_HIT_BUDGET_MS := 33.0 # two 60fps frames — a slam may cost a hitch, not a freeze
var _fail := 0
func _init() -> void:
_run.call_deferred()
func _us() -> int:
return Time.get_ticks_usec()
func _stats(us: Array) -> Dictionary:
var total := 0
var worst := 0
for v: int in us:
total += v
worst = maxi(worst, v)
var avg := (total / us.size()) if us.size() > 0 else 0
return {"avg_ms": avg / 1000.0, "max_ms": worst / 1000.0}
func _sample(frames: int) -> Dictionary:
var proc_sum := 0.0
var phys_sum := 0.0
for f: int in frames:
await process_frame
proc_sum += Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0
phys_sum += Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0
return {"proc_ms": proc_sum / frames, "phys_ms": phys_sum / frames}
func _spawn_one() -> Node3D:
var m: Node3D = _matador.instantiate()
m.position = Vector3(randf_range(-20.0, 20.0), 0.0, randf_range(-20.0, 20.0))
root.add_child(m)
return m
func _run() -> void:
# Warm up: first instance pays one-time import/JIT/shader costs that would skew sample 1.
_matador = load("res://Matador.tscn") as PackedScene
var warm := _spawn_one()
await process_frame
warm.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
await process_frame
warm.free()
await process_frame
print("[stage] warmup done")
# ── spawn: instantiate() vs add_child(_ready = ragdoll build) ───────────────
var inst_us: Array = []
var ready_us: Array = []
for i: int in N:
var t0 := _us()
var m: Node3D = _matador.instantiate()
var t1 := _us()
root.add_child(m) # _ready runs synchronously → MatadorRagdoll.build (21 bodies + shapes)
var t2 := _us()
inst_us.append(t1 - t0)
ready_us.append(t2 - t1)
m.free()
await process_frame
print("[stage] spawn done")
# ── component: sim start (Jolt body creation) and blood burst, in isolation ──
var simstart_us: Array = []
var burst_us: Array = []
for i: int in N:
var m := _spawn_one()
await process_frame
var sim: Node = m.get(&"_sim")
if sim != null:
sim.set("active", true)
var t0 := _us()
sim.call(&"physical_bones_start_simulation")
simstart_us.append(_us() - t0)
var blood: Node = m.get(&"_blood_burst")
if blood != null:
var t2 := _us()
blood.call(&"burst", m.global_position + Vector3(0, 0.9, 0), Vector3(1, 0, 0))
burst_us.append(_us() - t2)
m.free()
await process_frame
print("[stage] components done")
# ── full hit: apply_ability_hit end to end (state WANDER → RAGDOLL) ──────────
var hit_us: Array = []
for i: int in N:
var m := _spawn_one()
await process_frame
var t0 := _us()
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
hit_us.append(_us() - t0)
await process_frame
m.free()
await process_frame
print("[stage] full-hit done")
# ── MASS hit: MASS matadors ragdolled in ONE frame (a slam catching a cluster) ─
var cluster: Array = []
for i: int in MASS:
cluster.append(_spawn_one())
await process_frame
await process_frame
var mt0 := _us()
for m: Node3D in cluster:
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
var mass_ms := (_us() - mt0) / 1000.0
for m: Node3D in cluster:
m.free()
await process_frame
print("[stage] mass done")
# ── STEADY load: per-frame cost of live_n ragdolls ALIVE at once (they live ~4 s each) ─
# The hit is instantaneous; the drag is every ragdoll still simulating afterward. Sample
# the frame cost with live_n matadors idle, then with all live_n ragdolling, and report the delta —
# split process (idle-frame GDScript = the mobile "SCRIPT" bucket) vs physics (the Jolt step).
var live_n := 10
var steady: Array = []
for i: int in live_n:
steady.append(_spawn_one())
for f: int in 15:
await process_frame
var idle: Dictionary = await _sample(20)
for m: Node3D in steady:
m.call(&"apply_ability_hit", Vector3(1.0, 0.0, 0.0), 12.0)
for f: int in 3:
await process_frame
var active: Dictionary = await _sample(20)
for m: Node3D in steady:
if is_instance_valid(m):
m.free()
# ── report ──────────────────────────────────────────────────────────────────
var inst := _stats(inst_us)
var rdy := _stats(ready_us)
var ss := _stats(simstart_us)
var bu := _stats(burst_us)
var hit := _stats(hit_us)
print("\n==== on-hit ragdoll cost (per matador, avg / worst) ====")
print(" instantiate() %6.2f / %6.2f ms" % [inst["avg_ms"], inst["max_ms"]])
print(" add_child (_ready build)%6.2f / %6.2f ms" % [rdy["avg_ms"], rdy["max_ms"]])
print(" sim start (Jolt bodies) %6.2f / %6.2f ms" % [ss["avg_ms"], ss["max_ms"]])
print(" blood burst %6.2f / %6.2f ms" % [bu["avg_ms"], bu["max_ms"]])
print(" FULL apply_ability_hit %6.2f / %6.2f ms" % [hit["avg_ms"], hit["max_ms"]])
print(" MASS hit (%d in 1 frame) %6.2f ms total" % [MASS, mass_ms])
print("---- steady per-frame cost, %d matadors idle vs ragdolling ----" % live_n)
print(" idle: process %5.2f ms physics %5.2f ms" % [idle["proc_ms"], idle["phys_ms"]])
print(" ragdolling: process %5.2f ms physics %5.2f ms" % [active["proc_ms"], active["phys_ms"]])
print(" delta/%d ragdolls: process +%5.2f ms physics +%5.2f ms (per ragdoll ~%.2f / %.2f ms)" % [
live_n, active["proc_ms"] - idle["proc_ms"], active["phys_ms"] - idle["phys_ms"],
(active["proc_ms"] - idle["proc_ms"]) / live_n, (active["phys_ms"] - idle["phys_ms"]) / live_n])
# Name the dominant component of the full hit so the fix target is obvious.
var parts := {"sim start": ss["avg_ms"], "blood burst": bu["avg_ms"]}
var worst_name := "sim start"
var worst_val := -1.0
for k: String in parts:
if parts[k] > worst_val:
worst_val = parts[k]
worst_name = k
print(" >> dominant hit cost: %s (%.2f ms of the %.2f ms hit)" % [
worst_name, worst_val, hit["avg_ms"]])
if hit["max_ms"] > SINGLE_HIT_BUDGET_MS:
_fail += 1
print(" FAIL: worst single hit %.2f ms > %.1f ms budget" % [hit["max_ms"], SINGLE_HIT_BUDGET_MS])
if mass_ms > MASS_HIT_BUDGET_MS:
_fail += 1
print(" FAIL: mass hit %.2f ms > %.1f ms budget" % [mass_ms, MASS_HIT_BUDGET_MS])
print("Results: %s" % ("FAIL (%d)" % _fail if _fail > 0 else "PASS"))
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://c8jvxwwmg7cp1
+93
View File
@@ -0,0 +1,93 @@
extends SceneTree
## Guard for the web/mobile rigid-skin conversion (rigid_skin.gd). The bug it protects against:
## the converter used to key geometry by destination bone only, so every surface funnelled to a
## bone collapsed onto one material — matadors came out bald with the wrong uniform colour on the
## web build. It now keys per (bone, surface), so all source materials survive. This asserts the
## matador keeps every distinct material after conversion, produces bone-attached pieces, and
## hides the original skinned meshes (so they never hit the invisible-on-Mali skinning path).
## Run: godot --headless --script res://tests/rigid_skin_test.gd
const RigidSkin = preload("res://rigid_skin.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)
func _surface_mats(mi: MeshInstance3D) -> Array:
var out: Array = []
var mesh := mi.mesh
if mesh == null:
return out
for s: int in mesh.get_surface_count():
var mat: Material = mi.material_override
if mat == null:
mat = mi.get_surface_override_material(s)
if mat == null:
mat = mesh.surface_get_material(s)
if mat != null and not out.has(mat):
out.append(mat)
return out
func _run() -> void:
var inst: Node = (load("res://Assets/Matador.glb") as PackedScene).instantiate()
root.add_child(inst)
# Distinct materials on the skinned source meshes, before conversion.
var source: Array = []
var skinned: Array = []
for mi: MeshInstance3D in inst.find_children("*", "MeshInstance3D", true, false):
if mi.skin != null:
skinned.append(mi)
for mat: Material in _surface_mats(mi):
if not source.has(mat):
source.append(mat)
_ok(source.size() >= 2, "matador source has multiple distinct materials (%d)" % source.size())
var converted := RigidSkin.convert_tree(inst)
_ok(converted > 0, "convert_tree rebuilt %d skinned mesh(es)" % converted)
# Distinct materials on the generated bone-attached pieces.
var pieces := 0
var piece_mats: Array = []
for att: BoneAttachment3D in inst.find_children("*", "BoneAttachment3D", true, false):
for mi: MeshInstance3D in att.find_children("*", "MeshInstance3D", true, false):
pieces += 1
var mesh := mi.mesh
if mesh == null:
continue
# Pieces are multi-surface now (a bone's materials each stay their own surface), so
# scan every surface — not just surface 0 — to confirm none were dropped.
for s: int in mesh.get_surface_count():
var mat := mesh.surface_get_material(s)
if mat != null and not piece_mats.has(mat):
piece_mats.append(mat)
_ok(pieces > 0, "conversion produced bone-attached pieces (%d)" % pieces)
_ok(piece_mats.size() >= source.size(),
"every source material survives conversion (%d of %d kept)" % [piece_mats.size(), source.size()])
var all_hidden := true
for mi: MeshInstance3D in skinned:
if mi.visible:
all_hidden = false
_ok(all_hidden, "original skinned meshes are hidden (never drawn on the failing path)")
_finish()
func _finish() -> void:
print("Results: %d passed, %d failed" % [_pass, _fail])
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://qmf1j0ytjag5
+123
View File
@@ -0,0 +1,123 @@
extends SceneTree
## Guardrail for the on-screen joystick's self-healing. The reported bug is a stick that
## "disappears / goes non-responsive": a touchend dropped during a resize/orientation flip (or
## an app backgrounding) leaves _stick_active stuck true, so the ghost hint hides and no new
## stick can start — and the bull keeps coasting on the held move actions. touch_controls.gd
## now resets on relayout + focus-out, prunes orphaned touches, and lets the latest left-zone
## touch re-acquire the stick. This drives synthetic touch events and asserts the stick never
## stays stranded and never leaves movement held.
## Run: godot --headless --script res://tests/touch_controls_test.gd
const _MOVE := [&"move_forward", &"move_back", &"move_left", &"move_right"]
var _pass := 0
var _fail := 0
var _tc: CanvasLayer
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)
func _press(index: int, pos: Vector2) -> void:
var e := InputEventScreenTouch.new()
e.index = index
e.position = pos
e.pressed = true
_tc._input(e)
func _release(index: int, pos: Vector2) -> void:
var e := InputEventScreenTouch.new()
e.index = index
e.position = pos
e.pressed = false
_tc._input(e)
func _drag(index: int, pos: Vector2) -> void:
var e := InputEventScreenDrag.new()
e.index = index
e.position = pos
_tc._input(e)
func _any_move_held() -> bool:
for a: StringName in _MOVE:
if Input.is_action_pressed(a):
return true
return false
# A point safely inside the left-hand joystick zone (independent of viewport size), plus the
# same point pushed a full radius "up" so the stick engages move_forward.
func _stick_origin() -> Vector2:
return _tc._stick_zone.position + _tc._stick_zone.size * Vector2(0.25, 0.5)
func _run() -> void:
var dp: Node = root.get_node("/root/DP")
var controls: Node = root.get_node("/root/Controls")
dp.call("set_value", "force_touch_controls", true)
_ok(controls.call("use_touch_ui"), "force flag makes use_touch_ui() true (touch UI live)")
# Headless boots a 64x64 root viewport, which collapses every UI rect on top of each other.
# Give it a real phone-ish landscape size so the joystick zone / buttons lay out sanely.
root.size = Vector2i(1152, 648)
_tc = (load("res://touch_controls.gd") as Script).new()
root.add_child(_tc)
await process_frame # _ready builds _surface and runs the first _relayout
_tc._relayout() # ensure the layout reflects the size set above
var o := _stick_origin()
var up := o + Vector2(0.0, -_tc._stick_radius)
# (a) A resize mid-drag (dropped touchend) must not strand the stick or keep the bull moving.
_tc._reset_touch_state()
_press(0, o)
_drag(0, up)
_ok(_tc._stick_active and _any_move_held(), "drag engages the stick and holds movement")
_tc._relayout() # the resize/orientation flip that used to swallow the release
_ok(not _tc._stick_active, "a relayout mid-drag stands the stick down (not stranded)")
_ok(not _any_move_held(), "movement is released on relayout (bull doesn't coast)")
# (b) App backgrounded / focus lost — same self-heal via the notification hook.
_tc._reset_touch_state()
_press(0, o)
_drag(0, up)
_tc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_OUT)
_ok(not _tc._stick_active and not _any_move_held(), "focus-out clears the stick and movement")
# (c) A stranded stick self-heals: with finger 0's release lost, a fresh finger 1 in the zone
# re-acquires the stick; finger 0's late release must NOT kill finger 1's stick.
_tc._reset_touch_state()
_press(0, o) # finger 0 owns the stick
_press(1, o + Vector2(20, 10)) # finger 1 lands while 0 is still (wrongly) held → takes over
_ok(_tc._stick_active and _tc._stick_index == 1, "latest left-zone touch re-acquires the stick")
_release(0, o) # the stranded finger finally reports up
_ok(_tc._stick_active and _tc._stick_index == 1, "a superseded finger's release doesn't end the stick")
_release(1, o)
_ok(not _tc._stick_active, "releasing the owning finger ends the stick")
# (d) Watchdog: a touch that vanishes from tracking (no release event at all) is pruned.
_tc._reset_touch_state()
_press(0, o)
_tc._touches.erase(0) # simulate the finger silently disappearing
_tc._process(0.016) # prune runs each frame
_ok(not _tc._stick_active and not _any_move_held(), "watchdog prunes a vanished touch and frees the stick")
_finish()
func _finish() -> void:
print("Results: %d passed, %d failed" % [_pass, _fail])
quit(1 if _fail > 0 else 0)
+1
View File
@@ -0,0 +1 @@
uid://behtmgyi2ktm0