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]