destroyulator/game/scripts/Roomba.gd
Monster Robot Party 32b556ff02 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>
2026-08-04 00:00:58 +10:00

227 lines
6.8 KiB
GDScript

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