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:
parent
d0cbe7267d
commit
ad0ca62f3e
129
game/scripts/GameMode.gd
Normal file
129
game/scripts/GameMode.gd
Normal 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
|
||||
1
game/scripts/GameMode.gd.uid
Normal file
1
game/scripts/GameMode.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dlx76o0dj3lpe
|
||||
146
game/scripts/GrabController.gd
Normal file
146
game/scripts/GrabController.gd
Normal 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()
|
||||
1
game/scripts/GrabController.gd.uid
Normal file
1
game/scripts/GrabController.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://ut0wnv8ltkhi
|
||||
@ -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]
|
||||
|
||||
@ -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
360
game/scripts/Record.gd
Normal 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)
|
||||
1
game/scripts/Record.gd.uid
Normal file
1
game/scripts/Record.gd.uid
Normal file
@ -0,0 +1 @@
|
||||
uid://dau6uw3elb3db
|
||||
Loading…
Reference in New Issue
Block a user