#96: the lancer, a rammer that announces itself

A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.

The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.

EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.

The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 11:52:18 +02:00
commit 890e1c812f
6 changed files with 508 additions and 1 deletions

View file

@ -2,9 +2,10 @@ class_name EnemyDefinitions
extends RefCounted
const DEFAULT_ID: String = "chaser"
const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
const ORDER: PackedStringArray = ["chaser", "lancer", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
const DEFINITIONS: Dictionary = {
"chaser": preload("res://enemies/ChaserEnemyDefinition.tres"),
"lancer": preload("res://enemies/LancerEnemyDefinition.tres"),
"sniper": preload("res://enemies/SniperEnemyDefinition.tres"),
"artillery": preload("res://enemies/ArtilleryEnemyDefinition.tres"),
"turret_spawner": preload("res://enemies/TurretSpawnerEnemyDefinition.tres"),

View file

@ -0,0 +1,195 @@
class_name EnemyLungeComponent
extends Node2D
# Forgejo #96. A ramming enemy that does not simply beeline the way
# EnemyPursuitComponent does: it drifts in from a distance, LOCKS a point,
# rocks back and forth on the spot while it winds up, shows the line it is
# about to fly down, and only then crosses the gap in one fast straight run.
#
# The lock is the whole point. Once WINDUP starts, nothing re-reads the
# fleet: the charge goes to where the target WAS, so the wind-up and the
# trajectory line are a real warning the player can act on, rather than
# decoration on an unavoidable hit. Same bargain as EnemyBigLaserBeam, which
# locks its aim in launch() for exactly this reason.
#
# A Node2D rather than a plain Node because the trajectory line has to be
# drawn somewhere, and top_level keeps that drawing in world space - the
# parent enemy spins to face its target, and a line that spun with it would
# not point where the charge is going.
#
# Never pair with a pursuit or standoff component: all three write the
# enemy's position every tick and would fight over it.
var capability_id: String = "lunge"
enum Phase { HUNT, WINDUP, CHARGE, RECOVER }
# Far enough that it is already on screen, and visibly waiting, before it
# commits - "starts a bit far from the target" is the behaviour, so a lancer
# that locked the instant it could see anything would not read as one.
@export var detection_range: float = 1500.0
@export var lock_range: float = 820.0
# The drift in HUNT is deliberately slower than the charge by an order of
# magnitude: the approach is a stalk, the charge is the attack.
@export var approach_speed_factor: float = 0.5
@export var windup_time: float = 1.2
# #96 to the letter: the trajectory line appears 300ms before it leaves.
@export var telegraph_time: float = 0.3
# The "small back and forth" - it recoils along the charge axis, so the
# motion reads as winding up to spring rather than as drifting.
@export var windup_sway: float = 30.0
@export var windup_cycles: float = 2.5
@export var charge_speed: float = 950.0
# It flies THROUGH the locked point rather than stopping on it. A charge
# that halted exactly where the ship used to be would look like it was
# aiming at empty space on purpose.
@export var charge_overshoot: float = 300.0
@export var recover_time: float = 1.3
@export var telegraph_color: Color = Color(1.0, 0.45, 0.15)
var phase: int = Phase.HUNT
var velocity: Vector2 = Vector2.ZERO
# Where the charge is going, and where it started - both frozen at lock.
var locked_point: Vector2 = Vector2.ZERO
var charge_direction: Vector2 = Vector2.RIGHT
var charge_anchor: Vector2 = Vector2.ZERO
var charge_distance: float = 0.0
var travelled: float = 0.0
var windup_left: float = 0.0
var recover_left: float = 0.0
func setup() -> void:
# World space, not the enemy's: see the class comment.
top_level = true
position = Vector2.ZERO
rotation = 0.0
# True while the trajectory line is up, i.e. the last telegraph_time of the
# wind-up. Public because it is the one piece of state a test can check that
# says "the warning is showing" without reaching into a _draw().
func is_telegraphing() -> bool:
return phase == Phase.WINDUP and windup_left <= telegraph_time
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
match phase:
Phase.HUNT:
_tick_hunt(enemy, delta)
Phase.WINDUP:
_tick_windup(enemy, delta)
Phase.CHARGE:
_tick_charge(enemy, delta)
Phase.RECOVER:
_tick_recover(enemy, delta)
queue_redraw()
func _tick_hunt(enemy: Node2D, delta: float) -> void:
var target: Node2D = _nearest_ship(enemy)
if target == null:
return
var to_target: Vector2 = target.global_position - enemy.global_position
var distance: float = to_target.length()
if distance > detection_range or distance < 0.001:
return
var direction: Vector2 = to_target / distance
if distance <= lock_range:
_lock(enemy, target)
return
var desired: Vector2 = direction * enemy.speed * approach_speed_factor
velocity = velocity.move_toward(desired, enemy.acceleration * delta)
enemy.global_position += velocity * delta
_face(enemy, direction, delta)
func _lock(enemy: Node2D, target: Node2D) -> void:
locked_point = target.global_position
charge_anchor = enemy.global_position
var to_target: Vector2 = locked_point - charge_anchor
charge_distance = to_target.length()
charge_direction = to_target.normalized() if charge_distance > 0.001 else Vector2.RIGHT
windup_left = windup_time
travelled = 0.0
velocity = Vector2.ZERO
phase = Phase.WINDUP
func _tick_windup(enemy: Node2D, delta: float) -> void:
windup_left -= delta
# Snap to the locked heading rather than easing into it: the nose is part
# of the warning, and a lancer still turning when the line appears would
# be lying about where it is going.
enemy.rotation = charge_direction.angle() + PI / 2.0
if windup_left <= 0.0:
phase = Phase.CHARGE
enemy.global_position = charge_anchor
return
# One full back-and-forth per cycle, starting and ending at the anchor,
# leaning BACKWARDS first (-sin) so the recoil comes before the spring.
var progress: float = 1.0 - windup_left / maxf(windup_time, 0.001)
var sway: float = -sin(progress * TAU * windup_cycles) * windup_sway
enemy.global_position = charge_anchor + charge_direction * sway
func _tick_charge(enemy: Node2D, delta: float) -> void:
var step: float = charge_speed * delta
travelled += step
enemy.global_position += charge_direction * step
enemy.rotation = charge_direction.angle() + PI / 2.0
if travelled >= charge_distance + charge_overshoot:
phase = Phase.RECOVER
recover_left = recover_time
velocity = charge_direction * charge_speed
func _tick_recover(enemy: Node2D, delta: float) -> void:
# Coasts to a stop instead of stopping dead, then hunts again. Without
# this it would re-lock the frame it finished and the wind-up would stop
# being a rhythm the player can read.
recover_left -= delta
velocity = velocity.move_toward(Vector2.ZERO, enemy.acceleration * delta)
enemy.global_position += velocity * delta
if recover_left <= 0.0:
phase = Phase.HUNT
velocity = Vector2.ZERO
func _face(enemy: Node2D, direction: Vector2, delta: float) -> void:
var target_angle: float = direction.angle() + PI / 2.0
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
func _nearest_ship(enemy: Node2D) -> Node2D:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
return null
var nearest: Node2D = fleet.ships[0]
var best_dist: float = enemy.global_position.distance_to(nearest.global_position)
for ship in fleet.ships:
var d: float = enemy.global_position.distance_to(ship.global_position)
if d < best_dist:
best_dist = d
nearest = ship
return nearest
func _draw() -> void:
if not is_telegraphing():
return
var enemy: Node2D = get_parent()
if enemy == null:
return
# Thin, and brightening as the launch approaches, so it reads as a sight
# line being drawn rather than as a beam already firing.
var urgency: float = clampf(1.0 - windup_left / maxf(telegraph_time, 0.001), 0.0, 1.0)
var from: Vector2 = enemy.global_position
var to: Vector2 = locked_point + charge_direction * charge_overshoot
draw_line(from, to, Color(telegraph_color, 0.18 + 0.30 * urgency), 2.0, true)
draw_line(from, to, Color(1.0, 0.92, 0.82, 0.25 + 0.45 * urgency), 1.0, true)
# A tick across the locked point: the line says WHERE, this says how far.
var across: Vector2 = Vector2(-charge_direction.y, charge_direction.x) * 14.0
draw_line(locked_point - across, locked_point + across,
Color(telegraph_color, 0.35 + 0.5 * urgency), 2.0, true)

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://enemies/EnemyLungeComponent.gd" id="1"]
[node name="EnemyLungeComponent" type="Node2D"]
script = ExtResource("1")

View file

@ -3,6 +3,12 @@ extends Node2D
@export var min_spawn_interval: float = 3.0
@export var max_spawn_interval: float = 6.0
@export var spawn_margin: float = 150.0
# #96: this spawner is the only place the player meets an enemy off an
# authored map, so a new open-space enemy has to take a share of it or it
# only ever exists in the setup editor. Kept a minority of the wave: the
# lancer is punctuation against a background of chasers, and a screen of
# them would be all telegraph and no fight.
@export var lancer_chance: float = 0.25
@onready var fleet: Node2D = get_node("../Fleet")
@ -38,5 +44,8 @@ func _spawn_enemy() -> void:
var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius
var enemy: Node2D = enemy_scene.instantiate()
# Set before add_child: Enemy._ready() is what resolves enemy_id into a
# definition, and it runs the moment the node enters the tree.
enemy.enemy_id = "lancer" if randf() < lancer_chance else "chaser"
enemy.position = spawn_pos
get_parent().add_child(enemy)

View file

@ -0,0 +1,19 @@
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://enemies/EnemyLungeComponent.tscn" id="2"]
[resource]
script = ExtResource("1")
enemy_id = "lancer"
display_name = "Lancer"
max_hp = 2
speed = 220.0
acceleration = 420.0
rotation_speed = 7.0
hull_points = PackedVector2Array(0, -32, 6, -8, 11, 14, 4, 10, 0, 20, -4, 10, -11, 14, -6, -8)
glow_color = Color(1, 0.45, 0.15, 1)
detail_lines = PackedVector2Array(0, -28, 0, 6, -7, -2, 7, -2)
detail_rings = PackedVector2Array(0, 8, 4, 0)
dies_on_contact = true
component_scenes = Array[PackedScene]([ExtResource("2")])