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>
188 lines
7.3 KiB
GDScript
188 lines
7.3 KiB
GDScript
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: 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:
|
|
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)
|