From ad0ca62f3ef5ccec31da1eb588e23a95b2a4c418 Mon Sep 17 00:00:00 2001 From: Monster Robot Party Date: Tue, 14 Jul 2026 19:40:52 +1000 Subject: [PATCH] LANE1: record-destruction ritual + Find-the-Misfiled-Disc mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- game/scripts/GameMode.gd | 129 +++++++++++ game/scripts/GameMode.gd.uid | 1 + game/scripts/GrabController.gd | 146 ++++++++++++ game/scripts/GrabController.gd.uid | 1 + game/scripts/Main.gd | 79 ++++++- game/scripts/Player.gd | 23 +- game/scripts/Record.gd | 360 +++++++++++++++++++++++++++++ game/scripts/Record.gd.uid | 1 + 8 files changed, 726 insertions(+), 14 deletions(-) create mode 100644 game/scripts/GameMode.gd create mode 100644 game/scripts/GameMode.gd.uid create mode 100644 game/scripts/GrabController.gd create mode 100644 game/scripts/GrabController.gd.uid create mode 100644 game/scripts/Record.gd create mode 100644 game/scripts/Record.gd.uid diff --git a/game/scripts/GameMode.gd b/game/scripts/GameMode.gd new file mode 100644 index 0000000..5ca70f7 --- /dev/null +++ b/game/scripts/GameMode.gd @@ -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 diff --git a/game/scripts/GameMode.gd.uid b/game/scripts/GameMode.gd.uid new file mode 100644 index 0000000..d0b0cb5 --- /dev/null +++ b/game/scripts/GameMode.gd.uid @@ -0,0 +1 @@ +uid://dlx76o0dj3lpe diff --git a/game/scripts/GrabController.gd b/game/scripts/GrabController.gd new file mode 100644 index 0000000..63e9e5a --- /dev/null +++ b/game/scripts/GrabController.gd @@ -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() diff --git a/game/scripts/GrabController.gd.uid b/game/scripts/GrabController.gd.uid new file mode 100644 index 0000000..6566cff --- /dev/null +++ b/game/scripts/GrabController.gd.uid @@ -0,0 +1 @@ +uid://ut0wnv8ltkhi diff --git a/game/scripts/Main.gd b/game/scripts/Main.gd index f7897ba..b7fa0f1 100644 --- a/game/scripts/Main.gd +++ b/game/scripts/Main.gd @@ -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] diff --git a/game/scripts/Player.gd b/game/scripts/Player.gd index 71a9e77..6aeca8f 100644 --- a/game/scripts/Player.gd +++ b/game/scripts/Player.gd @@ -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. diff --git a/game/scripts/Record.gd b/game/scripts/Record.gd new file mode 100644 index 0000000..e729097 --- /dev/null +++ b/game/scripts/Record.gd @@ -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) diff --git a/game/scripts/Record.gd.uid b/game/scripts/Record.gd.uid new file mode 100644 index 0000000..5dddf96 --- /dev/null +++ b/game/scripts/Record.gd.uid @@ -0,0 +1 @@ +uid://dau6uw3elb3db