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