309 lines
12 KiB
GDScript
309 lines
12 KiB
GDScript
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()
|