"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>
283 lines
9.5 KiB
GDScript
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
|