THE OTHER ONE (Entity.Kind.MIRROR)
After three loops a second one turns up, and it shares the first one's silhouette on
purpose — same height, same suit, same dark — so at range you cannot tell which you are
looking at. Its rules are the hunter's rules INVERTED:
hunter the other one
while watched frozen IT COMES
while unwatched it closes inert
noise hunts it deaf
correct play keep it in view LOOK AWAY
Every instinct the first one trains into you is the way the second one kills you, and
that is the entire design. The tells are close-range only and all wrong in the same
direction: head thrown back so it stares at the ceiling instead of you, arms raised, and
feet that never reach the carpet (Backrooms floats it 14 cm).
It also makes no static and doesn't breathe, so the only warning the tape gives for it
is the edge closing in — even while you are looking straight at it.
ONE THING PER LOOP (Backrooms._disturb)
Exactly one change per wrap. Never two. That constraint IS the effect: change nothing
and a loop is just a teleport, change several and it reads as a new room, which is the
opposite of what this place is for. One change means you are never sure whether you
noticed something or imagined it — and since the room is otherwise identical, when you
ARE sure it is worse.
Escalating: early loops shift a prop half a metre, turn one, or kill a bank of lights.
Later ones hang a totem that wasn't there, permanently reveal an object nobody dusted,
or put the hunter exactly where you were standing a moment ago.
The shift is half a metre, not one — a metre is enough to shove a neighbouring prop and
read as TWO changes, and it's too obvious anyway. Half is "…was that there?"
dev/probe_two.gd verifies the inversion with numbers (unwatched 0.00 m, watched 4.39 m,
noise heat 0.00) and that the disturbance fires per loop. Note the probe has to wait out
WRAP_COOLDOWN between laps or only the first wrap ever fires.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
336 lines
12 KiB
GDScript
336 lines
12 KiB
GDScript
extends Node3D
|
|
class_name Entity
|
|
|
|
## The thing in LEVEL 0.
|
|
##
|
|
## It borrows from three places, and the borrowings do different jobs:
|
|
##
|
|
## SLENDERMAN — it does not move while you are looking at it. Look away and it has
|
|
## closed the distance. Looking at it is also what builds the STATIC, so
|
|
## the safe option (keep it in view) is the one that kills you slowly and
|
|
## the fast option (run) is the one that lets it catch up. That's the
|
|
## whole trap, and it's why Slender worked.
|
|
##
|
|
## XENOMORPH — it hunts by SOUND. And the thing you came to LEVEL 0 to do is smash
|
|
## furniture, so the game's own objective feeds it. Every break and every
|
|
## puff of the extinguisher drops a noise marker it walks toward.
|
|
##
|
|
## BLAIR WITCH— it uses the loop. When the level wraps you round to the far side, it
|
|
## gets to reposition, so "I've been here before" and "it's already here"
|
|
## arrive together.
|
|
##
|
|
## It cannot be fought. Weapons pass through it. The only outs are distance, silence, and
|
|
## not looking at it — three things the rest of the game actively discourages.
|
|
##
|
|
## Godot 4.7 GDScript 2.0.
|
|
|
|
const MODEL := "res://assets/store/entity.glb"
|
|
const MODEL_OTHER := "res://assets/store/entity2.glb"
|
|
|
|
enum S { DORMANT, STALKING, HUNTING, LUNGE }
|
|
|
|
## Two of them, and the second one's rules are the FIRST one's rules inverted.
|
|
##
|
|
## HUNTER freezes while you look at it, closes when you don't, and hunts sound.
|
|
## Correct play: keep it in view, stay quiet.
|
|
## MIRROR advances ONLY while you look at it, is inert when you don't, and is deaf.
|
|
## Correct play: look away. Noise is irrelevant.
|
|
##
|
|
## They share a silhouette on purpose. At thirty metres in a yellow room you cannot tell
|
|
## which one you're looking at, so every sighting is a question, and the answer you
|
|
## learned first is the one that kills you. The tells are close-range only: the MIRROR's
|
|
## head is thrown back, its arms are up, and its feet never reach the carpet.
|
|
enum Kind { HUNTER, MIRROR }
|
|
var kind: int = Kind.HUNTER
|
|
|
|
## How far it can be seen from; beyond this it simply isn't rendered.
|
|
const SEE_DIST := 34.0
|
|
## Inside this, a lunge begins.
|
|
const LUNGE_DIST := 3.6
|
|
## Cone (dot product) within which the player counts as LOOKING at it.
|
|
const LOOK_DOT := 0.55
|
|
|
|
@export var stalk_speed := 2.5
|
|
@export var hunt_speed := 4.4
|
|
@export var lunge_speed := 9.5
|
|
## Static gained per second while it is on screen, scaled by how close it is.
|
|
@export var stare_static := 0.28
|
|
@export var static_decay := 0.22
|
|
|
|
var state: int = S.DORMANT
|
|
var static_level := 0.0 ## 0..1 — Slender's interference. At 1.0 it takes you.
|
|
var seen_now := false
|
|
var noise_at := Vector3.ZERO
|
|
var noise_heat := 0.0
|
|
|
|
var _player: Node3D
|
|
var _cam: Camera3D
|
|
var _bounds := 19.0
|
|
var _mesh: Node3D
|
|
var _cd := 0.0 ## seconds until it may act again after a scare
|
|
var _think := 0.0
|
|
var _breath: AudioStreamPlayer3D
|
|
var _sting: AudioStreamPlayer
|
|
|
|
signal caught_you() ## the jumpscare fires; Backrooms decides the cost
|
|
|
|
func setup(player: Node3D, cam: Camera3D, half_extent: float,
|
|
which: int = Kind.HUNTER) -> void:
|
|
_player = player
|
|
_cam = cam
|
|
_bounds = half_extent
|
|
kind = which
|
|
var path := MODEL if kind == Kind.HUNTER else MODEL_OTHER
|
|
if ResourceLoader.exists(path):
|
|
var packed := load(path) as PackedScene
|
|
if packed != null:
|
|
_mesh = packed.instantiate()
|
|
add_child(_mesh)
|
|
visible = false
|
|
|
|
# it breathes, and you hear it before you see it
|
|
_breath = AudioStreamPlayer3D.new()
|
|
_breath.stream = _noise_wav(1.6, 70.0, 1.4, true)
|
|
_breath.unit_size = 14.0
|
|
_breath.max_db = -2.0
|
|
_breath.autoplay = false
|
|
add_child(_breath)
|
|
|
|
_sting = AudioStreamPlayer.new()
|
|
_sting.stream = _sting_wav()
|
|
_sting.volume_db = 2.0
|
|
add_child(_sting)
|
|
|
|
## Put it somewhere far from the player and switch it on.
|
|
func wake() -> void:
|
|
state = S.STALKING
|
|
static_level = 0.0
|
|
_cd = 0.0
|
|
visible = true
|
|
_reposition(18.0)
|
|
if _breath != null and not _breath.playing:
|
|
_breath.play()
|
|
|
|
func sleep() -> void:
|
|
state = S.DORMANT
|
|
visible = false
|
|
static_level = 0.0
|
|
if _breath != null:
|
|
_breath.stop()
|
|
|
|
func is_awake() -> bool:
|
|
return state != S.DORMANT
|
|
|
|
## Something made a noise. The xenomorph half of it walks toward the last loud thing.
|
|
func hear(at: Vector3, loudness: float) -> void:
|
|
if state == S.DORMANT or kind == Kind.MIRROR:
|
|
return # the other one does not care what you break
|
|
if loudness >= noise_heat * 0.6:
|
|
noise_at = at
|
|
noise_heat = clampf(noise_heat + loudness, 0.0, 3.0)
|
|
# A loud enough noise overrides the Slender freeze. Standing still and staring at it
|
|
# is a valid answer to a stalker; it is not a valid answer to something that just
|
|
# heard you put a sledgehammer through a filing cabinet. The escalation is the point:
|
|
# the quiet rules protect you right up until you do the thing the game is about.
|
|
if noise_heat > 1.2 and state == S.STALKING:
|
|
state = S.HUNTING
|
|
|
|
## Teleport somewhere at least `min_dist` away, out of the player's current view if it
|
|
## can manage it — being *found* somewhere new is better than being *seen* moving there.
|
|
func _reposition(min_dist: float) -> void:
|
|
if _player == null:
|
|
return
|
|
for attempt in 24:
|
|
var p := Vector3(randf_range(-_bounds + 2.0, _bounds - 2.0), 0.0,
|
|
randf_range(-_bounds + 2.0, _bounds - 2.0))
|
|
if p.distance_to(_player.global_position) < min_dist:
|
|
continue
|
|
if attempt < 16 and _in_view(p):
|
|
continue
|
|
global_position = p
|
|
return
|
|
global_position = Vector3(randf_range(-_bounds, _bounds), 0.0,
|
|
randf_range(-_bounds, _bounds))
|
|
|
|
## In the view cone AND actually visible. The line-of-sight test is not optional: this
|
|
## is a maze, so most of the time the cone contains a wall, and without the raycast the
|
|
## static built while you stared at plasterboard with the thing quietly behind it.
|
|
func _in_view(p: Vector3) -> bool:
|
|
if _cam == null:
|
|
return false
|
|
var eye := _cam.global_position
|
|
var to := p - eye
|
|
if to.length() < 0.01:
|
|
return true
|
|
if to.normalized().dot(-_cam.global_transform.basis.z) <= LOOK_DOT:
|
|
return false
|
|
return _clear_line(eye, p + Vector3(0.0, 1.7, 0.0))
|
|
|
|
## True if nothing static blocks the line. Only walls and fittings occlude — the loose
|
|
## furniture is chest-high and shouldn't hide a two-and-a-half metre figure.
|
|
func _clear_line(from: Vector3, to: Vector3) -> bool:
|
|
var space := get_world_3d().direct_space_state
|
|
if space == null:
|
|
return true
|
|
var q := PhysicsRayQueryParameters3D.create(from, to)
|
|
q.collide_with_bodies = true
|
|
q.collide_with_areas = false
|
|
var hit := space.intersect_ray(q)
|
|
if hit.is_empty():
|
|
return true
|
|
var col = hit.get("collider")
|
|
return not (col is StaticBody3D)
|
|
|
|
# ---------------------------------------------------------------- per-frame
|
|
func _physics_process(dt: float) -> void:
|
|
if state == S.DORMANT or _player == null or _cam == null:
|
|
return
|
|
_cd = maxf(0.0, _cd - dt)
|
|
noise_heat = maxf(0.0, noise_heat - dt * 0.35)
|
|
|
|
var to_player := _player.global_position - global_position
|
|
var dist := to_player.length()
|
|
seen_now = _in_view(global_position) and dist < SEE_DIST
|
|
|
|
# always face you. It has no other business.
|
|
var flat := Vector3(to_player.x, 0.0, to_player.z)
|
|
if flat.length() > 0.05:
|
|
var want := atan2(-flat.x, -flat.z)
|
|
rotation.y = lerp_angle(rotation.y, want, clampf(dt * 6.0, 0.0, 1.0))
|
|
|
|
# --- STATIC ---
|
|
# Only the HUNTER's stare degrades the signal. The MIRROR doesn't stare at you at
|
|
# all — it's looking at the ceiling — so its threat is purely that it is closer than
|
|
# it was, and it makes no noise on the tape to warn you.
|
|
if seen_now and kind == Kind.HUNTER:
|
|
var closeness := clampf(1.0 - dist / SEE_DIST, 0.0, 1.0)
|
|
static_level = clampf(static_level + stare_static * (0.35 + closeness) * dt,
|
|
0.0, 1.0)
|
|
else:
|
|
static_level = maxf(0.0, static_level - static_decay * dt)
|
|
|
|
if static_level >= 1.0 and _cd <= 0.0:
|
|
_catch()
|
|
return
|
|
|
|
if kind == Kind.MIRROR:
|
|
_tick_mirror(dt, dist)
|
|
return
|
|
|
|
match state:
|
|
S.STALKING:
|
|
_tick_stalk(dt, dist)
|
|
S.HUNTING:
|
|
_tick_hunt(dt, dist)
|
|
S.LUNGE:
|
|
_tick_lunge(dt, dist)
|
|
|
|
## Slender's rule: frozen while watched, and closer every time you look back.
|
|
func _tick_stalk(dt: float, dist: float) -> void:
|
|
_think -= dt
|
|
if seen_now:
|
|
return # it does not move while you watch
|
|
# closes ground, but toward the last noise if there was one — the two rules compose
|
|
var goal := _player.global_position
|
|
if noise_heat > 0.4:
|
|
goal = noise_at
|
|
_step_toward(goal, stalk_speed * dt)
|
|
if dist < LUNGE_DIST * 2.2 or noise_heat > 1.6:
|
|
state = S.HUNTING
|
|
|
|
func _tick_hunt(dt: float, dist: float) -> void:
|
|
# hunting, it no longer respects being watched. That escalation is the point.
|
|
var goal := _player.global_position
|
|
if noise_heat > 1.2:
|
|
goal = noise_at
|
|
_step_toward(goal, hunt_speed * dt)
|
|
if dist < LUNGE_DIST:
|
|
state = S.LUNGE
|
|
elif dist > SEE_DIST * 0.8 and noise_heat < 0.3:
|
|
state = S.STALKING
|
|
|
|
## The inversion. It is inert until observed, and then it comes — fast, and in a
|
|
## straight line, and it does not stop while you are still looking. Every instinct the
|
|
## HUNTER trained into you (keep it in sight, it's safe while watched) is now the way
|
|
## you die.
|
|
func _tick_mirror(dt: float, dist: float) -> void:
|
|
if not seen_now:
|
|
return # unwatched, it is furniture
|
|
var speed := hunt_speed * 1.15
|
|
if dist < LUNGE_DIST * 1.6:
|
|
speed = lunge_speed
|
|
_step_toward(_player.global_position, speed * dt)
|
|
if dist < 1.5 and _cd <= 0.0:
|
|
_catch()
|
|
|
|
func _tick_lunge(dt: float, dist: float) -> void:
|
|
_step_toward(_player.global_position, lunge_speed * dt)
|
|
if dist < 1.5 and _cd <= 0.0:
|
|
_catch()
|
|
|
|
func _step_toward(goal: Vector3, amount: float) -> void:
|
|
var d := goal - global_position
|
|
d.y = 0.0
|
|
if d.length() < 0.05:
|
|
return
|
|
global_position += d.normalized() * amount
|
|
|
|
func _catch() -> void:
|
|
_cd = 3.0
|
|
static_level = 0.0
|
|
_sting.pitch_scale = randf_range(0.94, 1.06)
|
|
_sting.play()
|
|
caught_you.emit()
|
|
state = S.STALKING
|
|
_reposition(16.0)
|
|
|
|
# ---------------------------------------------------------------- audio
|
|
## A slow rasp for the breathing; `rough` doubles the noise and drops the tone.
|
|
func _noise_wav(dur: float, tone: float, decay: float, rough: bool) -> 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(-fposmod(t, 0.8) * decay) * 0.8
|
|
var s := sin(TAU * tone * t) * 0.4 * env
|
|
if rough:
|
|
s += (randf() * 2.0 - 1.0) * 0.35 * env
|
|
s += sin(TAU * tone * 0.5 * t) * 0.25 * env
|
|
out[i] = clampf(s, -1.0, 1.0)
|
|
return _wav(out, rate, true)
|
|
|
|
## The jumpscare sting: a downward shriek over a hit of noise.
|
|
func _sting_wav() -> AudioStreamWAV:
|
|
var rate := 22050
|
|
var dur := 1.15
|
|
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 * 3.2)
|
|
var f := 1800.0 - 1500.0 * clampf(t / dur, 0.0, 1.0)
|
|
var s := sin(TAU * f * t) * 0.45 * env
|
|
s += sin(TAU * f * 1.5 * t) * 0.20 * env
|
|
s += (randf() * 2.0 - 1.0) * 0.55 * exp(-t * 11.0)
|
|
s += sin(TAU * 46.0 * t) * 0.35 * exp(-t * 2.0)
|
|
out[i] = clampf(s, -1.0, 1.0)
|
|
return _wav(out, rate, false)
|
|
|
|
func _wav(samples: PackedFloat32Array, rate: int, loop: bool) -> AudioStreamWAV:
|
|
var w := AudioStreamWAV.new()
|
|
w.format = AudioStreamWAV.FORMAT_16_BITS
|
|
w.mix_rate = rate
|
|
w.stereo = false
|
|
var b := PackedByteArray()
|
|
b.resize(samples.size() * 2)
|
|
for i in samples.size():
|
|
b.encode_s16(i * 2, int(samples[i] * 32767.0))
|
|
w.data = b
|
|
if loop:
|
|
w.loop_mode = AudioStreamWAV.LOOP_FORWARD
|
|
w.loop_end = samples.size()
|
|
return w
|