Add enemy foundation: definitions, components, ranged damage (#44)
Enemy.gd now resolves stats/hull/color from EnemyDefinition resources and composes behaviour from capability components (EnemyPursuitComponent holds today's chase-the-nearest-ship logic), the same shape Ship.gd already uses for vessels. Fleet lookups go through a "player_fleet" group instead of a hardcoded ../Fleet sibling path, so an enemy parented anywhere (e.g. under a planet surface's SurfaceFeatures) still finds it. Also closes a real gap: enemies previously had no way to damage the fleet at range at all (Ship only reacted to physical contact, Projectile.gd was player-only). Ship.take_damage() is now a proper entry point - contact damage routes through it unchanged - and EnemyProjectile lets an enemy component fire at player_ships. Both ranged enemy archetypes in #45/#46 build on this. Part of the #40 conquest-mechanic breakdown.
This commit is contained in:
parent
5326852807
commit
f14ee656ea
20 changed files with 393 additions and 42 deletions
16
ChaserEnemyDefinition.tres
Normal file
16
ChaserEnemyDefinition.tres
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
|
||||||
|
[ext_resource type="PackedScene" path="res://EnemyPursuitComponent.tscn" id="2"]
|
||||||
|
|
||||||
|
[resource]
|
||||||
|
script = ExtResource("1")
|
||||||
|
enemy_id = "chaser"
|
||||||
|
display_name = "Chaser"
|
||||||
|
max_hp = 3
|
||||||
|
speed = 140.0
|
||||||
|
acceleration = 260.0
|
||||||
|
rotation_speed = 6.0
|
||||||
|
hull_points = PackedVector2Array(0, -16, 12, 0, 0, 16, -12, 0)
|
||||||
|
glow_color = Color(1, 0.15, 0.2, 1)
|
||||||
|
component_scenes = Array[PackedScene]([ExtResource("2")])
|
||||||
76
Enemy.gd
76
Enemy.gd
|
|
@ -2,50 +2,53 @@ extends Area2D
|
||||||
|
|
||||||
signal destroyed
|
signal destroyed
|
||||||
|
|
||||||
@export var speed: float = 140.0
|
const ENEMY_DEFINITIONS = preload("res://EnemyDefinitions.gd")
|
||||||
@export var acceleration: float = 260.0
|
|
||||||
@export var rotation_speed: float = 6.0
|
|
||||||
@export var max_hp: int = 3
|
|
||||||
|
|
||||||
var velocity: Vector2 = Vector2.ZERO
|
@export var enemy_id: String = "chaser"
|
||||||
|
|
||||||
|
var definition: Resource
|
||||||
var hp: int
|
var hp: int
|
||||||
|
|
||||||
@onready var fleet: Node2D = get_node("../Fleet")
|
# Mirrors Ship.gd's own max_speed/acceleration: copied from the definition
|
||||||
|
# once, then mutable, so a test/preview can pin an enemy in place (e.g.
|
||||||
|
# `enemy.speed = 0.0`) without needing to know about definitions at all.
|
||||||
|
var speed: float
|
||||||
|
var acceleration: float
|
||||||
|
var rotation_speed: float
|
||||||
|
var max_hp: int
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
hp = max_hp
|
|
||||||
add_to_group("enemies")
|
add_to_group("enemies")
|
||||||
|
definition = ENEMY_DEFINITIONS.get_definition(enemy_id)
|
||||||
|
speed = definition.speed
|
||||||
|
acceleration = definition.acceleration
|
||||||
|
rotation_speed = definition.rotation_speed
|
||||||
|
max_hp = definition.max_hp
|
||||||
|
hp = max_hp
|
||||||
|
for component_scene in definition.component_scenes:
|
||||||
|
var component: Node = component_scene.instantiate()
|
||||||
|
add_child(component)
|
||||||
|
if component.has_method("setup"):
|
||||||
|
component.setup()
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func get_component(capability: String) -> Node:
|
||||||
|
for child in get_children():
|
||||||
|
if child.get("capability_id") == capability:
|
||||||
|
return child
|
||||||
|
return null
|
||||||
|
|
||||||
|
|
||||||
|
func has_capability(capability: String) -> bool:
|
||||||
|
return get_component(capability) != null
|
||||||
|
|
||||||
|
|
||||||
func _physics_process(delta: float) -> void:
|
func _physics_process(delta: float) -> void:
|
||||||
var target_pos: Vector2 = _nearest_ship_position()
|
for child in get_children():
|
||||||
var to_target: Vector2 = target_pos - position
|
if child.has_method("tick"):
|
||||||
|
child.tick(delta)
|
||||||
if to_target.length() > 1.0:
|
|
||||||
var desired_velocity: Vector2 = to_target.normalized() * speed
|
|
||||||
velocity = velocity.move_toward(desired_velocity, acceleration * delta)
|
|
||||||
position += velocity * delta
|
|
||||||
|
|
||||||
if velocity.length() > 5.0:
|
|
||||||
var target_angle: float = velocity.angle() + PI / 2.0
|
|
||||||
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
|
||||||
|
|
||||||
|
|
||||||
func _nearest_ship_position() -> Vector2:
|
|
||||||
if fleet.ships.is_empty():
|
|
||||||
return position
|
|
||||||
|
|
||||||
var nearest: Node2D = fleet.ships[0]
|
|
||||||
var best_dist: float = position.distance_to(nearest.position)
|
|
||||||
for ship in fleet.ships:
|
|
||||||
var d: float = position.distance_to(ship.position)
|
|
||||||
if d < best_dist:
|
|
||||||
best_dist = d
|
|
||||||
nearest = ship
|
|
||||||
return nearest.position
|
|
||||||
|
|
||||||
|
|
||||||
func take_damage(amount: int) -> void:
|
func take_damage(amount: int) -> void:
|
||||||
|
|
@ -57,8 +60,9 @@ func take_damage(amount: int) -> void:
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
var points: PackedVector2Array = PackedVector2Array([
|
var points: PackedVector2Array = definition.hull_points if definition != null else PackedVector2Array([
|
||||||
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0)
|
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
|
||||||
])
|
])
|
||||||
|
var glow: Color = definition.glow_color if definition != null else Color(1.0, 0.15, 0.2)
|
||||||
# Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw.
|
# Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw.
|
||||||
NeonDraw.draw_glowing_polygon(self, points, Color(1.0, 0.15, 0.2), 1.0)
|
NeonDraw.draw_glowing_polygon(self, points, glow, 1.0)
|
||||||
|
|
|
||||||
19
EnemyDefinition.gd
Normal file
19
EnemyDefinition.gd
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
class_name EnemyDefinition
|
||||||
|
extends Resource
|
||||||
|
|
||||||
|
# Data-driven enemy stats and appearance, mirroring VesselDefinition's role
|
||||||
|
# for player ships. Enemy.gd instantiates component_scenes exactly like
|
||||||
|
# Ship.gd does, so behaviour (pursuit, ranged fire, ...) lives in capability
|
||||||
|
# components rather than in a type switch here.
|
||||||
|
|
||||||
|
@export var enemy_id: String = "chaser"
|
||||||
|
@export var display_name: String = "Chaser"
|
||||||
|
@export var max_hp: int = 3
|
||||||
|
@export var speed: float = 140.0
|
||||||
|
@export var acceleration: float = 260.0
|
||||||
|
@export var rotation_speed: float = 6.0
|
||||||
|
@export var hull_points: PackedVector2Array = PackedVector2Array([
|
||||||
|
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
|
||||||
|
])
|
||||||
|
@export var glow_color: Color = Color(1.0, 0.15, 0.2)
|
||||||
|
@export var component_scenes: Array[PackedScene] = []
|
||||||
1
EnemyDefinition.gd.uid
Normal file
1
EnemyDefinition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bkgt6o1m12l0g
|
||||||
19
EnemyDefinitions.gd
Normal file
19
EnemyDefinitions.gd
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
class_name EnemyDefinitions
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const DEFAULT_ID: String = "chaser"
|
||||||
|
const ORDER: PackedStringArray = ["chaser"]
|
||||||
|
const DEFINITIONS: Dictionary = {
|
||||||
|
"chaser": preload("res://ChaserEnemyDefinition.tres"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static func get_definition(enemy_id: String) -> Resource:
|
||||||
|
return DEFINITIONS.get(enemy_id, DEFINITIONS[DEFAULT_ID])
|
||||||
|
|
||||||
|
|
||||||
|
static func all() -> Array:
|
||||||
|
var result: Array = []
|
||||||
|
for enemy_id in ORDER:
|
||||||
|
result.append(DEFINITIONS[enemy_id])
|
||||||
|
return result
|
||||||
1
EnemyDefinitions.gd.uid
Normal file
1
EnemyDefinitions.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cfc4w3atpf8yj
|
||||||
45
EnemyProjectile.gd
Normal file
45
EnemyProjectile.gd
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
extends Area2D
|
||||||
|
|
||||||
|
# Mirrors Projectile.gd, but travels the other direction: fired by an enemy
|
||||||
|
# component (see EnemyRangedWeaponComponent, #45) and damages player_ships.
|
||||||
|
# This is the half of ranged combat that never existed before #44 - enemies
|
||||||
|
# could previously only hurt the fleet by physical contact.
|
||||||
|
|
||||||
|
@export var speed: float = 260.0
|
||||||
|
@export var lifetime: float = 3.0
|
||||||
|
@export var damage: int = 1
|
||||||
|
@export var bolt_length: float = 20.0
|
||||||
|
@export var color: Color = Color(1.0, 0.4, 0.35)
|
||||||
|
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
var age: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group("enemy_projectiles")
|
||||||
|
area_entered.connect(_on_area_entered)
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func launch(direction: Vector2) -> void:
|
||||||
|
velocity = direction.normalized() * speed
|
||||||
|
rotation = direction.angle() + PI / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
func _physics_process(delta: float) -> void:
|
||||||
|
position += velocity * delta
|
||||||
|
age += delta
|
||||||
|
if age >= lifetime:
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_area_entered(area: Area2D) -> void:
|
||||||
|
if area.is_in_group("player_ships"):
|
||||||
|
area.take_damage(damage)
|
||||||
|
queue_free()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var tip: Vector2 = Vector2(0, -bolt_length * 0.6)
|
||||||
|
var tail: Vector2 = Vector2(0, bolt_length * 0.4)
|
||||||
|
NeonDraw.draw_glowing_line(self, tail, tip, color, 1.0, 0.5)
|
||||||
1
EnemyProjectile.gd.uid
Normal file
1
EnemyProjectile.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b4hja3oj3rdrc
|
||||||
12
EnemyProjectile.tscn
Normal file
12
EnemyProjectile.tscn
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
[gd_scene load_steps=3 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyProjectile.gd" id="1"]
|
||||||
|
|
||||||
|
[sub_resource type="CircleShape2D" id="1"]
|
||||||
|
radius = 4.0
|
||||||
|
|
||||||
|
[node name="EnemyProjectile" type="Area2D"]
|
||||||
|
script = ExtResource("1")
|
||||||
|
|
||||||
|
[node name="CollisionShape2D" type="CollisionShape2D" parent="."]
|
||||||
|
shape = SubResource("1")
|
||||||
38
EnemyPursuitComponent.gd
Normal file
38
EnemyPursuitComponent.gd
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
class_name EnemyPursuitComponent
|
||||||
|
extends Node
|
||||||
|
|
||||||
|
# Chases the nearest player ship. Moved out of Enemy.gd verbatim so the
|
||||||
|
# behaviour is opt-in per definition rather than hardcoded into every enemy.
|
||||||
|
|
||||||
|
var capability_id: String = "pursuit"
|
||||||
|
var velocity: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
|
func tick(delta: float) -> void:
|
||||||
|
var enemy: Node2D = get_parent()
|
||||||
|
var target_pos: Vector2 = _nearest_ship_position(enemy)
|
||||||
|
var to_target: Vector2 = target_pos - enemy.position
|
||||||
|
|
||||||
|
if to_target.length() > 1.0:
|
||||||
|
var desired_velocity: Vector2 = to_target.normalized() * enemy.speed
|
||||||
|
velocity = velocity.move_toward(desired_velocity, enemy.acceleration * delta)
|
||||||
|
enemy.position += velocity * delta
|
||||||
|
|
||||||
|
if velocity.length() > 5.0:
|
||||||
|
var target_angle: float = velocity.angle() + PI / 2.0
|
||||||
|
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
|
||||||
|
|
||||||
|
|
||||||
|
func _nearest_ship_position(enemy: Node2D) -> Vector2:
|
||||||
|
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
|
||||||
|
if fleet == null or fleet.ships.is_empty():
|
||||||
|
return enemy.position
|
||||||
|
|
||||||
|
var nearest: Node2D = fleet.ships[0]
|
||||||
|
var best_dist: float = enemy.position.distance_to(nearest.position)
|
||||||
|
for ship in fleet.ships:
|
||||||
|
var d: float = enemy.position.distance_to(ship.position)
|
||||||
|
if d < best_dist:
|
||||||
|
best_dist = d
|
||||||
|
nearest = ship
|
||||||
|
return nearest.position
|
||||||
1
EnemyPursuitComponent.gd.uid
Normal file
1
EnemyPursuitComponent.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://n8wohkrnl42e
|
||||||
6
EnemyPursuitComponent.tscn
Normal file
6
EnemyPursuitComponent.tscn
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://EnemyPursuitComponent.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="EnemyPursuitComponent" type="Node"]
|
||||||
|
script = ExtResource("1")
|
||||||
4
Fleet.gd
4
Fleet.gd
|
|
@ -32,6 +32,10 @@ var steer_direction: Vector2 = Vector2.ZERO
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
|
# Lets anything resolve "the fleet" without a hardcoded ../Fleet sibling
|
||||||
|
# path - enemies parented under different hosts (e.g. a planet surface's
|
||||||
|
# SurfaceFeatures) need this to find their target.
|
||||||
|
add_to_group("player_fleet")
|
||||||
# Researching a vessel upgrade improves the ships already flying, not
|
# Researching a vessel upgrade improves the ships already flying, not
|
||||||
# just the next one built (#5).
|
# just the next one built (#5).
|
||||||
GameState.vessel_upgraded.connect(_on_vessel_upgraded)
|
GameState.vessel_upgraded.connect(_on_vessel_upgraded)
|
||||||
|
|
|
||||||
22
Ship.gd
22
Ship.gd
|
|
@ -115,17 +115,27 @@ func _physics_process(delta: float) -> void:
|
||||||
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
rotation = lerp_angle(rotation, target_angle, rotation_speed * delta)
|
||||||
|
|
||||||
|
|
||||||
func _on_area_entered(area: Area2D) -> void:
|
func take_damage(_amount: int) -> bool:
|
||||||
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
|
# Ships have no hit points - any unabsorbed hit is lethal. A shield
|
||||||
return
|
# charge absorbs a hit entirely regardless of its amount, matching
|
||||||
|
# VesselShieldComponent's existing all-or-nothing semantics; `_amount`
|
||||||
|
# is accepted (not `int`-clamped away) so callers like EnemyProjectile
|
||||||
|
# have a normal damage parameter to pass even though it isn't graded here.
|
||||||
for child in get_children():
|
for child in get_children():
|
||||||
if child.has_method("absorb_hit") and child.absorb_hit():
|
if child.has_method("absorb_hit") and child.absorb_hit():
|
||||||
if area.is_in_group("asteroids") and is_instance_valid(area):
|
return true
|
||||||
area.queue_free()
|
|
||||||
return
|
|
||||||
ShipExplosionVFX.spawn(get_tree().root, position)
|
ShipExplosionVFX.spawn(get_tree().root, position)
|
||||||
destroyed.emit()
|
destroyed.emit()
|
||||||
queue_free()
|
queue_free()
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _on_area_entered(area: Area2D) -> void:
|
||||||
|
if not (area.is_in_group("enemies") or area.is_in_group("asteroids")):
|
||||||
|
return
|
||||||
|
var absorbed: bool = take_damage(1)
|
||||||
|
if absorbed and area.is_in_group("asteroids") and is_instance_valid(area):
|
||||||
|
area.queue_free()
|
||||||
|
|
||||||
|
|
||||||
func _draw() -> void:
|
func _draw() -> void:
|
||||||
|
|
|
||||||
|
|
@ -39,5 +39,5 @@ func _ready() -> void:
|
||||||
for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]:
|
for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]:
|
||||||
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
||||||
enemy.position = skill.aim_target + offset
|
enemy.position = skill.aim_target + offset
|
||||||
enemy.speed = 0.0
|
|
||||||
main.add_child(enemy)
|
main.add_child(enemy)
|
||||||
|
enemy.speed = 0.0 # after add_child - _ready() sets it from the definition
|
||||||
|
|
|
||||||
78
tests/test_enemy_composition.gd
Normal file
78
tests/test_enemy_composition.gd
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_enemy_composition.gd
|
||||||
|
# Verifies:
|
||||||
|
# 1. an Enemy resolves its stats/hull/color from EnemyDefinitions, not
|
||||||
|
# hardcoded fields
|
||||||
|
# 2. an enemy instantiated under a node that is NOT a sibling of Fleet
|
||||||
|
# still finds the fleet and pursues it (the old `../Fleet` path would
|
||||||
|
# break here - this is exactly the planet-surface hazard #44 exists
|
||||||
|
# to close before #45/#46 land)
|
||||||
|
|
||||||
|
var main_scene: Node
|
||||||
|
var fleet: Node
|
||||||
|
var nested_host: Node2D
|
||||||
|
var enemy: Node
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
var stage: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
var chaser_def: Resource = EnemyDefinitions.get_definition("chaser")
|
||||||
|
if chaser_def.enemy_id != "chaser" or chaser_def.max_hp != 3:
|
||||||
|
return _fail("EnemyDefinitions did not resolve the chaser definition")
|
||||||
|
|
||||||
|
main_scene = load("res://Main.tscn").instantiate()
|
||||||
|
root.add_child(main_scene)
|
||||||
|
fleet = main_scene.get_node("Fleet")
|
||||||
|
main_scene.get_node("EnemySpawner").set_process(false)
|
||||||
|
|
||||||
|
# Deliberately NOT a sibling of Fleet - two levels deep under an
|
||||||
|
# unrelated node, the way a planet surface's SurfaceFeatures parents
|
||||||
|
# things.
|
||||||
|
nested_host = Node2D.new()
|
||||||
|
nested_host.name = "NestedHost"
|
||||||
|
main_scene.add_child(nested_host)
|
||||||
|
var inner: Node2D = Node2D.new()
|
||||||
|
inner.name = "Inner"
|
||||||
|
nested_host.add_child(inner)
|
||||||
|
|
||||||
|
enemy = load("res://Enemy.tscn").instantiate()
|
||||||
|
enemy.position = fleet.get_centroid() + Vector2(600, 0)
|
||||||
|
inner.add_child(enemy)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
if elapsed < 0.1:
|
||||||
|
return false
|
||||||
|
if enemy.definition == null or enemy.definition.enemy_id != "chaser":
|
||||||
|
return _fail("enemy did not resolve its definition on _ready")
|
||||||
|
if enemy.hp != enemy.definition.max_hp:
|
||||||
|
return _fail("enemy hp did not come from the definition")
|
||||||
|
if not enemy.has_capability("pursuit"):
|
||||||
|
return _fail("chaser did not compose a pursuit component")
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
# Give the pursuit component a few physics ticks to close distance.
|
||||||
|
if elapsed < 1.0:
|
||||||
|
return false
|
||||||
|
var distance: float = enemy.position.distance_to(fleet.get_centroid())
|
||||||
|
print("distance_after_1s=%.1f (started at 600)" % distance)
|
||||||
|
if distance >= 550.0:
|
||||||
|
return _fail("enemy under a non-sibling host never approached the fleet")
|
||||||
|
print("TEST_ENEMY_COMPOSITION: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_ENEMY_COMPOSITION: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_enemy_composition.gd.uid
Normal file
1
tests/test_enemy_composition.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dltu3a0n318lo
|
||||||
91
tests/test_enemy_projectile_damage.gd
Normal file
91
tests/test_enemy_projectile_damage.gd
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
# Headless gameplay test: run with
|
||||||
|
# godot --headless -s res://tests/test_enemy_projectile_damage.gd
|
||||||
|
# Verifies the ranged damage path #44 adds: an EnemyProjectile destroys an
|
||||||
|
# unshielded ship on contact, and is absorbed by a shielded ship, removing
|
||||||
|
# exactly one charge rather than destroying it.
|
||||||
|
|
||||||
|
var game_state: Node
|
||||||
|
var scene: Node
|
||||||
|
var fleet: Node
|
||||||
|
var interceptor: Node
|
||||||
|
var shield_ship: Node
|
||||||
|
var stage: int = 0
|
||||||
|
var elapsed: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
game_state = root.get_node("GameState")
|
||||||
|
game_state.buildings.clear()
|
||||||
|
game_state.extractors.clear()
|
||||||
|
game_state.transporters.clear()
|
||||||
|
game_state.stored_vessels.clear()
|
||||||
|
game_state.build_time_scale = 0.02
|
||||||
|
game_state.reset_progression()
|
||||||
|
game_state.rocks = 2000
|
||||||
|
scene = load("res://Main.tscn").instantiate()
|
||||||
|
root.add_child(scene)
|
||||||
|
fleet = scene.get_node("Fleet")
|
||||||
|
|
||||||
|
|
||||||
|
func _fire_at(ship: Node) -> void:
|
||||||
|
var projectile: Area2D = load("res://EnemyProjectile.tscn").instantiate()
|
||||||
|
projectile.global_position = ship.global_position
|
||||||
|
projectile.launch(Vector2.ZERO) # already on top of the target
|
||||||
|
scene.add_child(projectile)
|
||||||
|
|
||||||
|
|
||||||
|
func _process(delta: float) -> bool:
|
||||||
|
elapsed += delta
|
||||||
|
match stage:
|
||||||
|
0:
|
||||||
|
scene.get_node("AsteroidField").set_process(false)
|
||||||
|
scene.get_node("EnemySpawner").set_process(false)
|
||||||
|
fleet.spawn_ships(1, Vector2.ZERO)
|
||||||
|
interceptor = fleet.ships[0]
|
||||||
|
fleet.add_ship(GameState.VESSEL_SHIELD)
|
||||||
|
shield_ship = fleet.ships[1]
|
||||||
|
shield_ship.position = Vector2(2000.0, 0.0)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 1
|
||||||
|
return false
|
||||||
|
1:
|
||||||
|
if elapsed < 0.2:
|
||||||
|
return false
|
||||||
|
if not shield_ship.has_shield():
|
||||||
|
return _fail("a fresh shield vessel has no shield")
|
||||||
|
_fire_at(interceptor)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 2
|
||||||
|
return false
|
||||||
|
2:
|
||||||
|
if elapsed < 0.4:
|
||||||
|
return false
|
||||||
|
if is_instance_valid(interceptor):
|
||||||
|
return _fail("an unshielded ship survived an enemy projectile")
|
||||||
|
print("unshielded interceptor destroyed by an enemy projectile")
|
||||||
|
|
||||||
|
_fire_at(shield_ship)
|
||||||
|
elapsed = 0.0
|
||||||
|
stage = 3
|
||||||
|
return false
|
||||||
|
3:
|
||||||
|
if elapsed < 0.4:
|
||||||
|
return false
|
||||||
|
if not is_instance_valid(shield_ship):
|
||||||
|
return _fail("a shielded ship was destroyed by an enemy projectile")
|
||||||
|
if shield_ship.shield_charges != 0:
|
||||||
|
return _fail("shield charges did not drop to 0 after absorbing one hit (charges=%d)" %
|
||||||
|
shield_ship.shield_charges)
|
||||||
|
print("shielded ship absorbed the hit and lost exactly one charge")
|
||||||
|
print("TEST_ENEMY_PROJECTILE_DAMAGE: PASS")
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
|
return false
|
||||||
|
|
||||||
|
|
||||||
|
func _fail(message: String) -> bool:
|
||||||
|
print("TEST_ENEMY_PROJECTILE_DAMAGE: FAIL (%s)" % message)
|
||||||
|
quit(1)
|
||||||
|
return true
|
||||||
1
tests/test_enemy_projectile_damage.gd.uid
Normal file
1
tests/test_enemy_projectile_damage.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cx7f3e82jkumb
|
||||||
|
|
@ -107,16 +107,19 @@ func _process(delta: float) -> bool:
|
||||||
if main.get_node("Camera2D").zoom.x >= original_zoom:
|
if main.get_node("Camera2D").zoom.x >= original_zoom:
|
||||||
return _fail_now("long aim did not zoom out")
|
return _fail_now("long aim did not zoom out")
|
||||||
|
|
||||||
|
# speed is set AFTER add_child(): _ready() copies it from the
|
||||||
|
# enemy's definition, which would otherwise clobber a
|
||||||
|
# pre-add_child override the instant the enemy entered the tree.
|
||||||
for offset in [Vector2(-60, 0), Vector2(70, 35)]:
|
for offset in [Vector2(-60, 0), Vector2(70, 35)]:
|
||||||
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
||||||
enemy.position = skill.aim_target + offset
|
enemy.position = skill.aim_target + offset
|
||||||
enemy.speed = 0.0
|
|
||||||
main.add_child(enemy)
|
main.add_child(enemy)
|
||||||
|
enemy.speed = 0.0
|
||||||
inside_enemies.append(enemy)
|
inside_enemies.append(enemy)
|
||||||
outside_enemy = load("res://Enemy.tscn").instantiate()
|
outside_enemy = load("res://Enemy.tscn").instantiate()
|
||||||
outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0)
|
outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0)
|
||||||
outside_enemy.speed = 0.0
|
|
||||||
main.add_child(outside_enemy)
|
main.add_child(outside_enemy)
|
||||||
|
outside_enemy.speed = 0.0
|
||||||
|
|
||||||
var release := InputEventScreenTouch.new()
|
var release := InputEventScreenTouch.new()
|
||||||
release.index = 7
|
release.index = 7
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue