From cc5b118d71352cc4270692286e40639cc3fc49a8 Mon Sep 17 00:00:00 2001 From: Monster Robot Party Date: Sat, 4 Jul 2026 00:29:17 +1000 Subject: [PATCH] Add juice layer: hitstop, screenshake, particles, procedural audio, combos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Juice.gd — the feel system, driven off Smashable.smashed (purely additive): - Hitstop freeze-frame on every impact (time_scale dip, released in real time) - Screenshake with quadratic trauma falloff, scaled by shard count - Per-material GPU particle bursts, colored from the PROFILES table - Procedural impact audio synthesized in code per material — no sound files, the dig.js trick: wood crunch, cardboard rip, vinyl crack+warble, glass tinkle, steel ring - Combo counter on the HUD Co-Authored-By: Claude Opus 4.8 --- scripts/Juice.gd | 162 +++++++++++++++++++++++++++++++++++++++++++ scripts/Juice.gd.uid | 1 + scripts/Main.gd | 14 +++- 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 scripts/Juice.gd create mode 100644 scripts/Juice.gd.uid diff --git a/scripts/Juice.gd b/scripts/Juice.gd new file mode 100644 index 0000000..a9307f3 --- /dev/null +++ b/scripts/Juice.gd @@ -0,0 +1,162 @@ +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"]: + _sounds[k] = _make_wav(_synth(k), 22050) + +## 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 + 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) + _: # 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 diff --git a/scripts/Juice.gd.uid b/scripts/Juice.gd.uid new file mode 100644 index 0000000..689d11a --- /dev/null +++ b/scripts/Juice.gd.uid @@ -0,0 +1 @@ +uid://cikcnvmpf8lrx diff --git a/scripts/Main.gd b/scripts/Main.gd index 3f1bf9f..2e6aa73 100644 --- a/scripts/Main.gd +++ b/scripts/Main.gd @@ -16,10 +16,14 @@ extends Node3D var _world: Node3D # everything smashable/debris lives here so reset is one line var _hud: Label var _smash_count := 0 +var _juice: Juice func _ready() -> void: randomize() _setup_world() + _juice = Juice.new() + add_child(_juice) + _juice.setup(_cam) # _setup_world() set _cam to the player's camera _build_rack(Vector3.ZERO) _setup_hud() @@ -140,8 +144,10 @@ func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder := s.smashed.connect(_on_smashed) return s -func _on_smashed(_kind: String, _at: Vector3, _shards: int) -> void: +func _on_smashed(kind: String, at: Vector3, shards: int) -> void: _smash_count += 1 + if _juice != null: + _juice.impact(kind, at, shards) # ---------------------------------------------------------------- input func _unhandled_input(event: InputEvent) -> void: @@ -192,5 +198,7 @@ func _process(_dt: float) -> void: if _hud == null: return var bodies := get_tree().get_nodes_in_group("smashable").size() + get_tree().get_nodes_in_group("debris").size() - _hud.text = "FPS %d bodies %d smashed %d\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] + 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]