extends Node2D signal finished const CHARGE_TIME := 2.3 const DURATION := CHARGE_TIME + 3.0 const EXPLOSION_AUDIO_PATH := "res://assets/sounds/effects/explosion.mp3" const FIRE_SHADER := preload("res://resources/effects/ship_fireball.gdshader") static var _smoke_texture: Texture2D var _elapsed := 0.0 var _texture: Texture2D var _ship_scale := Vector2.ONE var _drift := Vector2.ZERO var _fragments: Array[Dictionary] = [] var _sparks: Array[Dictionary] = [] var _smoke: Array[Dictionary] = [] var _camera: Camera2D var _camera_offset := Vector2.ZERO var _detonated := false var _smoke_timer := 0.0 var _done := false var _rng := RandomNumberGenerator.new() var _fireball: MeshInstance2D var _fire_material: ShaderMaterial var _charge_audio: AudioStreamPlayer2D var _burns: Array[Dictionary] = [] var _ignition_timer := 0.0 func configure(sprite: Sprite2D, drift: Vector2, camera: Camera2D) -> void: _texture = sprite.texture global_position = sprite.global_position global_rotation = sprite.global_rotation _ship_scale = sprite.global_scale # The whole effect inherits world velocity; fragment velocities are relative. _drift = drift _camera = camera if is_instance_valid(_camera): _camera_offset = _camera.offset func _ready() -> void: z_index = 45 light_mask = 4 texture_filter = CanvasItem.TEXTURE_FILTER_LINEAR # Animation advances in render frames, so don't interpolate its transform again. physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF _rng.randomize() _build_fragments() _prepare_smoke_texture() _ignite_burn() _ignite_burn() _fireball = MeshInstance2D.new() var quad := QuadMesh.new() quad.size = Vector2(240, 240) _fireball.mesh = quad _fire_material = ShaderMaterial.new() _fire_material.shader = FIRE_SHADER _fire_material.set_shader_parameter("variation", _rng.randf_range(0, 100)) _fireball.material = _fire_material _fireball.light_mask = 4 _fireball.z_index = 1 _fireball.hide() add_child(_fireball) _play_charge_sound() func _build_fragments() -> void: if _texture == null: return var dimensions := _texture.get_size() var image := _texture.get_image() for y in 8: for x in 8: var a := Vector2(x, y) * dimensions / 8.0 var b := Vector2(x + 1, y) * dimensions / 8.0 var c := Vector2(x + 1, y + 1) * dimensions / 8.0 var d := Vector2(x, y + 1) * dimensions / 8.0 for triangle in [[a, b, c], [a, c, d]]: var center: Vector2 = (triangle[0] + triangle[1] + triangle[2]) / 3.0 if image != null and image.get_pixel(clampi(int(center.x / dimensions.x * image.get_width()), 0, image.get_width() - 1), clampi(int(center.y / dimensions.y * image.get_height()), 0, image.get_height() - 1)).a < 0.1: continue var origin := (center - dimensions * 0.5) * _ship_scale var points := PackedVector2Array() var uv := PackedVector2Array() for vertex in triangle: points.append((vertex - center) * _ship_scale) uv.append(vertex / dimensions) var wreck := origin.length() < 12.0 var radial := origin.normalized() if origin.length_squared() > 0.1 else Vector2.RIGHT.rotated(_rng.randf_range(0, TAU)) _fragments.append({"points": points, "uv": uv, "position": origin, "velocity": radial.rotated(_rng.randf_range(-0.45, 0.45)) * _rng.randf_range(65, 155) if not wreck else radial * 5.0, "angle": 0.0, "spin": _rng.randf_range(-5, 5) if not wreck else _rng.randf_range(-0.3, 0.3), "wreck": wreck}) func _process(delta: float) -> void: global_position += _drift * delta if _done and _elapsed >= DURATION + 1.8: if not _smoke.is_empty() or not _sparks.is_empty(): _smoke.clear() _sparks.clear() queue_redraw() return _elapsed += delta if not _detonated and _elapsed < CHARGE_TIME: _ignition_timer -= delta if _ignition_timer <= 0.0: _ignite_burn() var progress := _elapsed / CHARGE_TIME _ignition_timer = _rng.randf_range(0.1, 0.22) * lerpf(1.0, 0.55, progress) for index in range(_burns.size() - 1, -1, -1): var burn := _burns[index] burn["age"] += delta var age := clampf(float(burn["age"]) / float(burn["life"]), 0, 1) var intensity := sin(age * PI) var wobble := sin(_elapsed * 23 + float(burn["phase"])) var flame: MeshInstance2D = burn["visual"] flame.position = burn["position"] + Vector2.UP.rotated(float(burn["angle"])) * 4 * intensity flame.rotation = float(burn["angle"]) + wobble * 0.3 flame.scale = Vector2(0.8 + intensity * 0.3, 0.7 + intensity * 0.4 + wobble * 0.1) var material: ShaderMaterial = burn["material"] material.set_shader_parameter("phase", 0.12 + age * 0.65) material.set_shader_parameter("strength", intensity * (1.4 + wobble * 0.3)) if age >= 1.0: flame.queue_free() _burns.remove_at(index) if _elapsed >= CHARGE_TIME and not _detonated: _detonated = true for burn in _burns: burn["visual"].queue_free() _burns.clear() _play_explosion_sound() for index in 16: var outward := Vector2.RIGHT.rotated(_rng.randf_range(0, TAU)) _smoke.append({"position": outward * _rng.randf_range(4, 18), "velocity": outward * _rng.randf_range(25, 65), "age": -0.08, "life": _rng.randf_range(1.8, 2.8), "radius": _rng.randf_range(15, 28)}) for index in 75: var direction := Vector2.RIGHT.rotated(_rng.randf_range(0, TAU)) _sparks.append({"position": direction * _rng.randf_range(3, 24), "velocity": direction * _rng.randf_range(80, 330), "life": _rng.randf_range(0.45, 1.8), "age": 0.0}) if _detonated: for fragment in _fragments: fragment["position"] += fragment["velocity"] * delta fragment["velocity"] *= exp(-0.7 * delta) fragment["angle"] += float(fragment["spin"]) * delta _smoke_timer += delta var smoke_interval := 0.065 if _detonated else 0.12 while _smoke_timer >= smoke_interval and _elapsed < CHARGE_TIME + 2.5: _smoke_timer -= smoke_interval _emit_smoke() for index in range(_sparks.size() - 1, -1, -1): var spark := _sparks[index] spark["position"] += spark["velocity"] * delta spark["velocity"] *= exp(-1.3 * delta) spark["age"] += delta if float(spark["age"]) >= float(spark["life"]): _sparks.remove_at(index) for index in range(_smoke.size() - 1, -1, -1): var puff := _smoke[index] puff["position"] += puff["velocity"] * delta puff["age"] += delta if float(puff["age"]) >= float(puff["life"]): _smoke.remove_at(index) if not _done: _update_camera() var blast_age := maxf(0, _elapsed - CHARGE_TIME) _fireball.visible = _detonated and blast_age < 1.3 _fire_material.set_shader_parameter("phase", blast_age) queue_redraw() if _elapsed >= DURATION and not _done: _done = true _restore_camera() finished.emit() func _hull_burn_position() -> Vector2: if not _fragments.is_empty(): return _fragments[_rng.randi_range(0, _fragments.size() - 1)]["position"] return Vector2.ZERO func _ignite_burn() -> void: var origin := _hull_burn_position() var angle := origin.angle() + PI * 0.5 + _rng.randf_range(-0.65, 0.65) var flame := MeshInstance2D.new() var quad := QuadMesh.new() quad.size = Vector2(_rng.randf_range(24, 34), _rng.randf_range(36, 52)) flame.mesh = quad var material := ShaderMaterial.new() material.shader = FIRE_SHADER material.set_shader_parameter("variation", _rng.randf_range(0, 100)) material.set_shader_parameter("strength", 0.0) flame.material = material flame.position = origin flame.rotation = angle flame.z_index = 1 add_child(flame) _burns.append({"position": origin, "angle": angle, "age": 0.0, "life": _rng.randf_range(0.3, 0.7), "phase": _rng.randf_range(0, TAU), "visual": flame, "material": material}) var direction := Vector2.UP.rotated(angle) for index in _rng.randi_range(3, 6): _sparks.append({"position": origin, "velocity": direction.rotated(_rng.randf_range(-0.9, 0.9)) * _rng.randf_range(25, 100), "life": _rng.randf_range(0.18, 0.5), "age": 0.0}) func _emit_smoke() -> void: var position := Vector2.ZERO if not _detonated: position = _burns[_rng.randi_range(0, _burns.size() - 1)]["position"] if not _burns.is_empty() else _hull_burn_position() elif not _fragments.is_empty() and _rng.randf() < 0.7: position = _fragments[_rng.randi_range(0, _fragments.size() - 1)]["position"] _smoke.append({"position": position, "velocity": Vector2(_rng.randf_range(-12, 12), _rng.randf_range(-22, 8)) - _drift.rotated(-global_rotation) * 0.25, "age": 0.0, "life": _rng.randf_range(0.9, 1.7), "radius": _rng.randf_range(5, 12)}) func _update_camera() -> void: if not is_instance_valid(_camera): return var time := _elapsed - CHARGE_TIME var strength := 0.8 if time < 0.0 else 7.0 * exp(-5.0 * time) _camera.global_position = global_position _camera.offset = _camera_offset + Vector2(sin(_elapsed * 83), cos(_elapsed * 67)) * strength func _restore_camera() -> void: if is_instance_valid(_camera): _camera.offset = _camera_offset func _exit_tree() -> void: _restore_camera() func _draw() -> void: if _texture == null: return var time := maxf(0.0, _elapsed - CHARGE_TIME) for puff in _smoke: var progress: float = float(puff["age"]) / float(puff["life"]) var opacity := (1.0 - progress) * minf(1, float(puff["age"]) * 8.0) var radius := float(puff["radius"]) * (1 + maxf(0, progress) * 1.5) var soot := Color("6c3e25").lerp(Color("3c4149"), clampf(progress * 3, 0, 1)) soot.a = maxf(0, opacity) * 0.7 draw_texture_rect(_smoke_texture, Rect2(Vector2(puff["position"]) - Vector2.ONE * radius, Vector2.ONE * radius * 2), false, soot) if not _detonated: var progress := clampf(_elapsed / CHARGE_TIME, 0, 1) var pulse := (sin(_elapsed * 19) + 1.0) * 0.5 var dimensions := _texture.get_size() * _ship_scale draw_texture_rect(_texture, Rect2(-dimensions * 0.5, dimensions), false, Color(1, 0.95 - progress * 0.1 + pulse * 0.05, 0.88 - progress * 0.08 + pulse * 0.1)) _draw_sparks() return for fragment in _fragments: var fade := 1.0 if bool(fragment["wreck"]) else clampf((3.4 - time) / 1.1, 0, 1) var shade := lerpf(1.0, 0.22 if bool(fragment["wreck"]) else 0.55, minf(1, time)) draw_set_transform(fragment["position"], fragment["angle"]) draw_polygon(fragment["points"], PackedColorArray([Color(shade, shade * 0.85, shade * 0.7, fade)]), fragment["uv"], _texture) if time < 1.7 and not bool(fragment["wreck"]): draw_line(Vector2.ZERO, -Vector2(fragment["velocity"]).normalized().rotated(-float(fragment["angle"])) * 9, Color(1, 0.35, 0.06, (1.0 - time / 1.7) * 0.7), 2, true) draw_set_transform(Vector2.ZERO) if time < 0.55: var fade := 1.0 - time / 0.55 for layer in 3: draw_arc(Vector2.ZERO, 18 + time * 230 + layer * 2, 0, TAU, 96, Color(0.85, 0.75, 0.6, fade * 0.08), 2, true) _draw_sparks() func _draw_sparks() -> void: for spark in _sparks: var fade := 1 - float(spark["age"]) / float(spark["life"]) var length := clampf(Vector2(spark["velocity"]).length() * 0.03, 2, 8) draw_line(spark["position"], spark["position"] - Vector2(spark["velocity"]).normalized() * length, Color(1, 0.65 + fade * 0.2, 0.2, fade), 1.2, true) func _prepare_smoke_texture() -> void: if _smoke_texture != null: return var image := Image.create(96, 96, false, Image.FORMAT_RGBA8) var noise := FastNoiseLite.new() noise.seed = 71539 noise.frequency = 0.06 noise.fractal_octaves = 4 for y in 96: for x in 96: var p := (Vector2(x, y) - Vector2(47.5, 47.5)) / 47.5 var radial := 1.0 - smoothstep(0.2, 1.0, p.length()) var density := clampf(noise.get_noise_2d(x, y) * 0.8 + 0.55, 0, 1) image.set_pixel(x, y, Color(1, 1, 1, radial * density)) _smoke_texture = ImageTexture.create_from_image(image) func _play_explosion_sound() -> void: if is_instance_valid(_charge_audio): _charge_audio.stop() _charge_audio.queue_free() var audio := AudioStreamPlayer2D.new() audio.name = "ExplosionAudio" audio.stream = load(EXPLOSION_AUDIO_PATH) as AudioStream if audio.stream == null: audio.free() return audio.volume_db = -6 audio.max_distance = 1800 add_child(audio) audio.finished.connect(audio.queue_free) audio.play() func _play_charge_sound() -> void: var rate := 22050 var frames := int(rate * CHARGE_TIME) var data := PackedByteArray() data.resize(frames * 2) var filtered := 0.0 for index in frames: var t := float(index) / rate filtered = lerpf(filtered, _rng.randf_range(-1, 1), 0.16) var value := sin(TAU * (80 + t * 70 / CHARGE_TIME) * t) * 0.06 * (t / CHARGE_TIME) + filtered * 0.025 data.encode_s16(index * 2, int(clampf(value, -1, 1) * 25000)) var sound := AudioStreamWAV.new() sound.format = AudioStreamWAV.FORMAT_16_BITS sound.mix_rate = rate sound.data = data var audio := AudioStreamPlayer2D.new() _charge_audio = audio audio.stream = sound audio.volume_db = -8 audio.max_distance = 1800 add_child(audio) audio.finished.connect(audio.queue_free) audio.play()