destroyulator/game/scripts/GrabController.gd
Monster Robot Party ad0ca62f3e LANE1: record-destruction ritual + Find-the-Misfiled-Disc mode
Turn records from one-hit props into the store's signature dexterity ritual,
and add the first game mode on top of it.

Record.gd (class_name Record extends Smashable): a cardboard SLEEVE that hides a
vinyl DISC. Splits record.glb's Sleeve_Mesh / Vinyl_Disc nodes at runtime — the
sleeve stays on the body, the disc becomes its own frozen-kinematic vinyl Smashable
that follows the sleeve until freed. State machine IN_CRATE -> HELD -> EXTRACTING
-> DISC_FREE. set_extract() slides the disc up and out of the mouth; release_disc()
arms it as a live brittle throwable; shatter() spills the disc first so brute-force
smashing still spills it. Genre tints on disc label / sleeve spine make a misfiled
disc read at a glance.

GrabController.gd (under Player): hold point on the camera, E grabs the Record under
the crosshair (fat-sphere query, Record-filtered), horizontal mouse drag pulls the
disc (~600px = full), LMB throws the freed disc then trashes the empty sleeve, G
drops. On-screen pull meter.

GameMode.gd (class_name GameMode, owned by Main): FreePlay (default) + FindMisfiled —
plants exactly one disc_genre != sleeve_genre, 60s timer, win by extracting it and
pressing F while holding it, scores the mess (bodies smashed) + records disturbed.
Prints the plant on start.

Wiring: Player creates the GrabController and routes E/G/LMB/drag (yaw suspended
mid-pull, Esc untouched); Main._build_rack spawns Record instances on the crates
(keeps supports cascade + sleeve-art skinning + per-record genres), forwards smashes
to the mode, and adds keys 1/2 (modes) and F (report).

Headless smoke test clean (0 script errors / 0 warnings, 15 records); a runtime
harness exercised grab/extract/throw/drop/shatter-spill/mode-switch/reset — all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:40:52 +10:00

147 lines
4.9 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
##
## 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
var _cam: Camera3D
var _hold_point: Node3D
var _held: Record = null
# extract meter UI
var _meter_root: Control
var _meter_fill: ColorRect
func setup(cam: Camera3D) -> void:
_cam = cam
_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
# ---------------------------------------------------------------- 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 _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 _held == null:
return
_held.drop()
_held = null
# ---------------------------------------------------------------- queries
func is_holding() -> bool:
return _held != null
## 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:
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()