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