destroyulator/game/scripts/Smashable.gd
Monster Robot Party 61757d24bc LANE6: rigged FPS viewmodel, weapon loadout + material matrix, 4 HUD styles
Hands/POV
- Cut a real first-person arms rig out of the GODVERSE modular character kit
  (tools/gen_fps_arms.py): ch01 hands + per-side sleeves on the full 65-bone
  mixamorig skeleton, so all 20 finger bones per hand are poseable at runtime.
  Textures shrunk to 1k; 4.2 MB.
- ViewModel.gd instances that rig ONCE PER HAND and places each instance so its
  own hand bone lands on the grip — no IK. Grip orientation is measured off the
  rig at load (pinky->index knuckle = bore axis, elbow->hand = forearm dir), so
  it survives re-tuning grip_rest_rot instead of needing new euler angles.
- Motion layers: look-sway with spring-back, walk bob scaled by speed and weapon
  heft, idle breathe, landing dip, weapon lower/raise on swap.
- Sleeve material overridden (donor asset is a fantasy leather bracer); hand
  material forced non-metallic (its spec/gloss maps rendered skin as bronze).

Weapons
- Weapon.gd replaces MeleeAttack: 6 weapons, 4 swing archetypes, and the
  weapon-vs-material matrix from the founding chat.
- Smashable moves from binary hits_to_break to hp/toughness, giving three
  outcomes: break, dent, or futile (dead clank, no score, HUD nudge). The box
  cutter genuinely shreds cardboard and genuinely cannot hurt a filing cabinet.
- The hit lands at Weapon.contact THROUGH the swing, not on the click — that
  delay is most of why the sledge feels different from the cutter.
- Slots on 1-6 / wheel / Q; game modes moved to M, HUD cycle to H.

HUD
- Hud.gd: Arcade, Minimal, Work Order (a corporate destruction docket that fills
  in line items) and Dev, over one shared data feed. Scoring + combo multipliers.

Dev harness (not shipped)
- macOS screen-recording perms aren't available to the CLI, so the game records
  itself: dev/demo.tscn + DemoDriver.gd drive a scripted tour for --write-movie,
  and dev/probe_*.gd print rig/scale/placement numbers.

Fix: tools/gen_viewmodel.py box() scaled by size/2 on top of primitive_cube_add's
already-unit side length, halving every box — which detached the bat's blade.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 16:34:00 +10:00

235 lines
10 KiB
GDScript

