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)