The only site whose contents are mostly SOFT, and that one material change rewrites the
level. `produce` is a new Smashable material that throws no shards at all — Splat.gd
bursts it instead: coloured pulp, a puddle that STAYS, a squelch, and litres counted,
because "how much juice did you make" is a far better score for a greengrocer than "how
many objects did you destroy".
Two knock-ons make the level play itself, both falling out of existing systems rather
than needing new ones:
* produce has a low brittle_speed, so anything heavy landing on it squashes it — which
means a pyramid crushes its OWN bottom layer as it collapses, with no special code.
Knock one off the top and the pile does the rest. Shove test: 257 items -> 45, and
42 litres of juice out of the collapse.
* puddles are slippery, cutting ACCELERATION rather than top speed so it reads as
sliding rather than as being slowed down. The more mess you make, the less the floor
cooperates.
Stacks are built bottom-up by Main._build_stack and nothing is glued or frozen — they
stand because they are stacked, so pulling one out of the bottom row does what you'd
hope. 258 produce items spawn and settle to 0.00 m/s with no spawn crush. The glass
bottles behind the juice bar are the deliberate exception: one shelf in the room that
still rewards a proper swing, and the contrast between litres and shards is the joke.
Round produce is authored CENTRED, not floor-centred like every other prop in this repo,
because a rolling body whose origin sits on the floor plane wobbles like a loaded die.
_glb_piece grew a `centred` flag for it.
REAL BUG FOUND, and it was costing hits in every level: _strike took the NEAREST collider
of any kind, so a static surface could eat a swing. Leaning over a display table, the
table edge is a few centimetres nearer than the fruit piled on it, so every swing hit the
table and nothing broke — 0/306 for the whole first take. It now prefers a Smashable and
only falls back to loose bodies if there isn't one. The offices were quietly losing hits
on desks and shelves the same way.
tools/gen_produce.py: apple, orange, tomato, watermelon, cabbage, banana hand, glass
juice bottle, display crate. The melon's stripes were radial boxes first time and it came
out looking like a sea mine; they're thin lenses through the middle now.
dev/probe_grocer.gd verifies stacks settle, that produce yields litres and NO debris,
that a collapse crushes, and that none of it leaks to the next site.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
241 lines
8.0 KiB
GDScript
241 lines
8.0 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: look at the stacks, take the bat to one, watch it come down, then
|
|
# the bottles — the one shelf in here that still wants a proper swing.
|
|
_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"},
|
|
# and the bottles
|
|
{"dur": 2.6, "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"},
|
|
{"dur": 2.6, "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
|
|
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()
|