destroyulator/game/dev/DemoDriver.gd
Monster Robot Party bcd67906a4 LANE7: you can die in LEVEL 0, and how you died decides which death you get
NO HEALTH BAR. Nothing else in this game does damage, so hit points would be a
survival-horror UI bolted onto a demolition game. Instead: one meter, DREAD, fed by
every way this place gets at you — being stared at, something close behind you, each
loop, and your own coughing. It decays when nothing is happening to you, and every loop
makes it grip harder (DREAD_LOOP_GRIP), so the place gets better at this the longer you
stay.

At 100% you die, and WHICH source pushed it over decides the death, which is what keeps
the three memes legible at the one moment it matters:

  STARE  "YOU LOOKED TOO LONG / it was still. it was always still. that was never the
         safe part."                                              (Slenderman)
  REACH  "IT WAS ALREADY IN THE ROOM / you were making so much noise."
         Sprays. This is the gory one.                            (the xenomorph)
  LOOP   "YOU ARE STILL IN THE ROOM / you walked it eleven times. you will walk it
         again." Eleven loops and the room simply keeps you.       (Blair Witch)

Gore.gd is procedural — seeded blobs that swell on the lens and then bleed downward in
streaks, a wet burst at the camera (physical deaths only; being taken by the static
shouldn't spray), the camera going down to land on its side on the carpet, then the
card. The sequence is deliberately SLOW: a jumpscare is a spike, but a death should take
its time, because the seconds spent watching the lens settle are where it lands. R wakes
you up.

Player.set_frozen() so you can't stroll around during your own death.

Renamed Backrooms' VHS layer to `tape` — `dread` is the meter now, and it was clearer
anyway.

dev/probe_death.gd drives all three deaths and the revive.

Note: the "Parameter material is null" warning only appears under the headless dummy
renderer; windowed runs are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 19:44:26 +10:00

221 lines
6.8 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: stand in front of it, let the dread max out, and die.
_script = [
{"dur": 1.2, "move_to": null, "look_at": null, "act": "level:backrooms"},
{"dur": 0.3, "move_to": Vector3(0, 0, 3.0), "look_at": Vector3(0, 1.5, -3.0),
"act": "ent:0,-3"},
{"dur": 2.6, "move_to": null, "look_at": null, "act": ""},
{"dur": 0.4, "move_to": null, "look_at": null, "act": "kill"},
{"dur": 7.0, "move_to": null, "look_at": null, "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 == "kill":
var b3 = _main.get("_backrooms")
if b3 != null:
b3.dread = 0.99
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()