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>
This commit is contained in:
Monster Robot Party 2026-07-14 19:40:52 +10:00
parent d0cbe7267d
commit ad0ca62f3e
8 changed files with 726 additions and 14 deletions

129
game/scripts/GameMode.gd Normal file
View File

@ -0,0 +1,129 @@
extends Node
class_name GameMode
## The rules layer. Owned by Main, fed the live records + the player's GrabController.
## Two modes:
## FreePlay ..... just smash the store, no timer (default).
## FindMisfiled . exactly ONE disc is in the wrong sleeve. Extract it and press F
## before the 60s timer runs out. The frantic dig leaves a mess — and
## the mess is scored, because the mess is the point.
##
## Main drives transitions (it owns the world reset + the input keys); GameMode owns
## the planting, the clock, the win/lose call, and the scoreboard line.
##
## Godot 4.7 GDScript 2.0.
enum Mode { FREE_PLAY, FIND_MISFILED }
const GENRES: Array[String] = ["jazz", "techno", "punk", "soul", "funk", "dub"]
const ROUND_TIME := 60.0
var mode: int = Mode.FREE_PLAY
var _records: Array[Record] = []
var _grab: GrabController = null
var _active := false # a FindMisfiled round is running
var _time_left := 0.0
var _mess := 0 # bodies smashed during the round (the score)
var _result := "" # win/lose banner, "" while playing
var _misfiled: Record = null
func setup(grab: GrabController) -> void:
_grab = grab
# ---------------------------------------------------------------- transitions
## FreePlay: no timer, every disc back in its right sleeve.
func begin_free_play(records: Array[Record]) -> void:
mode = Mode.FREE_PLAY
_records = records
_active = false
_result = ""
_misfiled = null
for r in _records:
if is_instance_valid(r):
r.set_genres(r.sleeve_genre, r.sleeve_genre)
## FindMisfiled: plant one mismatch, start the clock.
func begin_find_misfiled(records: Array[Record]) -> void:
mode = Mode.FIND_MISFILED
_records = records
_active = true
_time_left = ROUND_TIME
_mess = 0
_result = ""
_plant()
## Put exactly one disc in the wrong sleeve. Prints the plant (acceptance requires it).
func _plant() -> void:
var valid: Array[Record] = []
for r in _records:
if is_instance_valid(r):
r.set_genres(r.sleeve_genre, r.sleeve_genre) # reset any prior mismatch
valid.append(r)
if valid.is_empty():
print("[FindMisfiled] no records to plant into")
return
_misfiled = valid[randi() % valid.size()]
var wrong := _other_genre(_misfiled.sleeve_genre)
_misfiled.set_genres(_misfiled.sleeve_genre, wrong)
print("[FindMisfiled] planted: a %s disc hidden inside a %s sleeve (of %d records)" % [
wrong, _misfiled.sleeve_genre, valid.size()])
func _other_genre(g: String) -> String:
var pool := GENRES.filter(func(x): return x != g)
if pool.is_empty():
return g
return pool[randi() % pool.size()]
# ---------------------------------------------------------------- events
## Main forwards Smashable.smashed here so the mess is scored during a round.
func on_smashed() -> void:
if _active:
_mess += 1
## F (report). Win only if the disc in hand is genuinely misfiled (disc != sleeve genre).
func report() -> void:
if mode != Mode.FIND_MISFILED or not _active:
return
var held: Record = _grab.held_record() if _grab != null else null
if held != null and held.has_disc_in_hand() and held.disc_genre != held.sleeve_genre:
_win(held)
else:
# a wrong call just nudges the banner; no hard penalty, keep it playful
_result = "NOT THAT ONE — keep digging"
func _win(held: Record) -> void:
_active = false
_result = "FOUND IT! the %s disc was in a %s sleeve · mess %d · %d records disturbed" % [
held.disc_genre, held.sleeve_genre, _mess, disturbed_count()]
func _lose(_reason: String) -> void:
_active = false
var where := "somewhere in the crates"
if is_instance_valid(_misfiled):
where = "a %s sleeve" % _misfiled.sleeve_genre
_result = "TIME'S UP — the misfiled disc was in %s · mess %d" % [where, _mess]
# ---------------------------------------------------------------- bookkeeping
func disturbed_count() -> int:
var n := 0
for r in _records:
if is_instance_valid(r) and r.was_disturbed():
n += 1
return n
func _process(delta: float) -> void:
if _active and mode == Mode.FIND_MISFILED:
_time_left -= delta
if _time_left <= 0.0:
_time_left = 0.0
_lose("timeout")
## The scoreboard line Main appends to the HUD.
func hud_line() -> String:
if mode == Mode.FREE_PLAY:
return "MODE: FreePlay (1 FreePlay · 2 Find-the-Misfiled-Disc)"
if _active:
return "MODE: Find Misfiled %02d s mess %d · pull discs, F to report the wrong one" % [
int(ceil(_time_left)), _mess]
return "MODE: Find Misfiled %s (2 to replay · 1 FreePlay)" % _result

View File

