From 32b556ff02672015e88ac88ea4041dde629c8aa3 Mon Sep 17 00:00:00 2001 From: Monster Robot Party Date: Tue, 4 Aug 2026 00:00:58 +1000 Subject: [PATCH] =?UTF-8?q?LANE9:=20the=20Roomba=20=E2=80=94=20company=20p?= =?UTF-8?q?roperty=20that=20eats=20your=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- game/dev/probe_roomba.gd | 75 ++++++++++++ game/dev/probe_settle.gd.uid | 1 + game/scripts/Main.gd | 23 ++++ game/scripts/Roomba.gd | 226 +++++++++++++++++++++++++++++++++++ game/scripts/Roomba.gd.uid | 1 + 5 files changed, 326 insertions(+) create mode 100644 game/dev/probe_roomba.gd create mode 100644 game/dev/probe_settle.gd.uid create mode 100644 game/scripts/Roomba.gd create mode 100644 game/scripts/Roomba.gd.uid diff --git a/game/dev/probe_roomba.gd b/game/dev/probe_roomba.gd new file mode 100644 index 0000000..ac6d80f --- /dev/null +++ b/game/dev/probe_roomba.gd @@ -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) diff --git a/game/dev/probe_settle.gd.uid b/game/dev/probe_settle.gd.uid new file mode 100644 index 0000000..81b2c9e --- /dev/null +++ b/game/dev/probe_settle.gd.uid @@ -0,0 +1 @@ +uid://bitprxa12yr8v diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index ef19d91..6a2c092 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -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. diff --git a/game/scripts/Roomba.gd b/game/scripts/Roomba.gd new file mode 100644 index 0000000..97e2eeb --- /dev/null +++ b/game/scripts/Roomba.gd @@ -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 diff --git a/game/scripts/Roomba.gd.uid b/game/scripts/Roomba.gd.uid new file mode 100644 index 0000000..fd41483 --- /dev/null +++ b/game/scripts/Roomba.gd.uid @@ -0,0 +1 @@ +uid://bogf003ncf4gb