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>
219 lines
7.0 KiB
GDScript
219 lines
7.0 KiB
GDScript
extends Node3D
|
|
|
|
## Dev-only capture harness. Loads main.tscn and drives the player through a scripted
|
|
## tour so a headless/CI run can record video of the actual game with Godot's
|
|
## --write-movie, without a human at the keyboard (and without screen-recording perms).
|
|
##
|
|
## /Applications/Godot.app/Contents/MacOS/Godot --path game \
|
|
## --resolution 1280x720 --write-movie /tmp/cap.avi --quit-after 900 \
|
|
## dev/demo.tscn
|
|
##
|
|
## Not shipped: excluded from export presets. Drives the player directly (position /
|
|
## yaw / pitch / _queue_attack) rather than faking InputEvents, so it stays immune to
|
|
## input-map changes.
|
|
|
|
const MAIN := "res://main.tscn"
|
|
|
|
var _main: Node3D
|
|
var _player: Player
|
|
var _t := 0.0
|
|
var _step := 0
|
|
var _step_t := 0.0
|
|
var _frames := 0
|
|
|
|
# Each step: {dur, move_to (Vector3 or null), look_at (Vector3 or null), act (String)}
|
|
# act: "" | "punch" | "kick" | "rain" | "reset"
|
|
var _script: Array = []
|
|
|
|
func _ready() -> void:
|
|
var packed := load(MAIN) as PackedScene
|
|
_main = packed.instantiate()
|
|
add_child(_main)
|
|
await get_tree().process_frame
|
|
_player = _find_player(_main)
|
|
if _player == null:
|
|
push_error("[demo] no Player found")
|
|
return
|
|
# the demo drives the camera; don't let the OS grab the pointer
|
|
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
|
|
_build_script()
|
|
|
|
func _find_player(n: Node) -> Player:
|
|
if n is Player:
|
|
return n
|
|
for c in n.get_children():
|
|
var r := _find_player(c)
|
|
if r != null:
|
|
return r
|
|
return null
|
|
|
|
func _build_script() -> void:
|
|
# LEVEL 0, the horror pass. NOTE the level swap is async and enter() repositions the
|
|
# entity on wake, so it has to be placed a step LATER or the wake overwrites it.
|
|
_script = [
|
|
{"dur": 1.2, "move_to": null, "look_at": null, "act": "level:backrooms"},
|
|
{"dur": 0.3, "move_to": Vector3(0, 0, 4.0), "look_at": Vector3(0, 1.5, -4.0),
|
|
"act": "ent:0,-4"},
|
|
{"dur": 2.4, "move_to": null, "look_at": null, "act": ""},
|
|
{"dur": 2.4, "move_to": Vector3(0, 0, 1.5), "look_at": Vector3(0, 1.6, -4.0), "act": ""},
|
|
{"dur": 1.6, "move_to": null, "look_at": null, "act": ""},
|
|
{"dur": 2.6, "move_to": Vector3(0, 0, 18.4), "look_at": Vector3(0, 1.3, 19.0), "act": ""},
|
|
{"dur": 2.4, "move_to": null, "look_at": Vector3(3.0, 1.3, -8.0), "act": ""},
|
|
]
|
|
|
|
func _motion(verbose := false) -> float:
|
|
var total := 0.0
|
|
var seen := {}
|
|
var worst: Array = []
|
|
for g in ["smashable", "debris"]: # every body is in one of these two
|
|
for n in get_tree().get_nodes_in_group(g):
|
|
if seen.has(n) or not (n is RigidBody3D):
|
|
continue
|
|
seen[n] = true
|
|
var b := n as RigidBody3D
|
|
if b.freeze:
|
|
continue
|
|
var v := b.linear_velocity.length()
|
|
total += v
|
|
worst.append([v, b])
|
|
if verbose:
|
|
worst.sort_custom(func(a, c): return a[0] > c[0])
|
|
for i in mini(6, worst.size()):
|
|
var b: RigidBody3D = worst[i][1]
|
|
var what := "RigidBody3D"
|
|
if b is Record:
|
|
what = "Record(sleeve %s)" % (b as Record).sleeve_genre
|
|
elif b is Smashable:
|
|
what = "Smashable(%s)" % (b as Smashable).kind
|
|
var groups := ""
|
|
for g in b.get_groups():
|
|
groups += str(g) + " "
|
|
print(" %7.1f m/s %-18s %-22s parent=%s groups=[%s] at %s" % [
|
|
worst[i][0], b.name, what, b.get_parent().name, groups.strip_edges(),
|
|
b.global_position.snappedf(0.01)])
|
|
return total
|
|
|
|
func _process(dt: float) -> void:
|
|
if _player == null or _step >= _script.size():
|
|
return
|
|
if _frames < 3:
|
|
_frames += 1
|
|
if _frames == 2:
|
|
print("[settle] FIRST FRAME — where things start:")
|
|
_motion(true)
|
|
if _t < 6.0 and int(_t) != int(_t + dt):
|
|
print("[settle] t=%ds total body speed = %.2f m/s" % [int(_t + dt), _motion(true)])
|
|
_t += dt
|
|
var s: Dictionary = _script[_step]
|
|
var dur: float = s["dur"]
|
|
# fire the action when the step BEGINS, so a capture at step_start + n frames
|
|
# actually shows the swing rather than the second of stillness before it
|
|
if not s.get("_fired", false):
|
|
s["_fired"] = true
|
|
_do(String(s["act"]))
|
|
_step_t += dt
|
|
|
|
var mt = s.get("move_to")
|
|
if mt != null:
|
|
var to: Vector3 = mt
|
|
var k: float = clampf(_step_t / maxf(dur, 0.001), 0.0, 1.0)
|
|
k = k * k * (3.0 - 2.0 * k) # smoothstep so the dolly eases
|
|
var from: Vector3 = s.get("_from", _player.global_position)
|
|
if not s.has("_from"):
|
|
s["_from"] = _player.global_position
|
|
from = _player.global_position
|
|
_player.global_position = from.lerp(to, k)
|
|
var la = s.get("look_at")
|
|
if la != null:
|
|
_aim(la, clampf(_step_t / maxf(dur, 0.001), 0.0, 1.0))
|
|
|
|
if _step_t >= dur:
|
|
_step += 1
|
|
_step_t = 0.0
|
|
|
|
## Turn the body (yaw) + head (pitch) toward a world point, eased.
|
|
func _aim(target: Vector3, k: float) -> void:
|
|
var eye: Vector3 = _player.global_position + Vector3(0, _player.eye_height, 0)
|
|
var d: Vector3 = target - eye
|
|
if d.length() < 0.001:
|
|
return
|
|
var want_yaw := atan2(-d.x, -d.z)
|
|
var want_pitch := atan2(d.y, Vector2(d.x, d.z).length())
|
|
var e := clampf(k * 0.14, 0.0, 1.0) + 0.06
|
|
_player.rotation.y = lerp_angle(_player.rotation.y, want_yaw, e)
|
|
var head: Node3D = _player.get_node_or_null("Head")
|
|
if head != null:
|
|
head.rotation.x = lerp_angle(head.rotation.x, want_pitch, e)
|
|
|
|
func _do(act: String) -> void:
|
|
if act.begins_with("weapon:"):
|
|
_player.select(int(act.substr(7)))
|
|
return
|
|
if act.begins_with("level:"):
|
|
if _main.has_method("_load_level"):
|
|
_main._load_level(act.substr(6))
|
|
return
|
|
if act.begins_with("mode:"):
|
|
var md = _main.get("_modes")
|
|
if md != null:
|
|
md.set_mode(int(act.substr(5)))
|
|
return
|
|
if act.begins_with("ent:"):
|
|
# park the entity somewhere specific so the tour can look at it on cue
|
|
var br = _main.get("_backrooms")
|
|
if br != null and br.entity != null:
|
|
var p := act.substr(4).split(",")
|
|
br.entity.global_position = Vector3(float(p[0]), 0.0, float(p[1]))
|
|
br.entity.state = Entity.S.STALKING
|
|
return
|
|
if act == "scare":
|
|
var br2 = _main.get("_backrooms")
|
|
if br2 != null and br2.entity != null:
|
|
br2.entity._cd = 0.0
|
|
br2.entity._catch()
|
|
return
|
|
if act == "puff":
|
|
var dd = _main.get("_dust")
|
|
if dd != null:
|
|
dd._cd = 0.0
|
|
dd.discharge()
|
|
return
|
|
if act == "interact":
|
|
var gg = _main.get("_gauntlet")
|
|
if gg != null:
|
|
gg.interact()
|
|
return
|
|
if act.begins_with("rage:"):
|
|
var r = _main.get("_rage")
|
|
if r != null:
|
|
r.provoke(float(act.substr(5)), "the demo is provoking you")
|
|
return
|
|
match act:
|
|
"task": # press E at the nearest station
|
|
var t = _main.get("_tasks")
|
|
if t != null:
|
|
t.try_start()
|
|
"type": # enter the requested figure correctly
|
|
var t2 = _main.get("_tasks")
|
|
if t2 != null and t2.is_working():
|
|
var st: Dictionary = t2.panel_state()
|
|
var want := String(st.get("instruction", "")).split(" ")[1]
|
|
for ch in want:
|
|
t2.handle_key(KEY_0 + int(ch))
|
|
"commit":
|
|
var t3 = _main.get("_tasks")
|
|
if t3 != null:
|
|
t3.handle_key(KEY_ENTER)
|
|
"swing":
|
|
_player._queued = true # same path a left-click takes
|
|
"hud":
|
|
var hud = _main.get("_hud")
|
|
if hud != null:
|
|
hud.cycle()
|
|
"rain":
|
|
if _main.has_method("_rain"):
|
|
_main._rain(500)
|
|
"reset":
|
|
if _main.has_method("_reset"):
|
|
_main._reset()
|