extends Projectile class_name ExplosiveProjectile var explosion_radius: float = 2.5 var explosion_knockback: float = 20.0 var can_self_damage: bool = false var falloff_curve: float = 1.0 # 1.0 is linear, 2.0 is quadratic (steep drop) var explosion_pitch: float = 1.0 # Audio pitch for explosion var flight_sound: AudioStreamPlayer3D var smoke_sys: GPUParticles3D var jet_sys: GPUParticles3D static var _smoke_tex: GradientTexture2D static func get_smoke_texture() -> GradientTexture2D: if _smoke_tex: return _smoke_tex _smoke_tex = GradientTexture2D.new() _smoke_tex.fill = GradientTexture2D.FILL_RADIAL _smoke_tex.fill_from = Vector2(0.5, 0.5) _smoke_tex.fill_to = Vector2(1.0, 1.0) var g = Gradient.new() g.add_point(0.0, Color(1, 1, 1, 1)) g.add_point(0.5, Color(1, 1, 1, 0.5)) g.add_point(1.0, Color(1, 1, 1, 0)) _smoke_tex.gradient = g _smoke_tex.width = 64 _smoke_tex.height = 64 return _smoke_tex func _ready() -> void: super._ready() _setup_effects() func destroy() -> void: if is_instance_valid(smoke_sys): smoke_sys.emitting = false var p = smoke_sys.get_parent() if p: var trans = smoke_sys.global_transform p.remove_child(smoke_sys) get_tree().current_scene.add_child(smoke_sys) smoke_sys.global_transform = trans var timer = smoke_sys.get_tree().create_timer(smoke_sys.lifetime) timer.timeout.connect(smoke_sys.queue_free) if is_instance_valid(jet_sys): jet_sys.emitting = false var p = jet_sys.get_parent() if p: var trans = jet_sys.global_transform p.remove_child(jet_sys) get_tree().current_scene.add_child(jet_sys) jet_sys.global_transform = trans var timer = jet_sys.get_tree().create_timer(jet_sys.lifetime) timer.timeout.connect(jet_sys.queue_free) super.destroy() func _setup_effects() -> void: # Audio flight_sound = AudioStreamPlayer3D.new() AudioManager.configure_player(flight_sound, "rocket_flight") flight_sound.autoplay = true # Localized: audible nearby, falls off with distance instead of droning # in every player's ear regardless of where the projectile is. flight_sound.unit_size = 6.0 flight_sound.max_db = 0.0 flight_sound.max_distance = 45.0 flight_sound.add_to_group("acoustic_occluded") # muffles behind walls add_child(flight_sound) flight_sound.play() # Jet Fire Particles jet_sys = GPUParticles3D.new() var jet_mat = ParticleProcessMaterial.new() jet_mat.direction = Vector3(0, 0, 1) # Backwards (since model looks at -Z) jet_mat.spread = 10.0 jet_mat.initial_velocity_min = 1.0 jet_mat.initial_velocity_max = 2.0 jet_mat.gravity = Vector3.ZERO var jet_mesh = BoxMesh.new() jet_mesh.size = Vector3(0.05, 0.05, 0.05) var mat = StandardMaterial3D.new() mat.albedo_color = Color(1.0, 0.6, 0.0) mat.emission_enabled = true mat.emission = Color(1.0, 0.5, 0.0) jet_mesh.material = mat jet_sys.draw_pass_1 = jet_mesh jet_sys.process_material = jet_mat jet_sys.amount = 16 jet_sys.lifetime = 0.2 jet_sys.position = Vector3(0, 0, 0.1) add_child(jet_sys) # Smoke Trail smoke_sys = GPUParticles3D.new() var smoke_mat = ParticleProcessMaterial.new() smoke_mat.direction = Vector3(0, 0, 1) smoke_mat.spread = 5.0 smoke_mat.initial_velocity_min = 0.0 smoke_mat.initial_velocity_max = 0.5 smoke_mat.gravity = Vector3(0, 0.5, 0) var grad = Gradient.new() grad.add_point(0.0, Color(0.5, 0.5, 0.5, 0.8)) grad.add_point(1.0, Color(0.8, 0.8, 0.8, 0.0)) var grad_tex = GradientTexture1D.new() grad_tex.gradient = grad smoke_mat.color_ramp = grad_tex var curve = Curve.new() curve.add_point(Vector2(0.0, 1.0)) curve.add_point(Vector2(1.0, 3.0)) var curve_tex = CurveTexture.new() curve_tex.curve = curve smoke_mat.scale_curve = curve_tex var smoke_mesh = QuadMesh.new() smoke_mesh.size = Vector2(0.2, 0.2) var s_mat = StandardMaterial3D.new() s_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA s_mat.vertex_color_use_as_albedo = true s_mat.albedo_texture = get_smoke_texture() s_mat.billboard_mode = BaseMaterial3D.BILLBOARD_PARTICLES s_mat.billboard_keep_scale = true smoke_mesh.material = s_mat smoke_sys.draw_pass_1 = smoke_mesh smoke_sys.process_material = smoke_mat smoke_sys.amount = 32 smoke_sys.lifetime = 1.5 smoke_sys.position = Vector3(0, 0, 0.15) add_child(smoke_sys) func _on_hit(result: Dictionary) -> void: if not result.collider.has_method("take_damage"): if result.collider is StaticBody3D or result.collider is CSGShape3D: ImpactSpawner.spawn(get_tree(), "crater", result.position, result.normal, explosion_radius * 1.5) elif owner_player and owner_player.multiplayer.is_server(): result.collider.take_damage(damage, result.position, owner_player) _explode(result.position) destroy() func _explode(pos: Vector3) -> void: var space_state = get_world_3d().direct_space_state # We use a sphere shape query to find all physics bodies in radius var sphere = SphereShape3D.new() sphere.radius = explosion_radius var params = PhysicsShapeQueryParameters3D.new() params.shape = sphere params.transform = Transform3D(Basis(), pos) # Find bodies and areas params.collide_with_bodies = true var results = space_state.intersect_shape(params) # We want to only damage each object once (intersect_shape can return multiple shapes of the same object) var processed_rids = {} for res in results: var col = res.collider if not col or processed_rids.has(res.rid): continue processed_rids[res.rid] = true # Calculate distance var target_pos = col.global_position # If it's a character, maybe aim at their center (offset y by 1.0) if col is CharacterBody3D or col.has_method("take_damage"): if col.has_node("CollisionShape3D"): target_pos = col.get_node("CollisionShape3D").global_position var dist = pos.distance_to(target_pos) if dist > explosion_radius: continue # Normalised distance (0 = epicenter, 1 = edge) var ndist = dist / explosion_radius var falloff = clampf(pow(ndist, falloff_curve), 0.0, 1.0) var damage_intensity = lerpf(1.0, 0.33, falloff) var knockback_intensity = 1.0 - falloff var final_damage = damage * damage_intensity var final_knockback = explosion_knockback * knockback_intensity # Knockback direction: from explosion pos to target var dir = (target_pos - pos).normalized() # Add a tiny bit of upward bias to knockback so it lifts them dir = (dir + Vector3(0, 0.5, 0)).normalized() # Apply knockback if it has server_apply_impulse (for rocket jumping) if col.has_method("server_apply_impulse") and col == owner_player: if multiplayer.has_multiplayer_peer() and not(multiplayer.multiplayer_peer is OfflineMultiplayerPeer): col.server_apply_impulse.rpc_id(1, dir * final_knockback) else: col.apply_impulse(dir * final_knockback) # Apply damage (and knockback for other players via take_damage) if col.has_method("take_damage"): if col == owner_player and not can_self_damage: # Skip self damage (we already applied knockback above) pass elif owner_player: col.take_damage(final_damage, target_pos, owner_player, dir * final_knockback) # Broadcast Visual Explosion Effect to other peers if owner_player and owner_player.is_multiplayer_authority() and owner_player.has_method("server_play_explosion") and owner_player.multiplayer.has_multiplayer_peer() and not(owner_player.multiplayer.multiplayer_peer is OfflineMultiplayerPeer): owner_player.server_play_explosion.rpc_id(1, pos, explosion_radius) # Local visual + audio: shared cel-shaded burst (also used by remote replay) ExplosionVFX.spawn(get_tree(), pos, explosion_radius, explosion_pitch)