From 64b3543e8a21bcf6db93cf0e1b31892e9bc7b80f Mon Sep 17 00:00:00 2001 From: Monster Robot Party Date: Tue, 4 Aug 2026 00:13:44 +1000 Subject: [PATCH] LANE9: make the stillness gate deterministic (scales + packed stacks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- game/scripts/Main.gd | 87 +++++++++++++++++++------------------------- 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index 16c9110..b6e7a35 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -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