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