class_name ExplosionVFXBase extends Node2D # Shared particle motion/fade/lifetime logic for all explosion VFX types. # Subclasses fix their own visual style and color as facts about # themselves (see LightBurstVFX/AsteroidExplosionVFX), not as parameters # passed in by whatever destroyed the object - so "ships always explode # blue" is guaranteed by the class hierarchy, not by remembering to pass # the right Color() at each call site. @export var particle_count: int = 12 @export var lifetime: float = 0.5 @export var min_speed: float = 60.0 @export var max_speed: float = 220.0 # An expanding ring drawn behind the particles. Off by default - a rock # breaking up does not flash - and switched on by whichever explosions want # the extra weight (#87). @export var shockwave_radius: float = 0.0 @export var shockwave_color: Color = Color.WHITE var age: float = 0.0 var particles: Array = [] func _ready() -> void: randomize() for i in range(particle_count): var angle: float = randf() * TAU var speed: float = randf_range(min_speed, max_speed) var p: Dictionary = { "pos": Vector2.ZERO, "vel": Vector2(cos(angle), sin(angle)) * speed, "rot": randf() * TAU, "rot_speed": randf_range(-6.0, 6.0), "size": _random_particle_size(), } # Each particle is its own child CanvasItem, drawn once at its own # fixed local shape (subclass decides the shape via # _configure_particle). From here on the "animation" is just this # child's position/rotation, exactly like Ship.gd/Asteroid.gd - no # redraw, no re-tessellating four polylines per particle per frame. var visual: ExplosionParticle = ExplosionParticle.new() _configure_particle(visual, p) if _particle_spins(): visual.rotation = p["rot"] add_child(visual) p["visual"] = visual particles.append(p) func _process(delta: float) -> void: age += delta if age >= lifetime: queue_free() return # CanvasItem modulate is inherited down the tree, so setting it once # here fades every particle's already-drawn shape for free - no need # to recompute or re-draw a single one of them. modulate.a = clamp(1.0 - (age / lifetime), 0.0, 1.0) if shockwave_radius > 0.0: queue_redraw() for p in particles: p["pos"] += p["vel"] * delta p["vel"] *= 0.9 p["rot"] += p["rot_speed"] * delta var visual: ExplosionParticle = p["visual"] visual.position = p["pos"] if _particle_spins(): visual.rotation = p["rot"] func _random_particle_size() -> float: return randf_range(10.0, 20.0) # Called once per particle at spawn time. Subclasses set visual.draw_callable # to a closure that draws their own fixed local shape (a streak, a rock # chunk, ...) - it must not depend on `p` beyond values fixed at spawn (size, # initial velocity direction), since it never runs again. func _configure_particle(_visual: ExplosionParticle, _p: Dictionary) -> void: pass # True for particle styles whose shape itself spins independently of its # direction of travel (tumbling debris). Energy streaks are drawn already # aligned to their (fixed) velocity direction, so giving them this unrelated # per-particle spin as well would visibly rotate them off their travel line. func _particle_spins() -> bool: return false func _draw() -> void: if shockwave_radius <= 0.0: return # Expands fast and thins as it goes, so the eye reads one sharp event # rather than a slow bloom. Drawn on the VFX node itself, so it sits # under every particle without needing its own child. var t: float = clampf(age / lifetime, 0.0, 1.0) var eased: float = 1.0 - pow(1.0 - t, 3.0) var ring: PackedVector2Array = PackedVector2Array() for i in range(24): var angle: float = TAU * float(i) / 24.0 ring.append(Vector2(cos(angle), sin(angle)) * shockwave_radius * eased) NeonDraw.draw_glowing_polygon(self, ring, shockwave_color, (1.0 - t) * 0.9, shockwave_color.darkened(0.7), maxf(0.2, 1.0 - t))