Enemy mothership #53

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

A large, slow, editor-placeable capital enemy that periodically launches smaller enemies and fires a heavily telegraphed beam capable of killing several player vessels at once.

Depends on #52 (turret vessel spawner) - it reuses that ticket's EnemySpawnerComponent rather than reimplementing spawning.

Scope

  1. Spawning: put two EnemySpawnerComponent instances on the definition rather than adding multi-type support to the component - one with spawned_enemy_id = "chaser" (short interval, e.g. 5s), one with spawned_enemy_id = "artillery" (long interval, e.g. 18s, max_alive = 2). Two instances of a single-purpose component is the composition the codebase already uses everywhere; a spawned_enemy_ids array would be the type-switch-shaped answer.
    • Judgment call to confirm or revise: "enemy bomber" in the original request is read here as the existing artillery enemy (slow telegraphed AOE), rather than inventing a fourth enemy type. Revisit if a distinct bomber is wanted.
  2. EnemyBigLaserComponent.tscn + .gd, capability_id = "big_laser":
    • @export var charge_time: float = 2.5, @export var fire_cooldown: float = 9.0, @export var beam_range: float = 1200.0, @export var beam_half_width: float = 35.0, @export var damage: int = 3
    • Charge phase: lock the aim direction at charge start (toward Fleet.get_centroid()) and hold it - it must not track the fleet while charging, or it cannot be dodged. Draw a widening beam-line telegraph along that direction for the whole charge_time, in the mothership's own colour, using the ring/arc idiom from ArtilleryShell._draw_ring adapted to a line.
    • Fire: instantaneous beam along the locked direction. Every node in group player_ships whose perpendicular distance to the segment is <= beam_half_width (and whose projection falls within the segment) takes Ship.take_damage(damage) - so a line of vessels dies together. Shield charges absorb per-ship, as everywhere else.
    • Brief post-fire beam flash, then cooldown.
  3. MothershipEnemyDefinition.tres: enemy_id = "enemy_mothership", max_hp = 60, speed = 35.0 (relatively slow), acceleration low, a large hull roughly 90-100 units across, distinct glow colour. Components: one pursuit (so it closes slowly), two spawners, one big laser.
  4. Register in EnemyDefinitions.ORDER and DEFINITIONS.

Acceptance criteria

  • The telegraph is visible for the full charge_time before any damage lands, and the aim does not re-track the fleet once charging starts.
  • Three ships lined up inside the beam all die from one shot; a ship that steps outside beam_half_width before the shot lands survives.
  • A shielded ship in the beam survives, losing exactly one charge.
  • It launches both chasers and artillery on their own intervals.
  • It closes on the fleet slowly and never reaches ramming speed.
  • No type switch: every stat comes from the definition, every behaviour from a component.

Tests

tests/test_enemy_mothership.gd (bash tests/run_tests.sh mothership), plus a 720x1280 screenshot of the charge telegraph mid-countdown. Note trap 6 in the repo notes: cache any scalar you need from a node that frees itself.

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_capture_points.gd.
  • Real screenshots: bash tests/screenshot.sh <res://Scene.tscn> <out.png> <frames> 720x1280. Use few frames (~15); 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 has no type switches (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://). GameState is an in-memory autoload. Do not invent a save system.
  • Commit this ticket separately and run the affected test groups plus the full suite before pushing.

