"stuff is invisible have to release dust or gas or something to coat items to see them to destroy them? and you inhale some of it too" THE SITE (Levels.backrooms) Mono-yellow, 2.55 m ceiling, damp carpet, a dense grid of buzzing troffers, no windows and no exit. THE WALLS ARE GENERATED — this is the site that proves the Floorplan spec can be emitted rather than authored: a seeded RNG lays partial wall runs on a 4.6 m lattice with random gaps, plus 22 freestanding pillars. It follows the reference photos rather than a maze algorithm on purpose — the Backrooms aren't corridors, they're big offset slabs in an open floor, and a proper maze would be legible. Legible is wrong. THE MECHANIC (Dust.gd) — it argues with itself, which is the whole point. Everything smashable here is invisible but SOLID; you find things by walking into them. RMB with the extinguisher throws a cloud of powder. Anything it touches is coated, glows faintly out of all that yellow for 7 seconds, then settles back into nothing. And you breathe it. Every cloud puts a little in your lungs, and the haze that builds is on YOUR side of the glass. So: spray more to see individual objects, and see the room less. Past 72% you cough — camera jolt, screen bloom, and a rage tick, because of course it does. Intended rhythm is spray -> switch to something heavy -> break it before the coating settles -> spray again, coughing. Pairs with TOTAL DESTRUCTION, since the level IS a search. Tuning that mattered: the first cloud was a wall of big blocky quads that hid the very thing it was revealing (150 finer, shorter-lived particles now), and a coated object has to POP out of a room that is entirely one colour, so the coat is near-white and faintly self-lit rather than a subtle tint. 78 objects, not 34 — a search with nothing in it is just a walk. Bug found while testing: `x as Smashable` on a freed object is itself an error in Godot 4, so the validity check has to come BEFORE the cast — destroying anything mid-level was spewing "trying to cast a freed object" every frame. dev/probe_backrooms.gd verifies the whole loop headlessly, including that the mechanic does NOT leak into the other four sites when you leave. All five sites still settle to 0.00 m/s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
213 lines
7.1 KiB
GDScript
213 lines
7.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:
|
|
# LEVEL 0. Walk in, see nothing, spray, see for a moment, spray again while the haze
|
|
# builds. Total Destruction is the right mode here — the level IS a search.
|
|
_script = [
|
|
{"dur": 0.4, "move_to": null, "look_at": null, "act": "level:backrooms"},
|
|
{"dur": 0.4, "move_to": null, "look_at": null, "act": "mode:2"},
|
|
{"dur": 2.6, "move_to": Vector3(0, 0, 13.0), "look_at": Vector3(0, 1.3, 2.0), "act": ""},
|
|
{"dur": 1.6, "move_to": Vector3(0, 0, 9.0), "look_at": Vector3(2.0, 1.3, 0.0), "act": ""},
|
|
{"dur": 1.8, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 1.6, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 2.2, "move_to": Vector3(2.0, 0, 5.0), "look_at": Vector3(-3.0, 1.2, -1.0), "act": ""},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "puff"},
|
|
{"dur": 2.6, "move_to": Vector3(-2.0, 0, 2.0), "look_at": Vector3(-6.0, 1.2, -4.0), "act": ""},
|
|
{"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 == "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()
|