Enemy foundation: definitions, capability components, and damage to the fleet #44

Closed
opened 2026-08-15 11:16:20 +00:00 by metaheavens · 1 comment
Owner

Part of #40. Prerequisite for both new enemy archetypes.

Two problems block them today:

  • Enemy.gd is one hardcoded chaser with no definition/component structure, unlike Ship/Building/vessels after #34-#35.
  • Enemies cannot damage the fleet at range at all. Ship._on_area_entered only reacts to physical contact with enemies/asteroids, and Projectile.gd is player-only (player_projectiles -> enemies). Both requested enemies are ranged, so the reverse damage path has to exist first.
  • Enemy.gd resolves the fleet with @onready var fleet := get_node("../Fleet"), which breaks the moment an enemy is parented under SurfaceFeatures on a planet surface.

Scope - A. Definitions and components

  1. EnemyDefinition.gd - class_name EnemyDefinition extends Resource: enemy_id, display_name, max_hp, speed, acceleration, rotation_speed, hull_points: PackedVector2Array, glow_color, component_scenes: Array[PackedScene]. Mirror VesselDefinition.gd.
  2. EnemyDefinitions.gd - registry with get_definition(id) and a default, mirroring VesselDefinitions.gd.
  3. Enemy.gd - @export var enemy_id: String = "chaser"; resolve the definition in _ready(), instantiate component_scenes as children, add get_component(capability) / has_capability(capability), tick components from _physics_process (the has_method("tick") loop Ship.gd uses), and draw definition.hull_points in definition.glow_color.
  4. EnemyPursuitComponent.tscn + .gd (capability_id = "pursuit") - today's chase-the-nearest-ship movement, moved out of Enemy.gd verbatim.
  5. ChaserEnemyDefinition.tres - reproduces the current enemy exactly: speed 140, acceleration 260, rotation_speed 6, max_hp 3, hull [(0,-16),(12,0),(0,16),(-12,0)], colour Color(1.0, 0.15, 0.2), pursuit component.
  6. Fleet lookup that works wherever the enemy is parented: add Fleet to a player_fleet group in Fleet._ready() and resolve via get_tree().get_first_node_in_group("player_fleet"). Remove every get_node("../Fleet") from enemy code.
  7. EnemySpawner.gd keeps working unchanged in space, spawning the chaser.

Scope - B. Ranged damage to the fleet

  1. EnemyProjectile.tscn + EnemyProjectile.gd - mirrors Projectile.gd but joins group enemy_projectiles and damages player_ships. Exported speed, damage, lifetime, colour.
  2. Ship.gd - extract the damage path out of _on_area_entered into func take_damage(amount: int) -> void: offer the hit to components implementing absorb_hit() (the shield) first, then explode and free. _on_area_entered becomes contact -> take_damage(1). Preserve exactly the existing behaviour where a shielded contact hit also destroys the colliding asteroid.
  3. EnemyProjectile._on_area_entered calls take_damage(damage) on player_ships members and frees itself.

Out of scope

The sniper and artillery enemies themselves - they are separate tickets that consume this.

Acceptance criteria

  • tests/test_enemy_combat.gd, tests/test_enemy_collision.gd and tests/test_shield_vessel.gd pass with behaviour unchanged.
  • An enemy instantiated under a node that is not a sibling of Fleet still finds the fleet and pursues it.
  • An enemy projectile destroys an unshielded interceptor; against a shielded vessel it is absorbed and removes exactly one charge.
  • Every enemy stat and visual comes from the definition; no enemy_id string comparison exists outside EnemyDefinitions.gd.
  • Full suite green.

Tests

tests/test_enemy_composition.gd and tests/test_enemy_projectile_damage.gd. bash tests/run_tests.sh enemy, then the full suite.