extends RigidBody3D
class_name Smashable
## A piece of the world that can be smashed.
##
## The cascade lives here: frame pieces start FROZEN (they act static and hold the
## stuff above them up). When you smash a frame piece it vanishes into shards, so
## everything resting on it falls — no scripted "release" needed, gravity does it.
## Brittle kinds (vinyl, glass) also shatter on their own when they hit something
## hard enough — that's the satisfying TAIL of the cascade: the record survives the
## tumble out of its jacket, then cracks a beat later when it smacks the floor.
@export_enum("wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper") var kind: String = "wood"
@export var start_frozen: bool = false
## Pre-fractured sibling (Lane 2 contract #2): a scene whose leaf `chunk_*` meshes are
## the real shards. When set, shatter() spawns those instead of primitive cubes. Main
## auto-assigns it from a `<name>.fractured.glb` next to the intact GLB.
@export var fractured_scene: PackedScene = null
## Scales this prop's hit points above its material default. The printer boss is 5.0.
@export var toughness_scale: float = 1.0
## Boss props get a bigger death burst (more juice particles).
@export var is_boss: bool = false
var _hp := 1.0
var _hp_max := 1.0
# per-material behaviour — this table is where "material identity" is authored.
# hp ........... damage it soaks. A swing deals Weapon.power * Weapon.vs[kind], so
# this is the other half of the weapon-vs-material matrix.
# brittle_speed 0.0 == not brittle (won't self-shatter on impact).
const PROFILES := {
"wood": {"color": Color(0.42, 0.28, 0.15), "shards": 7, "brittle_speed": 0.0, "bounce": 0.05, "hp": 2.2},
"cardboard": {"color": Color(0.66, 0.5, 0.32), "shards": 4, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.6},
"vinyl": {"color": Color(0.04, 0.04, 0.05), "shards": 6, "brittle_speed": 2.2, "bounce": 0.12, "hp": 0.9},
"glass": {"color": Color(0.55, 0.8, 0.85), "shards": 12, "brittle_speed": 1.6, "bounce": 0.0, "hp": 1.0},
"steel": {"color": Color(0.7, 0.72, 0.76), "shards": 0, "brittle_speed": 0.0, "bounce": 0.25, "hp": 4.5},
"plastic": {"color": Color(0.82, 0.78, 0.70), "shards": 6, "brittle_speed": 0.0, "bounce": 0.15, "hp": 1.4},
"paper": {"color": Color(0.92, 0.90, 0.84), "shards": 3, "brittle_speed": 0.0, "bounce": 0.0, "hp": 0.3},
}
signal smashed(kind: String, at: Vector3, shard_count: int)
## A hit landed but this weapon can't meaningfully hurt this material — the
## box-cutter-on-a-filing-cabinet clank. Juice turns it into a sound + a small shake.
signal resisted(kind: String, at: Vector3)
## A hit that hurt but didn't kill. `frac` is remaining hp, 0..1.
signal damaged(kind: String, at: Vector3, frac: float)
var _shattered := false
var _speed := 0.0 # last physics-frame speed, sampled before any collision resolves
## Pieces this one holds up. When THIS shatters, these get released (unfrozen) so
## gravity drops them. Wired by Main._build_rack (e.g. post.supports.append(shelf)).
## Losing ANY primary support drops the load — arcade feel, no partial-support math.
var supports: Array[Node] = []
var _released := false # so a piece is only released once (idempotent, cycle-safe)
func _ready() -> void:
add_to_group("smashable")
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
_hp_max = float(p["hp"]) * maxf(toughness_scale, 0.01)
_hp = _hp_max
physics_material_override = PhysicsMaterial.new()
physics_material_override.bounce = p["bounce"]
if start_frozen:
freeze_mode = RigidBody3D.FREEZE_MODE_STATIC
freeze = true
if float(p["brittle_speed"]) > 0.0:
contact_monitor = true
max_contacts_reported = 4
body_entered.connect(_on_body_entered)
func _physics_process(_dt: float) -> void:
if not freeze:
_speed = linear_velocity.length()
func _on_body_entered(_body: Node) -> void:
if _shattered:
return
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
if _speed >= float(p["brittle_speed"]):
shatter(Vector3.ZERO)
## Take a swing. `damage` is what the weapon deals to THIS material (see Weapon.vs);
## the default of -1 means "whatever it takes", which keeps thrown-object and scripted
## smashes working without needing a weapon.
##
## Three outcomes, and the middle one is the point of the whole matrix:
## damage kills it ....... shatter
## damage dents it ....... knockback + `damaged`, so heavy props are a fight
## damage is futile ...... `resisted` — the clank that tells you to switch weapon
func smash(impulse: Vector3, damage: float = -1.0) -> void:
if _shattered:
return
if damage < 0.0:
shatter(impulse)
return
if damage < _hp_max * 0.10:
# too weak to matter: bounce off, make a noise, leave the prop intact
sleeping = false
apply_central_impulse(impulse.limit_length(4.0) * 0.10)
resisted.emit(kind, global_position)
return
_hp -= damage
if _hp <= 0.0:
shatter(impulse)
return
# survived — a physical knockback so it visibly reacts to the beating
sleeping = false
apply_central_impulse(impulse.limit_length(6.0) * 0.25)
damaged.emit(kind, global_position, clampf(_hp / _hp_max, 0.0, 1.0))
## Remaining hp as 0..1, for HUD damage reads.
func hp_frac() -> float:
return clampf(_hp / maxf(_hp_max, 0.001), 0.0, 1.0)
func shatter(impulse: Vector3) -> void:
if _shattered:
return
_shattered = true
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
var burst := 24 if is_boss else int(p["shards"]) # boss dies in a bigger cloud of juice
smashed.emit(kind, global_position, burst)
_release_supported() # drop what I was holding up BEFORE I leave the tree — the cascade
if fractured_scene != null:
_spawn_chunks(impulse) # real GLB shards (Lane 2)
else:
_spawn_shards(int(p["shards"]), p["color"], impulse)
queue_free()
## Wake every piece this one was holding up. Guarded on both ends (each child's own
## _released flag) so a shared load or an accidental cycle can't loop.
func _release_supported() -> void:
for node in supports:
if is_instance_valid(node) and node is Smashable:
node.release()
## Turn a frozen support into a live dynamic body and give it a comedic nudge so it
## visibly topples instead of sinking straight down. Idempotent: two legs releasing
## the same shelf only drop it once.
func release() -> void:
if _released:
return
_released = true
if _shattered:
return # already gone; nothing to drop
if freeze:
freeze = false # gravity + forces resume immediately
sleeping = false # defensive against a Jolt sleep frame
apply_central_impulse(Vector3(randf_range(-0.6, 0.6), 0.15, randf_range(-0.6, 0.6)))
## Real destruction: instance the pre-fractured sibling and turn each `chunk_*` mesh into
## its own rigid body that inherits our velocity + the smash impulse. Chunks are named
## chunk_00…chunk_NN with each origin at its own centroid, in this model's local space
## (Lane 2 contract #2). We add the template at our transform so each chunk's global
## transform is already correct, then reparent it onto a fresh body. Debris group so the
## HUD count + Juice keep working. Falls back to primitive shards if the template is empty.
func _spawn_chunks(impulse: Vector3) -> void:
var parent := get_parent()
if parent == null:
return
var tmpl: Node3D = fractured_scene.instantiate()
parent.add_child(tmpl)
tmpl.global_transform = global_transform # chunks now sit exactly where the intact body was
var chunks: Array[MeshInstance3D] = []
_collect_chunk_meshes(tmpl, chunks)
if chunks.is_empty():
tmpl.queue_free()
var p: Dictionary = PROFILES.get(kind, PROFILES["wood"])
_spawn_shards(int(p["shards"]), p["color"], impulse)
return
var inherited := linear_velocity
var here := global_position
for chunk in chunks:
var gt := chunk.global_transform
var body := RigidBody3D.new()
body.add_to_group("debris")
body.mass = 0.25
# reparent the chunk mesh onto the body, centred on the body origin
chunk.owner = null
chunk.get_parent().remove_child(chunk)
body.add_child(chunk)
chunk.transform = Transform3D.IDENTITY
var ab := chunk.get_aabb()
var col := CollisionShape3D.new()
var box := BoxShape3D.new()
box.size = ab.size.max(Vector3(0.02, 0.02, 0.02))
col.shape = box
col.position = ab.get_center()
body.add_child(col)
parent.add_child(body)
body.global_transform = gt
# fling outward from the smash centre + inherit motion + the hit impulse
var out := (gt.origin - here)
out = out.normalized() if out.length() > 0.001 else Vector3(randf_range(-1, 1), 0.5, randf_range(-1, 1)).normalized()
body.linear_velocity = inherited + out * randf_range(1.2, 3.2) + impulse * 0.3
body.angular_velocity = Vector3(randf_range(-8, 8), randf_range(-8, 8), randf_range(-8, 8))
tmpl.queue_free() # the now-empty template wrapper
func _collect_chunk_meshes(n: Node, acc: Array[MeshInstance3D]) -> void:
if n is MeshInstance3D and n.name.to_lower().begins_with("chunk"):
acc.append(n)
for c in n.get_children():
_collect_chunk_meshes(c, acc)
func _spawn_shards(n: int, color: Color, impulse: Vector3) -> void:
if n <= 0:
return
var parent := get_parent()
if parent == null:
return
var mat := StandardMaterial3D.new()
mat.albedo_color = color
var inherited := linear_velocity
for i in n:
var s := RigidBody3D.new()
s.add_to_group("debris")
s.mass = 0.08
var sz := randf_range(0.03, 0.09)
var mesh := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = Vector3(sz, sz, sz)
mesh.mesh = bm
mesh.material_override = mat
s.add_child(mesh)
var col := CollisionShape3D.new()
var shape := BoxShape3D.new()
shape.size = bm.size
col.shape = shape
s.add_child(col)
parent.add_child(s)
s.global_position = global_position + Vector3(randf_range(-0.08, 0.08), randf_range(-0.08, 0.08), randf_range(-0.08, 0.08))
var kick := Vector3(randf_range(-1, 1), randf_range(0.3, 1.4), randf_range(-1, 1)).normalized() * randf_range(0.6, 2.2)
s.linear_velocity = inherited + kick + impulse * 0.3
s.angular_velocity = Vector3(randf_range(-7, 7), randf_range(-7, 7), randf_range(-7, 7))