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>
130 lines
4.5 KiB
GDScript
130 lines
4.5 KiB
GDScript
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
|