#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>
2026-08-23 11:52:18 +02:00
|
|
|
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
|
2026-08-23 21:38:08 +02:00
|
|
|
# Shortened with the cycle count (#115): one back-and-forth in the time two
|
|
|
|
|
# and a half used to take would be a slow-motion tell.
|
|
|
|
|
@export var windup_time: float = 0.85
|
#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>
2026-08-23 11:52:18 +02:00
|
|
|
# #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.
|
2026-08-23 21:38:08 +02:00
|
|
|
@export var windup_sway: float = 34.0
|
|
|
|
|
# #115: exactly ONE back and forth, not two and a half. The tell is a recoil
|
|
|
|
|
# and a spring; repeating it turned a wind-up into a fidget, and the player
|
|
|
|
|
# stopped reading it as "about to happen".
|
|
|
|
|
@export var windup_cycles: float = 1.0
|
|
|
|
|
# #115: and it leaves hard. At 950 the charge read as a fast drift rather
|
|
|
|
|
# than as the thing the whole wind-up was promising.
|
|
|
|
|
@export var charge_speed: float = 1650.0
|
#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>
2026-08-23 11:52:18 +02:00
|
|
|
# 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()
|
2026-08-23 21:38:08 +02:00
|
|
|
if distance < 0.001:
|
|
|
|
|
return
|
|
|
|
|
# #113: see EnemyPursuitComponent.
|
|
|
|
|
if not EnemyTargets.ignores_range(enemy) and distance > detection_range:
|
#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>
2026-08-23 11:52:18 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
#101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.
EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.
Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.
What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.
Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00
|
|
|
# #101: a lancer will lock a structure exactly as it locks a vessel - the
|
|
|
|
|
# wind-up, the line and the charge are the same, and so is what happens when
|
|
|
|
|
# it arrives.
|
#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>
2026-08-23 11:52:18 +02:00
|
|
|
func _nearest_ship(enemy: Node2D) -> Node2D:
|
#101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.
EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.
Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.
What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.
Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00
|
|
|
return EnemyTargets.nearest_target(self, enemy.global_position)
|
#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>
2026-08-23 11:52:18 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|