LANE9: the Roomba — company property that eats your evidence
A robot vacuum (scripts/Roomba.gd, kind=steel) docks near spawn, lifeless, and deploys the moment the first thing in the site breaks: the cleaner arrives because there is mess. It patrols, sniffs out debris within 5 m, and eats it — each shard shrinks into the intake, beeps smugly, and takes 2 points off your score. Dent it (crowbar and up — the matrix already knew) and it panics and flees at 2.7x cruise. A sledgehammer genuinely kills it, at which point the payslip bills you for a destroyed Roomba, because it was company property and you were seen. Voice is three synthesized chirps, no assets. Not spawned in LEVEL 0 — nothing cute lives in the Backrooms. Docked = genuinely asleep, so probe_levels stays 0.00. dev/probe_roomba.gd measures the contract: docked at spawn, wakes on first smash, shard dropped in its path is eaten within 5 s and the score goes down. ROOMBA OK. probe_levels 6x 0.00 m/s - probe_overlap CLEAN. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0e80d393c1
commit
32b556ff02
75
game/dev/probe_roomba.gd
Normal file
75
game/dev/probe_roomba.gd
Normal file
@ -0,0 +1,75 @@
|
||||
extends SceneTree
|
||||
|
||||
## The Roomba contract, measured:
|
||||
## 1. docked at spawn — contributes nothing to residual motion (probe_levels covers
|
||||
## the number; here we assert it is genuinely asleep and un-awake)
|
||||
## 2. wakes on the first smash in the site
|
||||
## 3. eats debris: a shard dropped in front of it is gone within a few seconds,
|
||||
## and the score went DOWN for it
|
||||
##
|
||||
## Godot --headless --path game --script dev/probe_roomba.gd
|
||||
|
||||
func _initialize() -> void:
|
||||
var main: Node = (load("res://main.tscn") as PackedScene).instantiate()
|
||||
get_root().add_child(main)
|
||||
await process_frame
|
||||
for i in 70:
|
||||
await physics_frame
|
||||
|
||||
var roombas := get_nodes_in_group("roomba")
|
||||
if roombas.size() != 1:
|
||||
print("FAIL: expected 1 roomba in scranton, got %d" % roombas.size())
|
||||
quit(1)
|
||||
return
|
||||
var r: Roomba = roombas[0]
|
||||
print("docked: awake=%s sleeping=%s at %s" % [r._awake, r.sleeping, r.global_position.snappedf(0.01)])
|
||||
if r._awake:
|
||||
print("FAIL: roomba should be docked until the first smash")
|
||||
quit(1)
|
||||
return
|
||||
|
||||
# smash the nearest smashable that isn't the roomba -> it must wake
|
||||
var victim: Smashable = null
|
||||
var best := INF
|
||||
for n in get_nodes_in_group("smashable"):
|
||||
if n is Roomba or not (n is Smashable) or (n as RigidBody3D).freeze:
|
||||
continue
|
||||
var d: float = (n.global_position - r.global_position).length_squared()
|
||||
if d < best:
|
||||
best = d
|
||||
victim = n
|
||||
victim.smash(Vector3(0, 2, 0), 999.0)
|
||||
await physics_frame
|
||||
if not r._awake:
|
||||
print("FAIL: roomba did not wake on the first smash")
|
||||
quit(1)
|
||||
return
|
||||
print("woke on first smash: OK")
|
||||
|
||||
# drop a fresh piece of debris directly in its path and count down
|
||||
var score_before: int = main.get("_score")
|
||||
var shard := RigidBody3D.new()
|
||||
shard.add_to_group("debris")
|
||||
var col := CollisionShape3D.new()
|
||||
var box := BoxShape3D.new()
|
||||
box.size = Vector3(0.06, 0.06, 0.06)
|
||||
col.shape = box
|
||||
shard.add_child(col)
|
||||
main.get("_world").add_child(shard)
|
||||
shard.global_position = r.global_position + Vector3(0.05, 0.12, 0.0)
|
||||
|
||||
var eaten := false
|
||||
for i in 300: # up to 5 s
|
||||
await physics_frame
|
||||
if not is_instance_valid(shard) or shard.is_queued_for_deletion():
|
||||
eaten = true
|
||||
break
|
||||
var score_after: int = main.get("_score")
|
||||
print("shard eaten=%s score %d -> %d roomba _eaten=%d" % [
|
||||
eaten, score_before, score_after, r._eaten])
|
||||
if eaten and r._eaten >= 1:
|
||||
print("ROOMBA OK")
|
||||
quit(0)
|
||||
else:
|
||||
print("FAIL: shard survived the roomba for 5 s")
|
||||
quit(1)
|
||||
1
game/dev/probe_settle.gd.uid
Normal file
1
game/dev/probe_settle.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bitprxa12yr8v
|
||||
@ -43,6 +43,7 @@ var _gauntlet: Gauntlet
|
||||
var _dust: Dust
|
||||
var _backrooms: Backrooms
|
||||
var _splat: Splat
|
||||
var _roomba: Roomba # the debris-eating antagonist — see Roomba.gd
|
||||
|
||||
func _ready() -> void:
|
||||
randomize()
|
||||
@ -269,6 +270,17 @@ func _populate() -> void:
|
||||
_glb_piece(CHAIR_GLB, "plastic", Vector2(t.origin.x, t.origin.z), false, 0.0,
|
||||
t.basis.get_euler().y)
|
||||
|
||||
# --- the cleaner: docked near spawn, lifeless until the first thing breaks. Not in
|
||||
# LEVEL 0 — nothing cute lives in the Backrooms. See Roomba.gd. ---
|
||||
_roomba = null
|
||||
if _level_id != "backrooms":
|
||||
_roomba = Roomba.new()
|
||||
_world.add_child(_roomba)
|
||||
var sp := _plan.spawn_point()
|
||||
_roomba.global_position = Vector3(sp.x, 0.02, sp.z) + Vector3(1.3, 0.0, 0.8)
|
||||
_roomba.ate_debris.connect(_on_roomba_ate)
|
||||
_wire(_roomba) # its death should score/bill like any other company property
|
||||
|
||||
print("[art] records skinned with sleeve art: ", _records_skinned)
|
||||
|
||||
## Spawn one grabbable Record (sleeve + nested disc) resting on `sit_on`. Mirrors
|
||||
@ -717,6 +729,17 @@ func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
|
||||
_backrooms.on_noise(at, 1.0)
|
||||
if _game_mode != null:
|
||||
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
|
||||
# the first break in the site deploys the cleaner (it may BE the thing smashed —
|
||||
# wake() no-ops on a broken Roomba)
|
||||
if _roomba != null and is_instance_valid(_roomba):
|
||||
_roomba.wake()
|
||||
|
||||
func _on_roomba_ate(at: Vector3) -> void:
|
||||
_score = maxi(0, _score - 2)
|
||||
if _juice != null:
|
||||
_juice.add_trauma(0.04)
|
||||
if _hud != null and randf() < 0.30:
|
||||
_hud.toast("THE ROOMBA IS EATING YOUR MESS")
|
||||
|
||||
## A swing that couldn't hurt what it hit. No score, no combo — just the clank, plus a
|
||||
## one-time nudge toward the right tool.
|
||||
|
||||
226
game/scripts/Roomba.gd
Normal file
226
game/scripts/Roomba.gd
Normal file
@ -0,0 +1,226 @@
|
||||
extends Smashable
|
||||
class_name Roomba
|
||||
|
||||
## The anti-combo antagonist: a robot vacuum that patrols the site EATING YOUR DEBRIS,
|
||||
## and every shard it eats comes off your score. It is company property (steel), so the
|
||||
## weapon matrix already knows what to do with it: bare hands clank off it, a crowbar
|
||||
## dents it (it panics and flees), and a sledgehammer genuinely kills it — at which point
|
||||
## the payslip bills you for a destroyed Roomba, because of course it does.
|
||||
##
|
||||
## It spawns DOCKED and lifeless, and deploys the moment the first thing in the site
|
||||
## breaks: the cleaner arrives because there is mess. (This is also what keeps
|
||||
## probe_levels honest — a docked Roomba is genuinely at rest.)
|
||||
##
|
||||
## All audio synthesized in _ready — no assets. House rule: nothing explains it.
|
||||
|
||||
signal ate_debris(at: Vector3)
|
||||
|
||||
const CRUISE := 0.85 ## m/s while patrolling
|
||||
const PANIC := 2.3 ## m/s while fleeing a dent
|
||||
const SNIFF := 5.0 ## debris detection radius
|
||||
const DRIVE := 26.0 ## N of forward push (mass ~4)
|
||||
const RETARGET_S := 3.0
|
||||
|
||||
var _awake := false
|
||||
var _panic_left := 0.0
|
||||
var _flee_dir := Vector3.ZERO
|
||||
var _target := Vector3.ZERO
|
||||
var _retarget := 0.0
|
||||
var _eaten := 0
|
||||
var _mouth: Area3D
|
||||
var _speaker: AudioStreamPlayer3D
|
||||
var _snd_beep: AudioStreamWAV
|
||||
var _snd_panic: AudioStreamWAV
|
||||
var _snd_slurp: AudioStreamWAV
|
||||
|
||||
func _init() -> void:
|
||||
kind = "steel"
|
||||
start_frozen = false
|
||||
|
||||
func _ready() -> void:
|
||||
super._ready()
|
||||
add_to_group("roomba")
|
||||
name = "roomba"
|
||||
mass = 4.0
|
||||
can_sleep = true # docked it sleeps; wake() flips this off
|
||||
angular_damp = 6.0 # a hockey puck, not a tumbling die
|
||||
linear_damp = 0.8
|
||||
center_of_mass_mode = RigidBody3D.CENTER_OF_MASS_MODE_CUSTOM
|
||||
center_of_mass = Vector3(0, 0.02, 0)
|
||||
|
||||
# --- the body: a dark disc with a bump, one bright ring so it reads at a glance ---
|
||||
var disc := MeshInstance3D.new()
|
||||
var cm := CylinderMesh.new()
|
||||
cm.top_radius = 0.17
|
||||
cm.bottom_radius = 0.17
|
||||
cm.height = 0.09
|
||||
disc.mesh = cm
|
||||
var mat := StandardMaterial3D.new()
|
||||
mat.albedo_color = Color(0.16, 0.16, 0.18)
|
||||
mat.roughness = 0.4
|
||||
mat.metallic = 0.3
|
||||
disc.material_override = mat
|
||||
disc.position.y = 0.045
|
||||
add_child(disc)
|
||||
var ring := MeshInstance3D.new()
|
||||
var rm := TorusMesh.new()
|
||||
rm.inner_radius = 0.055
|
||||
rm.outer_radius = 0.075
|
||||
ring.mesh = rm
|
||||
var rmat := StandardMaterial3D.new()
|
||||
rmat.albedo_color = Color(0.95, 0.55, 0.15)
|
||||
rmat.emission_enabled = true
|
||||
rmat.emission = Color(0.95, 0.55, 0.15)
|
||||
rmat.emission_energy_multiplier = 0.7
|
||||
ring.material_override = rmat
|
||||
ring.position.y = 0.092
|
||||
add_child(ring)
|
||||
var col := CollisionShape3D.new()
|
||||
var cs := CylinderShape3D.new()
|
||||
cs.radius = 0.17
|
||||
cs.height = 0.09
|
||||
col.shape = cs
|
||||
col.position.y = 0.045
|
||||
add_child(col)
|
||||
|
||||
# --- the mouth: debris that touches this gets eaten ---
|
||||
_mouth = Area3D.new()
|
||||
var ms := CollisionShape3D.new()
|
||||
var msph := SphereShape3D.new()
|
||||
msph.radius = 0.26
|
||||
ms.shape = msph
|
||||
ms.position.y = 0.05
|
||||
_mouth.add_child(ms)
|
||||
add_child(_mouth)
|
||||
_mouth.body_entered.connect(_on_mouth)
|
||||
|
||||
# --- voice ---
|
||||
_snd_beep = _wav(_chirp([880.0, 1320.0], 0.09))
|
||||
_snd_panic = _wav(_chirp([1560.0, 990.0, 620.0], 0.07))
|
||||
_snd_slurp = _wav(_noise_swoosh(0.16))
|
||||
_speaker = AudioStreamPlayer3D.new()
|
||||
_speaker.unit_size = 5.0
|
||||
add_child(_speaker)
|
||||
|
||||
damaged.connect(_on_dented)
|
||||
|
||||
## Deployed by Main on the first smash in the site. Until then: docked, silent, asleep.
|
||||
func wake() -> void:
|
||||
if _awake or is_broken():
|
||||
return
|
||||
_awake = true
|
||||
can_sleep = false
|
||||
sleeping = false
|
||||
_say(_snd_beep)
|
||||
_retarget = 0.0
|
||||
|
||||
func _physics_process(dt: float) -> void:
|
||||
super._physics_process(dt)
|
||||
if not _awake or freeze or is_broken():
|
||||
return
|
||||
var speed_cap := CRUISE
|
||||
var dir: Vector3
|
||||
if _panic_left > 0.0:
|
||||
_panic_left -= dt
|
||||
dir = _flee_dir
|
||||
speed_cap = PANIC
|
||||
else:
|
||||
_retarget -= dt
|
||||
if _retarget <= 0.0:
|
||||
_retarget = RETARGET_S
|
||||
_pick_target()
|
||||
dir = _target - global_position
|
||||
dir.y = 0.0
|
||||
if dir.length() < 0.4:
|
||||
_retarget = 0.0
|
||||
dir = Vector3.ZERO
|
||||
else:
|
||||
dir = dir.normalized()
|
||||
if dir != Vector3.ZERO and linear_velocity.length() < speed_cap:
|
||||
apply_central_force(dir * DRIVE * (2.0 if _panic_left > 0.0 else 1.0))
|
||||
|
||||
## Nearest debris in sniff range beats a random wander point.
|
||||
func _pick_target() -> void:
|
||||
var best_d := SNIFF * SNIFF
|
||||
var best := Vector3.INF
|
||||
for n in get_tree().get_nodes_in_group("debris"):
|
||||
var b := n as Node3D
|
||||
if b == null:
|
||||
continue
|
||||
var d := (b.global_position - global_position).length_squared()
|
||||
if d < best_d:
|
||||
best_d = d
|
||||
best = b.global_position
|
||||
if best != Vector3.INF:
|
||||
_target = best
|
||||
else:
|
||||
var a := randf() * TAU
|
||||
_target = global_position + Vector3(cos(a), 0.0, sin(a)) * randf_range(2.0, 5.0)
|
||||
if randf() < 0.25:
|
||||
_say(_snd_beep) # contented patrol beep, occasionally
|
||||
|
||||
func _on_mouth(b: Node) -> void:
|
||||
if not _awake or not (b is RigidBody3D) or not b.is_in_group("debris"):
|
||||
return
|
||||
var rb := b as RigidBody3D
|
||||
_eaten += 1
|
||||
_say(_snd_slurp)
|
||||
ate_debris.emit(rb.global_position)
|
||||
# the shard shrinks into the intake rather than blinking out
|
||||
rb.freeze = true
|
||||
var tw := create_tween()
|
||||
tw.tween_property(rb, "scale", Vector3.ONE * 0.05, 0.22)
|
||||
tw.tween_callback(rb.queue_free)
|
||||
|
||||
## A dent means someone hit it with something heavy enough to matter: panic and flee.
|
||||
func _on_dented(_kind: String, at: Vector3, _frac: float) -> void:
|
||||
wake()
|
||||
_flee_dir = (global_position - at)
|
||||
_flee_dir.y = 0.0
|
||||
_flee_dir = _flee_dir.normalized() if _flee_dir.length() > 0.01 else Vector3.FORWARD
|
||||
_panic_left = 2.5
|
||||
_say(_snd_panic)
|
||||
|
||||
# ---------------------------------------------------------------- tiny synth voice
|
||||
func _say(w: AudioStreamWAV) -> void:
|
||||
if _speaker == null or w == null:
|
||||
return
|
||||
_speaker.stream = w
|
||||
_speaker.pitch_scale = randf_range(0.96, 1.05)
|
||||
_speaker.play()
|
||||
|
||||
func _wav(samples: PackedFloat32Array) -> AudioStreamWAV:
|
||||
var w := AudioStreamWAV.new()
|
||||
w.format = AudioStreamWAV.FORMAT_16_BITS
|
||||
w.mix_rate = 22050
|
||||
w.stereo = false
|
||||
var bytes := PackedByteArray()
|
||||
bytes.resize(samples.size() * 2)
|
||||
for i in samples.size():
|
||||
bytes.encode_s16(i * 2, int(clampf(samples[i], -1.0, 1.0) * 32767.0))
|
||||
w.data = bytes
|
||||
return w
|
||||
|
||||
## A little sequence of pure tones — the entire emotional range of a robot vacuum.
|
||||
func _chirp(freqs: Array, per: float) -> PackedFloat32Array:
|
||||
var rate := 22050
|
||||
var n := int(rate * per * freqs.size())
|
||||
var out := PackedFloat32Array()
|
||||
out.resize(n)
|
||||
var per_n := int(rate * per)
|
||||
for i in n:
|
||||
var seg := mini(i / per_n, freqs.size() - 1)
|
||||
var t := float(i) / float(rate)
|
||||
var env := sin(PI * float(i % per_n) / float(per_n))
|
||||
out[i] = sin(TAU * float(freqs[seg]) * t) * 0.4 * env
|
||||
return out
|
||||
|
||||
func _noise_swoosh(dur: float) -> PackedFloat32Array:
|
||||
var rate := 22050
|
||||
var n := int(rate * dur)
|
||||
var out := PackedFloat32Array()
|
||||
out.resize(n)
|
||||
for i in n:
|
||||
var f := float(i) / float(n)
|
||||
out[i] = (randf() * 2.0 - 1.0) * 0.35 * sin(PI * f) * (0.4 + 0.6 * f)
|
||||
return out
|
||||
1
game/scripts/Roomba.gd.uid
Normal file
1
game/scripts/Roomba.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://bogf003ncf4gb
|
||||
Loading…
Reference in New Issue
Block a user