The three things the greengrocer was still missing, plus the physics bug that
finding them uncovered.
HANGING SCALES. Real PinJoint3D pendulums, not animations: a static dial and
rod, and a scale-pan rigid body pinned at its own origin so it can only rotate
about the pivot. They hang at chest height down the aisles — dial at eye level,
dish below — where a real shop scale hangs and where you keep walking into it.
The dish swings 0.40 m off a light knock and the pin holds the pivot to 1.1 mm.
The pan is steel so it never breaks; what you get is a heavy brass weight loose
in a room full of stacked fruit.
THE BAG YOU CANNOT OPEN. Tearing one off the roll is the easy half — that's the
setup. Then you have to open it, and the bag has two ends, only one of which
opens, and nothing tells you which. Rubbing alternates arrow keys, because
mashing one key is not rubbing. Either the meter climbs or it doesn't, and the
only way to learn which end you're holding is to have already lost several
seconds to the other one. SPACE turns it over. That is the whole solution and
the game never says so.
CARRY AND THROW. E picks up anything under 6 kg that isn't a record; LMB throws
it at 11 m/s, six times any brittle threshold in the game. Thrown fruit bursts
on landing through the same brittle_speed path a collapse uses — no separate
thrown-object code at all.
Then the part that took the longest. Round produce got sphere colliders (a box
on an apple is why nothing ever rolled) and every display in the shop instantly
fell over. Three separate things were wrong:
- the stack radii were TYPED, not measured. The table said a cabbage was
84 mm; the model is 213 mm, so that pyramid was built with every row driven
a third of the way into the row below it. Main._glb_radius() reads the asset.
- the lattice was box geometry. For spheres the rise per row is
sqrt(4r^2 - step^2/2) — the height at which a fruit touches all four
beneath it. Anything else spawns every row above the first in mid-air.
- there was no tray. A pyramid of spheres on a bare flat table cannot stand;
nothing holds the bottom row in, so the weight above wedges it outward and
the display walks itself apart in a second. Main._stack_tray() frames each
pile in four low timber walls sized to its base row, which is what every
greengrocer on earth already does.
All 310 bodies asleep within 3 s.
While chasing that, the spawn guard fired on a cardboard box in the OFFICE. The
guard is a net, not a test: it only catches a pair Jolt happens to resolve
violently on the frames it's watching, and this one had been interpenetrating
in four levels for weeks. dev/probe_overlap.gd now finds them by measurement,
comparing every pair of dynamic colliders across all six sites. It found 15,
including a row of filing cabinets 0.70 m apart that are 0.80 m wide, and a
stapler inside a monitor. The box asset is 1.35 m across, so boxes are now
stacked into piles rather than dotted about, and the Backrooms scatter uses
rejection sampling with a 1.6 m minimum. All six sites read CLEAN.
Also: the rage veins were drawing every branch from its own start point
regardless of how far the trunk had grown, so at low rage you got disconnected
fragments floating mid-screen that read as biro scribble rather than blood. A
branch now can't appear before the trunk carrying it, and trunks are thick and
dark where capillaries are fine and pale.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
284 lines
11 KiB
GDScript
284 lines
11 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 GREENGROCER: the stacks come down, the scale swings, an apple gets thrown at
|
|
# the bottles, and the bag does not open.
|
|
_script = [
|
|
{"dur": 1.2, "move_to": null, "look_at": null, "act": "level:grocer"},
|
|
{"dur": 0.3, "move_to": null, "look_at": null, "act": "mode:2"},
|
|
{"dur": 2.6, "move_to": Vector3(-2.0, 0, 4.4), "look_at": Vector3(-3.6, 1.3, 0.0), "act": ""},
|
|
{"dur": 2.0, "move_to": Vector3(-4.6, 0, 0.4), "look_at": Vector3(-5.4, 1.15, 1.4), "act": ""},
|
|
{"dur": 0.4, "move_to": null, "look_at": null, "act": "weapon:2"},
|
|
{"dur": 1.0, "move_to": null, "look_at": null, "act": "swing"},
|
|
{"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"},
|
|
{"dur": 2.4, "move_to": Vector3(-4.9, 0, -1.4), "look_at": Vector3(-5.4, 1.10, -2.2), "act": ""},
|
|
{"dur": 1.2, "move_to": null, "look_at": null, "act": "swing"},
|
|
{"dur": 1.6, "move_to": null, "look_at": null, "act": "swing"},
|
|
# the hanging scale, which is at face height and stays swinging
|
|
{"dur": 2.4, "move_to": Vector3(-3.7, 0, 0.9), "look_at": Vector3(-3.7, 1.78, -0.4), "act": ""},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"},
|
|
{"dur": 2.6, "move_to": null, "look_at": null, "act": ""},
|
|
# pick an orange up off the pile and throw it at the bottle shelf
|
|
{"dur": 2.2, "move_to": Vector3(-5.4, 0, 2.5), "look_at": Vector3(-5.4, 1.10, 1.4), "act": ""},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "grab"},
|
|
{"dur": 1.0, "move_to": null, "look_at": Vector3(-4.4, 1.70, -7.7), "act": ""},
|
|
{"dur": 0.4, "move_to": null, "look_at": null, "act": "throw"},
|
|
{"dur": 1.8, "move_to": null, "look_at": null, "act": ""},
|
|
# and the bottles, properly
|
|
{"dur": 2.4, "move_to": Vector3(-3.0, 0, -6.4), "look_at": Vector3(-3.0, 1.75, -7.7), "act": ""},
|
|
{"dur": 0.4, "move_to": null, "look_at": null, "act": "weapon:4"},
|
|
{"dur": 1.4, "move_to": null, "look_at": null, "act": "swing"},
|
|
{"dur": 1.6, "move_to": null, "look_at": null, "act": "swing"},
|
|
# the bag. Stations only exist while you're on the clock, so clock back on.
|
|
{"dur": 0.3, "move_to": null, "look_at": null, "act": "mode:0"},
|
|
{"dur": 2.8, "move_to": Vector3(-0.3, 0, 2.2), "look_at": Vector3(-0.3, 1.06, 3.6), "act": ""},
|
|
{"dur": 0.8, "move_to": null, "look_at": null, "act": "task"},
|
|
{"dur": 1.0, "move_to": null, "look_at": null, "act": "key:space"},
|
|
# rub away at it. The meter climbs the whole time you're getting nowhere.
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:right"},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:right"},
|
|
{"dur": 0.5, "move_to": null, "look_at": null, "act": "rage:0.16"},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:right"},
|
|
{"dur": 0.5, "move_to": null, "look_at": null, "act": "rage:0.16"},
|
|
# turn it over. This was the answer the whole time.
|
|
{"dur": 1.0, "move_to": null, "look_at": null, "act": "key:space"},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:right"},
|
|
{"dur": 0.6, "move_to": null, "look_at": null, "act": "key:left"},
|
|
{"dur": 1.0, "move_to": null, "look_at": null, "act": "key:right"},
|
|
{"dur": 3.4, "move_to": Vector3(-1.0, 0, 2.0), "look_at": Vector3(-4.0, 0.6, -0.5), "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
|
|
if act.begins_with("key:"): # feed one key to the open task panel
|
|
var tk = _main.get("_tasks")
|
|
var codes := {"space": KEY_SPACE, "left": KEY_LEFT, "right": KEY_RIGHT,
|
|
"up": KEY_UP, "down": KEY_DOWN, "enter": KEY_ENTER}
|
|
if tk != null:
|
|
tk.handle_key(int(codes.get(act.substr(4), KEY_SPACE)))
|
|
return
|
|
match act:
|
|
"grab": # E on whatever is under the crosshair
|
|
if _player.grab != null:
|
|
_player.grab.try_grab()
|
|
"throw": # LMB while holding = chuck it
|
|
if _player.grab != null:
|
|
_player.grab.primary()
|
|
"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()
|