NO HEALTH BAR. Nothing else in this game does damage, so hit points would be a
survival-horror UI bolted onto a demolition game. Instead: one meter, DREAD, fed by
every way this place gets at you — being stared at, something close behind you, each
loop, and your own coughing. It decays when nothing is happening to you, and every loop
makes it grip harder (DREAD_LOOP_GRIP), so the place gets better at this the longer you
stay.
At 100% you die, and WHICH source pushed it over decides the death, which is what keeps
the three memes legible at the one moment it matters:
STARE "YOU LOOKED TOO LONG / it was still. it was always still. that was never the
safe part." (Slenderman)
REACH "IT WAS ALREADY IN THE ROOM / you were making so much noise."
Sprays. This is the gory one. (the xenomorph)
LOOP "YOU ARE STILL IN THE ROOM / you walked it eleven times. you will walk it
again." Eleven loops and the room simply keeps you. (Blair Witch)
Gore.gd is procedural — seeded blobs that swell on the lens and then bleed downward in
streaks, a wet burst at the camera (physical deaths only; being taken by the static
shouldn't spray), the camera going down to land on its side on the carpet, then the
card. The sequence is deliberately SLOW: a jumpscare is a spike, but a death should take
its time, because the seconds spent watching the lens settle are where it lands. R wakes
you up.
Player.set_frozen() so you can't stroll around during your own death.
Renamed Backrooms' VHS layer to `tape` — `dread` is the meter now, and it was clearer
anyway.
dev/probe_death.gd drives all three deaths and the revive.
Note: the "Parameter material is null" warning only appears under the headless dummy
renderer; windowed runs are clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364 lines
11 KiB
GDScript
364 lines
11 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"
|
|
|
|
## DREAD is the death meter, and it is deliberately the ONLY new number.
|
|
##
|
|
## A health bar would be a foreign object in this game — nothing else does damage, and
|
|
## adding hit points just to have something to subtract from is how you end up with a
|
|
## survival-horror UI bolted to a demolition game. So instead: one meter, fed by every
|
|
## way this place gets at you, and at 100% you die.
|
|
##
|
|
## Which death you get depends on WHICH input pushed it over, so the three memes stay
|
|
## distinguishable in the moment it matters:
|
|
## STARE you looked too long, and the static took you. (Slenderman)
|
|
## REACH it got to you. (gory; the xenomorph)
|
|
## LOOP you walked the same room too many times. (Blair Witch)
|
|
enum Death { NONE, STARE, REACH, LOOP }
|
|
|
|
const DREAD_FROM_STARE := 0.155 ## per second at full stare pressure
|
|
const DREAD_FROM_NEAR := 0.085 ## per second when it's right behind you, unseen
|
|
const DREAD_FROM_LOOP := 0.11 ## per wrap
|
|
const DREAD_FROM_LUNGS := 0.030 ## per second while you're coughing your guts up
|
|
const DREAD_CALM := 0.055 ## per second when nothing is happening to you
|
|
## Every loop makes the place better at this. Twelve loops and it barely lets go.
|
|
const DREAD_LOOP_GRIP := 0.055
|
|
|
|
## 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 tape: Dread ## the found-footage layer
|
|
var rage: Rage
|
|
var dust: Dust
|
|
|
|
var loops := 0 ## how many times the room has come back around
|
|
var caught := 0
|
|
var dread := 0.0 ## 0..1 — the death meter
|
|
var dying := false
|
|
var death_cause: int = Death.NONE
|
|
var gore: Gore
|
|
|
|
var _death_t := 0.0
|
|
var _death_from := Transform3D.IDENTITY
|
|
|
|
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
|
|
|
|
tape = Dread.new()
|
|
tape.setup()
|
|
host.add_child(tape)
|
|
|
|
gore = Gore.new()
|
|
gore.setup()
|
|
host.add_child(gore)
|
|
|
|
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 = 0.0
|
|
dying = false
|
|
death_cause = Death.NONE
|
|
gore.clear()
|
|
tape.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()
|
|
tape.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
|
|
if dying:
|
|
_tick_death(dt)
|
|
return
|
|
_wrap_cd = maxf(0.0, _wrap_cd - dt)
|
|
_check_wrap()
|
|
_tick_dread(dt)
|
|
|
|
# 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)
|
|
tape.drive(dt, entity.static_level, near)
|
|
|
|
gore.set_blood(clampf((dread - 0.72) * 2.2, 0.0, 0.55))
|
|
if dread > 0.72:
|
|
gore.arm()
|
|
|
|
# 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")
|
|
|
|
## Everything this place does to you lands in one number. Whichever source pushed it
|
|
## over the line decides how you die — that's what keeps three deaths readable instead of
|
|
## one generic fail.
|
|
func _tick_dread(dt: float) -> void:
|
|
var src := Death.NONE
|
|
var gain := 0.0
|
|
|
|
if entity.seen_now:
|
|
gain += DREAD_FROM_STARE * (0.4 + entity.static_level)
|
|
src = Death.STARE
|
|
var d := entity.global_position.distance_to(_player.global_position)
|
|
if d < 7.0:
|
|
var g := DREAD_FROM_NEAR * (1.0 - d / 7.0)
|
|
if g > gain:
|
|
src = Death.REACH
|
|
gain += g
|
|
if dust != null and dust.inhaled > 0.6:
|
|
gain += DREAD_FROM_LUNGS * dust.inhaled
|
|
|
|
if gain <= 0.0:
|
|
dread = maxf(0.0, dread - DREAD_CALM * dt)
|
|
return
|
|
# the longer you've been going round, the harder it grips
|
|
dread = clampf(dread + gain * dt * (1.0 + loops * DREAD_LOOP_GRIP), 0.0, 1.0)
|
|
if dread >= 1.0:
|
|
_die(src if src != Death.NONE else Death.STARE)
|
|
|
|
# ---------------------------------------------------------------- dying
|
|
const EPITAPHS := {
|
|
Death.STARE: ["YOU LOOKED TOO LONG",
|
|
"it was still. it was always still. that was never the safe part."],
|
|
Death.REACH: ["IT WAS ALREADY IN THE ROOM",
|
|
"you were making so much noise."],
|
|
Death.LOOP: ["YOU ARE STILL IN THE ROOM",
|
|
"you walked it eleven times. you will walk it again."],
|
|
}
|
|
|
|
func _die(cause: int) -> void:
|
|
if dying:
|
|
return
|
|
dying = true
|
|
death_cause = cause
|
|
dread = 1.0
|
|
_death_t = 0.0
|
|
gore.arm()
|
|
entity.state = Entity.S.LUNGE
|
|
if _cam != null:
|
|
_death_from = _cam.transform
|
|
# only the physical deaths spray. Being taken by the static shouldn't.
|
|
if cause != Death.STARE:
|
|
Gore.splatter(_host, _cam.global_position - _cam.global_transform.basis.z * 0.4,
|
|
-_cam.global_transform.basis.z)
|
|
if _player != null and _player.has_method("set_frozen"):
|
|
_player.set_frozen(true)
|
|
|
|
## Slow on purpose. A jumpscare is a spike; a death should take its time, because the
|
|
## seconds spent watching the camera settle onto the carpet are where it lands.
|
|
func _tick_death(dt: float) -> void:
|
|
_death_t += dt
|
|
var t := _death_t
|
|
|
|
# blood floods the glass over the first second and a half
|
|
gore.set_blood(clampf(t / 1.5, 0.0, 1.0))
|
|
|
|
if _cam != null:
|
|
if t < 0.35:
|
|
# the hit
|
|
_cam.transform = _death_from
|
|
_cam.rotation.x = _death_from.basis.get_euler().x + randf_range(-0.30, 0.30)
|
|
_cam.rotation.z = randf_range(-0.35, 0.35)
|
|
else:
|
|
# and then you go down, and the camera lands on its side on the carpet
|
|
var k := clampf((t - 0.35) / 2.4, 0.0, 1.0)
|
|
k = k * k * (3.0 - 2.0 * k)
|
|
_cam.position.y = lerpf(0.0, -1.35, k)
|
|
_cam.rotation.z = lerpf(_cam.rotation.z, 1.35, clampf(dt * 2.0, 0.0, 1.0))
|
|
_cam.rotation.x = lerpf(_cam.rotation.x, -0.55, clampf(dt * 2.0, 0.0, 1.0))
|
|
|
|
if t > 2.6:
|
|
gore.set_fade(clampf((t - 2.6) / 1.4, 0.0, 1.0))
|
|
if t > 4.0 and not _card_shown:
|
|
_card_shown = true
|
|
var e: Array = EPITAPHS.get(death_cause, EPITAPHS[Death.STARE])
|
|
gore.show_card(String(e[0]), String(e[1]))
|
|
|
|
var _card_shown := false
|
|
|
|
## 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
|
|
tape.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
|
|
dread = clampf(dread + DREAD_FROM_LOOP, 0.0, 1.0)
|
|
if loops >= 11 or dread >= 1.0:
|
|
_die(Death.LOOP) # the room keeps you
|
|
return
|
|
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
|
|
tape.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")
|
|
dread = clampf(dread + 0.34, 0.0, 1.0)
|
|
if dread >= 1.0:
|
|
_die(Death.REACH)
|
|
return
|
|
if _hud != null:
|
|
_hud.toast("IT TOUCHED YOU")
|
|
|
|
## R after a death. Rebuild is Main's job; this just clears the horror state.
|
|
func revive() -> void:
|
|
dying = false
|
|
_card_shown = false
|
|
death_cause = Death.NONE
|
|
dread = 0.0
|
|
loops = 0
|
|
caught = 0
|
|
gore.clear()
|
|
if _cam != null:
|
|
_cam.position = Vector3.ZERO
|
|
_cam.rotation = Vector3.ZERO
|
|
if _player != null and _player.has_method("set_frozen"):
|
|
_player.set_frozen(false)
|
|
entity.wake()
|
|
|
|
func hud_line() -> String:
|
|
if not active:
|
|
return ""
|
|
if dying:
|
|
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"
|
|
var label := "steady"
|
|
if dread > 0.85:
|
|
label = "YOU ARE GOING TO DIE HERE"
|
|
elif dread > 0.62:
|
|
label = "get out get out get out"
|
|
elif dread > 0.34:
|
|
label = "you are not alone"
|
|
return "loops %d · touched %d · signal %d%% · DREAD %d%% (%s)%s" % [
|
|
loops, caught, st, int(round(dread * 100.0)), label, warn]
|