277 lines
12 KiB
GDScript3
277 lines
12 KiB
GDScript3
|
|
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
|