destroyulator/game/scripts/Dust.gd
Monster Robot Party 006c324f1f LANE7: LEVEL 0 gets an entity, a tape, and a loop
THE ENTITY (Entity.gd) borrows from three places and each borrowing does a job:

  SLENDERMAN  it does not move while you look at it, and has closed the distance every
              time you look back. Looking is ALSO what builds the static — so the safe
              option (keep it in view) kills you slowly and the fast option (run) lets
              it catch up. That's the trap, and it's why Slender worked.
  XENOMORPH   it hunts SOUND, and the thing you came here to do is smash furniture.
              Every break and every puff of powder drops a marker it walks toward.
              Playing well is what feeds it. A loud enough noise overrides the freeze,
              because staring is a valid answer to a stalker and not a valid answer to
              something that just heard a sledgehammer hit a filing cabinet.
  BLAIR WITCH the level wraps: walk far enough and you come out of the opposite wall
              into the same room, because every room here IS the same room. A wrap is
              also when it repositions, so "I've been here before" and "it's already
              here" land in the same second.

It can't be fought. Getting caught doesn't kill you — it costs a lungful, a spike of
rage, and the knowledge that it can do that whenever it likes.

Deliberately unresolved design: 2.5 m, faceless, arms past the knees, cranium swept
backwards. Slenderman from the front, something else in profile. A silhouette your brain
finishes beats a monster it recognises. Blair Witch stick totems hang in the maze.

THE TAPE (Dread.gd) — one shader: grain, scanlines, chroma split, a wandering tracking
tear, an edge that breathes when it's near but unseen, and Slender interference driven
straight off the stare. The static IS the read-out; there's no meter for it.

TWO BUGS WORTH THE COMMIT MESSAGE
1. `seen_now` was a pure view-cone test with NO line of sight, so it built static
   through walls — and in a maze that's most of the time.
2. Fixing that exposed the real one: the generated maze was so dense there was no
   sightline longer than a few metres ANYWHERE, which kills the entity outright, since
   a stalker you can never see can never be stared at. Thinned the generator — skip a
   third of the lattice lines, much bigger gaps — which also matches the reference
   photographs far better. They're mostly open floor with occasional slabs, not
   corridors.

dev/probe_horror.gd verifies all of it headlessly, including hunting for a clear
sightline first (otherwise the Slender test silently tests nothing).

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

286 lines
9.7 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 heard_by: Backrooms ## a puff is a noise, and it hunts sound
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()
if heard_by != null:
heard_by.on_noise(_player.global_position, 0.7)
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