From 0c48b5e7397d6ee372f24ea43da39aa6b884da24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sun, 23 Aug 2026 21:58:56 +0200 Subject: [PATCH] #114: the enemy bomber flies, and its bombs arrive 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 --- enemies/ArtilleryEnemyDefinition.tres | 12 ++--- enemies/ArtilleryShell.gd | 38 +++++++++++++++- enemies/EnemyArtilleryComponent.gd | 64 +++++++++++++++++---------- vfx/BombBlastVFX.gd | 26 +++++++++++ 4 files changed, 109 insertions(+), 31 deletions(-) create mode 100644 vfx/BombBlastVFX.gd diff --git a/enemies/ArtilleryEnemyDefinition.tres b/enemies/ArtilleryEnemyDefinition.tres index e193e24..52973b3 100644 --- a/enemies/ArtilleryEnemyDefinition.tres +++ b/enemies/ArtilleryEnemyDefinition.tres @@ -8,12 +8,12 @@ script = ExtResource("1") enemy_id = "artillery" display_name = "Artillery" max_hp = 8 -speed = 90.0 -acceleration = 180.0 -rotation_speed = 3.0 -hull_points = PackedVector2Array(0, -30, 14, -22, 16, -6, 26, 2, 22, 18, 10, 26, -10, 26, -22, 18, -26, 2, -16, -6, -14, -22) +speed = 215.0 +acceleration = 390.0 +rotation_speed = 4.2 +hull_points = PackedVector2Array(0, -34, 7, -18, 34, -4, 40, 8, 20, 6, 12, 20, 4, 28, -4, 28, -12, 20, -20, 6, -40, 8, -34, -4, -7, -18) glow_color = Color(1.0, 0.5, 0.2, 1) -detail_lines = PackedVector2Array(-14, -20, 14, -20, -12, 6, -20, 16, 12, 6, 20, 16, 0, -22, 0, 6) -detail_rings = PackedVector2Array(0, -20, 8, 0) +detail_lines = PackedVector2Array(0, -30, 0, 22, -20, 4, 20, 4, -30, 0, -30, 6, 30, 0, 30, 6, -9, 12, 9, 12) +detail_rings = PackedVector2Array(0, 14, 9, 0, 0, -16, 5, 0) component_scenes = Array[PackedScene]([ExtResource("2")]) contact_damage = 6 diff --git a/enemies/ArtilleryShell.gd b/enemies/ArtilleryShell.gd index ce7e46e..e87e77d 100644 --- a/enemies/ArtilleryShell.gd +++ b/enemies/ArtilleryShell.gd @@ -18,7 +18,7 @@ signal finished # 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 = 180.0 +@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) @@ -30,6 +30,13 @@ const EXPLOSION_DURATION: float = 0.8 # 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 @@ -78,9 +85,16 @@ 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: @@ -91,14 +105,34 @@ func _draw() -> void: _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) + 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. diff --git a/enemies/EnemyArtilleryComponent.gd b/enemies/EnemyArtilleryComponent.gd index 9230a2f..0077386 100644 --- a/enemies/EnemyArtilleryComponent.gd +++ b/enemies/EnemyArtilleryComponent.gd @@ -1,20 +1,32 @@ class_name EnemyArtilleryComponent extends Node -# Stationary siege piece: drifts slowly toward the fleet (deliberately no -# pursuit component - closing distance is incidental, not its purpose) and -# fires a slow, clearly visible AOE shell on a long cooldown via a -# standalone ArtilleryShell, so an already-launched shot survives this -# enemy dying mid-flight, same as EnemyProjectile/Projectile already do. +# The bomber: it moves to where its bombs will land and drops them there. +# +# #114 turned it from a siege piece into an aircraft. It used to creep at +# 20px/s and lob a shell that took five seconds to arrive, which 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 flies at its own speed and its bombs travel at 460. +# +# The shell is still a standalone ArtilleryShell added to the root, so an +# already-dropped bomb survives this enemy dying mid-flight, same as +# EnemyProjectile/Projectile already do. var capability_id: String = "artillery" -@export var fire_cooldown: float = 6.0 +@export var fire_cooldown: float = 4.0 @export var fire_range: float = 900.0 -@export var shell_speed: float = 180.0 +# #114: fast enough that the bomb arrives while the target is still roughly +# where it was aimed, and slow enough that the impact cross is a warning +# rather than a formality. +@export var shell_speed: float = 460.0 @export var aoe_radius: float = 220.0 @export var damage: int = 3 -@export var drift_speed: float = 20.0 +# It flies now rather than creeping. Kept as its own number rather than the +# definition's `speed` so a run-in can be deliberately slower than a chaser's +# top speed without making the hull slow. +@export var drift_speed: float = 150.0 @export var color: Color = Color(1.0, 0.5, 0.2) var cooldown_left: float = 0.0 @@ -29,10 +41,12 @@ func tick(delta: float) -> void: if cooldown_left > 0.0: return - var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") - if fleet == null or fleet.ships.is_empty(): + # #101/#113: it bombs what the player has BUILT as readily as what they + # are flying, and asks the shared targeting rather than the fleet alone. + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) + if target == null: return - var centroid: Vector2 = fleet.get_centroid() + var centroid: Vector2 = target.global_position if enemy.global_position.distance_to(centroid) > fire_range: return @@ -47,18 +61,22 @@ func tick(delta: float) -> void: func _drift(enemy: Node2D, delta: float) -> void: - # Local position throughout, same convention as EnemyPursuitComponent/ - # EnemyStandoffComponent: valid as long as the enemy's container and - # Fleet share a common origin, which every scene in this project does. - var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet") - if fleet == null or fleet.ships.is_empty(): + var target: Node2D = EnemyTargets.nearest_target(self, enemy.global_position) + if target == null: return - var to_fleet: Vector2 = fleet.get_centroid() - enemy.position - # #69: gated by the same fire_range it already shells within, rather - # than a second tunable that could drift out of sync - outside its own - # firing envelope it has nothing to be drifting toward yet. - if to_fleet.length() > fire_range: + var to_target: Vector2 = target.global_position - enemy.global_position + var distance: float = to_target.length() + if distance < 1.0: return - if to_fleet.length() < 1.0: + # #69: gated by the same fire_range it already bombs within, rather than + # a second tunable that could drift out of sync - outside its own firing + # envelope it has nothing to be flying toward yet. #113: unless it came + # in with a wave, in which case it has somewhere to be. + if distance > fire_range and not EnemyTargets.ignores_range(enemy): return - enemy.position += to_fleet.normalized() * drift_speed * delta + var heading: Vector2 = to_target / distance + enemy.global_position += heading * drift_speed * delta + # #114: it faces where it is going. A bomber that flew sideways was most + # of why the old one read as scenery being nudged along. + enemy.rotation = lerp_angle(enemy.rotation, heading.angle() + PI / 2.0, + enemy.rotation_speed * delta) diff --git a/vfx/BombBlastVFX.gd b/vfx/BombBlastVFX.gd new file mode 100644 index 0000000..845a832 --- /dev/null +++ b/vfx/BombBlastVFX.gd @@ -0,0 +1,26 @@ +class_name BombBlastVFX +extends LightBurstVFX + + +func _init() -> void: + # #114: the bomber's blast. Between a hull going up and a structure + # coming down - it is the biggest thing an enemy can do to the player + # without destroying anything outright, and it should land like it. + color = Color(1.0, 0.5, 0.2) + particle_count = 26 + lifetime = 0.8 + min_speed = 140.0 + max_speed = 520.0 + shockwave_radius = 150.0 + shockwave_color = Color(1.0, 0.78, 0.4) + + +# Takes the colour from the shell that threw it, so a bomber recoloured in +# its definition does not explode in the default orange. +static func spawn(parent: Node, world_position: Vector2, + blast_color: Color = Color(1.0, 0.5, 0.2)) -> void: + var vfx: BombBlastVFX = BombBlastVFX.new() + vfx.position = world_position + vfx.color = blast_color + vfx.shockwave_color = blast_color.lerp(Color(1.0, 0.95, 0.8), 0.45) + parent.add_child(vfx)