feat(particles): bubble trail behind dolphin + block break burst

- BubbleTrail.gd: GPUParticles3D, auto-configured in _ready, set_intensity() API
- BlockBreakParticles.gd: one_shot burst, emit_burst(pos, color) API
- DolphinController.gd: bubble_trail onready + speed_factor hook in _update_movement
- Dolphin.tscn: BubbleEmitterPoint (0,0,1.2) > BubbleTrail child added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Floppyrj45
2026-04-19 18:09:21 +02:00
parent cafdb7d27e
commit a8341355e3
12 changed files with 318 additions and 58 deletions

View File

@@ -0,0 +1,47 @@
extends Node3D
# Creates horizontal caustic planes at multiple depths, follows the player
# Add as child of any node that follows the player (e.g. PlanktonFollower)
const LAYER_DEPTHS := [-5.0, -15.0, -25.0]
const LAYER_SIZE := 60.0
var caustic_meshes: Array[MeshInstance3D] = []
var caustic_material: ShaderMaterial
func _ready() -> void:
var shader := load("res://shaders/caustics.gdshader")
if not shader:
push_warning("CausticsLayer: caustics.gdshader not found")
return
caustic_material = ShaderMaterial.new()
caustic_material.shader = shader
for depth in LAYER_DEPTHS:
var mi := MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = Vector2(LAYER_SIZE, LAYER_SIZE)
mi.mesh = plane
# Each layer gets its own material instance to allow per-depth tuning
var mat := caustic_material.duplicate() as ShaderMaterial
# Lower layers = less intense
var fade := clamp(1.0 + depth / 30.0, 0.0, 1.0)
mat.set_shader_parameter("intensity", 1.5 * fade)
mi.material_override = mat
mi.position = Vector3(0.0, depth, 0.0)
add_child(mi)
caustic_meshes.append(mi)
func _process(_delta: float) -> void:
# Follow camera XZ, keep fixed Y depths
var cam := get_viewport().get_camera_3d()
if cam:
var cx := cam.global_position.x
var cz := cam.global_position.z
for i in caustic_meshes.size():
var mi := caustic_meshes[i]
mi.global_position.x = cx
mi.global_position.z = cz
# Keep the fixed depth offset
mi.global_position.y = LAYER_DEPTHS[i]

View File

@@ -0,0 +1,31 @@
extends MeshInstance3D
var godrays_material: ShaderMaterial
func _ready() -> void:
var quad := QuadMesh.new()
quad.size = Vector2(20.0, 40.0)
mesh = quad
godrays_material = ShaderMaterial.new()
var shader := load("res://shaders/godrays.gdshader")
if shader:
godrays_material.shader = shader
var mat_override := godrays_material
material_override = mat_override
# Position in front of camera, slightly above center
position = Vector3(0.0, 5.0, -10.0)
func _process(_delta: float) -> void:
if not godrays_material:
return
# Get player/camera world Y to drive fade_bottom
var cam := get_viewport().get_camera_3d()
if cam:
var world_y := cam.global_position.y
# Deeper = fade bottom rises, reducing godray reach
var dynamic_fade := clamp(world_y - 20.0, -100.0, 0.0)
godrays_material.set_shader_parameter("fade_bottom", dynamic_fade)

View File

@@ -16,13 +16,24 @@ func _ready() -> void:
env.fog_aerial_perspective = 0.3
env.volumetric_fog_enabled = true
env.volumetric_fog_density = 0.04
env.volumetric_fog_density = 0.05
env.volumetric_fog_albedo = Color(0.2, 0.5, 0.7)
env.volumetric_fog_emission = Color(0.02, 0.06, 0.1)
env.volumetric_fog_emission_energy = 0.1
env.volumetric_fog_length = 64.0
env.volumetric_fog_detail_spread = 2.0
# Attach custom fog shader
var fog_mat := FogMaterial.new()
var fog_shader := load("res://shaders/underwater_fog.gdshader")
if fog_shader:
fog_mat.set_shader(fog_shader)
var fog_volume := FogVolume.new()
fog_volume.size = Vector3(2000.0, 300.0, 2000.0)
fog_volume.material = fog_mat
add_child(fog_volume)
env.glow_enabled = true
env.glow_intensity = 0.5
env.glow_bloom = 0.1

View File

@@ -0,0 +1,38 @@
extends GPUParticles3D
func _ready() -> void:
amount = 30
lifetime = 0.8
one_shot = true
emitting = false
local_coords = false
var mat := ParticleProcessMaterial.new()
mat.direction = Vector3(0.0, 1.0, 0.0)
mat.initial_velocity_min = 1.0
mat.initial_velocity_max = 3.0
mat.spread = 60.0
mat.gravity = Vector3(0.0, -2.0, 0.0)
mat.scale_min = 0.05
mat.scale_max = 0.15
process_material = mat
var box := BoxMesh.new()
box.size = Vector3(0.08, 0.08, 0.08)
var burst_mat := StandardMaterial3D.new()
burst_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
burst_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
burst_mat.albedo_color = Color(0.8, 0.7, 0.5, 0.9)
box.surface_set_material(0, burst_mat)
draw_pass_1 = box
func emit_burst(pos: Vector3, color: Color) -> void:
global_position = pos
if draw_pass_1 and draw_pass_1.surface_get_material(0):
var mat := draw_pass_1.surface_get_material(0) as StandardMaterial3D
if mat:
mat.albedo_color = Color(color.r, color.g, color.b, 0.9)
emitting = true

