Add juice layer: hitstop, screenshake, particles, procedural audio, combos
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 <noreply@anthropic.com>
This commit is contained in:
parent
a8b735f79e
commit
cc5b118d71
162
scripts/Juice.gd
Normal file
162
scripts/Juice.gd
Normal file
@ -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
|
||||||
1
scripts/Juice.gd.uid
Normal file
1
scripts/Juice.gd.uid
Normal file
@ -0,0 +1 @@
|
|||||||
|
uid://cikcnvmpf8lrx
|
||||||
@ -16,10 +16,14 @@ extends Node3D
|
|||||||
var _world: Node3D # everything smashable/debris lives here so reset is one line
|
var _world: Node3D # everything smashable/debris lives here so reset is one line
|
||||||
var _hud: Label
|
var _hud: Label
|
||||||
var _smash_count := 0
|
var _smash_count := 0
|
||||||
|
var _juice: Juice
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
randomize()
|
randomize()
|
||||||
_setup_world()
|
_setup_world()
|
||||||
|
_juice = Juice.new()
|
||||||
|
add_child(_juice)
|
||||||
|
_juice.setup(_cam) # _setup_world() set _cam to the player's camera
|
||||||
_build_rack(Vector3.ZERO)
|
_build_rack(Vector3.ZERO)
|
||||||
_setup_hud()
|
_setup_hud()
|
||||||
|
|
||||||
@ -140,8 +144,10 @@ func _piece(kind: String, size: Vector3, pos: Vector3, frozen: bool, cylinder :=
|
|||||||
s.smashed.connect(_on_smashed)
|
s.smashed.connect(_on_smashed)
|
||||||
return s
|
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
|
_smash_count += 1
|
||||||
|
if _juice != null:
|
||||||
|
_juice.impact(kind, at, shards)
|
||||||
|
|
||||||
# ---------------------------------------------------------------- input
|
# ---------------------------------------------------------------- input
|
||||||
func _unhandled_input(event: InputEvent) -> void:
|
func _unhandled_input(event: InputEvent) -> void:
|
||||||
@ -192,5 +198,7 @@ func _process(_dt: float) -> void:
|
|||||||
if _hud == null:
|
if _hud == null:
|
||||||
return
|
return
|
||||||
var bodies := get_tree().get_nodes_in_group("smashable").size() + get_tree().get_nodes_in_group("debris").size()
|
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" % [
|
var c := _juice.combo() if _juice != null else 0
|
||||||
Engine.get_frames_per_second(), bodies, _smash_count]
|
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]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user