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>
361 lines
14 KiB
GDScript
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)
|