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" enum S { DORMANT, STALKING, HUNTING, LUNGE } ## 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) -> void: _player = player _cam = cam _bounds = half_extent if ResourceLoader.exists(MODEL): var packed := load(MODEL) 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: return 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: looking at it is what hurts you --- if seen_now: 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 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 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