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)