diff --git a/TODO/GAL-38.md b/TODO/GAL-38.md index 6c999c8..c1d0e48 100644 --- a/TODO/GAL-38.md +++ b/TODO/GAL-38.md @@ -13,5 +13,5 @@ Special stages every few levels with intricate non-shooting flight patterns and ## Sub-issues - [x] [GAL-39](GAL-39.md) — Implement a special stage type (e.g., every 3-4 levels). -- [ ] [GAL-40](GAL-40.md) — Design and implement intricate flight patterns for enemies that do not shoot. +- [x] [GAL-40](GAL-40.md) — Design and implement intricate flight patterns for enemies that do not shoot. - [ ] [GAL-41](GAL-41.md) — Award bonus points for destroying all enemies in the stage. diff --git a/TODO/GAL-40.md b/TODO/GAL-40.md index e48bc30..0679558 100644 --- a/TODO/GAL-40.md +++ b/TODO/GAL-40.md @@ -1,7 +1,7 @@ --- id: GAL-40 title: Design intricate flight patterns for non-shooting enemies -status: Todo + status: Done parent: GAL-38 labels: [gameplay, advanced-mechanics] --- @@ -12,14 +12,18 @@ Design and implement intricate flight patterns for enemies that do not shoot. ## Acceptance criteria -- [ ] On a special stage (`is_special_stage(stage_number) == true`), enemies follow scripted curved paths (e.g. spline / Bezier / Lissajous), not the formation flow. -- [ ] These enemies do **not** call `enemy_shoot` and never spawn `EnemyBullet`. -- [ ] Enemies despawn when their path reaches its end off-screen. -- [ ] At least 2 distinct path shapes used in a single special stage for visual variety. -- [ ] Difficulty / pattern parameters live in `StageConfigurations` (or a new resource) so future stages can compose them. +- [x] On a special stage (`is_special_stage(stage_number) == true`), enemies follow scripted curved paths (e.g. spline / Bezier / Lissajous), not the formation flow. +- [x] These enemies do **not** call `enemy_shoot` and never spawn `EnemyBullet`. +- [x] Enemies despawn when their path reaches its end off-screen. +- [x] At least 2 distinct path shapes used in a single special stage for visual variety. +- [x] Difficulty / pattern parameters live in `StageConfigurations` (or a new resource) so future stages can compose them. ## Integration test hints - Build a headless `App` with `CurrentStage = 3` (first special stage); tick for N seconds; assert: zero `EnemyBullet` entities ever spawned, all special-stage enemies eventually despawned (count returns to 0). - Snapshot the path: at fixed intervals record enemy `Transform.translation`; assert curvature (e.g. y is non-monotonic to prove it isn't a straight line). - Visual smoke test via `nix run .#take-screenshots -- ./target/debug/bglga 3 6 1 ./shots` after triggering a special stage; eyeball that paths look intentional. + +## Comments + +- 2026-05-07 — Branch `opencode/happy-rocket`, commit a0863b2 — Design and implement intricate flight patterns for enemies that do not shoot diff --git a/src/components.rs b/src/components.rs index 96824f1..11d41bf 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1,5 +1,7 @@ use bevy::prelude::*; +use crate::flight_paths::PathType; + #[derive(Component)] pub struct Player { pub speed: f32, @@ -69,6 +71,12 @@ pub enum EnemyState { InFormation, Attacking(AttackPattern), ReturningWithCaptive, + FollowingPath { + path: PathType, + t: f32, + delay: f32, + elapsed: f32, + }, } #[derive(Component)] @@ -98,3 +106,7 @@ pub struct Star { pub struct Explosion { pub timer: Timer, } + +/// Marks an enemy as following a scripted flight path. +#[derive(Component)] +pub struct ScriptedPath; diff --git a/src/enemy.rs b/src/enemy.rs index feb032a..82ca360 100644 --- a/src/enemy.rs +++ b/src/enemy.rs @@ -3,13 +3,14 @@ use std::time::Duration; use crate::components::{ AttackPattern, Captured, Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget, - OriginalFormationPosition, Player, TractorBeam, TractorBeamSprite, + OriginalFormationPosition, Player, ScriptedPath, TractorBeam, TractorBeamSprite, }; use crate::constants::{ BEAM_CORE_COLOR, BEAM_GLOW_COLOR, BEAM_GLOW_WIDTH, BEAM_PULSE_AMPLITUDE, BEAM_PULSE_FREQ, CAPTURE_DURATION, ENEMY_BULLET_SIZE, ENEMY_SIZE, ENEMY_SPEED, TRACTOR_BEAM_DURATION, TRACTOR_BEAM_WIDTH, WINDOW_HEIGHT, WINDOW_WIDTH, }; +use crate::flight_paths; use crate::resources::{ is_special_stage, AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations, @@ -45,6 +46,72 @@ pub fn spawn_enemies( return; } + // Special stage: path-following enemies + if is_special_stage(stage.number) { + let flight_patterns = &stage_configs.flight_patterns; + if flight_patterns.is_empty() { + // Fall through to normal formation spawn + } else { + let slot = formation_state.next_slot_index; + if slot >= config.enemy_count { + return; + } + let pattern = &flight_patterns[slot % flight_patterns.len()]; + let spawn_pos = flight_paths::evaluate(pattern, 0.0); + let delay = slot as f32 * 0.15; + + let boss_chance = BOSS_BASE_CHANCE + + (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP); + let enemy_type = if fastrand::f32() < boss_chance { + EnemyType::Boss + } else { + EnemyType::Grunt + }; + + let color = match enemy_type { + EnemyType::Grunt => Color::srgb(1.0, 0.2, 0.2), + EnemyType::Boss => Color::srgb(0.8, 0.2, 1.0), + }; + + commands.spawn(( + Sprite { + color, + custom_size: Some(ENEMY_SIZE), + ..default() + }, + Transform::from_translation(spawn_pos), + Enemy { + enemy_type, + shoot_cooldown: Timer::new( + Duration::from_secs_f32(config.enemy_shoot_interval), + TimerMode::Once, + ), + }, + ScriptedPath, + EnemyState::FollowingPath { + path: pattern.clone(), + t: 0.0, + delay, + elapsed: 0.0, + }, + )); + + formation_state.next_slot_index = (slot + 1) % config.enemy_count; + formation_state.total_spawned_this_stage += 1; + stage.waiting_for_clear = true; + + if formation_state.total_spawned_this_stage < config.enemy_count { + timer.timer.reset(); + } else { + info!( + "Stage {} special stage formation fully spawned", + stage.number + ); + } + return; // Skip normal spawn logic + } + } + let slot = formation_state.next_slot_index; if slot >= config.formation_layout.positions.len() { warn!( @@ -111,11 +178,13 @@ pub fn spawn_enemies( } } +#[allow(clippy::too_many_arguments)] pub fn move_enemies( mut entering_query: Query< (Entity, &mut Transform, &FormationTarget, &mut EnemyState), (With, With), >, + mut path_query: Query<(Entity, &mut Transform, &mut EnemyState), With>, mut attacking_query: Query< ( Entity, @@ -124,7 +193,7 @@ pub fn move_enemies( &Enemy, Option<&OriginalFormationPosition>, ), - (With, Without), + (With, Without, Without), >, time: Res