destroyulator/game/scripts/Dust.gd
Monster Robot Party 7c282c298e LANE7: LEVEL 0 — the Backrooms, generated walls, and the dust mechanic
"stuff is invisible have to release dust or gas or something to coat items to see them
to destroy them? and you inhale some of it too"

THE SITE (Levels.backrooms)
Mono-yellow, 2.55 m ceiling, damp carpet, a dense grid of buzzing troffers, no windows
and no exit. THE WALLS ARE GENERATED — this is the site that proves the Floorplan spec
can be emitted rather than authored: a seeded RNG lays partial wall runs on a 4.6 m
lattice with random gaps, plus 22 freestanding pillars. It follows the reference photos
rather than a maze algorithm on purpose — the Backrooms aren't corridors, they're big
offset slabs in an open floor, and a proper maze would be legible. Legible is wrong.

THE MECHANIC (Dust.gd) — it argues with itself, which is the whole point.
Everything smashable here is invisible but SOLID; you find things by walking into them.
RMB with the extinguisher throws a cloud of powder. Anything it touches is coated,
glows faintly out of all that yellow for 7 seconds, then settles back into nothing.

And you breathe it. Every cloud puts a little in your lungs, and the haze that builds is
on YOUR side of the glass. So: spray more to see individual objects, and see the room
less. Past 72% you cough — camera jolt, screen bloom, and a rage tick, because of course
it does. Intended rhythm is spray -> switch to something heavy -> break it before the
coating settles -> spray again, coughing. Pairs with TOTAL DESTRUCTION, since the level
IS a search.

Tuning that mattered: the first cloud was a wall of big blocky quads that hid the very
thing it was revealing (150 finer, shorter-lived particles now), and a coated object has
to POP out of a room that is entirely one colour, so the coat is near-white and faintly
self-lit rather than a subtle tint. 78 objects, not 34 — a search with nothing in it is
just a walk.

Bug found while testing: `x as Smashable` on a freed object is itself an error in Godot
4, so the validity check has to come BEFORE the cast — destroying anything mid-level was
spewing "trying to cast a freed object" every frame.

dev/probe_backrooms.gd verifies the whole loop headlessly, including that the mechanic
does NOT leak into the other four sites when you leave.

All five sites still settle to 0.00 m/s.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:05:56 +10:00

283 lines
9.5 KiB
GDScript

