spaceship/enemies/Enemy.gd
Loïc Wisniewski 3debcd7edd #101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.

EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.

Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.

What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.

Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00

99 lines
3 KiB
GDScript

extends Area2D
signal destroyed
const ENEMY_DEFINITIONS = preload("res://enemies/EnemyDefinitions.gd")
@export var enemy_id: String = "chaser"
var definition: Resource
var hp: int
# 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:
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()
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:
for child in get_children():
if child.has_method("tick"):
child.tick(delta)
# Called by a player vessel this has just collided with. Whether the impact
# destroys it is the definition's call - see EnemyDefinition.dies_on_contact.
# Routed through take_damage so it dies exactly as it dies to gunfire: same
# explosion, same `destroyed` signal, same bookkeeping for whatever is
# listening.
func collide_with_player_vessel() -> void:
_resolve_ram()
# #101: a structure resolves an impact by the same rule - whether it costs
# the enemy its life is the definition's call, not the target's type.
func collide_with_player_building() -> void:
_resolve_ram()
func _resolve_ram() -> void:
if definition != null and bool(definition.dies_on_contact):
take_damage(hp)
# What one strike against a structure takes off it. Data on the definition,
# for the same reason dies_on_contact is: a lancer arriving at 950px/s is not
# a sniper drifting into a wall.
func contact_damage() -> int:
return 6 if definition == null else int(definition.contact_damage)
func take_damage(amount: int) -> void:
hp -= amount
if hp <= 0:
EnemyExplosionVFX.spawn(get_tree().root, position)
destroyed.emit()
queue_free()
func _draw() -> void:
# Drawn through EnemyDefinitions.draw_definition, the same routine the
# map editor's markers and palette use, so a definition cannot end up
# looking one way where it is placed and another where it is fought.
if definition == null:
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
Vector2(0, -16), Vector2(12, 0), Vector2(0, 16), Vector2(-12, 0),
]), Color(1.0, 0.15, 0.2), 1.0)
return
# Constant glow - see Ship.gd. Nothing animates, so no per-frame redraw.
ENEMY_DEFINITIONS.draw_definition(self, definition, definition.glow_color)