One back and forth rather than two and a half, over 0.85s rather than 1.2. 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". And the charge is 1650px/s, up from 950, which read as a fast drift rather than as the thing the whole wind-up was promising. The test counts how many times the wind-up crosses its anchor: one back and forth crosses exactly once, two would cross three times. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
7.9 KiB
GDScript
198 lines
7.9 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
|
|
# 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 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 = 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
|
|
# 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 < 0.001:
|
|
return
|
|
# #113: see EnemyPursuitComponent.
|
|
if not EnemyTargets.ignores_range(enemy) and distance > detection_range:
|
|
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)
|