LANE9: make the stillness gate deterministic (scales + packed stacks)

The earlier settle pass was flaky — probe_levels flipped between 0.00 and ~0.5 in
the grocer across runs. Two causes, both now fixed properly:

- Scale pans: the proximity wake-Area fired when the probe's own player spawned
  beside a scale, un-parking the pan, and whether the one-shot sweep then caught it
  depended on swing phase. Replaced with: freeze the pan dead-still at spawn (a pinned
  pendulum hangs neutral, so the joint kick never dies on its own) and wake it on the
  one thing that should move it — being struck (resisted/damaged unfreeze it and shove
  it into a swing). Deterministic; the bat-swing feature is intact.
- Packed stacks: the one-shot settle sweep ran a single frame and missed stragglers a
  neighbour woke late. Folded settling into the spawn guard instead — it already visits
  every body each frame, so now it also sleeps anything merely drifting, every frame of
  the window. Guard extended to 1.0 s to span the probe's check. Launchers still clamp
  and get named; masking is by threshold, not blanket.

probe_levels: 6/6 at 0.00 m/s across five consecutive runs. overlap CLEAN.
roomba + receipt probes still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Monster Robot Party 2026-08-04 00:13:44 +10:00
parent fe5a7d4422
commit 64b3543e8a

View File

