destroyulator/game/dev/DemoDriver.gd
2026-07-31 18:15:07 +10:00

236 lines
8.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 showcase: a lap of all four sites, then Cubicle Hell (work -> tilt -> snap ->
# wreck), then the Gauntlet.
_script = []
var tours := {
"scranton": [[Vector3(-8.6, 0, 6.4), Vector3(-4.0, 1.1, 0.0)],
[Vector3(-2.6, 0, 3.4), Vector3(-0.4, 1.0, -1.0)]],
"pawnee": [[Vector3(-8.0, 0, 6.6), Vector3(-2.0, 1.1, 1.0)],
[Vector3(-1.0, 0, 2.6), Vector3(-2.6, 1.2, -5.2)]],
"house": [[Vector3(-1.0, 0, 7.0), Vector3(-4.4, 1.2, 0.6)],
[Vector3(0.5, 0, 2.0), Vector3(6.0, 1.3, 6.0)]],
"basement": [[Vector3(1.0, 0, -5.2), Vector3(-8.0, 1.3, -1.0)],
[Vector3(-4.0, 0, -1.0), Vector3(-10.6, 1.4, -1.0)]],
}
for id in ["scranton", "pawnee", "house", "basement"]:
var shots: Array = tours[id]
_script.append({"dur": 0.4, "move_to": shots[0][0], "look_at": shots[0][1],
"act": "level:" + id})
_script.append({"dur": 2.0, "move_to": null, "look_at": null, "act": ""})
_script.append({"dur": 2.4, "move_to": shots[1][0], "look_at": shots[1][1],
"act": ""})
_script.append({"dur": 1.2, "move_to": null, "look_at": null, "act": ""})
# --- CUBICLE HELL, back at Scranton
_script += [
{"dur": 0.4, "move_to": null, "look_at": null, "act": "level:scranton"},
{"dur": 0.4, "move_to": null, "look_at": null, "act": "mode:0"},
{"dur": 2.2, "move_to": Vector3(-4.6, 0, -0.55), "look_at": Vector3(-4.6, 1.0, -1.4), "act": ""},
{"dur": 1.0, "move_to": null, "look_at": null, "act": "task"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "type"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "commit"},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "rage:0.32"},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "rage:0.32"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "rage:0.36"},
{"dur": 0.9, "move_to": Vector3(-4.6, 0, 0.3), "look_at": Vector3(-4.6, 0.9, -0.7), "act": "weapon:4"},
{"dur": 1.1, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.3, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.8, "move_to": Vector3(-2.4, 0, 4.9), "look_at": Vector3(-2.3, 0.25, 5.2), "act": ""},
{"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"},
{"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"},
]
# --- THE GAUNTLET
_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.0, "move_to": null, "look_at": null, "act": "interact"},
{"dur": 2.4, "move_to": null, "look_at": null, "act": "interact"},
{"dur": 2.6, "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()