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
|
|
@ -39,5 +39,5 @@ func _ready() -> void:
|
|||
for offset in [Vector2(-65, 0), Vector2(45, -45), Vector2(55, 65)]:
|
||||
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
||||
enemy.position = skill.aim_target + offset
|
||||
enemy.speed = 0.0
|
||||
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:
|
||||
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)]:
|
||||
var enemy: Node = load("res://Enemy.tscn").instantiate()
|
||||
enemy.position = skill.aim_target + offset
|
||||
enemy.speed = 0.0
|
||||
main.add_child(enemy)
|
||||
enemy.speed = 0.0
|
||||
inside_enemies.append(enemy)
|
||||
outside_enemy = load("res://Enemy.tscn").instantiate()
|
||||
outside_enemy.position = skill.aim_target + Vector2(GasBombController.AOE_RADIUS + 80.0, 0)
|
||||
outside_enemy.speed = 0.0
|
||||
main.add_child(outside_enemy)
|
||||
outside_enemy.speed = 0.0
|
||||
|
||||
var release := InputEventScreenTouch.new()
|
||||
release.index = 7
|
||||
|
|
|
|||
Loading…
Reference in a new issue