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