It was a siege piece that crept at 20px/s and lobbed a shell taking five seconds to cross the gap. That read as a thing that was stuck rather than a thing that was dangerous: by the time a shell landed the fleet had wandered off, and the tower was still approaching. It now moves at 150px/s, faces where it is going, and its bombs travel at 460 - fast enough to arrive while the target is roughly where it was aimed, slow enough that the impact cross is a warning rather than a formality. The hull is a bomber rather than a lumpy octagon: swept wings, a short fuselage, a bay under it. The bomb has fins and a hot core down its smoke trail, and the blast throws real debris - a BombBlastVFX built from the same LightBurstVFX as every other explosion, plus a second ring chasing the first outwards and a white flash that is gone almost at once. It also bombs what the player has BUILT, through the shared targeting rather than the fleet alone, and its blast damages structures - it had been asking Fleet directly and hitting only player_ships, which was already out of step with #101. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
6.2 KiB
GDScript
153 lines
6.2 KiB
GDScript
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)
|