destroyulator/game/dev/DemoDriver.gd
Monster Robot Party e5f4e8c5e4 LANE7: the Gauntlet, four modes, two more provocations, and ambient attrition
THE GAUNTLET (Gauntlet.gd + Cabinet.gd)
Five identical cabinets, each with its alphabetical range on a Label3D. The brief names
a CLIENT, never a cabinet, so working out that HENDERSON lives in F-J is the puzzle —
no waypoint, nothing glows. Destruction is granular on purpose: pull a drawer, it
slides out on a spring and goes live, and the folders inside become individual
smashables. Doing that by hand inside the clock isn't really possible.

The perfect tool: every site hides a SHREDDER that eats a whole open drawer at once,
and the game never says so. Noticing it and working out what it's for IS the level.
Getting it wrong isn't fatal, just slow — the right shape for a secret, since it
rewards the observant without hard-failing everyone else.

Each site nominates its own records area in its spec. Building the bank 3.4 m in front
of wherever the player happened to be standing put it through walls.

MODES (Modes.gd, M cycles)
  CUBICLE HELL       the main loop
  THE GAUNTLET       above
  TOTAL DESTRUCTION  no job, no pay, clear the site, scored on time
  AGAINST THE CLOCK  fixed timer, maximise damage VALUE — the exact inverse of Cubicle
                     Hell, where expensive things become the goal rather than the
                     mistake. Same level, opposite instinct.
Entering a mode arms only what that mode needs; Cubicle Hell and the Gauntlet are
mutually exclusive, because you cannot be doing your job and shredding the Henderson
file at the same time.

TWO MORE PROVOCATIONS (Tasks.gd)
  STAPLES     SPACE lifts a staple, then it must be pulled the way it's bent — and it's
              bent differently each time. Dither and the sheet tears, bundle reprints.
  GUILLOTINE  align to a mark and cut. The guide has a PERMANENT offset and nothing on
              screen admits it. Once you work out the error it's easy, which is the
              joke: you have to learn to distrust the equipment.
Desks now alternate spreadsheet/staples so "a desk" isn't always the same minigame.

AMBIENCE (Ambience.gd) — the attrition you can't play around. The tube above your desk
flickers, and actually flickers the real lights. Fish in the microwave. A phone ringing
four desks away. Reply-all, forty-one recipients, "thanks!". Small periodic ticks with
no interaction at all; it's what makes the room hostile rather than merely beige.

Fixes: the payslip was rendering over the Gauntlet (it belongs to Cubicle Hell, and only
once the shift has actually ended); gauntlet drawer reach was 2.0 m when the true
distance from standing-at-a-cabinet to a bottom drawer is over 2 m.

dev/probe_gauntlet.gd drives a whole run headlessly — mode switch, labels, open every
drawer, shred, win — because that catches the failure that matters: a run you can't
finish because the files never become destroyable.

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

196 lines
6.1 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 GAUNTLET. Switching mode now teleports the player to the records area, so the
# tour just needs to stand back, read the labels, open a drawer and shred it.
_script = [
{"dur": 0.5, "move_to": null, "look_at": null, "act": "mode:1"},
{"dur": 3.0, "move_to": null, "look_at": null, "act": ""},
{"dur": 2.4, "move_to": null, "look_at": null, "act": "interact"},
{"dur": 2.4, "move_to": null, "look_at": null, "act": "interact"},
{"dur": 3.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 == "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()