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:
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8em8poj26a57
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c8jvxwwmg7cp1
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://qmf1j0ytjag5
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://behtmgyi2ktm0
|
||||
Reference in New Issue
Block a user