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. 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