class_name ArtilleryShell extends Node2D # Self-contained travel -> explode sequence for the artillery enemy's attack # (see EnemyArtilleryComponent). Lives independently of the # enemy that fired it once launched - added to the tree root, exactly like # EnemyProjectile/Projectile - so the shooter dying mid-flight doesn't # cancel a shell already in the air. # # Shield interaction: a shield charge absorbs this hit entirely, same # all-or-nothing semantics as every other damage source in this game # (VesselShieldComponent.absorb_hit) - _explode() just calls # Ship.take_damage() like everything else and lets that logic decide. signal finished # The tower fires as soon as it decides to; the warning is the shell you can # SEE coming and the cross marking where it will land. There used to be a # 1.8s charge-up before anything left the barrel, which arrived long before # the threat was legible and mostly read as the tower being stuck. @export var shell_speed: float = 460.0 @export var aoe_radius: float = 220.0 @export var damage: int = 3 @export var color: Color = Color(1.0, 0.5, 0.2) enum Phase { TRAVELING, EXPLODING } const EXPLOSION_DURATION: float = 0.8 # The impact cross. Small on purpose: it says WHERE, and the shell in flight # already says that something is coming. A full blast-radius ring here read # as an attack that had already happened. const IMPACT_CROSS_RADIUS: float = 20.0 # #114: the blast throws real debris now. The ring alone read as a decal # being stamped on the map; a burst gives the moment some weight, and it is # the same LightBurstVFX every other explosion in the game is built from. const BLAST_VFX = preload("res://vfx/BombBlastVFX.gd") # Fins on the falling bomb, so it reads as an object with a direction rather # than a travelling dot. const FIN_LENGTH: float = 13.0 var phase: int = Phase.TRAVELING var target: Vector2 = Vector2.ZERO var shell_pos: Vector2 = Vector2.ZERO var explosion_time: float = 0.0 var trail: PackedVector2Array = PackedVector2Array() func _ready() -> void: add_to_group("artillery_shells") func launch(from: Vector2, at: Vector2) -> void: target = at shell_pos = from phase = Phase.TRAVELING trail = PackedVector2Array([shell_pos]) position = shell_pos queue_redraw() func _process(delta: float) -> void: match phase: Phase.TRAVELING: var to_target: Vector2 = target - shell_pos var step: float = shell_speed * delta if to_target.length() <= step: shell_pos = target _explode() else: shell_pos += to_target.normalized() * step trail.append(shell_pos) while trail.size() > 14: trail.remove_at(0) position = shell_pos queue_redraw() Phase.EXPLODING: explosion_time -= delta queue_redraw() if explosion_time <= 0.0: finished.emit() queue_free() func _explode() -> void: phase = Phase.EXPLODING explosion_time = EXPLOSION_DURATION position = target BLAST_VFX.spawn(get_tree().root, target, color) for ship in get_tree().get_nodes_in_group("player_ships"): if is_instance_valid(ship) and ship.global_position.distance_to(target) <= aoe_radius: ship.take_damage(damage) # #101: a bomb does not care whether what it landed on flies. The hitbox # is a child Area2D of the Building, which is a plain Node2D and would # otherwise be unhittable. for hitbox in get_tree().get_nodes_in_group("player_buildings"): if is_instance_valid(hitbox) and hitbox.global_position.distance_to(target) <= aoe_radius: hitbox.take_damage(damage) func _draw() -> void: match phase: Phase.TRAVELING: # Where it will land, drawn on the ground. Local coordinates, # because this node rides along with the shell while it flies. _draw_impact_cross(target - shell_pos) for i in range(1, trail.size()): var alpha: float = float(i) / trail.size() draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(color, alpha * 0.6), 5.0, true) # A hot core down the middle of the smoke, brightest at the # bomb itself. draw_line(trail[i - 1] - shell_pos, trail[i] - shell_pos, Color(1.0, 0.94, 0.86, alpha * 0.5), 1.6, true) draw_circle(Vector2.ZERO, 14.0, Color(color, 0.35)) # Fins, laid across the direction of travel. var heading: Vector2 = (target - shell_pos) heading = heading.normalized() if heading.length() > 0.001 else Vector2.DOWN var across: Vector2 = Vector2(-heading.y, heading.x) * FIN_LENGTH * 0.55 var tail: Vector2 = -heading * FIN_LENGTH NeonDraw.draw_glowing_line(self, tail - across, tail + across, color, 0.9, 0.4) NeonDraw.draw_glowing_line(self, tail, heading * 6.0, color, 0.9, 0.45) NeonDraw.draw_glowing_circle(self, Vector2.ZERO, 8.0, color, 1.0) Phase.EXPLODING: var eprogress: float = 1.0 - explosion_time / EXPLOSION_DURATION var eradius: float = lerpf(30.0, aoe_radius, minf(eprogress * 1.5, 1.0)) draw_circle(Vector2.ZERO, eradius, Color(color, (1.0 - eprogress) * 0.18)) _draw_ring(eradius, 1.0 - eprogress) # A second ring chasing the first outwards, and a white flash # that is gone almost at once - the two together are what make a # blast read as a shock rather than a circle growing. if eprogress < 0.55: _draw_ring(eradius * 0.62, (0.55 - eprogress) * 1.6) if eprogress < 0.18: draw_circle(Vector2.ZERO, eradius * 0.7, Color(1.0, 0.95, 0.88, (0.18 - eprogress) * 3.0)) # A small cross where the shell will land, plus a dot at the exact point. # Deliberately not a filled circle the size of the blast: the player needs to # read "move off this spot" at a glance, and a large translucent area was # easy to mistake for the explosion itself. func _draw_impact_cross(at: Vector2) -> void: var r: float = IMPACT_CROSS_RADIUS for offset in [Vector2(r, r), Vector2(r, -r)]: draw_line(at - offset, at + offset, Color(color, 0.35), 7.0, true) draw_line(at - offset, at + offset, Color(1.0, 0.92, 0.86, 0.85), 2.5, true) draw_arc(at, r * 0.32, 0.0, TAU, 20, Color(color, 0.9), 2.5, true) func _draw_ring(radius: float, alpha: float) -> void: draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.16 * alpha), 16.0, true) draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(color, 0.5 * alpha), 6.0, true) draw_arc(Vector2.ZERO, radius, 0.0, TAU, 64, Color(1.0, 0.92, 0.86, 0.9 * alpha), 2.0, true)