diff --git a/enemies/EnemyDefinitions.gd b/enemies/EnemyDefinitions.gd index ba34fea..8d2d893 100644 --- a/enemies/EnemyDefinitions.gd +++ b/enemies/EnemyDefinitions.gd @@ -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"), diff --git a/enemies/EnemyLungeComponent.gd b/enemies/EnemyLungeComponent.gd new file mode 100644 index 0000000..41f6b02 --- /dev/null +++ b/enemies/EnemyLungeComponent.gd @@ -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) diff --git a/enemies/EnemyLungeComponent.tscn b/enemies/EnemyLungeComponent.tscn new file mode 100644 index 0000000..eeddb21 --- /dev/null +++ b/enemies/EnemyLungeComponent.tscn @@ -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") diff --git a/enemies/EnemySpawner.gd b/enemies/EnemySpawner.gd index f70ab84..759e19b 100644 --- a/enemies/EnemySpawner.gd +++ b/enemies/EnemySpawner.gd @@ -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) diff --git a/enemies/LancerEnemyDefinition.tres b/enemies/LancerEnemyDefinition.tres new file mode 100644 index 0000000..40dc2b4 --- /dev/null +++ b/enemies/LancerEnemyDefinition.tres @@ -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")]) diff --git a/tests/test_lancer_enemy.gd b/tests/test_lancer_enemy.gd new file mode 100644 index 0000000..5adc7dd --- /dev/null +++ b/tests/test_lancer_enemy.gd @@ -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. 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