spaceship/enemies/EnemyLungeComponent.gd

188 lines
7.3 KiB
GDScript3
Raw Normal View History

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)
#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.
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)
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)