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>
198 lines
5.8 KiB
GDScript
198 lines
5.8 KiB
GDScript
extends Node
|
|
class_name Backrooms
|
|
|
|
## LEVEL 0's horror layer: the entity, the loop, the totems, and the scares.
|
|
##
|
|
## THE LOOP is the Blair Witch bit and the most important part. Walk far enough in any
|
|
## direction and you come out of the opposite wall — into the same room, because the maze
|
|
## is generated from a fixed seed and every part of it looks like every other part. You
|
|
## get a lurch, a tear on the tape, and the growing certainty that you have been here.
|
|
##
|
|
## Crucially, a wrap is also when the entity gets to reposition. So "I've been here
|
|
## before" and "it's already here" arrive in the same second, which is a far better
|
|
## scare than either on its own.
|
|
##
|
|
## THE NOISE RULE ties the horror to the game: the entity hunts sound, and the objective
|
|
## is to break things. Playing well is what feeds it. Playing quietly means not playing.
|
|
##
|
|
## Godot 4.7 GDScript 2.0.
|
|
|
|
const TOTEM := "res://assets/store/totem.glb"
|
|
|
|
## How close to the outer wall triggers a wrap.
|
|
const WRAP_MARGIN := 1.4
|
|
## Seconds after a wrap before another can fire (stops thrashing on a corner).
|
|
const WRAP_COOLDOWN := 1.2
|
|
|
|
var active := false
|
|
var entity: Entity
|
|
var dread: Dread
|
|
var rage: Rage
|
|
var dust: Dust
|
|
|
|
var loops := 0 ## how many times the room has come back around
|
|
var caught := 0
|
|
|
|
var _player: Node3D
|
|
var _cam: Camera3D
|
|
var _hud: Hud
|
|
var _host: Node3D
|
|
var _half := 19.0
|
|
var _wrap_cd := 0.0
|
|
var _totems: Array[Node3D] = []
|
|
var _next_whisper := 0.0
|
|
|
|
## Lines that fire on a wrap. Deliberately flat — the room is doing the work.
|
|
const WRAP_LINES := [
|
|
"…you've been here.",
|
|
"this is the same room.",
|
|
"you turned left. you have always turned left.",
|
|
"the carpet has the same stain.",
|
|
"you are further in than you were.",
|
|
"it was behind you a moment ago.",
|
|
]
|
|
|
|
func setup(player: Node3D, cam: Camera3D, hud: Hud, r: Rage, d: Dust,
|
|
host: Node3D) -> void:
|
|
_player = player
|
|
_cam = cam
|
|
_hud = hud
|
|
rage = r
|
|
dust = d
|
|
_host = host
|
|
|
|
dread = Dread.new()
|
|
dread.setup()
|
|
host.add_child(dread)
|
|
|
|
entity = Entity.new()
|
|
host.add_child(entity)
|
|
entity.setup(player, cam, _half)
|
|
entity.caught_you.connect(_on_caught)
|
|
|
|
## Called when a level finishes building. `half` is the site's half-extent.
|
|
func enter(half: float) -> void:
|
|
_half = half
|
|
active = true
|
|
loops = 0
|
|
caught = 0
|
|
_wrap_cd = 0.0
|
|
entity._bounds = half
|
|
entity.wake()
|
|
dread.set_active(true)
|
|
_scatter_totems()
|
|
if _hud != null:
|
|
_hud.toast("there is no exit. there is no one else here.")
|
|
|
|
func leave() -> void:
|
|
active = false
|
|
entity.sleep()
|
|
dread.set_active(false)
|
|
for t in _totems:
|
|
if is_instance_valid(t):
|
|
t.queue_free()
|
|
_totems.clear()
|
|
|
|
## Every destroyed object and every puff of powder is a dinner bell.
|
|
func on_noise(at: Vector3, loudness: float) -> void:
|
|
if active:
|
|
entity.hear(at, loudness)
|
|
|
|
# ---------------------------------------------------------------- totems
|
|
## Hung in doorways you have definitely already walked through.
|
|
func _scatter_totems() -> void:
|
|
for t in _totems:
|
|
if is_instance_valid(t):
|
|
t.queue_free()
|
|
_totems.clear()
|
|
if not ResourceLoader.exists(TOTEM):
|
|
return
|
|
var packed := load(TOTEM) as PackedScene
|
|
for i in 7:
|
|
var n: Node3D = packed.instantiate()
|
|
_host.add_child(n)
|
|
n.position = Vector3(randf_range(-_half + 3.0, _half - 3.0), 1.30,
|
|
randf_range(-_half + 3.0, _half - 3.0))
|
|
n.rotation.y = randf_range(0.0, TAU)
|
|
_totems.append(n)
|
|
|
|
# ---------------------------------------------------------------- per-frame
|
|
func _process(dt: float) -> void:
|
|
if not active or _player == null:
|
|
return
|
|
_wrap_cd = maxf(0.0, _wrap_cd - dt)
|
|
_check_wrap()
|
|
|
|
# how near it is while UNSEEN — the vignette breathing is the only tell you get
|
|
var near := 0.0
|
|
if entity.is_awake() and not entity.seen_now:
|
|
var d := entity.global_position.distance_to(_player.global_position)
|
|
near = clampf(1.0 - d / 16.0, 0.0, 1.0)
|
|
dread.drive(dt, entity.static_level, near)
|
|
|
|
# it whispers when it's close and behind you
|
|
_next_whisper -= dt
|
|
if near > 0.55 and _next_whisper <= 0.0:
|
|
_next_whisper = randf_range(5.0, 11.0)
|
|
if _hud != null:
|
|
_hud.toast("something is breathing")
|
|
if rage != null:
|
|
rage.provoke(0.03, "something is breathing")
|
|
|
|
## Cross the boundary and you come out the far side — into a room that is identical,
|
|
## because every room here is identical. The entity repositions on the same frame.
|
|
func _check_wrap() -> void:
|
|
if _wrap_cd > 0.0:
|
|
return
|
|
var p := _player.global_position
|
|
var lim := _half - WRAP_MARGIN
|
|
var wrapped := false
|
|
if absf(p.x) > lim:
|
|
p.x = -signf(p.x) * (lim - 1.2)
|
|
wrapped = true
|
|
if absf(p.z) > lim:
|
|
p.z = -signf(p.z) * (lim - 1.2)
|
|
wrapped = true
|
|
if not wrapped:
|
|
return
|
|
_wrap_cd = WRAP_COOLDOWN
|
|
loops += 1
|
|
_player.global_position = p
|
|
if _player is CharacterBody3D:
|
|
(_player as CharacterBody3D).velocity = Vector3.ZERO
|
|
dread.flash()
|
|
# the good bit: it is somewhere new, and you have no idea where
|
|
entity._reposition(9.0)
|
|
if entity.state == Entity.S.STALKING and loops >= 2:
|
|
entity.state = Entity.S.HUNTING # it learns
|
|
if _hud != null:
|
|
_hud.toast(WRAP_LINES[loops % WRAP_LINES.size()])
|
|
if rage != null:
|
|
rage.provoke(0.035, "the same room")
|
|
|
|
func _on_caught() -> void:
|
|
caught += 1
|
|
dread.flash()
|
|
if _cam != null:
|
|
_cam.rotation.x += randf_range(-0.22, 0.22)
|
|
_cam.rotation.z += randf_range(-0.2, 0.2)
|
|
# it doesn't kill you. It costs you: a lungful, a spike of rage, and the certainty
|
|
# that it can do that whenever it likes.
|
|
if dust != null:
|
|
dust.inhaled = clampf(dust.inhaled + 0.30, 0.0, 1.0)
|
|
if rage != null:
|
|
rage.provoke(0.16, "IT TOUCHED YOU")
|
|
if _hud != null:
|
|
_hud.toast("IT TOUCHED YOU")
|
|
|
|
func hud_line() -> String:
|
|
if not active:
|
|
return ""
|
|
var st := int(round(entity.static_level * 100.0))
|
|
var warn := ""
|
|
if entity.state == Entity.S.HUNTING:
|
|
warn = " · it is looking for you"
|
|
elif entity.state == Entity.S.LUNGE:
|
|
warn = " · RUN"
|
|
return "loops %d · touched %d · signal %d%%%s" % [loops, caught, st, warn]
|