destroyulator/game/scripts/Juice.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

200 lines
7.5 KiB
GDScript

extends Node
class_name Juice
## The feel layer. Everything that makes a smash *land* without adding simulation:
## hitstop, screenshake, per-material particle bursts, procedural impact audio, combos.
## Driven off Smashable's `smashed` signal (Main forwards it to impact()), so it's
## purely additive — the physics doesn't know this exists.
##
## Audio is SYNTHESIZED in code (AudioStreamWAV built from a generated PCM buffer),
## same zero-asset trick as dig.js. Each material gets its own little waveform.
##
## Godot 4.7 GDScript 2.0.
@export var shake_max_offset: float = 0.14 ## metres the camera jitters at full trauma
@export var shake_max_roll: float = 0.05 ## radians of camera roll at full trauma
@export var shake_decay: float = 1.6 ## trauma lost per second
@export var combo_window_ms: int = 1600 ## smashes within this window keep the combo alive
@export var hitstop_scale: float = 0.06 ## Engine.time_scale during a freeze-frame
var _cam: Camera3D
var _trauma: float = 0.0
var _hitstop_until_ms: int = 0
var _combo: int = 0
var _last_smash_ms: int = -100000
var _sounds: Dictionary = {} # kind -> AudioStreamWAV
func setup(cam: Camera3D) -> void:
_cam = cam
process_mode = Node.PROCESS_MODE_ALWAYS # keep releasing hitstop even during a freeze
for k in ["wood", "cardboard", "vinyl", "glass", "steel", "plastic", "paper", "clank"]:
_sounds[k] = _make_wav(_synth(k), 22050)
## A hit that couldn't hurt the material — box cutter on a filing cabinet. Deliberately
## unsatisfying: a dead clank, a token wobble, no particles, and NO combo credit. It has
## to read as "wrong tool" without a tutorial popup.
func resist(_kind: String, pos: Vector3) -> void:
_play_sound("clank", pos)
add_trauma(0.09)
## A hit that hurt but didn't kill. The material's own voice, a small spray of chips,
## and a shorter hitstop than a kill so heavy props feel like work, not like a stall.
func dent(kind: String, pos: Vector3, _frac: float) -> void:
var color: Color = Smashable.PROFILES.get(kind, Smashable.PROFILES["wood"])["color"]
_burst(color, pos, 5)
_play_sound(kind, pos)
add_trauma(0.16)
_hitstop(0.025)
## 1.0 just after a smash, falling to 0.0 as the combo window closes. Drives the HUD bar.
func combo_decay() -> float:
if _combo <= 1:
return 0.0
var left := float(combo_window_ms) - float(Time.get_ticks_msec() - _last_smash_ms)
return clampf(left / float(combo_window_ms), 0.0, 1.0)
## Called once per shatter (Main forwards Smashable.smashed here).
func impact(kind: String, pos: Vector3, shards: int) -> void:
var color: Color = Smashable.PROFILES.get(kind, Smashable.PROFILES["wood"])["color"]
_burst(color, pos, clampi(shards + 4, 6, 26))
_play_sound(kind, pos)
add_trauma(0.26 + float(min(shards, 12)) * 0.03)
_hitstop(0.04 + float(min(shards, 12)) * 0.005)
var now := Time.get_ticks_msec()
_combo = _combo + 1 if now - _last_smash_ms <= combo_window_ms else 1
_last_smash_ms = now
func combo() -> int:
return 0 if Time.get_ticks_msec() - _last_smash_ms > combo_window_ms else _combo
func add_trauma(a: float) -> void:
_trauma = clampf(_trauma + a, 0.0, 1.0)
func _hitstop(dur: float) -> void:
Engine.time_scale = hitstop_scale
_hitstop_until_ms = maxi(_hitstop_until_ms, Time.get_ticks_msec() + int(dur * 1000.0))
func _process(delta: float) -> void:
# release the freeze in REAL time (Time.get_ticks_msec is not scaled by time_scale)
if Engine.time_scale < 1.0 and Time.get_ticks_msec() >= _hitstop_until_ms:
Engine.time_scale = 1.0
# screenshake: quadratic falloff feels punchier than linear
if _cam != null:
var amt := _trauma * _trauma
_cam.position = Vector3(randf_range(-1.0, 1.0), randf_range(-1.0, 1.0), 0.0) * amt * shake_max_offset
_cam.rotation.z = randf_range(-1.0, 1.0) * amt * shake_max_roll
_trauma = maxf(0.0, _trauma - shake_decay * delta)
# ---------------------------------------------------------------- particles
func _burst(color: Color, pos: Vector3, count: int) -> void:
var p := GPUParticles3D.new()
p.one_shot = true
p.explosiveness = 1.0
p.amount = count
p.lifetime = 0.7
p.local_coords = false # debris keeps falling in world space
var pm := ParticleProcessMaterial.new()
pm.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
pm.emission_sphere_radius = 0.12
pm.direction = Vector3(0, 1, 0)
pm.spread = 78.0
pm.initial_velocity_min = 1.4
pm.initial_velocity_max = 4.6
pm.gravity = Vector3(0, -9.8, 0)
pm.scale_min = 0.3
pm.scale_max = 1.1
pm.angular_velocity_min = -420.0
pm.angular_velocity_max = 420.0
p.process_material = pm
var mesh := BoxMesh.new()
mesh.size = Vector3(0.04, 0.04, 0.04)
var dmat := StandardMaterial3D.new()
dmat.albedo_color = color
mesh.material = dmat
p.draw_pass_1 = mesh
add_child(p)
p.global_position = pos
p.emitting = true
p.finished.connect(p.queue_free)
# ---------------------------------------------------------------- audio
func _play_sound(kind: String, pos: Vector3) -> void:
var wav: AudioStreamWAV = _sounds.get(kind)
if wav == null:
return
var a := AudioStreamPlayer3D.new()
a.stream = wav
a.pitch_scale = randf_range(0.9, 1.14) # per-hit variation so it never sounds looped
a.unit_size = 6.0
a.max_db = 4.0
add_child(a)
a.global_position = pos
a.finished.connect(a.queue_free)
a.play()
## Build a mono 16-bit AudioStreamWAV from a float[-1,1] sample buffer.
func _make_wav(samples: PackedFloat32Array, rate: int) -> AudioStreamWAV:
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = rate
wav.stereo = false
var bytes := PackedByteArray()
bytes.resize(samples.size() * 2)
for i in samples.size():
bytes.encode_s16(i * 2, int(clampf(samples[i], -1.0, 1.0) * 32767.0))
wav.data = bytes
return wav
## Author each material's voice: noise + tuned sines shaped by a decay envelope.
## wood = low dry crunch · cardboard = soft rip · vinyl = crack + warble
## glass = bright tinkle · steel = ringing bonngg
func _synth(kind: String) -> PackedFloat32Array:
var rate := 22050
var dur := 0.26
match kind:
"glass": dur = 0.30
"steel": dur = 0.55
"vinyl": dur = 0.22
"cardboard": dur = 0.18
"plastic": dur = 0.20
"paper": dur = 0.16
"clank": dur = 0.14
var n := int(rate * dur)
var out := PackedFloat32Array()
out.resize(n)
for i in n:
var t := float(i) / float(rate)
var s := 0.0
var env := 0.0
match kind:
"cardboard":
env = exp(-t * 26.0)
s = (randf() * 2.0 - 1.0) * 0.45 * env
"vinyl":
env = exp(-t * 16.0)
var click := (randf() * 2.0 - 1.0) if t < 0.004 else 0.0
s = click * 0.9 + sin(TAU * (520.0 - 300.0 * t) * t) * env * 0.5 + (randf() * 2.0 - 1.0) * 0.15 * env
"glass":
env = exp(-t * 34.0)
s = (randf() * 2.0 - 1.0) * 0.5 * env + sin(TAU * 5200.0 * t) * 0.25 * env + sin(TAU * 3300.0 * t) * 0.2 * env
"steel":
env = exp(-t * 5.0)
s = sin(TAU * 190.0 * t) * 0.5 * env + sin(TAU * 285.0 * t) * 0.25 * env + sin(TAU * 470.0 * t) * 0.12 * env + (randf() * 2.0 - 1.0) * 0.2 * exp(-t * 40.0)
"plastic":
env = exp(-t * 24.0)
s = sin(TAU * 780.0 * t) * 0.35 * env + (randf() * 2.0 - 1.0) * 0.4 * env
"paper":
env = exp(-t * 30.0)
s = (randf() * 2.0 - 1.0) * 0.32 * env * (0.5 + 0.5 * sin(TAU * 40.0 * t))
"clank":
# short, dull, no sustain — the sound of a tool bouncing off, and of
# a decision to swap weapon.
env = exp(-t * 46.0)
s = sin(TAU * 240.0 * t) * 0.34 * env + (randf() * 2.0 - 1.0) * 0.26 * exp(-t * 90.0)
_: # wood (and default)
env = exp(-t * 20.0)
s = (randf() * 2.0 - 1.0) * 0.7 * env + sin(TAU * 140.0 * t) * 0.4 * env
out[i] = clampf(s, -1.0, 1.0)
return out