destroyulator/game/dev/DemoDriver.gd
Monster Robot Party a806272a7f LANE7: there are two of them, and the room changes one thing every loop
THE OTHER ONE (Entity.Kind.MIRROR)
After three loops a second one turns up, and it shares the first one's silhouette on
purpose — same height, same suit, same dark — so at range you cannot tell which you are
looking at. Its rules are the hunter's rules INVERTED:

                     hunter                 the other one
  while watched      frozen                 IT COMES
  while unwatched    it closes              inert
  noise              hunts it               deaf
  correct play       keep it in view        LOOK AWAY

Every instinct the first one trains into you is the way the second one kills you, and
that is the entire design. The tells are close-range only and all wrong in the same
direction: head thrown back so it stares at the ceiling instead of you, arms raised, and
feet that never reach the carpet (Backrooms floats it 14 cm).

It also makes no static and doesn't breathe, so the only warning the tape gives for it
is the edge closing in — even while you are looking straight at it.

ONE THING PER LOOP (Backrooms._disturb)
Exactly one change per wrap. Never two. That constraint IS the effect: change nothing
and a loop is just a teleport, change several and it reads as a new room, which is the
opposite of what this place is for. One change means you are never sure whether you
noticed something or imagined it — and since the room is otherwise identical, when you
ARE sure it is worse.

Escalating: early loops shift a prop half a metre, turn one, or kill a bank of lights.
Later ones hang a totem that wasn't there, permanently reveal an object nobody dusted,
or put the hunter exactly where you were standing a moment ago.

The shift is half a metre, not one — a metre is enough to shove a neighbouring prop and
read as TWO changes, and it's too obvious anyway. Half is "…was that there?"

dev/probe_two.gd verifies the inversion with numbers (unwatched 0.00 m, watched 4.39 m,
noise heat 0.00) and that the disturbance fires per loop. Note the probe has to wait out
WRAP_COOLDOWN between laps or only the first wrap ever fires.

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

232 lines
7.2 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:
# The two of them. Same silhouette at range; the tells are close-range only — the
# other one's head is thrown back, its arms are up, and its feet don't reach.
_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.6, -4.0),
"act": "ent:0,-4"},
{"dur": 3.0, "move_to": null, "look_at": null, "act": ""},
{"dur": 0.3, "move_to": null, "look_at": Vector3(4.0, 1.6, -4.0), "act": "two:4,-4"},
{"dur": 3.4, "move_to": null, "look_at": null, "act": ""},
{"dur": 3.0, "move_to": Vector3(2.0, 0, 1.0), "look_at": Vector3(4.0, 1.7, -4.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.begins_with("two:"):
var b4 = _main.get("_backrooms")
if b4 != null and b4.other != null:
var q := act.substr(4).split(",")
b4.other.wake()
b4.other.global_position = Vector3(float(q[0]), 0.14, float(q[1]))
b4.other.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()