@ -0,0 +1 @@
uid://dlx76o0dj3lpe

View File

@ -0,0 +1,146 @@
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()

View File

@ -0,0 +1 @@
uid://ut0wnv8ltkhi

View File

@ -17,6 +17,9 @@ var _world: Node3D # everything smashable/debris lives here so reset is
var _hud: Label
var _smash_count := 0
var _juice: Juice
var _player: Player # kept so GameMode can reach player.grab
var _records: Array[Record] = [] # the live records, handed to GameMode each round
var _game_mode: GameMode
func _ready() -> void:
randomize()
@ -25,6 +28,13 @@ func _ready() -> void:
add_child(_juice)
_juice.setup(_cam) # _setup_world() set _cam to the player's camera
_build_rack(Vector3.ZERO)
# rules layer: starts in FreePlay; keys 1/2 switch, F reports in FindMisfiled
_game_mode = GameMode.new()
add_child(_game_mode)
_game_mode.setup(_player.grab)
_game_mode.begin_free_play(_records)
_setup_hud()
# ---------------------------------------------------------------- world / camera
@ -46,11 +56,11 @@ func _setup_world() -> void:
add_child(sun)
# First-person player replaces the old fixed camera. add_child() runs its _ready()
# synchronously, so player.camera exists on the next line; we reuse it for HUD/debug.
var player := Player.new()
add_child(player)
player.global_position = Vector3(0, 0, 4) # stand back, facing -Z toward the rack
_cam = player.camera
# synchronously, so player.camera/grab exist on the next line; we reuse them.
_player = Player.new()
add_child(_player)
_player.global_position = Vector3(0, 0, 4) # stand back, facing -Z toward the rack
_cam = _player.camera
# floor
var floor_body := StaticBody3D.new()
@ -93,6 +103,7 @@ var _sleeve_textures: Array[Texture2D] = []
var _records_skinned := 0
func _build_rack(origin: Vector3) -> void:
_records.clear() # stale refs from the freed world; repopulated by _place_record
# two racks form a smashable back wall of the booth
_glb_piece(RACK_GLB, "wood", Vector2(origin.x - 0.95, origin.z - 0.7), true)
_glb_piece(RACK_GLB, "wood", Vector2(origin.x + 0.95, origin.z - 0.7), true)
@ -106,13 +117,35 @@ func _build_rack(origin: Vector3) -> void:
var xz: Vector2 = spot + Vector2(origin.x, origin.z)
var crate := _glb_piece(CRATE_GLB, "wood", xz, false)
var crate_top: float = crate["top"]
# a few records stacked on the crate; smashing the crate spills them
# a few records stacked on the crate; smashing the crate spills them, and each
# one is now a grabbable Record (sleeve + hidden disc), not a one-hit prop
for i in range(3):
var jitter := Vector2(randf_range(-0.06, 0.06), randf_range(-0.06, 0.06))
var rec := _glb_piece(RECORD_GLB, "vinyl", xz + jitter, false, crate_top + float(i) * 0.03)
(crate["piece"] as Smashable).supports.append(rec["piece"])
var rec := _place_record(xz + jitter, crate_top + float(i) * 0.03)
(crate["piece"] as Smashable).supports.append(rec)
print("[art] records skinned with sleeve art: ", _records_skinned)
## Spawn one grabbable Record (sleeve + nested disc) resting on `sit_on`. Mirrors
## _glb_piece's "drop the AABB bottom onto sit_on" trick, but the Record builds its own
## visuals/colliders/disc in _ready, so we just skin the sleeve and place it.
func _place_record(xz: Vector2, sit_on: float) -> Record:
var rec := Record.new()
rec.setup()
var g: String = GameMode.GENRES[randi() % GameMode.GENRES.size()]
rec.sleeve_genre = g
rec.disc_genre = g # matches until GameMode plants a mismatch
_world.add_child(rec) # _ready() splits record.glb, builds the disc
if rec.sleeve_visual != null:
_skin_record(rec.sleeve_visual) # random cover art on the jacket faces
var ab := _body_local_aabb(rec, rec.sleeve_visual) if rec.sleeve_visual != null else AABB()
rec.global_position = Vector3(xz.x, sit_on - ab.position.y, xz.y)
rec.smashed.connect(_on_smashed) # the empty sleeve breaking counts + juices
var disc := rec.disc_body()
if disc != null:
disc.smashed.connect(_on_smashed) # the cracked disc counts + juices too
_records.append(rec)
return rec
## Instance a GLB as the VISUAL of a Smashable, give it a box collider auto-sized to
## the mesh AABB, and drop it so its bottom rests at height `sit_on`. Returns
## { piece: Smashable, top: float } so a caller can stack the next thing on top.
@ -243,16 +276,24 @@ func _on_smashed(kind: String, at: Vector3, shards: int) -> void:
_smash_count += 1
if _juice != null:
_juice.impact(kind, at, shards)
if _game_mode != null:
_game_mode.on_smashed() # scores the mess during a FindMisfiled round
# ---------------------------------------------------------------- input
func _unhandled_input(event: InputEvent) -> void:
# Melee (L-click punch / R-click kick) and Esc live in Player.gd. Here we keep the
# debug keys. Rain moved off S -> B, because the player now uses S to walk backward.
# Melee (L-click punch / R-click kick), grab (E/G/drag), and Esc live in Player.gd.
# Here: debug keys + the game-mode keys. Rain is on B (S walks backward now).
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_B:
_rain(500)
elif event.keycode == KEY_R:
_reset()
elif event.keycode == KEY_1:
_switch_mode(GameMode.Mode.FREE_PLAY)
elif event.keycode == KEY_2:
_switch_mode(GameMode.Mode.FIND_MISFILED)
elif event.keycode == KEY_F and _game_mode != null:
_game_mode.report() # report the disc you're holding (FindMisfiled)
# The Day-0 stress gate: rain N bodies, watch the FPS counter hold.
func _rain(n: int) -> void:
@ -278,6 +319,19 @@ func _reset() -> void:
_smash_count = 0
await get_tree().process_frame
_build_rack(Vector3.ZERO)
# re-arm whatever mode we're in against the fresh set of records
if _game_mode != null:
if _game_mode.mode == GameMode.Mode.FIND_MISFILED:
_game_mode.begin_find_misfiled(_records)
else:
_game_mode.begin_free_play(_records)
## Switch mode and rebuild the store fresh so a round always starts clean.
func _switch_mode(m: int) -> void:
if _game_mode == null:
return
_game_mode.mode = m # _reset() reads this to pick begin_free_play/begin_find_misfiled
await _reset()
# ---------------------------------------------------------------- HUD
func _setup_hud() -> void:
@ -295,5 +349,6 @@ func _process(_dt: float) -> void:
var bodies := get_tree().get_nodes_in_group("smashable").size() + get_tree().get_nodes_in_group("debris").size()
var c := _juice.combo() if _juice != null else 0
var combo_str := (" COMBO x%d" % c) if c > 1 else ""
_hud.text = "FPS %d bodies %d smashed %d%s\nWASD move Space jump L-click punch R-click kick Esc release B: rain 500 R: reset" % [
Engine.get_frames_per_second(), bodies, _smash_count, combo_str]
var mode_line := _game_mode.hud_line() if _game_mode != null else ""
_hud.text = "FPS %d bodies %d smashed %d%s\n%s\nWASD move E grab drag ←→ pull LMB throw/punch G drop F report B rain R reset" % [
Engine.get_frames_per_second(), bodies, _smash_count, combo_str, mode_line]

