#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>
This commit is contained in:
parent
5f1b5a9b29
commit
890e1c812f
6 changed files with 508 additions and 1 deletions
|
|
@ -2,9 +2,10 @@ class_name EnemyDefinitions
|
|||
extends RefCounted
|
||||
|
||||
const DEFAULT_ID: String = "chaser"
|
||||
const ORDER: PackedStringArray = ["chaser", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
|
||||
const ORDER: PackedStringArray = ["chaser", "lancer", "sniper", "artillery", "turret_spawner", "enemy_mothership"]
|
||||
const DEFINITIONS: Dictionary = {
|
||||
"chaser": preload("res://enemies/ChaserEnemyDefinition.tres"),
|
||||
"lancer": preload("res://enemies/LancerEnemyDefinition.tres"),
|
||||
"sniper": preload("res://enemies/SniperEnemyDefinition.tres"),
|
||||
"artillery": preload("res://enemies/ArtilleryEnemyDefinition.tres"),
|
||||
"turret_spawner": preload("res://enemies/TurretSpawnerEnemyDefinition.tres"),
|
||||
|
|
|
|||
195
enemies/EnemyLungeComponent.gd
Normal file
195
enemies/EnemyLungeComponent.gd
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
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)
|
||||
|
||||
|
||||
func _nearest_ship(enemy: Node2D) -> Node2D:
|
||||
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||
if fleet == null or fleet.ships.is_empty():
|
||||
return null
|
||||
var nearest: Node2D = fleet.ships[0]
|
||||
var best_dist: float = enemy.global_position.distance_to(nearest.global_position)
|
||||
for ship in fleet.ships:
|
||||
var d: float = enemy.global_position.distance_to(ship.global_position)
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
nearest = ship
|
||||
return nearest
|
||||
|
||||
|
||||
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)
|
||||
6
enemies/EnemyLungeComponent.tscn
Normal file
6
enemies/EnemyLungeComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[gd_scene load_steps=2 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyLungeComponent.gd" id="1"]
|
||||
|
||||
[node name="EnemyLungeComponent" type="Node2D"]
|
||||
script = ExtResource("1")
|
||||
|
|
@ -3,6 +3,12 @@ extends Node2D
|
|||
@export var min_spawn_interval: float = 3.0
|
||||
@export var max_spawn_interval: float = 6.0
|
||||
@export var spawn_margin: float = 150.0
|
||||
# #96: this spawner is the only place the player meets an enemy off an
|
||||
# authored map, so a new open-space enemy has to take a share of it or it
|
||||
# only ever exists in the setup editor. Kept a minority of the wave: the
|
||||
# lancer is punctuation against a background of chasers, and a screen of
|
||||
# them would be all telegraph and no fight.
|
||||
@export var lancer_chance: float = 0.25
|
||||
|
||||
@onready var fleet: Node2D = get_node("../Fleet")
|
||||
|
||||
|
|
@ -38,5 +44,8 @@ func _spawn_enemy() -> void:
|
|||
var spawn_pos: Vector2 = fleet.get_centroid() + Vector2(cos(angle), sin(angle)) * radius
|
||||
|
||||
var enemy: Node2D = enemy_scene.instantiate()
|
||||
# Set before add_child: Enemy._ready() is what resolves enemy_id into a
|
||||
# definition, and it runs the moment the node enters the tree.
|
||||
enemy.enemy_id = "lancer" if randf() < lancer_chance else "chaser"
|
||||
enemy.position = spawn_pos
|
||||
get_parent().add_child(enemy)
|
||||
|
|
|
|||
19
enemies/LancerEnemyDefinition.tres
Normal file
19
enemies/LancerEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
|
||||
|
||||
[ext_resource type="Script" path="res://enemies/EnemyDefinition.gd" id="1"]
|
||||
[ext_resource type="PackedScene" path="res://enemies/EnemyLungeComponent.tscn" id="2"]
|
||||
|
||||
[resource]
|
||||
script = ExtResource("1")
|
||||
enemy_id = "lancer"
|
||||
display_name = "Lancer"
|
||||
max_hp = 2
|
||||
speed = 220.0
|
||||
acceleration = 420.0
|
||||
rotation_speed = 7.0
|
||||
hull_points = PackedVector2Array(0, -32, 6, -8, 11, 14, 4, 10, 0, 20, -4, 10, -11, 14, -6, -8)
|
||||
glow_color = Color(1, 0.45, 0.15, 1)
|
||||
detail_lines = PackedVector2Array(0, -28, 0, 6, -7, -2, 7, -2)
|
||||
detail_rings = PackedVector2Array(0, 8, 4, 0)
|
||||
dies_on_contact = true
|
||||
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||
277
tests/test_lancer_enemy.gd
Normal file
277
tests/test_lancer_enemy.gd
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
extends SceneTree
|
||||
|
||||
# Headless gameplay test: run with
|
||||
# godot --headless -s res://tests/test_lancer_enemy.gd
|
||||
# Forgejo #96, the lancer: a ramming enemy that announces itself.
|
||||
#
|
||||
# "A new enemy vessel tries to suicide itself like the default vessel.
|
||||
# Though this new enemy starts a bit far from the target, lock it and
|
||||
# wait a bit. A small back and forth announce its departure to its
|
||||
# target, in a straight line, quickly. 300ms before it actually starts
|
||||
# going, a small line goes from it to its target to announce its
|
||||
# trajectory."
|
||||
#
|
||||
# Covers:
|
||||
# A. it is in the catalog, composes a lunge component and nothing that
|
||||
# would fight it over position, and rams like the chaser does
|
||||
# B. from beyond lock_range it does NOT charge - it closes slowly, so the
|
||||
# player sees it coming
|
||||
# C. once locked it holds station for the wind-up and rocks back and forth
|
||||
# along the charge axis instead of drifting toward the target
|
||||
# D. the trajectory line comes up during the wind-up and lasts about
|
||||
# 300ms - not the whole wind-up, and not nothing
|
||||
# E. the charge is straight, fast, and aimed at where the target WAS: a
|
||||
# ship that moves after the lock is missed, which is what makes the
|
||||
# telegraph worth reading
|
||||
# F. it dies on contact, taking the vessel with it
|
||||
# G. the roaming spawner can actually produce one, and still produces
|
||||
# chasers - a new enemy nobody meets is not a new enemy
|
||||
|
||||
var game_state: Node
|
||||
var main: Node
|
||||
var fleet: Node
|
||||
var lancer: Node2D
|
||||
var target_ship: Node2D
|
||||
var victim: Node2D
|
||||
var stage: int = 0
|
||||
var elapsed: float = 0.0
|
||||
var lunge: Node
|
||||
|
||||
var approach_start: float = 0.0
|
||||
var windup_seen: bool = false
|
||||
var windup_anchor: Vector2 = Vector2.ZERO
|
||||
var max_sway: float = 0.0
|
||||
var max_lateral: float = 0.0
|
||||
var telegraph_time_seen: float = 0.0
|
||||
var charge_start: Vector2 = Vector2.ZERO
|
||||
var dodged_to: Vector2 = Vector2.ZERO
|
||||
var charge_locked: Vector2 = Vector2.ZERO
|
||||
var max_off_axis: float = 0.0
|
||||
var charge_seen: bool = false
|
||||
var destroyed_fired: bool = false
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
# GameState is an autoload: a bare GameState.<instance member> does not
|
||||
# compile in a SceneTree script, so the node is fetched by name instead.
|
||||
game_state = root.get_node("GameState")
|
||||
game_state.buildings.clear()
|
||||
game_state.extractors.clear()
|
||||
game_state.reset_progression()
|
||||
main = load("res://world/Main.tscn").instantiate()
|
||||
root.add_child(main)
|
||||
current_scene = main
|
||||
fleet = main.get_node("Fleet")
|
||||
|
||||
|
||||
func _spawn_lancer(at: Vector2) -> Node2D:
|
||||
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
|
||||
enemy.enemy_id = "lancer"
|
||||
enemy.position = at
|
||||
main.add_child(enemy)
|
||||
return enemy
|
||||
|
||||
|
||||
func _process(delta: float) -> bool:
|
||||
elapsed += delta
|
||||
match stage:
|
||||
0:
|
||||
if elapsed < 0.3:
|
||||
return false
|
||||
main.get_node("EnemySpawner").set_process(false)
|
||||
main.get_node("AsteroidField").set_process(false)
|
||||
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(hazard):
|
||||
hazard.queue_free()
|
||||
|
||||
# --- A. catalogued, composed, and a rammer -------------------
|
||||
var definitions: Script = load("res://enemies/EnemyDefinitions.gd")
|
||||
var definition: Resource = definitions.get_definition("lancer")
|
||||
if definition.enemy_id != "lancer":
|
||||
return _fail("EnemyDefinitions has no \"lancer\" - get_definition fell back")
|
||||
if not bool(definition.dies_on_contact):
|
||||
return _fail("the lancer is not marked dies_on_contact, so it is not a rammer")
|
||||
|
||||
fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
|
||||
target_ship = fleet.ships[0]
|
||||
# Ship steers toward move_target every frame; pinning it there is
|
||||
# how a vessel is held still (see test_sniper_enemy.gd).
|
||||
target_ship.move_target = target_ship.position
|
||||
|
||||
lancer = _spawn_lancer(target_ship.global_position + Vector2(1300.0, 0.0))
|
||||
lunge = lancer.get_component("lunge")
|
||||
if lunge == null:
|
||||
return _fail("the lancer composed no lunge component")
|
||||
if lancer.has_capability("pursuit") or lancer.has_capability("standoff"):
|
||||
return _fail("the lancer also carries a mover that would fight the lunge for position")
|
||||
if 1300.0 <= lunge.lock_range:
|
||||
return _fail("the test spawns inside lock_range, so case B proves nothing")
|
||||
print("lancer: hp %d, lock_range %.0f, windup %.2fs, telegraph %.2fs" % [
|
||||
lancer.max_hp, lunge.lock_range, lunge.windup_time, lunge.telegraph_time])
|
||||
approach_start = lancer.global_position.distance_to(target_ship.global_position)
|
||||
elapsed = 0.0
|
||||
stage = 1
|
||||
return false
|
||||
1:
|
||||
# --- B. it approaches rather than charging from out there ----
|
||||
if elapsed < 0.6:
|
||||
if lunge.phase == lunge.Phase.CHARGE:
|
||||
return _fail("the lancer charged from beyond its lock_range")
|
||||
return false
|
||||
var closed: float = approach_start - lancer.global_position.distance_to(
|
||||
target_ship.global_position)
|
||||
if closed <= 1.0:
|
||||
return _fail("the lancer never closed on the fleet from %.0f away" % approach_start)
|
||||
# The stalk must be slower than the charge, or the "starts a bit
|
||||
# far, waits" reading is lost.
|
||||
var approach_speed: float = closed / elapsed
|
||||
if approach_speed >= lunge.charge_speed * 0.5:
|
||||
return _fail("it approached at %.0fpx/s, barely short of its %.0fpx/s charge" % [
|
||||
approach_speed, lunge.charge_speed])
|
||||
print("closed %.0fpx in %.1fs (%.0fpx/s) without charging" % [
|
||||
closed, elapsed, approach_speed])
|
||||
elapsed = 0.0
|
||||
stage = 2
|
||||
return false
|
||||
2:
|
||||
# --- C/D. wind-up: rocks in place, and puts the line up late --
|
||||
if lunge.phase == lunge.Phase.WINDUP:
|
||||
if not windup_seen:
|
||||
windup_seen = true
|
||||
windup_anchor = lunge.charge_anchor
|
||||
charge_locked = lunge.locked_point
|
||||
var offset: Vector2 = lancer.global_position - windup_anchor
|
||||
max_sway = maxf(max_sway, absf(offset.dot(lunge.charge_direction)))
|
||||
var across: Vector2 = Vector2(-lunge.charge_direction.y, lunge.charge_direction.x)
|
||||
max_lateral = maxf(max_lateral, absf(offset.dot(across)))
|
||||
if lunge.is_telegraphing():
|
||||
telegraph_time_seen += delta
|
||||
if dodged_to == Vector2.ZERO:
|
||||
# What the warning is FOR: the moment the line shows,
|
||||
# the ship steps off it. The lock is already frozen,
|
||||
# so this must not drag the charge along with it.
|
||||
var sidestep: Vector2 = Vector2(-lunge.charge_direction.y,
|
||||
lunge.charge_direction.x) * 400.0
|
||||
target_ship.position += sidestep
|
||||
target_ship.move_target = target_ship.position
|
||||
dodged_to = target_ship.position
|
||||
return false
|
||||
if not windup_seen:
|
||||
if elapsed > 6.0:
|
||||
return _fail("the lancer never entered a wind-up within 6s of locking range")
|
||||
return false
|
||||
if lunge.phase != lunge.Phase.CHARGE:
|
||||
return _fail("the wind-up ended in phase %d rather than a charge" % lunge.phase)
|
||||
|
||||
if max_sway < 1.0:
|
||||
return _fail("the lancer sat perfectly still through the wind-up - no back and forth")
|
||||
if max_sway > lunge.windup_sway + 1.0:
|
||||
return _fail("it moved %.0fpx along the charge axis, more than the %.0fpx sway - that is departing early" % [
|
||||
max_sway, lunge.windup_sway])
|
||||
if max_lateral > 1.0:
|
||||
return _fail("it wandered %.0fpx sideways during the wind-up instead of holding station" % max_lateral)
|
||||
print("wind-up: rocked %.0fpx back and forth, %.0fpx sideways" % [max_sway, max_lateral])
|
||||
|
||||
# 300ms of line, give or take the frames either side of the
|
||||
# threshold. Both bounds matter: a line up for the whole wind-up
|
||||
# is not a 300ms warning either.
|
||||
if absf(telegraph_time_seen - lunge.telegraph_time) > 0.1:
|
||||
return _fail("the trajectory line showed for %.2fs, not the %.2fs it promises" % [
|
||||
telegraph_time_seen, lunge.telegraph_time])
|
||||
if lunge.telegraph_time >= lunge.windup_time:
|
||||
return _fail("the line covers the whole wind-up, so it announces nothing")
|
||||
print("trajectory line up for %.2fs of the %.2fs wind-up" % [
|
||||
telegraph_time_seen, lunge.windup_time])
|
||||
|
||||
charge_start = lancer.global_position
|
||||
elapsed = 0.0
|
||||
stage = 3
|
||||
return false
|
||||
3:
|
||||
# --- E. straight, fast, and at the locked point --------------
|
||||
if lunge.phase == lunge.Phase.CHARGE:
|
||||
charge_seen = true
|
||||
var travel: Vector2 = lancer.global_position - charge_start
|
||||
var across: Vector2 = Vector2(-lunge.charge_direction.y, lunge.charge_direction.x)
|
||||
max_off_axis = maxf(max_off_axis, absf(travel.dot(across)))
|
||||
return false
|
||||
if not charge_seen:
|
||||
return _fail("the charge was over before a single frame of it was observed")
|
||||
if dodged_to == Vector2.ZERO:
|
||||
return _fail("the trajectory line never showed, so nothing was dodged")
|
||||
if not is_instance_valid(target_ship) or target_ship.is_queued_for_deletion():
|
||||
return _fail("the ship stepped off the telegraphed line and was hit anyway")
|
||||
if max_off_axis > 1.0:
|
||||
return _fail("the charge bent %.0fpx off its locked axis - it homed instead of flying straight" % max_off_axis)
|
||||
var covered: float = charge_start.distance_to(lancer.global_position)
|
||||
var speed: float = covered / maxf(elapsed, 0.001)
|
||||
if speed < lunge.charge_speed * 0.8:
|
||||
return _fail("the charge averaged %.0fpx/s against a stated %.0fpx/s" % [
|
||||
speed, lunge.charge_speed])
|
||||
# It aimed at the locked point, not at wherever the ship is now.
|
||||
var passed_by: float = (charge_locked - target_ship.global_position).length()
|
||||
if passed_by < 200.0:
|
||||
return _fail("the charge followed the ship as it dodged - the lock is not frozen")
|
||||
print("charged %.0fpx at %.0fpx/s, %.0fpx off axis, locked point %.0fpx from the ship" % [
|
||||
covered, speed, max_off_axis, passed_by])
|
||||
lancer.queue_free()
|
||||
|
||||
# --- F. contact kills both ----------------------------------
|
||||
fleet.spawn_ships(1, Vector2(4000.0, 0.0), PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
|
||||
victim = fleet.ships[0]
|
||||
elapsed = 0.0
|
||||
stage = 4
|
||||
return false
|
||||
4:
|
||||
if elapsed < 0.2:
|
||||
return false
|
||||
lancer = _spawn_lancer(victim.global_position)
|
||||
lancer.destroyed.connect(func(): destroyed_fired = true)
|
||||
elapsed = 0.0
|
||||
stage = 5
|
||||
return false
|
||||
5:
|
||||
if elapsed < 0.5:
|
||||
return false
|
||||
if is_instance_valid(lancer) and not lancer.is_queued_for_deletion():
|
||||
return _fail("the lancer survived ramming a vessel")
|
||||
if not destroyed_fired:
|
||||
return _fail("the lancer vanished without firing `destroyed` - not routed through take_damage")
|
||||
if is_instance_valid(victim) and not victim.is_queued_for_deletion():
|
||||
return _fail("the rammed vessel survived, so the lancer cost nothing to stop")
|
||||
print("lancer and vessel destroyed each other")
|
||||
|
||||
# --- G. the roaming spawner puts it in the game --------------
|
||||
var spawner: Node2D = main.get_node("EnemySpawner")
|
||||
if spawner.lancer_chance <= 0.0 or spawner.lancer_chance >= 1.0:
|
||||
return _fail("the spawner's lancer_chance is %.2f - it makes one enemy or the other, never a mix" \
|
||||
% spawner.lancer_chance)
|
||||
# The spawner refuses to spawn against an empty fleet, and the
|
||||
# ram just emptied it - the wipe safety net normally refills it,
|
||||
# but this must not depend on that having happened yet.
|
||||
if fleet.ships.is_empty():
|
||||
fleet.spawn_ships(1, Vector2(8000.0, 0.0))
|
||||
var made: Dictionary = {}
|
||||
for i in range(40):
|
||||
spawner.lancer_chance = 1.0 if i % 2 == 0 else 0.0
|
||||
spawner._spawn_enemy()
|
||||
for enemy in get_nodes_in_group("enemies"):
|
||||
if is_instance_valid(enemy):
|
||||
made[enemy.enemy_id] = true
|
||||
enemy.queue_free()
|
||||
if not made.has("lancer"):
|
||||
return _fail("40 spawns at lancer_chance 1.0 produced no lancer")
|
||||
if not made.has("chaser"):
|
||||
return _fail("40 spawns at lancer_chance 0.0 produced no chaser")
|
||||
print("the roaming spawner makes both %s" % str(made.keys()))
|
||||
|
||||
print("TEST_LANCER_ENEMY: PASS")
|
||||
quit(0)
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
print("TEST_LANCER_ENEMY: FAIL (%s)" % message)
|
||||
quit(1)
|
||||
return true
|
||||
Loading…
Reference in a new issue