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>
286 lines
9.7 KiB
GDScript
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
|