View File

@ -23,6 +23,7 @@ class_name Player
@export var eye_height: float = 1.6
var camera: Camera3D # Main reads this for the crosshair raycast
var grab: GrabController # the record grab/extract/throw hands (Main reads this)
var _head: Node3D
var _hands_rig: Node3D # cosmetic viewmodel (hands + leg), swung on attack
@ -68,6 +69,11 @@ func _ready() -> void:
_build_crosshair()
_make_attacks()
# the record ritual hands: a child node that owns a hold point under the camera
grab = GrabController.new()
add_child(grab)
grab.setup(camera)
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# ---------------------------------------------------------------- attacks
@ -162,7 +168,12 @@ func _build_crosshair() -> void:
# ---------------------------------------------------------------- input
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
rotate_y(-event.relative.x * mouse_sensitivity) # yaw on the body only
# while pulling a disc out, horizontal drag slides the disc (yaw is suspended);
# vertical still looks up/down so you're never fully locked.
if grab != null and grab.is_extracting():
grab.feed_drag(event.relative.x)
else:
rotate_y(-event.relative.x * mouse_sensitivity) # yaw on the body only
_pitch = clamp(
_pitch - event.relative.y * mouse_sensitivity,
deg_to_rad(-pitch_limit_deg), deg_to_rad(pitch_limit_deg))
@ -174,7 +185,11 @@ func _unhandled_input(event: InputEvent) -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # click recaptures; this one isn't an attack
return
if event.button_index == MOUSE_BUTTON_LEFT:
_queue_attack(_punch)
# holding a record? LMB throws the disc / trashes the sleeve, not a punch.
if grab != null and grab.is_holding():
grab.primary()
else:
_queue_attack(_punch)
elif event.button_index == MOUSE_BUTTON_RIGHT:
_queue_attack(_kick)
return
@ -182,6 +197,10 @@ func _unhandled_input(event: InputEvent) -> void:
if event is InputEventKey and event.pressed and not event.echo:
if event.keycode == KEY_ESCAPE:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event.keycode == KEY_E and grab != null:
grab.try_grab() # grab the record under the crosshair
elif event.keycode == KEY_G and grab != null:
grab.drop() # drop / trash whatever's in hand
func _queue_attack(atk: MeleeAttack) -> void:
# buffer the click; the hit resolves in _physics_process where the space state is valid.

360
game/scripts/Record.gd Normal file
View File

@ -0,0 +1,360 @@
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)

View File

@ -0,0 +1 @@
uid://dau6uw3elb3db