destroyulator/game/scripts/Dread.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

111 lines
3.8 KiB
GDScript

extends CanvasLayer
class_name Dread
## The camera you are apparently holding.
##
## One full-screen shader doing four jobs, because they're all the same job — making the
## image feel like a recording of something rather than a view of it:
##
## VHS ......... grain, scanlines, a chroma split, and a tracking tear that wanders.
## Found-footage grammar. It also quietly hides the fact that the
## geometry is very simple.
## STATIC ...... Slender's interference. Driven by Entity.static_level, so it is a
## read-out of how long you've been looking at it — the HUD element that
## isn't a HUD element.
## SCARE ....... a white-hot bloom and a violent tear on the frame it takes you.
## PULSE ....... a slow breathing darkness at the edges when it's near but unseen,
## which is the only warning you get that it's behind you.
##
## Godot 4.7 GDScript 2.0.
const SHADER := """
shader_type canvas_item;
uniform float t = 0.0;
uniform float grain : hint_range(0.0, 1.0) = 0.35;
uniform float static_lv : hint_range(0.0, 1.0) = 0.0;
uniform float scare : hint_range(0.0, 1.0) = 0.0;
uniform float near : hint_range(0.0, 1.0) = 0.0;
uniform sampler2D screen_tex : hint_screen_texture, filter_linear;
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void fragment() {
vec2 uv = SCREEN_UV;
// tracking tear: a band that slides down the frame and drags the image sideways
float band = fract(uv.y * 1.4 - t * 0.11);
float tear = smoothstep(0.982, 1.0, band) * (0.55 + static_lv + scare * 4.0);
uv.x += tear * (hash(vec2(floor(uv.y * 200.0), floor(t * 12.0))) - 0.5) * 0.06;
// static shoves the image around in blocks — legibility falls apart as it stares
if (static_lv > 0.02) {
vec2 blk = floor(uv * vec2(48.0, 90.0));
float j = hash(blk + floor(t * 20.0));
uv += (vec2(hash(blk.yx), j) - 0.5) * static_lv * 0.05 * step(0.72, j);
}
// chroma split, widening with dread
float split = (0.0016 + static_lv * 0.010 + scare * 0.020);
vec3 col;
col.r = texture(screen_tex, uv + vec2(split, 0.0)).r;
col.g = texture(screen_tex, uv).g;
col.b = texture(screen_tex, uv - vec2(split, 0.0)).b;
// grain + scanlines
float g = hash(uv * vec2(1024.0, 768.0) + fract(t) * 91.0);
col += (g - 0.5) * (grain * 0.13 + static_lv * 0.34);
col *= 0.90 + 0.10 * sin(SCREEN_UV.y * 900.0);
// the white blocks of real interference
col = mix(col, vec3(g), static_lv * static_lv * 0.34);
// edges close in when it's near but out of sight — your only warning
vec2 d = (SCREEN_UV - vec2(0.5)) * vec2(1.0, 0.62);
float r = length(d) * 2.0;
float breathe = 0.5 + 0.5 * sin(t * 2.1);
col *= 1.0 - smoothstep(0.42, 1.25, r) * (near * (0.45 + 0.25 * breathe));
// the frame it takes you
col = mix(col, vec3(1.0), scare * 0.62);
COLOR = vec4(col, 1.0);
}
"""
var _rect: ColorRect
var _t := 0.0
var _scare := 0.0
var _face: TextureRect
var _face_left := 0.0
func setup() -> void:
layer = 30 # above everything, including the rage vignette
_rect = ColorRect.new()
_rect.set_anchors_preset(Control.PRESET_FULL_RECT)
_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
var sh := Shader.new()
sh.code = SHADER
var m := ShaderMaterial.new()
m.shader = sh
_rect.material = m
add_child(_rect)
visible = false
## Driven every frame by Backrooms.
func drive(dt: float, static_lv: float, near: float) -> void:
_t += dt
_scare = maxf(0.0, _scare - dt * 1.7)
var m := _rect.material as ShaderMaterial
m.set_shader_parameter("t", _t)
m.set_shader_parameter("static_lv", static_lv)
m.set_shader_parameter("near", near)
m.set_shader_parameter("scare", _scare)
func flash() -> void:
_scare = 1.0
func set_active(on: bool) -> void:
visible = on
if not on:
_scare = 0.0