The game had no room. Props sat on a grey disc in a black void, which quietly broke the premise: a game about wrecking your workplace needs a workplace. Office.gd — LEVEL 01 - Open-plan office laid out after The Office (US) floor plan: bullpen of facing desk pairs behind cubicle partitions, reception, glass-walled manager's office, conference room, break room with vending machines, copier alcove, warehouse roller door. - Drop ceiling on a T-bar grid with fluorescent troffers that ARE the light sources (the old scene lit an interior with one outdoor directional lamp, which is exactly why it read as "props on a plane"), window wall with blinds, magnolia and carpet. - Office owns the shell + static fittings; Main._populate() owns everything smashable and asks Office where things go. Walls/desks/counters are boxes because they ARE boxes; chairs, vending machines, microwave and plant come from a new Blender generator (tools/gen_office_props.py) because a box reads as wrong for those. The level no longer falls over on its own - Records were stacked 3 cm apart vertically while being 30 cm TALL, so Jolt resolved 27 cm of interpenetration explosively on frame one and shoved the furniture over before the player touched anything. They now stand side by side. - Per-material mass (MASSES): everything was 1 kg, so a thrown record could tip a filing cabinet. - Spawn guard: placing ~40 props by formula guarantees an occasional overlap, and depenetration is violent (a chair left the building at 500 m/s). Dynamic bodies are speed-limited for 0.75 s, and each offender is named once with the position it was PLACED at, so the cause stays visible instead of being papered over. It then found the real bug: bullpen rows 3.6 m apart left the two rows' chairs meeting back-to-back with 3 cm to spare. Rows are now 4.8 m apart. - dev/DemoDriver.gd act 0 touches nothing for 5 s and prints total body speed. Now reads 0.00 m/s with zero spawn warnings. Two real melee bugs found while testing the level - The hit test was a SPHERE parked at `reach`, so anything CLOSER than the weapon's reach fell in front of it and was missed — you could stand against the printer with a sledgehammer and swing straight through it. Now a capsule swept from the camera. - Swings started at eye height (1.6 m), so a carton on the floor was ~1.5 m away even standing over it and short-reach weapons could never touch anything on the ground. Swings now originate at hand height. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
362 lines
14 KiB
GDScript
362 lines
14 KiB
GDScript
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.name = "disc"
|
|
_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)
|