extends Node
class_name Dust
## LEVEL 0's mechanic: you cannot see what you are here to destroy.
##
## Every smashable in the Backrooms is invisible but SOLID — you find things by walking
## into them. Discharging the fire extinguisher throws a cloud of powder; anything it
## touches is coated and shows for a few seconds before the coating settles and the
## object goes back to being nothing.
##
## And you breathe it. Every cloud puts a little in your lungs, and the haze that builds
## is on YOUR side of the glass — it whites out the room. So the mechanic argues with
## itself: spray more to see individual objects, and see the room less. The intended
## rhythm is spray → switch to something heavy → break it before the coating settles →
## spray again, coughing.
##
## Enough dust in you and you cough: the camera jolts, the screen blooms, and it feeds
## Rage, because of course it does.
##
## Godot 4.7 GDScript 2.0.
const CLOUD_RANGE := 9.5 ## how far the powder carries
const CLOUD_ANGLE := 0.80 ## half-angle of the cone, radians
const CLOUD_ENVELOPE := 2.6 ## everything this close is coated regardless of aim
const COAT_TIME := 7.0 ## seconds an object stays visible
const INHALE_PER_PUFF := 0.085
const DECAY := 0.021 ## dust per second your lungs clear
const COUGH_AT := 0.72
const PUFF_COOLDOWN := 0.55
var rage: Rage
var active := false ## only true on a site whose spec says invisible
var inhaled := 0.0 ## 0..1
var _cd := 0.0
var _cough_cd := 0.0
var _cam: Camera3D
var _player: Node3D
var _hidden: Array = [] ## every smashable we've hidden on this site
var _haze: ColorRect
var _cough_snd: AudioStreamPlayer
var _puff_snd: AudioStreamPlayer
const HAZE_SHADER := """
shader_type canvas_item;
uniform float dust : hint_range(0.0, 1.0) = 0.0;
uniform float bloom : hint_range(0.0, 1.0) = 0.0;
void fragment() {
// flat lift plus a heavier wash at the edges: powder in the air, and powder on you
vec2 d = (UV - vec2(0.5)) * vec2(1.0, 0.62);
float r = length(d) * 2.0;
float edge = smoothstep(0.25, 1.25, r);
float a = dust * (0.16 + 0.38 * edge) + bloom * 0.45;
COLOR = vec4(0.93, 0.91, 0.80, clamp(a, 0.0, 0.92));
}
"""
func setup(player: Node3D, cam: Camera3D, r: Rage, hud_layer: CanvasLayer) -> void:
_player = player
_cam = cam
rage = r
_haze = ColorRect.new()
_haze.set_anchors_preset(Control.PRESET_FULL_RECT)
_haze.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sh := Shader.new()
sh.code = HAZE_SHADER
var m := ShaderMaterial.new()
m.shader = sh
_haze.material = m
hud_layer.add_child(_haze)
_puff_snd = AudioStreamPlayer.new()
_puff_snd.stream = _make_noise(0.55, 900.0, 3.0)
_puff_snd.volume_db = -8.0
add_child(_puff_snd)
_cough_snd = AudioStreamPlayer.new()
_cough_snd.stream = _make_noise(0.42, 190.0, 11.0)
_cough_snd.volume_db = -4.0
add_child(_cough_snd)
# ---------------------------------------------------------------- site setup
## Called after a level is populated. On an `invisible` site every smashable is hidden;
## anywhere else this clears itself out so the mechanic can't leak between levels.
func adopt(tree: SceneTree, invisible: bool) -> void:
for e in _hidden:
if is_instance_valid(e):
_show(e as Smashable, true)
_hidden.clear()
inhaled = 0.0
active = invisible
if not invisible:
return
for n in tree.get_nodes_in_group("smashable"):
var s := n as Smashable
if s == null:
continue
_hidden.append(s)
# _coat writes surface overrides; a mesh with no override slots silently ignores
# them, so seed each surface with a copy of its own material up front
_seed_overrides(s)
_show(s, false)
## _coat writes surface OVERRIDES, and a surface with no override slot silently ignores
## them — so seed every surface up front with a copy of whatever material it actually
## resolves to. Imported GLBs put materials in either place, hence the two-step lookup.
func _seed_overrides(s: Smashable) -> void:
for mi in Floorplan.meshes(s):
var m := mi as MeshInstance3D
if m.mesh == null:
continue
for i in m.mesh.get_surface_count():
var src := _surface_material(m, i)
if src != null:
m.set_surface_override_material(i, src.duplicate())
static func _surface_material(m: MeshInstance3D, i: int) -> Material:
var over := m.get_surface_override_material(i)
if over != null:
return over
if m.mesh == null:
return null
return m.mesh.surface_get_material(i)
func _show(s: Smashable, visible: bool) -> void:
if not is_instance_valid(s):
return
for mi in Floorplan.meshes(s):
(mi as MeshInstance3D).visible = visible
# ---------------------------------------------------------------- the cloud
## Fired by Player on right-click while the extinguisher is out. Returns true if it went.
func discharge() -> bool:
if not active or _cd > 0.0 or _cam == null:
return false
_cd = PUFF_COOLDOWN
inhaled = clampf(inhaled + INHALE_PER_PUFF, 0.0, 1.0)
_puff_snd.pitch_scale = randf_range(0.94, 1.08)
_puff_snd.play()
_spawn_puff()
var origin := _cam.global_position
var dir := -_cam.global_transform.basis.z
var coated := 0
for e in _hidden:
if not is_instance_valid(e):
continue # check BEFORE the cast: casting a freed
var s := e as Smashable # object is itself an error in Godot 4
var to := s.global_position - origin
var dist := to.length()
if dist > CLOUD_RANGE:
continue
# a cone — except close in, where the cloud simply envelops you and everything
# in arm's reach, which is also how you find the thing you just walked into
if dist > CLOUD_ENVELOPE and to.normalized().dot(dir) < cos(CLOUD_ANGLE):
continue
_coat(s)
coated += 1
return true
func _coat(s: Smashable) -> void:
_show(s, true)
s.set_meta("dust_until", Time.get_ticks_msec() + int(COAT_TIME * 1000.0))
# powder-white wash so a coated object reads as coated, not merely present
for mi in Floorplan.meshes(s):
var m := mi as MeshInstance3D
if m.mesh == null:
continue
for i in m.mesh.get_surface_count():
var src := _surface_material(m, i) as BaseMaterial3D
if src == null:
continue
var d := src.duplicate() as BaseMaterial3D
d.albedo_color = src.albedo_color.lerp(Color(0.97, 0.96, 0.92), 0.80)
d.emission_enabled = true
d.emission = Color(0.55, 0.54, 0.48)
d.emission_energy_multiplier = 0.55
m.set_surface_override_material(i, d)
func _spawn_puff() -> void:
var p := GPUParticles3D.new()
p.one_shot = true
p.explosiveness = 0.82
p.amount = 150
p.lifetime = 1.1
p.local_coords = false
var pm := ParticleProcessMaterial.new()
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
pm.emission_sphere_radius = 0.18
pm.direction = -_cam.global_transform.basis.z
pm.spread = 26.0
pm.initial_velocity_min = 3.0
pm.initial_velocity_max = 7.5
pm.damping_min = 3.0
pm.damping_max = 6.0
pm.gravity = Vector3(0, -0.6, 0)
pm.scale_min = 0.5
pm.scale_max = 1.6
p.process_material = pm
var mesh := QuadMesh.new()
mesh.size = Vector2(0.20, 0.20)
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(0.96, 0.94, 0.86, 0.22)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mesh.material = mat
p.draw_pass_1 = mesh
add_child(p)
p.global_position = _cam.global_position - _cam.global_transform.basis.z * 0.6
p.emitting = true
p.finished.connect(p.queue_free)
# ---------------------------------------------------------------- per-frame
func _process(dt: float) -> void:
_cd = maxf(0.0, _cd - dt)
_cough_cd = maxf(0.0, _cough_cd - dt)
var bloom := 0.0
if active:
inhaled = maxf(0.0, inhaled - DECAY * dt)
# coatings settle
var now := Time.get_ticks_msec()
for e in _hidden:
if not is_instance_valid(e):
continue
var s := e as Smashable
var until := int(s.get_meta("dust_until", 0))
if until > 0 and now >= until:
s.set_meta("dust_until", 0)
_show(s, false)
# coughing: a jolt, a bloom, and it winds you up
if inhaled > COUGH_AT and _cough_cd <= 0.0:
_cough_cd = randf_range(2.4, 4.2)
_cough_snd.pitch_scale = randf_range(0.9, 1.12)
_cough_snd.play()
bloom = 1.0
if rage != null:
rage.provoke(0.045, "you are breathing this")
if _cam != null:
_cam.rotation.x += randf_range(-0.05, 0.05)
_cam.rotation.z += randf_range(-0.04, 0.04)
if _haze != null:
var m := _haze.material as ShaderMaterial
m.set_shader_parameter("dust", inhaled)
m.set_shader_parameter("bloom", bloom)
_haze.visible = active
# ---------------------------------------------------------------- readouts
func hud_line() -> String:
if not active:
return ""
var lungs := int(round(inhaled * 100.0))
var warn := " · COUGHING" if inhaled > COUGH_AT else ""
return "LEVEL 0 · RMB: dust (extinguisher) · in your lungs %d%%%s" % [
lungs, warn]
## A short breath of shaped noise — same zero-asset trick as Juice.gd.
func _make_noise(dur: float, tone: float, decay: float) -> AudioStreamWAV:
var rate := 22050
var n := int(rate * dur)
var out := PackedFloat32Array()
out.resize(n)
for i in n:
var t := float(i) / float(rate)
var env := exp(-t * decay) * (1.0 - exp(-t * 60.0))
var s := (randf() * 2.0 - 1.0) * 0.7 * env
s += sin(TAU * tone * t) * 0.18 * env
out[i] = clampf(s, -1.0, 1.0)
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = rate
wav.stereo = false
var bytes := PackedByteArray()
bytes.resize(n * 2)
for i in n:
bytes.encode_s16(i * 2, int(out[i] * 32767.0))
wav.data = bytes
return wav