destroyulator/game/scripts/Record.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

361 lines
14 KiB
GDScript

extends Smashable
class_name Record
## A record = a cardboard SLEEVE (this body) that hides a vinyl DISC.
##
## The store's signature dexterity ritual lives here:
## look at a record in the crate -> GRAB (E) lifts the whole sleeve to your hand ->
## DRAG the mouse left<->right to slide the black disc up and out of the sleeve ->
## the freed disc is a throwable brittle vinyl (LMB), the empty sleeve is trash.
##
## Why `extends Smashable`: the sleeve IS a cardboard smashable (color/shards/juice/
## the supports[] cascade all come free) and the disc is its own vinyl Smashable, so
## both ends of the ritual reuse the exact shatter/`smashed`/Juice path the rest of the
## store uses. Record just layers a grab+extract STATE MACHINE and a nested disc on top.
## (The lane spec said `extends RigidBody3D`; Smashable IS a RigidBody3D and gives us the
## trashable-cardboard behaviour for free, so we extend it instead — same base type.)
##
## Geometry note: record.glb already ships BOTH a `Sleeve_Mesh` (flat panel, thin in Z)
## and a `Vinyl_Disc` (0.28 disc, stood up flat-in-Z, peeking out the top). We split those
## two nodes at runtime: the sleeve mesh stays on THIS body, the disc mesh moves onto a
## child vinyl Smashable. So the disc slides out along the sleeve's local +Y (up/out the
## top) — that matches the art; the spec's "+X" was just a naming convention.
##
## Godot 4.7 GDScript 2.0.
const RECORD_GLB := "res://assets/store/record.glb"
## How far (metres) the disc travels from nested to fully clear of the sleeve mouth.
## Filled from the sleeve AABB height in _split_glb(); this is a sane fallback.
var _slide_len: float = 0.30
## Local axis the disc slides along, in this body's space. +Y = up and out the top.
const EXTRACT_AXIS := Vector3.UP
enum State { IN_CRATE, HELD, EXTRACTING, DISC_FREE }
var state: int = State.IN_CRATE
# --- ritual identity (set by Main / GameMode) ------------------------------
## Usually equal; FindMisfiled sets exactly ONE record's disc_genre != sleeve_genre.
@export var sleeve_genre: String = "jazz"
@export var disc_genre: String = "jazz"
# --- extract progress ------------------------------------------------------
var _extract: float = 0.0 # 0 = nested, 1 = fully out
var _disc: Smashable = null # the vinyl disc, frozen inside until freed
var _disc_active: bool = true # while true, THIS body drives the disc's transform
var _disc_thrown: bool = false # disc has left the hand for good
var sleeve_visual: Node3D = null # the record.glb Sleeve_Mesh, reparented onto us
var disc_visual: Node3D = null # the record.glb Vinyl_Disc, reparented onto _disc
# --- held-follow state -----------------------------------------------------
var _hold_point: Node3D = null # the player's hand; we track it kinematically
var _disturbed := false # grabbed at least once (mess/score bookkeeping)
# local pose (relative to THIS body) the disc rests at while nested, and its stood-up basis
var _disc_rest_pos: Vector3 = Vector3(0, 0.21, 0)
var _disc_basis: Basis = Basis.IDENTITY
# where the disc sits once pulled out and held in-hand (above the sleeve mouth)
var _disc_out_pos: Vector3 = Vector3(0, 0.51, 0)
signal disc_freed(rec: Record) # disc reached full extraction (now throwable)
signal disc_thrown_out(rec: Record) # disc left the hand
# Genre -> a label/edge tint so a misfiled disc reads visually once you pull it.
const GENRE_COLORS := {
"jazz": Color(0.80, 0.55, 0.15),
"techno":Color(0.20, 0.85, 0.90),
"punk": Color(0.90, 0.15, 0.30),
"soul": Color(0.65, 0.30, 0.80),
"funk": Color(0.95, 0.80, 0.20),
"dub": Color(0.25, 0.70, 0.35),
}
# ---------------------------------------------------------------- setup
## Build the sleeve visual + collider and the nested disc. Call BEFORE add_child so
## `kind`/`start_frozen` are set; the actual node work happens in _ready once in-tree.
func setup() -> void:
kind = "cardboard" # the sleeve's material identity (Smashable.PROFILES)
start_frozen = false # sits dynamically in the crate until grabbed
func _ready() -> void:
super._ready() # Smashable: group, physics material, brittleness wiring
add_to_group("record")
_split_glb()
_apply_genre_tints()
## Instance record.glb, keep Sleeve_Mesh on this body, move Vinyl_Disc onto a child
## vinyl Smashable, and size box colliders from each piece's own mesh AABB.
func _split_glb() -> void:
var packed := load(RECORD_GLB) as PackedScene
if packed == null:
return
var scene: Node3D = packed.instantiate()
var sleeve_mesh := scene.find_child("Sleeve_Mesh", true, false) as Node3D
var disc_mesh := scene.find_child("Vinyl_Disc", true, false) as Node3D
# --- sleeve: this body's visual + collider ---
if sleeve_mesh != null:
var st := sleeve_mesh.transform
sleeve_mesh.owner = null # drop the old glb-scene owner before reparenting
sleeve_mesh.get_parent().remove_child(sleeve_mesh)
add_child(sleeve_mesh)
sleeve_mesh.transform = st
sleeve_visual = sleeve_mesh
var ab := _local_aabb(self, sleeve_mesh)
if ab.size != Vector3.ZERO:
_slide_len = ab.size.y
_add_box_collider(self, ab)
# --- disc: its own frozen vinyl Smashable that lives in the world and follows us ---
_disc = Smashable.new()
_disc.kind = "vinyl"
_disc.start_frozen = false # we drive freeze ourselves (kinematic follow)
_disc.add_to_group("disc")
var host := get_parent() # the World node; disc is a sibling, never nested
if host == null:
host = self
host.add_child(_disc)
_disc.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
_disc.freeze = true
if disc_mesh != null:
# keep the stood-up rotation, drop the translation so the mesh centres on the body
_disc_basis = disc_mesh.transform.basis
_disc_rest_pos = disc_mesh.transform.origin # nested pose vs. sleeve origin
disc_mesh.owner = null # drop the old glb-scene owner before reparenting
disc_mesh.get_parent().remove_child(disc_mesh)
_disc.add_child(disc_mesh)
disc_mesh.transform = Transform3D(_disc_basis, Vector3.ZERO)
var dab := _local_aabb(_disc, disc_mesh)
if dab.size != Vector3.ZERO:
_add_box_collider(_disc, dab, true) # disabled until the disc is freed
disc_visual = disc_mesh
# pulled-out hold pose: nested pose plus a full slide along the mouth axis
_disc_out_pos = _disc_rest_pos + EXTRACT_AXIS * _slide_len
_place_disc()
scene.queue_free() # the now-empty record.glb root; its meshes were reparented
# ---------------------------------------------------------------- genre look
func _apply_genre_tints() -> void:
_tint_material(sleeve_visual, ["Sleeve_Edge"], _genre_color(sleeve_genre))
_tint_material(disc_visual, ["Vinyl_Label"], _genre_color(disc_genre))
## Re-label this record and refresh its tints (GameMode uses this to plant a mismatch).
func set_genres(sleeve: String, disc: String) -> void:
sleeve_genre = sleeve
disc_genre = disc
if sleeve_visual != null or disc_visual != null:
_apply_genre_tints()
func _genre_color(g: String) -> Color:
return GENRE_COLORS.get(g, Color(0.7, 0.7, 0.7))
## Override the named surface material(s) under `vis` with an emissive-ish tint so the
## disc label / sleeve spine reads its genre at a glance (mismatch = the misfiled one).
func _tint_material(vis: Node3D, name_fragments: Array, col: Color) -> void:
if vis == null:
return
var mis: Array[MeshInstance3D] = []
_collect_meshes(vis, mis)
for mi in mis:
if mi.mesh == null:
continue
for si in mi.mesh.get_surface_count():
var mat := mi.mesh.surface_get_material(si)
if mat == null or not (mat is BaseMaterial3D):
continue
var nm: String = mat.resource_name
for frag in name_fragments:
if nm.contains(frag):
var dup := (mat as BaseMaterial3D).duplicate() as BaseMaterial3D
dup.albedo_color = col
dup.emission_enabled = true
dup.emission = col
dup.emission_energy_multiplier = 0.35
mi.set_surface_override_material(si, dup)
break
# ---------------------------------------------------------------- grab / hold
## Lift the whole record to the player's hand. Freezes kinematic; we track the hand
## each physics step (freeze+follow, the pattern the lane spec recommends for held bodies).
func grab(hold_point: Node3D) -> void:
if state == State.DISC_FREE:
return
_hold_point = hold_point
_disturbed = true
freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
freeze = true
sleeping = false
if state == State.IN_CRATE:
state = State.HELD
## Let go of the sleeve (G, or "trash the empty sleeve"). Drops it back to live physics.
func drop() -> void:
# if the disc is still nested, spill it too so it doesn't vanish with the sleeve
if _disc_active and not _disc_thrown:
_spill_disc(Vector3.ZERO)
_hold_point = null
state = State.IN_CRATE
freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
freeze = false
sleeping = false
apply_central_impulse(Vector3(randf_range(-0.3, 0.3), 0.1, randf_range(-0.3, 0.3)))
# ---------------------------------------------------------------- extraction
## Slide the disc along the mouth axis. 0..1; at 1.0 the disc pops free into the hand.
func set_extract(amount: float) -> void:
if state != State.HELD and state != State.EXTRACTING:
return
_extract = clampf(amount, 0.0, 1.0)
if _extract > 0.0 and _extract < 1.0:
state = State.EXTRACTING
_place_disc()
if _extract >= 1.0:
release_disc()
func extract_amount() -> float:
return _extract
## Disc reached the mouth: it's now a live-in-hand throwable. The sleeve stays held
## (empty, trashable). Brittleness is only ARMED here — never while nested (its collider
## was disabled), so it can't crack inside the jacket.
func release_disc() -> void:
if state == State.DISC_FREE or _disc == null:
return
state = State.DISC_FREE
_extract = 1.0
_enable_disc_collider(true) # arm contact -> vinyl self-shatters on hard landings
_place_disc()
disc_freed.emit(self)
## LMB while the disc is in hand: chuck it. It leaves for good, keeps its inherited
## motion, and cracks when it smacks something (Smashable brittle path).
func throw_disc(dir: Vector3, force: float) -> void:
if state != State.DISC_FREE or _disc == null or _disc_thrown:
return
_disc_active = false
_disc_thrown = true
_disc.freeze = false # freeze=false makes it a live dynamic body again
_disc.sleeping = false
_disc.apply_central_impulse(dir.normalized() * force)
_disc.angular_velocity = Vector3(randf_range(-6, 6), randf_range(-6, 6), randf_range(-6, 6))
disc_thrown_out.emit(self)
## Gently release the in-hand disc without a throw (used by drop()).
func _spill_disc(extra_impulse: Vector3) -> void:
if _disc == null or _disc_thrown:
return
_disc_active = false
_disc_thrown = true
_enable_disc_collider(true)
_disc.freeze = false # freeze=false makes it a live dynamic body again
_disc.sleeping = false
_disc.apply_central_impulse(extra_impulse + Vector3(0, 0.2, 0))
# ---------------------------------------------------------------- state queries
func has_disc_in_hand() -> bool:
return state == State.DISC_FREE and not _disc_thrown
func is_empty_sleeve() -> bool:
return _disc_thrown
func is_held() -> bool:
return _hold_point != null
func is_extractable() -> bool:
return (state == State.HELD or state == State.EXTRACTING) and not _disc_thrown
func was_disturbed() -> bool:
return _disturbed
## The vinyl disc body (a Smashable in its own right). Main connects its `smashed` to
## Juice/score so a cracked disc reads the same as any other break.
func disc_body() -> Smashable:
return _disc
# ---------------------------------------------------------------- per-frame follow
func _physics_process(delta: float) -> void:
super._physics_process(delta) # Smashable samples speed for brittleness
if _hold_point != null and is_instance_valid(_hold_point):
# track the hand: face the player, centre the record on the hand point
global_transform = _hold_point.global_transform * HELD_OFFSET
if _disc_active and _disc != null and is_instance_valid(_disc):
_place_disc()
## Held pose: rotate 180deg about Y so the sleeve FRONT faces the camera, and drop the
## record so its centre (not its floor-origin) sits at the hand. Tune by eye in-editor.
const HELD_OFFSET := Transform3D(
Basis(Vector3.UP, PI),
Vector3(0.0, -0.15, 0.0))
## Put the disc at its correct spot for the current state (nested-lerp / out-in-hand).
func _place_disc() -> void:
if _disc == null or not is_instance_valid(_disc) or _disc_thrown:
return
var local_pos: Vector3
if state == State.DISC_FREE:
local_pos = _disc_out_pos
else:
local_pos = _disc_rest_pos + EXTRACT_AXIS * (_extract * _slide_len)
_disc.global_transform = global_transform * Transform3D(_disc_basis, local_pos)
# ---------------------------------------------------------------- shatter override
## If the sleeve is smashed (melee/thrown-collision) while the disc is still inside,
## spill the disc first so it survives as its own body — then do the normal cardboard
## shatter (shards + Juice + supports cascade).
func shatter(impulse: Vector3) -> void:
if _disc_active and not _disc_thrown:
_spill_disc(impulse * 0.4)
super.shatter(impulse)
# ---------------------------------------------------------------- collider helpers
func _enable_disc_collider(enabled: bool) -> void:
if _disc == null:
return
for c in _disc.get_children():
if c is CollisionShape3D:
(c as CollisionShape3D).disabled = not enabled
func _add_box_collider(body: Node3D, ab: AABB, disabled := false) -> void:
var col := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = ab.size
col.shape = box
col.position = ab.get_center()
col.disabled = disabled
body.add_child(col)
## Combined AABB of every MeshInstance3D under `visual_root`, in `body`'s local space.
## Same trick Main._body_local_aabb uses; kept here so Record is self-contained.
static func _local_aabb(body: Node3D, visual_root: Node3D) -> AABB:
var mis: Array[MeshInstance3D] = []
_collect_meshes(visual_root, mis)
var inv := body.global_transform.affine_inverse()
var acc := AABB()
var started := false
for mi in mis:
if mi.mesh == null:
continue
var a := mi.get_aabb()
var rel := inv * mi.global_transform
for i in range(8):
var corner := a.position + Vector3(
a.size.x * float(i & 1),
a.size.y * float((i >> 1) & 1),
a.size.z * float((i >> 2) & 1))
var p := rel * corner
if not started:
acc = AABB(p, Vector3.ZERO)
started = true
else:
acc = acc.expand(p)
return acc
static func _collect_meshes(n: Node, acc: Array[MeshInstance3D]) -> void:
if n is MeshInstance3D:
acc.append(n)
for c in n.get_children():
_collect_meshes(c, acc)