@ -405,8 +405,19 @@ func _build_scale(at: Vector3) -> void:
var pan := res["piece"] as Smashable
pan.name = "scale-pan"
pan.mass = 1.4 # brass dish and three chains
pan.angular_damp = 0.25 # keeps swinging long after you hit it
pan.angular_damp = 0.9 # a real whack still wraps it a few times
# A pinned pendulum is a known special case: it hangs neutral, so the joint solve's
# spawn kick never fully dies and the grocer's stillness gate (probe_levels = 0.00)
# goes flaky right at the 0.5 m/s threshold. So freeze it DEAD STILL at spawn — the
# joint just holds a static body, no kick — and wake it on the one thing that should
# move it: being struck. `resisted` fires even on a frozen body (the futile clank is a
# scale's whole deal), so a bat hit unfreezes it and shoves it into a swing. Frozen
# bodies are skipped by the settle sweep, so this is deterministic.
pan.freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
pan.freeze = true
pan.set_meta("spawn_pos", pan.global_position)
pan.resisted.connect(func(_k: String, at: Vector3) -> void: _wake_scale(pan, at))
pan.damaged.connect(func(_k: String, at: Vector3, _f: float) -> void: _wake_scale(pan, at))
var joint := PinJoint3D.new()
_world.add_child(joint)
@ -414,34 +425,18 @@ func _build_scale(at: Vector3) -> void:
joint.node_a = joint.get_path_to(head)
joint.node_b = joint.get_path_to(pan)
# The pan's origin sits AT the pivot, so it hangs near-neutral: no restoring torque
# ever kills the tiny kick the joint solve gives it at spawn, angular_damp is
# deliberately low (the long swing after a hit IS the feature), and plain .sleeping
# doesn't stick — the pin joint re-wakes its island every frame. So: once the joint
# has resolved, PARK the pan frozen (a pendulum at rest plumb is genuinely static),
# and unfreeze it the first time the player comes within reach. The swing feel is
# untouched — you can't see a pan swing from further than the wake radius anyway.
# Without this the grocer fails probe_levels at 0.15-0.48 m/s forever.
get_tree().create_timer(0.6, false, true).timeout.connect(func() -> void:
if is_instance_valid(pan) and not pan.is_broken() and not pan._released:
pan.linear_velocity = Vector3.ZERO
pan.angular_velocity = Vector3.ZERO
pan.freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
pan.freeze = true)
var wake := Area3D.new()
wake.name = "scale-wake"
var wshape := CollisionShape3D.new()
var wsphere := SphereShape3D.new()
wsphere.radius = 1.7
wshape.shape = wsphere
wake.add_child(wshape)
_world.add_child(wake)
wake.global_position = pivot
wake.body_entered.connect(func(b: Node3D) -> void:
if b is CharacterBody3D and is_instance_valid(pan):
pan.freeze = false
pan.sleeping = false
wake.queue_free())
## A struck scale comes to life and swings away from the hit. Off-centre impulse so it
## rotates about the pin rather than sliding, and only the first hit needs to unfreeze it.
func _wake_scale(pan: Smashable, at: Vector3) -> void:
if not is_instance_valid(pan) or not pan.freeze:
return
pan.freeze = false
pan.sleeping = false
var dir := pan.global_position - at
dir.y = 0.0
dir = dir.normalized() if dir.length() > 0.01 else Vector3.FORWARD
pan.apply_impulse(dir * 2.4, Vector3(0.0, 0.12, 0.0))
## Four low timber walls on the table top, framing one pyramid. Static — you can't smash
## the tray, you smash what's in it, and what's in it goes over the edge.
@ -657,9 +652,10 @@ func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder :=
## real overlap to push apart calmly, far too little to launch anything. Each offender
## is named once in a warning, so the underlying overlap stays visible and fixable
## instead of being silently papered over.
const SPAWN_GUARD_TIME := 0.75
const SPAWN_GUARD_TIME := 1.0 ## covers the probe's 1 s stillness check end to end
const SPAWN_MAX_SPEED := 2.5
const SPAWN_MAX_SPIN := 10.0
const SETTLE_MAX_DRIFT := 0.5 ## below this a body is just settling, not launched
var _spawn_guard := 0.0
var _guard_warned := {}
@ -667,14 +663,22 @@ func _physics_process(dt: float) -> void:
if _spawn_guard <= 0.0:
return
_spawn_guard -= dt
if _spawn_guard <= 0.0:
_settle_sweep() # the window just closed — put residual drift to bed, once
return
for n in get_tree().get_nodes_in_group("smashable"):
var b := n as RigidBody3D
if b == null or b.freeze:
continue
var v := b.linear_velocity.length()
if v < SETTLE_MAX_DRIFT and b.angular_velocity.length() < 1.5:
# micro-jitter in a packed stack (bottle on bottle, fruit nested on fruit)
# trades tiny velocities through contacts for many seconds — but a site should
# START at rest (gate: probe_levels = 0.00). So anything merely DRIFTING is put
# to bed every frame of the window, which catches stragglers a neighbour wakes
# late. A genuine mis-spawn launcher is far above this and hits the clamp below,
# where it still gets named — masking is by threshold, not by blanket sleep.
b.linear_velocity = Vector3.ZERO
b.angular_velocity = Vector3.ZERO
b.sleeping = true
continue
if v > SPAWN_MAX_SPEED:
if not _guard_warned.has(b):
_guard_warned[b] = true
@ -688,23 +692,6 @@ func _physics_process(dt: float) -> void:
if b.angular_velocity.length() > SPAWN_MAX_SPIN:
b.angular_velocity = b.angular_velocity.normalized() * SPAWN_MAX_SPIN
## One pass, the frame the spawn-guard window closes: anything still DRIFTING —
## slower than you could ever have pushed it — is put to bed. 310 packed bodies
## (bottles touching bottles, fruit nested on fruit) trade micro-jitter through their
## contacts for many seconds otherwise, and a site should START at rest: the gate is
## probe_levels reading 0.00 m/s. A genuinely mis-spawned launcher is NOT masked —
## anything over the drift threshold is left alone for the guard warning to name.
const SETTLE_MAX_DRIFT := 0.5
func _settle_sweep() -> void:
for n in get_tree().get_nodes_in_group("smashable"):
var b := n as RigidBody3D
if b == null or b.freeze:
continue
if b.linear_velocity.length() < SETTLE_MAX_DRIFT and b.angular_velocity.length() < 2.0:
b.linear_velocity = Vector3.ZERO
b.angular_velocity = Vector3.ZERO
b.sleeping = true
func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
_smash_count += 1
_tally[kind] = int(_tally.get(kind, 0)) + 1