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>
147 lines
4.9 KiB
GDScript
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()
|