Randoloot/scripts/game/enemy_destruction_effect.gd
2026-10-31 16:08:17 +01:00

198 lines
7.8 KiB
GDScript

extends Node2D
## Short, layered death effect. All particles are local visuals, without physics bodies.
const FIRE_SHADER := preload("res://resources/effects/ship_fireball.gdshader")
const EXPLOSION_AUDIO := preload("res://assets/sounds/effects/explosion.mp3")
const MAX_EFFECTS := 18
static var _smoke_texture: Texture2D
var _elapsed := 0.0
var _duration := 2.1
var _size := 56.0
var _tier := 0
var _texture: Texture2D
var _tint := Color.WHITE
var _ship_scale := Vector2.ONE
var _angle := 0.0
var _drift := Vector2.ZERO
var _fragments: Array[Dictionary] = []
var _sparks: Array[Dictionary] = []
var _smoke: Array[Dictionary] = []
var _bursts: Array[Dictionary] = []
var _rng := RandomNumberGenerator.new()
var play_audio := true
func configure(sprite: Sprite2D, drift: Vector2, tier: int) -> void:
top_level = true
_texture = sprite.texture
_ship_scale = sprite.global_scale
_angle = sprite.global_rotation
_tint = sprite.modulate
global_position = sprite.global_position
_drift = drift * 0.65
_tier = clampi(tier,0,2)
_size = maxf(_texture.get_width()*_ship_scale.x,_texture.get_height()*_ship_scale.y) if _texture != null else 56.0
_duration = 2.1 + _tier * 0.25
func _ready() -> void:
z_index = 35
light_mask = 4
texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR
physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
add_to_group("enemy_explosions")
var active := get_tree().get_nodes_in_group("enemy_explosions")
if active.size() > MAX_EFFECTS:
for old in active:
if old != self and not old.is_queued_for_deletion():
old.queue_free()
break
_rng.randomize()
_prepare_smoke()
_build_fragments()
_build_particles()
_add_fireball(Vector2.ZERO,_size*3.8,0.0)
for index in 2 + _tier:
var direction := Vector2.from_angle(_rng.randf()*TAU)
_add_fireball(direction*_size*_rng.randf_range(0.2,0.5),_size*_rng.randf_range(1.5,2.3),0.07+index*0.07)
if play_audio: _play_sound()
_update_fireballs()
func _add_fireball(origin: Vector2, diameter: float, delay: float) -> void:
var mesh := MeshInstance2D.new()
var quad := QuadMesh.new()
quad.size = Vector2.ONE * diameter
mesh.mesh = quad
mesh.position = origin
mesh.rotation = _rng.randf()*TAU
mesh.light_mask = 4
mesh.z_index = 1
var fire := ShaderMaterial.new()
fire.shader = FIRE_SHADER
fire.set_shader_parameter("variation",_rng.randf_range(0,100))
mesh.material = fire
add_child(mesh)
_bursts.append({"visual":mesh,"material":fire,"delay":delay})
func _build_fragments() -> void:
if _texture == null: return
var dimensions := _texture.get_size()
# Textured triangles preserve the actual hull, rather than generic square debris.
var grid := 3 if _tier < 2 else 4
for y in grid:
for x in grid:
var a := Vector2(x,y)*dimensions/grid
var b := Vector2(x+1,y)*dimensions/grid
var c := Vector2(x+1,y+1)*dimensions/grid
var d := Vector2(x,y+1)*dimensions/grid
for triangle in [[a,b,c],[a,c,d]]:
var center: Vector2 = (triangle[0]+triangle[1]+triangle[2])/3.0
var origin := ((center-dimensions*0.5)*_ship_scale).rotated(_angle)
var points := PackedVector2Array()
var uv := PackedVector2Array()
for vertex in triangle:
points.append((vertex-center)*_ship_scale)
uv.append(vertex/dimensions)
var direction := origin.normalized().rotated(_rng.randf_range(-0.4,0.4))
_fragments.append({"origin":origin,"velocity":direction*_rng.randf_range(80,175)*(1.0+_tier*0.2),"points":points,"uv":uv,"spin":_rng.randf_range(-5,5)})
func _build_particles() -> void:
for index in 32 + _tier * 14:
var direction := Vector2.from_angle(_rng.randf()*TAU)
_sparks.append({"velocity":direction*_rng.randf_range(100,370)*(1.0+0.18*_tier),"life":_rng.randf_range(0.4,1.35),"width":_rng.randf_range(0.8,2.1),"warm":_rng.randf()})
for index in 10 + _tier * 3:
var direction := Vector2.from_angle(_rng.randf()*TAU)
_smoke.append({"origin":direction*_size*_rng.randf_range(0.1,0.35),"velocity":direction*_rng.randf_range(16,48),"radius":_size*_rng.randf_range(0.22,0.4),"angle":_rng.randf()*TAU,"delay":_rng.randf_range(0.08,0.25)})
static func _prepare_smoke() -> void:
if _smoke_texture != null: return
var noise := FastNoiseLite.new()
noise.seed = 9172
noise.frequency = 0.075
var image := Image.create(64,64,false,Image.FORMAT_RGBA8)
for y in 64:
for x in 64:
var point := (Vector2(x,y)-Vector2(31.5,31.5))/31.5
var edge := 1.0-smoothstep(0.2,1.0,point.length())
var density := clampf(0.65+noise.get_noise_2d(x,y)*0.65,0,1)
image.set_pixel(x,y,Color(1,1,1,edge*density))
_smoke_texture = ImageTexture.create_from_image(image)
func _play_sound() -> void:
if get_tree().get_nodes_in_group("enemy_explosion_audio").size() >= 8: return
# Separate audio lifetime lets the tail finish after the visual is freed.
var audio := AudioStreamPlayer2D.new()
audio.stream = EXPLOSION_AUDIO
audio.volume_db = -15.0 + _tier*2.5
audio.pitch_scale = _rng.randf_range(0.95,1.12) - _tier*0.13
audio.max_distance = 1500
audio.max_polyphony = 1
audio.add_to_group("enemy_explosion_audio")
get_parent().add_child(audio)
audio.global_position = global_position
audio.finished.connect(audio.queue_free)
audio.play()
func _process(delta: float) -> void:
_elapsed += delta
global_position += _drift * delta
_drift *= exp(-1.4*delta)
if _elapsed >= _duration:
queue_free()
return
_update_fireballs()
queue_redraw()
func _update_fireballs() -> void:
for burst in _bursts:
var age: float = _elapsed - float(burst.delay)
var visual: MeshInstance2D = burst.visual
visual.visible = age >= 0 and age < 1.3
if visual.visible:
var fire: ShaderMaterial = burst.material
fire.set_shader_parameter("phase",age)
fire.set_shader_parameter("strength",1.25 if _tier == 2 else 1.0)
func _draw() -> void:
var time := _elapsed
# A soft, irregular smoke cloud stays behind as the fire cools.
for puff in _smoke:
var age := maxf(0,time-float(puff.delay))
var alpha := smoothstep(0.0,0.3,age)*(1.0-smoothstep(0.6,_duration,age))*0.45
var center: Vector2 = puff.origin + puff.velocity * (1.0-exp(-age*1.5))/1.5
var radius: float = puff.radius*(0.6+age*0.85)
draw_set_transform(center,float(puff.angle)+age*0.15)
draw_texture_rect(_smoke_texture,Rect2(-Vector2.ONE*radius,Vector2.ONE*radius*2),false,Color(0.12,0.14,0.18,alpha))
draw_set_transform(Vector2.ZERO)
# Thin blue ion shock followed by a wider amber pressure front.
for ring in 2:
var age := time-ring*0.055
if age < 0 or age > 0.65: continue
var progress := age/0.65
var radius := _size*(0.16+1.75*(1.0-pow(1.0-progress,3)))
var fade := pow(1.0-progress,2)
var color := Color("87dfff") if ring == 0 else Color("ff9d49")
draw_arc(Vector2.ZERO,radius,0,TAU,80,Color(color,fade*0.1),9.0*(1.0-progress)+1,true)
draw_arc(Vector2.ZERO,radius,0,TAU,80,Color(color,fade*0.75),1.2+fade,true)
var debris_fade := 1.0-smoothstep(0.7,_duration,time)
var travel := (1.0-exp(-time*1.4))/1.4
for piece in _fragments:
var center: Vector2 = piece.origin + piece.velocity*travel
draw_set_transform(center,_angle+float(piece.spin)*time)
var hot := exp(-time*4.0)
var color := _tint.lerp(Color("ffd28b"),hot).darkened((1.0-hot)*0.55)
color.a = debris_fade
draw_polygon(piece.points,PackedColorArray([color]),piece.uv,_texture)
draw_set_transform(Vector2.ZERO)
for spark in _sparks:
if time > float(spark.life): continue
var fade := pow(1.0-time/float(spark.life),1.5)
var velocity: Vector2 = spark.velocity
var point := velocity*travel
var tail := point-velocity.normalized()*(3.0+fade*16.0)
var color := Color("ffc35c").lerp(Color("fff2c4"),float(spark.warm))
draw_line(tail,point,Color(color,fade*0.14),float(spark.width)*4,true)
draw_line(tail,point,Color(color,fade),float(spark.width),true)
var flash := exp(-time*30.0)
if flash > 0.01:
for layer in range(5,0,-1):
draw_circle(Vector2.ZERO,_size*0.45*layer/5.0,Color(1,0.83,0.55,flash*0.12))
draw_line(Vector2(-_size*1.2,0),Vector2(_size*1.2,0),Color(1,0.92,0.75,flash*0.75),1.6,true)