Traps that have already cost hours on this codebase

  1. Never put a top-level const X = preload(...) in a test file that is itself the -s entry point. It evaluates during that file's own early compilation, which can run before autoloads are registered; any script it pulls in that references GameState inside a function body then fails to compile with Identifier not found: GameState. Use var X: Script plus load() inside _initialize() instead. See the header comment in tests/test_capture_points.gd.
  2. Assigning a plain [] to a typed Array[Resource] export from another script fails at runtime - the compiler only sees the target as Resource and cannot confirm the element type. Leave the declared default alone, or build the typed array inside the owning script.
  3. Fleet.spawn_ships(0, ...) does not empty the fleet - it falls back to one default interceptor by design. Clear fleet.ships explicitly if a test or preview needs genuinely zero ships.
  4. Setting ship.position without also setting ship.move_target lets Ship._physics_process steer it straight back toward the stale target within a few frames.
  5. Physics ticks can run ahead of a SceneTree test's own _process() clock under device load, even with --fixed-fps 60. Gate waits on the system's own state (a component's timer or flag), not on elapsed >= duration.
  6. A node that queue_free()s itself (projectiles, shells) must not be re-read inside a per-frame wait condition - cache the scalar you need before the wait.
  7. Preview scenes for screenshots are added to the tree from inside _screenshot_tool.gd's _initialize(), so their _ready() races the game scene's own _ready(). Do setup on the first _process() tick, and get_tree().paused = true once everything is placed if the scene must hold still.
A large, slow, editor-placeable capital enemy that periodically launches smaller enemies and fires a heavily telegraphed beam capable of killing several player vessels at once. **Depends on #52 (turret vessel spawner)** - it reuses that ticket's `EnemySpawnerComponent` rather than reimplementing spawning. ### Scope 1. **Spawning**: put **two** `EnemySpawnerComponent` instances on the definition rather than adding multi-type support to the component - one with `spawned_enemy_id = "chaser"` (short interval, e.g. 5s), one with `spawned_enemy_id = "artillery"` (long interval, e.g. 18s, `max_alive = 2`). Two instances of a single-purpose component is the composition the codebase already uses everywhere; a `spawned_enemy_ids` array would be the type-switch-shaped answer. - Judgment call to confirm or revise: "enemy bomber" in the original request is read here as the **existing** `artillery` enemy (slow telegraphed AOE), rather than inventing a fourth enemy type. Revisit if a distinct bomber is wanted. 2. `EnemyBigLaserComponent.tscn` + `.gd`, `capability_id = "big_laser"`: - `@export var charge_time: float = 2.5`, `@export var fire_cooldown: float = 9.0`, `@export var beam_range: float = 1200.0`, `@export var beam_half_width: float = 35.0`, `@export var damage: int = 3` - **Charge phase**: lock the aim direction at charge start (toward `Fleet.get_centroid()`) and hold it - it must not track the fleet while charging, or it cannot be dodged. Draw a widening beam-line telegraph along that direction for the whole `charge_time`, in the mothership's own colour, using the ring/arc idiom from `ArtilleryShell._draw_ring` adapted to a line. - **Fire**: instantaneous beam along the locked direction. Every node in group `player_ships` whose perpendicular distance to the segment is `<= beam_half_width` (and whose projection falls within the segment) takes `Ship.take_damage(damage)` - so a line of vessels dies together. Shield charges absorb per-ship, as everywhere else. - Brief post-fire beam flash, then cooldown. 3. `MothershipEnemyDefinition.tres`: `enemy_id = "enemy_mothership"`, `max_hp = 60`, `speed = 35.0` (relatively slow), `acceleration` low, a large hull roughly 90-100 units across, distinct glow colour. Components: one pursuit (so it closes slowly), two spawners, one big laser. 4. Register in `EnemyDefinitions.ORDER` and `DEFINITIONS`. ### Acceptance criteria - The telegraph is visible for the full `charge_time` before any damage lands, and the aim does **not** re-track the fleet once charging starts. - Three ships lined up inside the beam all die from one shot; a ship that steps outside `beam_half_width` before the shot lands survives. - A shielded ship in the beam survives, losing exactly one charge. - It launches both chasers and artillery on their own intervals. - It closes on the fleet slowly and never reaches ramming speed. - No type switch: every stat comes from the definition, every behaviour from a component. ### Tests `tests/test_enemy_mothership.gd` (`bash tests/run_tests.sh mothership`), plus a 720x1280 screenshot of the charge telegraph mid-countdown. Note trap 6 in the repo notes: cache any scalar you need from a node that frees itself. ### 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_capture_points.gd`. - Real screenshots: `bash tests/screenshot.sh <res://Scene.tscn> <out.png> <frames> 720x1280`. Use few frames (~15); 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 has **no type switches** (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://`). `GameState` is an in-memory autoload. Do not invent a save system. - Commit this ticket separately and run the affected test groups plus the full suite before pushing. ### Traps that have already cost hours on this codebase 1. **Never put a top-level `const X = preload(...)` in a test file that is itself the `-s` entry point.** It evaluates during that file's own early compilation, which can run before autoloads are registered; any script it pulls in that references `GameState` inside a function body then fails to compile with `Identifier not found: GameState`. Use `var X: Script` plus `load()` inside `_initialize()` instead. See the header comment in `tests/test_capture_points.gd`. 2. **Assigning a plain `[]` to a typed `Array[Resource]` export from another script fails at runtime** - the compiler only sees the target as `Resource` and cannot confirm the element type. Leave the declared default alone, or build the typed array inside the owning script. 3. **`Fleet.spawn_ships(0, ...)` does not empty the fleet** - it falls back to one default interceptor by design. Clear `fleet.ships` explicitly if a test or preview needs genuinely zero ships. 4. **Setting `ship.position` without also setting `ship.move_target`** lets `Ship._physics_process` steer it straight back toward the stale target within a few frames. 5. **Physics ticks can run ahead of a `SceneTree` test's own `_process()` clock** under device load, even with `--fixed-fps 60`. Gate waits on the system's own state (a component's timer or flag), not on `elapsed >= duration`. 6. **A node that `queue_free()`s itself** (projectiles, shells) must not be re-read inside a per-frame wait condition - cache the scalar you need before the wait. 7. **Preview scenes for screenshots** are added to the tree from inside `_screenshot_tool.gd`'s `_initialize()`, so their `_ready()` races the game scene's own `_ready()`. Do setup on the first `_process()` tick, and `get_tree().paused = true` once everything is placed if the scene must hold still.
metaheavens referenced this issue from a commit 2026-08-16 08:19:19 +00:00
Author
Owner

Done in commit 472de1d, pushed to main.

Spawning reuses #52's EnemySpawnerComponent as-is via two preset scenes (EnemySpawnerComponentChaser.tscn: 5s interval/chaser, EnemySpawnerComponentArtillery.tscn: 18s/max 2/artillery) rather than adding multi-type support to the component - two instances of a single-purpose component, matching the composition this codebase already uses everywhere. "Enemy bomber" in the original request is read here as the existing artillery enemy's slow telegraphed AOE rather than a new enemy type, per the judgment call flagged in the ticket.

New EnemyBigLaserComponent.gd (big_laser) fires a standalone EnemyBigLaserBeam on a long cooldown - the beam owns its whole charge/fire/flash sequence once launched, so the mothership dying mid-charge doesn't cancel an already-telegraphed shot, same design as EnemyArtilleryComponent/ArtilleryShell. The aim direction is locked once in launch() and never re-read - nothing in the charging phase touches the fleet's position again, which is what makes it dodgeable. Damage is a line-segment check (projection + perpendicular distance) against every player_ships member; Ship.take_damage() already handles shield absorption per-ship, so no new shield logic was needed.

MothershipEnemyDefinition.tres: max_hp 60, speed 35 (slow), a ~96-unit hull, pursuit + two spawners + big_laser components, no type switch anywhere - every stat from the definition, every behaviour from a component.

Tests: new tests/test_enemy_mothership.gd, split into beam mechanics (tested directly, not through the component's own cooldown timing, which would make the geometry assertions timing-fragile) and the composed definition (tested through a real Enemy.tscn instance). Covers: telegraph holds with no damage for most of the charge; the locked aim direction survives the fleet moving mid-charge; three ships in a line all die from one shot while one that dodges out survives; a shielded ship survives losing exactly one charge; both chaser and artillery spawn on their own intervals; it closes on the fleet without ever exceeding its own defined speed. Screenshot of the charge telegraph mid-countdown (shots/mothership_beam_charge.png). Full suite: 80/80.

Found and fixed a real bug along the way, in EnemySpawnerComponent.gd (#52): its enemy_scene: PackedScene = preload("res://Enemy.tscn") member-level preload could get permanently baked in as a broken, empty PackedScene when a definition's component_scenes chain led back to Enemy.tscn while it was still mid-load - confirmed empirically via ResourceLoader.has_cached() being true (a good copy existed in the cache) while the preloaded reference's own resource_path was empty. This only surfaces when a SINGLE definition composes two instances of a script carrying such a preload (the mothership's chaser/artillery spawner presets; #52's single-spawner turret never hit it, which is why it shipped unnoticed). Fixed by resolving enemy_scene lazily via load() on first use instead of at compile time. Also made a fresh spawner count down from a full interval before its first spawn rather than firing on tick one - reads more naturally, and incidentally keeps two sibling spawners with different intervals from ever landing their first attempt on the exact same frame.

Done in commit 472de1d, pushed to main. Spawning reuses #52's `EnemySpawnerComponent` as-is via two preset scenes (`EnemySpawnerComponentChaser.tscn`: 5s interval/chaser, `EnemySpawnerComponentArtillery.tscn`: 18s/max 2/artillery) rather than adding multi-type support to the component - two instances of a single-purpose component, matching the composition this codebase already uses everywhere. "Enemy bomber" in the original request is read here as the existing `artillery` enemy's slow telegraphed AOE rather than a new enemy type, per the judgment call flagged in the ticket. New `EnemyBigLaserComponent.gd` (`big_laser`) fires a standalone `EnemyBigLaserBeam` on a long cooldown - the beam owns its whole charge/fire/flash sequence once launched, so the mothership dying mid-charge doesn't cancel an already-telegraphed shot, same design as `EnemyArtilleryComponent`/`ArtilleryShell`. The aim direction is locked once in `launch()` and never re-read - nothing in the charging phase touches the fleet's position again, which is what makes it dodgeable. Damage is a line-segment check (projection + perpendicular distance) against every `player_ships` member; `Ship.take_damage()` already handles shield absorption per-ship, so no new shield logic was needed. `MothershipEnemyDefinition.tres`: `max_hp` 60, `speed` 35 (slow), a ~96-unit hull, pursuit + two spawners + big_laser components, no type switch anywhere - every stat from the definition, every behaviour from a component. Tests: new `tests/test_enemy_mothership.gd`, split into beam mechanics (tested directly, not through the component's own cooldown timing, which would make the geometry assertions timing-fragile) and the composed definition (tested through a real `Enemy.tscn` instance). Covers: telegraph holds with no damage for most of the charge; the locked aim direction survives the fleet moving mid-charge; three ships in a line all die from one shot while one that dodges out survives; a shielded ship survives losing exactly one charge; both chaser and artillery spawn on their own intervals; it closes on the fleet without ever exceeding its own defined speed. Screenshot of the charge telegraph mid-countdown (`shots/mothership_beam_charge.png`). Full suite: 80/80. Found and fixed a real bug along the way, in `EnemySpawnerComponent.gd` (#52): its `enemy_scene: PackedScene = preload("res://Enemy.tscn")` member-level preload could get permanently baked in as a broken, empty `PackedScene` when a definition's `component_scenes` chain led back to `Enemy.tscn` while it was still mid-load - confirmed empirically via `ResourceLoader.has_cached()` being true (a good copy existed in the cache) while the preloaded reference's own `resource_path` was empty. This only surfaces when a SINGLE definition composes two instances of a script carrying such a preload (the mothership's chaser/artillery spawner presets; #52's single-spawner turret never hit it, which is why it shipped unnoticed). Fixed by resolving `enemy_scene` lazily via `load()` on first use instead of at compile time. Also made a fresh spawner count down from a full interval before its first spawn rather than firing on tick one - reads more naturally, and incidentally keeps two sibling spawners with different intervals from ever landing their first attempt on the exact same frame.
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#53
No description provided.