From a0863b290c6082c61044ac687a07e9934b9f5013 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Thu, 7 May 2026 19:54:18 +0200 Subject: [PATCH] feat(gameplay): add scripted flight paths for special stage enemies Add Lissajous and cubic Bezier path types for enemies on special stages (every 3rd level). Path-following enemies spawn with ScriptedPath marker and FollowingPath state, move along parametric curves, and despawn when their path ends off-screen. Key changes: - New flight_paths module with pure path evaluation (Lissajous + Bezier) - ScriptedPath marker component and FollowingPath EnemyState variant - flight_patterns field on StageConfigurations for composable path data - Special stage spawn branch in spawn_enemies() with stagger delays - Path-following movement in move_enemies() with delay gating and despawn - Formation complete early return for special stages - Without filter on attacking_query to prevent double-processing - 13 new unit tests for path evaluation and data contracts Refs: GAL-40 --- src/components.rs | 12 ++ src/enemy.rs | 267 ++++++++++++++++++++++++++++++++++++++++- src/flight_paths.rs | 141 ++++++++++++++++++++++ src/game_state.rs | 4 +- src/lib.rs | 4 +- src/player.rs | 8 +- src/resources.rs | 95 +++++++++++++++ src/systems.rs | 6 +- tests/special_stage.rs | 5 +- 9 files changed, 526 insertions(+), 16 deletions(-) create mode 100644 src/flight_paths.rs diff --git a/src/components.rs b/src/components.rs index c7fb877..c7a9e4c 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)] @@ -95,3 +103,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 20f59c8..82ca360 100644 --- a/src/enemy.rs +++ b/src/enemy.rs @@ -3,16 +3,17 @@ 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::{ - AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations, - is_special_stage, + is_special_stage, AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, + StageConfigurations, }; const BOSS_BASE_CHANCE: f32 = 0.30; @@ -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!( @@ -64,8 +131,8 @@ pub fn spawn_enemies( 0.0, ); - let boss_chance = BOSS_BASE_CHANCE - + (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP); + 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 { @@ -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