destroyulator/game/scripts/Entity.gd
Monster Robot Party 006c324f1f LANE7: LEVEL 0 gets an entity, a tape, and a loop
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>
2026-07-31 19:25:28 +10:00

297 lines
9.7 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"
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