Repo notes (read before starting)

  • Godot 4.7.1, portrait 720x1280, touch UI. Project dir: /storage/emulated/0/GodotProjects/SpaceshipTest.
  • After editing any project file, run touch on it. Android FUSE does not bump mtime under ~/storage/shared, and Godot's import cache will silently keep serving the old version, making a correct fix look like it did nothing.
  • Headless tests: bash tests/run_tests.sh <substring>; full suite with no argument. Tests are SceneTree scripts printing TEST_NAME: PASS / TEST_NAME: FAIL (reason) and calling quit(0|1) - copy the shape of tests/test_gas_bomber_cooldowns.gd.
  • Real screenshots: bash tests/screenshot.sh <res://Scene.tscn> <out.png> <frames> 720x1280. Use few frames (~10-20); the software rasterizer is slow. --headless renders nothing at all, so it cannot validate visuals.
  • Drawing goes through NeonDraw / NeonPiece; do not introduce a different visual idiom.
  • The repo deliberately has no type switches left (tickets #31-#35): behaviour comes from *Definition resources plus capability component scenes. Do not add if type == "..." branches.
  • There is no persistence layer (no FileAccess, no user:// anywhere outside this ticket's explicit scope). GameState is an in-memory autoload. Do not invent a save system.
  • Commit each ticket separately and run the full suite before pushing.
Part of #40. Prerequisite for both new enemy archetypes. Two problems block them today: - `Enemy.gd` is one hardcoded chaser with no definition/component structure, unlike `Ship`/`Building`/vessels after #34-#35. - **Enemies cannot damage the fleet at range at all.** `Ship._on_area_entered` only reacts to physical contact with `enemies`/`asteroids`, and `Projectile.gd` is player-only (`player_projectiles` -> `enemies`). Both requested enemies are ranged, so the reverse damage path has to exist first. - `Enemy.gd` resolves the fleet with `@onready var fleet := get_node("../Fleet")`, which breaks the moment an enemy is parented under `SurfaceFeatures` on a planet surface. ### Scope - A. Definitions and components 1. `EnemyDefinition.gd` - `class_name EnemyDefinition extends Resource`: `enemy_id`, `display_name`, `max_hp`, `speed`, `acceleration`, `rotation_speed`, `hull_points: PackedVector2Array`, `glow_color`, `component_scenes: Array[PackedScene]`. Mirror `VesselDefinition.gd`. 2. `EnemyDefinitions.gd` - registry with `get_definition(id)` and a default, mirroring `VesselDefinitions.gd`. 3. `Enemy.gd` - `@export var enemy_id: String = "chaser"`; resolve the definition in `_ready()`, instantiate `component_scenes` as children, add `get_component(capability)` / `has_capability(capability)`, tick components from `_physics_process` (the `has_method("tick")` loop `Ship.gd` uses), and draw `definition.hull_points` in `definition.glow_color`. 4. `EnemyPursuitComponent.tscn` + `.gd` (`capability_id = "pursuit"`) - today's chase-the-nearest-ship movement, moved out of `Enemy.gd` verbatim. 5. `ChaserEnemyDefinition.tres` - reproduces the current enemy exactly: speed 140, acceleration 260, rotation_speed 6, max_hp 3, hull `[(0,-16),(12,0),(0,16),(-12,0)]`, colour `Color(1.0, 0.15, 0.2)`, pursuit component. 6. Fleet lookup that works wherever the enemy is parented: add `Fleet` to a `player_fleet` group in `Fleet._ready()` and resolve via `get_tree().get_first_node_in_group("player_fleet")`. Remove every `get_node("../Fleet")` from enemy code. 7. `EnemySpawner.gd` keeps working unchanged in space, spawning the chaser. ### Scope - B. Ranged damage to the fleet 8. `EnemyProjectile.tscn` + `EnemyProjectile.gd` - mirrors `Projectile.gd` but joins group `enemy_projectiles` and damages `player_ships`. Exported `speed`, `damage`, `lifetime`, colour. 9. `Ship.gd` - extract the damage path out of `_on_area_entered` into `func take_damage(amount: int) -> void`: offer the hit to components implementing `absorb_hit()` (the shield) first, then explode and free. `_on_area_entered` becomes contact -> `take_damage(1)`. **Preserve exactly** the existing behaviour where a shielded contact hit also destroys the colliding asteroid. 10. `EnemyProjectile._on_area_entered` calls `take_damage(damage)` on `player_ships` members and frees itself. ### Out of scope The sniper and artillery enemies themselves - they are separate tickets that consume this. ### Acceptance criteria - `tests/test_enemy_combat.gd`, `tests/test_enemy_collision.gd` and `tests/test_shield_vessel.gd` pass with behaviour unchanged. - An enemy instantiated under a node that is *not* a sibling of `Fleet` still finds the fleet and pursues it. - An enemy projectile destroys an unshielded interceptor; against a shielded vessel it is absorbed and removes exactly one charge. - Every enemy stat and visual comes from the definition; no `enemy_id` string comparison exists outside `EnemyDefinitions.gd`. - Full suite green. ### Tests `tests/test_enemy_composition.gd` and `tests/test_enemy_projectile_damage.gd`. `bash tests/run_tests.sh enemy`, then the full suite. ### Repo notes (read before starting) - Godot 4.7.1, portrait 720x1280, touch UI. Project dir: `/storage/emulated/0/GodotProjects/SpaceshipTest`. - **After editing any project file, run `touch` on it.** Android FUSE does not bump mtime under `~/storage/shared`, and Godot's import cache will silently keep serving the old version, making a correct fix look like it did nothing. - Headless tests: `bash tests/run_tests.sh <substring>`; full suite with no argument. Tests are `SceneTree` scripts printing `TEST_NAME: PASS` / `TEST_NAME: FAIL (reason)` and calling `quit(0|1)` - copy the shape of `tests/test_gas_bomber_cooldowns.gd`. - Real screenshots: `bash tests/screenshot.sh <res://Scene.tscn> <out.png> <frames> 720x1280`. Use few frames (~10-20); the software rasterizer is slow. `--headless` renders nothing at all, so it cannot validate visuals. - Drawing goes through `NeonDraw` / `NeonPiece`; do not introduce a different visual idiom. - The repo deliberately has **no type switches** left (tickets #31-#35): behaviour comes from `*Definition` resources plus capability component scenes. Do not add `if type == "..."` branches. - There is **no persistence layer** (no `FileAccess`, no `user://` anywhere outside this ticket's explicit scope). `GameState` is an in-memory autoload. Do not invent a save system. - Commit each ticket separately and run the full suite before pushing.
Author
Owner

Implemented and pushed as f14ee65.

  • EnemyDefinition/EnemyDefinitions + capability components (EnemyPursuitComponent holds today's chase logic, moved verbatim out of Enemy.gd), same shape as Ship.gd/VesselDefinition.
  • Enemies resolve "the fleet" via a player_fleet group instead of a hardcoded ../Fleet sibling path, so one parented anywhere (e.g. under a planet surface's SurfaceFeatures) still finds it - this was a real bug that would have hit both #45 and #46 the moment an enemy spawned on a landed surface.
  • Closed the other real gap: enemies had no way to damage the fleet at range at all. Ship.take_damage() is now a proper entry point (contact damage routes through it unchanged, verified the shielded-hit-destroys-the-asteroid behavior is preserved exactly), and EnemyProjectile lets a ranged component fire at player_ships.

New tests tests/test_enemy_composition.gd (definition-driven stats, works from a non-sibling parent) and tests/test_enemy_projectile_damage.gd (unshielded ship dies, shielded ship absorbs and loses exactly one charge). Full 71/71 regression suite passed clean, including one real fix caught along the way: two existing tests set enemy.speed = 0.0 before add_child() to freeze a test enemy in place, which the new _ready()-driven stat copy was clobbering - moved those overrides to after add_child().

#45 and #46 build directly on this.

Implemented and pushed as `f14ee65`. - `EnemyDefinition`/`EnemyDefinitions` + capability components (`EnemyPursuitComponent` holds today's chase logic, moved verbatim out of `Enemy.gd`), same shape as `Ship.gd`/`VesselDefinition`. - Enemies resolve "the fleet" via a `player_fleet` group instead of a hardcoded `../Fleet` sibling path, so one parented anywhere (e.g. under a planet surface's `SurfaceFeatures`) still finds it - this was a real bug that would have hit both #45 and #46 the moment an enemy spawned on a landed surface. - Closed the other real gap: enemies had no way to damage the fleet at range at all. `Ship.take_damage()` is now a proper entry point (contact damage routes through it unchanged, verified the shielded-hit-destroys-the-asteroid behavior is preserved exactly), and `EnemyProjectile` lets a ranged component fire at `player_ships`. New tests `tests/test_enemy_composition.gd` (definition-driven stats, works from a non-sibling parent) and `tests/test_enemy_projectile_damage.gd` (unshielded ship dies, shielded ship absorbs and loses exactly one charge). Full 71/71 regression suite passed clean, including one real fix caught along the way: two existing tests set `enemy.speed = 0.0` before `add_child()` to freeze a test enemy in place, which the new `_ready()`-driven stat copy was clobbering - moved those overrides to after `add_child()`. #45 and #46 build directly on this.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
metaheavens/spaceship#44
No description provided.