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)