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>
253 lines
8.6 KiB
GDScript
253 lines
8.6 KiB
GDScript
extends Node
|
|
class_name GrabController
|
|
|
|
## The hands half of the record ritual. Lives under Player and is driven BY Player's
|
|
## input (Player already owns the mouse), so there is no _input() here to fight over
|
|
## event ordering — Player calls try_grab()/feed_drag()/primary()/drop() directly.
|
|
##
|
|
## Flow:
|
|
## E ............. grab the Record under the crosshair -> it lifts to the hand
|
|
## drag mouse X .. slide the disc up and out of the sleeve (feed_drag)
|
|
## LMB ........... throw the freed disc (then a second LMB trashes the empty sleeve)
|
|
## G ............. drop / trash whatever's in hand
|
|
##
|
|
## E also PICKS UP anything light and loose — an apple, a bottle, a stapler — when
|
|
## there's no record under the crosshair. LMB throws it. A thrown apple is travelling
|
|
## far faster than its 1.8 m/s brittle threshold, so it bursts wherever it lands, which
|
|
## is the whole reason the greengrocer exists.
|
|
##
|
|
## Godot 4.7 GDScript 2.0.
|
|
|
|
const GRAB_REACH := 2.0 ## metres in front of the camera a record is grabbable
|
|
const GRAB_RADIUS := 0.55 ## forgiving fat sphere, same idea as MeleeAttack
|
|
const DRAG_FULL_PX := 600.0 ## pixels of horizontal drag == one full disc pull
|
|
const THROW_FORCE := 6.5 ## impulse handed to a chucked disc
|
|
|
|
const CARRY_MAX_MASS := 6.0 ## heavier than this and you have to swing at it instead
|
|
const CARRY_REACH := 2.4
|
|
const CARRY_RADIUS := 0.60
|
|
const THROW_SPEED := 11.0 ## m/s, comfortably past every brittle threshold
|
|
const THROW_SPIN := 7.0
|
|
|
|
var _cam: Camera3D
|
|
var _hold_point: Node3D
|
|
var _held: Record = null
|
|
var _carried: RigidBody3D = null ## a loose prop in the off hand
|
|
var _carry_layer := 0
|
|
var _carry_mask := 0
|
|
var _body: Node3D = null ## the player, for inheriting throw velocity
|
|
|
|
# extract meter UI
|
|
var _meter_root: Control
|
|
var _meter_fill: ColorRect
|
|
|
|
func setup(cam: Camera3D, body: Node3D = null) -> void:
|
|
_cam = cam
|
|
_body = body
|
|
_hold_point = Node3D.new()
|
|
_hold_point.name = "HoldPoint"
|
|
_cam.add_child(_hold_point)
|
|
_hold_point.position = Vector3(0.0, -0.1, -0.5) # a touch below + in front of the eye
|
|
_build_meter()
|
|
|
|
# ---------------------------------------------------------------- grab
|
|
## Grab the nearest Record under the crosshair, if any within reach. Fat-sphere query
|
|
## against the physics space (mirrors MeleeAttack.strike) filtered to Record bodies.
|
|
func try_grab() -> void:
|
|
if _held != null:
|
|
return # already holding — E is grab-only
|
|
if _cam == null:
|
|
return
|
|
var space := _cam.get_world_3d().direct_space_state
|
|
if space == null:
|
|
return
|
|
var origin := _cam.global_position
|
|
var dir := -_cam.global_transform.basis.z
|
|
|
|
var sphere := SphereShape3D.new()
|
|
sphere.radius = GRAB_RADIUS
|
|
var params := PhysicsShapeQueryParameters3D.new()
|
|
params.shape = sphere
|
|
params.transform = Transform3D(Basis.IDENTITY, origin + dir * (GRAB_REACH * 0.5))
|
|
params.collide_with_bodies = true
|
|
params.collide_with_areas = false
|
|
|
|
var hits := space.intersect_shape(params, 12)
|
|
var best: Record = null
|
|
var best_d := INF
|
|
for h in hits:
|
|
var col = h.get("collider")
|
|
if col is Record and (col as Record).is_extractable():
|
|
var d: float = (col.global_position - origin).length_squared()
|
|
if d < best_d:
|
|
best_d = d
|
|
best = col
|
|
if best != null:
|
|
best.grab(_hold_point)
|
|
_held = best
|
|
return
|
|
_try_carry()
|
|
|
|
# ---------------------------------------------------------------- carry / throw
|
|
## No record under the crosshair, so pick up whatever loose thing is there instead.
|
|
## Anything you could actually lift: apples, bottles, staplers, folders.
|
|
func _try_carry() -> void:
|
|
if _carried != null or _cam == null:
|
|
return
|
|
var space := _cam.get_world_3d().direct_space_state
|
|
if space == null:
|
|
return
|
|
var origin := _cam.global_position
|
|
var dir := -_cam.global_transform.basis.z
|
|
|
|
var sphere := SphereShape3D.new()
|
|
sphere.radius = CARRY_RADIUS
|
|
var params := PhysicsShapeQueryParameters3D.new()
|
|
params.shape = sphere
|
|
params.transform = Transform3D(Basis.IDENTITY, origin + dir * (CARRY_REACH * 0.5))
|
|
params.collide_with_areas = false
|
|
|
|
var best: RigidBody3D = null
|
|
var best_d := INF
|
|
for h in space.intersect_shape(params, 16):
|
|
var col = h.get("collider")
|
|
if col is RigidBody3D and not (col is Record):
|
|
var rb := col as RigidBody3D
|
|
if rb.freeze or rb.mass > CARRY_MAX_MASS:
|
|
continue
|
|
var d: float = (rb.global_position - origin).length_squared()
|
|
if d < best_d:
|
|
best_d = d
|
|
best = rb
|
|
if best == null:
|
|
return
|
|
_carried = best
|
|
_carry_layer = best.collision_layer
|
|
_carry_mask = best.collision_mask
|
|
# it rides in the hand, so it must stop shoving the player and the shelf it came off
|
|
best.collision_layer = 0
|
|
best.collision_mask = 0
|
|
best.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
|
|
best.freeze = true
|
|
best.sleeping = false
|
|
|
|
## Hand the prop back to physics wherever it currently is.
|
|
func _release_carried(throw_it: bool) -> void:
|
|
if _carried == null:
|
|
return
|
|
var rb := _carried
|
|
_carried = null
|
|
if not is_instance_valid(rb):
|
|
return
|
|
rb.collision_layer = _carry_layer
|
|
rb.collision_mask = _carry_mask
|
|
rb.freeze = false
|
|
rb.sleeping = false
|
|
if throw_it and _cam != null:
|
|
var dir := -_cam.global_transform.basis.z
|
|
var inherit := Vector3.ZERO
|
|
if _body is CharacterBody3D:
|
|
inherit = (_body as CharacterBody3D).velocity
|
|
rb.linear_velocity = dir * THROW_SPEED + inherit
|
|
rb.angular_velocity = Vector3(
|
|
randf_range(-1.0, 1.0), randf_range(-1.0, 1.0), randf_range(-1.0, 1.0)
|
|
).normalized() * THROW_SPIN
|
|
else:
|
|
rb.linear_velocity = Vector3.ZERO
|
|
|
|
func is_carrying() -> bool:
|
|
return _carried != null and is_instance_valid(_carried)
|
|
|
|
# ---------------------------------------------------------------- extract
|
|
## Feed horizontal mouse motion into the held record's disc pull.
|
|
func feed_drag(dx: float) -> void:
|
|
if _held == null or not _held.is_extractable():
|
|
return
|
|
_held.set_extract(_held.extract_amount() + dx / DRAG_FULL_PX)
|
|
|
|
# ---------------------------------------------------------------- primary (LMB)
|
|
## Two-stage: throw the freed disc, then a later click trashes the empty sleeve.
|
|
## Returns true if the click was consumed (so Player doesn't ALSO punch).
|
|
func primary() -> bool:
|
|
if is_carrying():
|
|
_release_carried(true)
|
|
return true
|
|
if _held == null:
|
|
return false
|
|
if _held.has_disc_in_hand():
|
|
var dir := -_cam.global_transform.basis.z if _cam != null else Vector3.FORWARD
|
|
_held.throw_disc(dir, THROW_FORCE) # disc leaves; sleeve stays in hand
|
|
else:
|
|
_held.drop() # trash the (empty or unopened) sleeve
|
|
_held = null
|
|
return true
|
|
|
|
# ---------------------------------------------------------------- drop (G)
|
|
func drop() -> void:
|
|
if is_carrying():
|
|
_release_carried(false)
|
|
return
|
|
if _held == null:
|
|
return
|
|
_held.drop()
|
|
_held = null
|
|
|
|
# ---------------------------------------------------------------- queries
|
|
func is_holding() -> bool:
|
|
return _held != null or is_carrying()
|
|
|
|
## While pulling a disc, Player routes horizontal mouse to feed_drag() instead of yaw.
|
|
func is_extracting() -> bool:
|
|
return _held != null and _held.is_extractable()
|
|
|
|
func held_record() -> Record:
|
|
return _held
|
|
|
|
# ---------------------------------------------------------------- extract meter
|
|
func _build_meter() -> void:
|
|
var layer := CanvasLayer.new()
|
|
layer.name = "GrabHUD"
|
|
add_child(layer)
|
|
_meter_root = Control.new()
|
|
_meter_root.set_anchors_preset(Control.PRESET_CENTER)
|
|
_meter_root.visible = false
|
|
layer.add_child(_meter_root)
|
|
|
|
var bg := ColorRect.new()
|
|
bg.color = Color(0, 0, 0, 0.5)
|
|
bg.position = Vector2(-90, 40)
|
|
bg.size = Vector2(180, 14)
|
|
_meter_root.add_child(bg)
|
|
|
|
_meter_fill = ColorRect.new()
|
|
_meter_fill.color = Color(1.0, 0.36, 0.62, 0.95)
|
|
_meter_fill.position = Vector2(-88, 42)
|
|
_meter_fill.size = Vector2(0, 10)
|
|
_meter_root.add_child(_meter_fill)
|
|
|
|
var lbl := Label.new()
|
|
lbl.text = "PULL ←→"
|
|
lbl.position = Vector2(-90, 18)
|
|
lbl.add_theme_font_size_override("font_size", 13)
|
|
_meter_root.add_child(lbl)
|
|
|
|
func _process(_dt: float) -> void:
|
|
# a carried prop is glued to the hand point every frame; it is kinematic-frozen so
|
|
# this is a move, not a teleport, and it still sweeps other bodies out of the way
|
|
if _carried != null:
|
|
if not is_instance_valid(_carried):
|
|
_carried = null
|
|
elif _hold_point != null:
|
|
var t := _hold_point.global_transform
|
|
# down and to the right of the crosshair: you are carrying it, not
|
|
# presenting it, and you still have to be able to see where you're going
|
|
_carried.global_position = t.origin - t.basis.z * 0.20 \
|
|
- t.basis.y * 0.13 + t.basis.x * 0.15
|
|
|
|
if _meter_root == null:
|
|
return
|
|
var pulling := is_extracting()
|
|
_meter_root.visible = pulling
|
|
if pulling and _meter_fill != null:
|
|
_meter_fill.size.x = 176.0 * _held.extract_amount()
|