View File

@@ -0,0 +1,52 @@
extends GPUParticles3D
func _ready() -> void:
amount = 80
lifetime = 1.5
emitting = false
local_coords = false
var mat := ParticleProcessMaterial.new()
mat.direction = Vector3(0.0, 0.3, 0.0)
mat.initial_velocity_min = 0.5
mat.initial_velocity_max = 1.5
mat.spread = 15.0
mat.gravity = Vector3(0.0, 0.5, 0.0)
mat.scale_min = 0.03
mat.scale_max = 0.12
var scale_curve := Curve.new()
scale_curve.add_point(Vector2(0.0, 0.2))
scale_curve.add_point(Vector2(0.5, 1.0))
scale_curve.add_point(Vector2(1.0, 0.0))
var scale_curve_tex := CurveTexture.new()
scale_curve_tex.curve = scale_curve
mat.scale_curve = scale_curve_tex
mat.color = Color(1.0, 1.0, 1.0, 0.6)
process_material = mat
var sphere := SphereMesh.new()
sphere.radius = 0.05
sphere.height = 0.1
sphere.rings = 4
sphere.radial_segments = 6
var bubble_mat := StandardMaterial3D.new()
bubble_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
bubble_mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
bubble_mat.albedo_color = Color(1.0, 1.0, 1.0, 0.55)
bubble_mat.emission_enabled = true
bubble_mat.emission = Color(0.8, 0.9, 1.0, 1.0)
bubble_mat.emission_energy_multiplier = 0.3
sphere.surface_set_material(0, bubble_mat)
draw_pass_1 = sphere
func set_intensity(speed_factor: float) -> void:
if speed_factor > 0.5:
emitting = true
amount_ratio = clamp(speed_factor / 1.5, 0.3, 1.0)
else:
emitting = false

View File

@@ -36,6 +36,9 @@ var _is_dead: bool = false
@onready var _camera: Camera3D = $CameraPivot/SpringArm/Camera
@onready var _dolphin_body: Node3D = $DolphinBody
@onready var _echo_pulse: Node3D = $EcholocationPulse
@onready var bubble_trail: Node = $BubbleEmitterPoint/BubbleTrail
var _turn_input: float = 0.0
func _ready() -> void:
@@ -120,10 +123,7 @@ func _update_movement(delta: float) -> void:
is_boosting = Input.is_action_pressed("boost")
var speed: float = swim_speed * (boost_multiplier if is_boosting else 1.0)
var forward: Vector3 = -global_transform.basis.z
var right: Vector3 = global_transform.basis.x
# Recompute forward/right using camera pitch for intuitive swim direction
# forward/right using camera pitch for intuitive swim direction
var cam_basis: Basis = _camera_pivot.global_transform.basis
var swim_forward: Vector3 = -cam_basis.z
var swim_right: Vector3 = cam_basis.x
@@ -150,6 +150,13 @@ func _update_movement(delta: float) -> void:
move_and_slide()
var current_speed: float = velocity.length()
var speed_factor: float = clamp(current_speed / swim_speed, 0.0, 1.5)
if is_boosting:
speed_factor *= 1.8
if bubble_trail:
bubble_trail.set_intensity(speed_factor)
func _update_stats(delta: float) -> void:
var changed: bool = false
@@ -204,14 +211,26 @@ func _respawn() -> void:
func _animate_body() -> void:
if not is_instance_valid(_dolphin_body):
return
# Body bob
_dolphin_body.position.y = sin(_time * 2.5) * 0.05
# Tail wag via last child if present
var child_count: int = _dolphin_body.get_child_count()
if child_count > 1:
var tail_node: Node3D = _dolphin_body.get_child(child_count - 1) as Node3D
if tail_node != null:
tail_node.rotation.y = sin(_time * 3.0) * 0.3
# Idle body bob
_dolphin_body.position.y = sin(_time * 2.5) * 0.04
# Speed factor for animation intensity
var spd: float = velocity.length()
var speed_factor: float = clamp(spd / swim_speed, 0.0, 1.5)
# Track turn input for body lean
var strafe: float = 0.0
if Input.is_action_pressed("strafe_right"):
strafe += 1.0
if Input.is_action_pressed("strafe_left"):
strafe -= 1.0
_turn_input = lerp(_turn_input, strafe, 0.1)
# Drive procedural mesh builder if present
var builder = _dolphin_body.get_node_or_null("DolphinMesh")
if builder != null and builder.has_method("animate"):
builder.animate(_time, speed_factor, is_boosting, _turn_input)
func _do_raycast_break() -> void: