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<ScriptedPath> filter on attacking_query to prevent double-processing - 13 new unit tests for path evaluation and data contracts Refs: GAL-40
This commit is contained in:
parent
459e8a2353
commit
a0863b290c
9 changed files with 526 additions and 16 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::flight_paths::PathType;
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct Player {
|
pub struct Player {
|
||||||
pub speed: f32,
|
pub speed: f32,
|
||||||
|
|
@ -69,6 +71,12 @@ pub enum EnemyState {
|
||||||
InFormation,
|
InFormation,
|
||||||
Attacking(AttackPattern),
|
Attacking(AttackPattern),
|
||||||
ReturningWithCaptive,
|
ReturningWithCaptive,
|
||||||
|
FollowingPath {
|
||||||
|
path: PathType,
|
||||||
|
t: f32,
|
||||||
|
delay: f32,
|
||||||
|
elapsed: f32,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
|
|
@ -95,3 +103,7 @@ pub struct Star {
|
||||||
pub struct Explosion {
|
pub struct Explosion {
|
||||||
pub timer: Timer,
|
pub timer: Timer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks an enemy as following a scripted flight path.
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct ScriptedPath;
|
||||||
|
|
|
||||||
267
src/enemy.rs
267
src/enemy.rs
|
|
@ -3,16 +3,17 @@ use std::time::Duration;
|
||||||
|
|
||||||
use crate::components::{
|
use crate::components::{
|
||||||
AttackPattern, Captured, Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget,
|
AttackPattern, Captured, Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget,
|
||||||
OriginalFormationPosition, Player, TractorBeam, TractorBeamSprite,
|
OriginalFormationPosition, Player, ScriptedPath, TractorBeam, TractorBeamSprite,
|
||||||
};
|
};
|
||||||
use crate::constants::{
|
use crate::constants::{
|
||||||
BEAM_CORE_COLOR, BEAM_GLOW_COLOR, BEAM_GLOW_WIDTH, BEAM_PULSE_AMPLITUDE, BEAM_PULSE_FREQ,
|
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,
|
CAPTURE_DURATION, ENEMY_BULLET_SIZE, ENEMY_SIZE, ENEMY_SPEED, TRACTOR_BEAM_DURATION,
|
||||||
TRACTOR_BEAM_WIDTH, WINDOW_HEIGHT, WINDOW_WIDTH,
|
TRACTOR_BEAM_WIDTH, WINDOW_HEIGHT, WINDOW_WIDTH,
|
||||||
};
|
};
|
||||||
|
use crate::flight_paths;
|
||||||
use crate::resources::{
|
use crate::resources::{
|
||||||
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations,
|
is_special_stage, AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState,
|
||||||
is_special_stage,
|
StageConfigurations,
|
||||||
};
|
};
|
||||||
|
|
||||||
const BOSS_BASE_CHANCE: f32 = 0.30;
|
const BOSS_BASE_CHANCE: f32 = 0.30;
|
||||||
|
|
@ -45,6 +46,72 @@ pub fn spawn_enemies(
|
||||||
return;
|
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;
|
let slot = formation_state.next_slot_index;
|
||||||
if slot >= config.formation_layout.positions.len() {
|
if slot >= config.formation_layout.positions.len() {
|
||||||
warn!(
|
warn!(
|
||||||
|
|
@ -64,8 +131,8 @@ pub fn spawn_enemies(
|
||||||
0.0,
|
0.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
let boss_chance = BOSS_BASE_CHANCE
|
let boss_chance =
|
||||||
+ (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP);
|
BOSS_BASE_CHANCE + (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP);
|
||||||
let enemy_type = if fastrand::f32() < boss_chance {
|
let enemy_type = if fastrand::f32() < boss_chance {
|
||||||
EnemyType::Boss
|
EnemyType::Boss
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -111,11 +178,13 @@ pub fn spawn_enemies(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn move_enemies(
|
pub fn move_enemies(
|
||||||
mut entering_query: Query<
|
mut entering_query: Query<
|
||||||
(Entity, &mut Transform, &FormationTarget, &mut EnemyState),
|
(Entity, &mut Transform, &FormationTarget, &mut EnemyState),
|
||||||
(With<Enemy>, With<FormationTarget>),
|
(With<Enemy>, With<FormationTarget>),
|
||||||
>,
|
>,
|
||||||
|
mut path_query: Query<(Entity, &mut Transform, &mut EnemyState), With<ScriptedPath>>,
|
||||||
mut attacking_query: Query<
|
mut attacking_query: Query<
|
||||||
(
|
(
|
||||||
Entity,
|
Entity,
|
||||||
|
|
@ -124,7 +193,7 @@ pub fn move_enemies(
|
||||||
&Enemy,
|
&Enemy,
|
||||||
Option<&OriginalFormationPosition>,
|
Option<&OriginalFormationPosition>,
|
||||||
),
|
),
|
||||||
(With<Enemy>, Without<FormationTarget>),
|
(With<Enemy>, Without<FormationTarget>, Without<ScriptedPath>),
|
||||||
>,
|
>,
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
|
|
@ -133,6 +202,37 @@ pub fn move_enemies(
|
||||||
has_beam_query: Query<&TractorBeam>,
|
has_beam_query: Query<&TractorBeam>,
|
||||||
) {
|
) {
|
||||||
let dt = time.delta_secs();
|
let dt = time.delta_secs();
|
||||||
|
|
||||||
|
// Path-following enemies (special stages)
|
||||||
|
let mut entities_to_despawn = Vec::new();
|
||||||
|
for (entity, mut transform, mut state) in path_query.iter_mut() {
|
||||||
|
if let EnemyState::FollowingPath {
|
||||||
|
path,
|
||||||
|
t,
|
||||||
|
delay,
|
||||||
|
elapsed,
|
||||||
|
} = state.as_mut()
|
||||||
|
{
|
||||||
|
let new_elapsed = *elapsed + dt;
|
||||||
|
if new_elapsed >= *delay {
|
||||||
|
let new_t = (*t + dt / path.duration()).clamp(0.0, 1.0);
|
||||||
|
let pos = flight_paths::evaluate(path, new_t);
|
||||||
|
transform.translation.x = pos.x;
|
||||||
|
transform.translation.y = pos.y;
|
||||||
|
*t = new_t;
|
||||||
|
*elapsed = new_elapsed;
|
||||||
|
if new_t >= 1.0 || transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
|
||||||
|
entities_to_despawn.push(entity);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*elapsed = new_elapsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for entity in entities_to_despawn {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
|
||||||
let speed_multiplier = stage_configs.for_stage(stage.number).enemy_speed_multiplier;
|
let speed_multiplier = stage_configs.for_stage(stage.number).enemy_speed_multiplier;
|
||||||
let base_speed = ENEMY_SPEED * speed_multiplier;
|
let base_speed = ENEMY_SPEED * speed_multiplier;
|
||||||
let attack_speed = base_speed * ATTACKER_SPEED_MULTIPLIER;
|
let attack_speed = base_speed * ATTACKER_SPEED_MULTIPLIER;
|
||||||
|
|
@ -257,6 +357,9 @@ pub fn check_formation_complete(
|
||||||
stage: Res<CurrentStage>,
|
stage: Res<CurrentStage>,
|
||||||
stage_configs: Res<StageConfigurations>,
|
stage_configs: Res<StageConfigurations>,
|
||||||
) {
|
) {
|
||||||
|
if is_special_stage(stage.number) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if formation_state.formation_complete {
|
if formation_state.formation_complete {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -513,6 +616,81 @@ pub fn update_tractor_beam_visual(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod spawn_contract_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::components::ScriptedPath;
|
||||||
|
use crate::flight_paths::PathType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_special_stage_0_returns_false() {
|
||||||
|
assert!(!is_special_stage(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_special_stage_1_returns_false() {
|
||||||
|
assert!(!is_special_stage(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_special_stage_2_returns_false() {
|
||||||
|
assert!(!is_special_stage(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_special_stage_3_returns_true() {
|
||||||
|
assert!(is_special_stage(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_special_stage_6_returns_true() {
|
||||||
|
assert!(is_special_stage(6));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_flight_patterns_has_two_entries() {
|
||||||
|
let cfg = StageConfigurations::default();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.flight_patterns.len(),
|
||||||
|
2,
|
||||||
|
"default StageConfigurations should have exactly 2 flight patterns"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flight_patterns_not_empty() {
|
||||||
|
let cfg = StageConfigurations::default();
|
||||||
|
assert!(
|
||||||
|
!cfg.flight_patterns.is_empty(),
|
||||||
|
"flight_patterns must not be empty to avoid panic in spawn logic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scripted_path_component_constructs() {
|
||||||
|
let _sp = ScriptedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn following_path_variant_has_correct_fields() {
|
||||||
|
let path = PathType::Lissajous {
|
||||||
|
center_x: 0.0,
|
||||||
|
center_y: 0.0,
|
||||||
|
amplitude_x: 100.0,
|
||||||
|
amplitude_y: 200.0,
|
||||||
|
ax: 1.0,
|
||||||
|
ay: 1.0,
|
||||||
|
delta: 0.0,
|
||||||
|
};
|
||||||
|
let _state = EnemyState::FollowingPath {
|
||||||
|
path,
|
||||||
|
t: 0.0,
|
||||||
|
delay: 0.0,
|
||||||
|
elapsed: 0.0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod beam_tests {
|
mod beam_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -565,3 +743,80 @@ mod beam_tests {
|
||||||
assert_eq!(result, 1.0);
|
assert_eq!(result, 1.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod movement_contract_tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::flight_paths::PathType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bezier_evaluate_at_t_half_advances_position() {
|
||||||
|
// A Bezier path from origin to off-screen bottom;
|
||||||
|
// position at t=0.5 must differ from t=0.0, proving movement.
|
||||||
|
let path = PathType::CubicBezier {
|
||||||
|
p0: Vec3::ZERO,
|
||||||
|
p1: Vec3::ZERO,
|
||||||
|
p2: Vec3::ZERO,
|
||||||
|
p3: Vec3::new(0.0, -450.0, 0.0),
|
||||||
|
};
|
||||||
|
let pos_start = flight_paths::evaluate(&path, 0.0);
|
||||||
|
let pos_mid = flight_paths::evaluate(&path, 0.5);
|
||||||
|
assert_ne!(
|
||||||
|
pos_mid, pos_start,
|
||||||
|
"Bezier evaluate at t=0.5 ({:?}) should differ from t=0.0 ({:?}), proving movement",
|
||||||
|
pos_mid, pos_start
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bezier_evaluate_at_t1_is_off_screen() {
|
||||||
|
// p3.y = -450, and the off-screen threshold is
|
||||||
|
// -WINDOW_HEIGHT/2.0 - ENEMY_SIZE.y = -400 - 40 = -440.
|
||||||
|
// At t=1.0 the Bezier lands exactly on p3, so y must be < -440.
|
||||||
|
let path = PathType::CubicBezier {
|
||||||
|
p0: Vec3::ZERO,
|
||||||
|
p1: Vec3::ZERO,
|
||||||
|
p2: Vec3::ZERO,
|
||||||
|
p3: Vec3::new(0.0, -450.0, 0.0),
|
||||||
|
};
|
||||||
|
let pos = flight_paths::evaluate(&path, 1.0);
|
||||||
|
assert!(
|
||||||
|
pos.y < -440.0,
|
||||||
|
"Bezier y at t=1.0 ({}) should be off-screen (< -440)",
|
||||||
|
pos.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lissajous_duration_is_4_seconds() {
|
||||||
|
let path = PathType::Lissajous {
|
||||||
|
center_x: 0.0,
|
||||||
|
center_y: 0.0,
|
||||||
|
amplitude_x: 100.0,
|
||||||
|
amplitude_y: 200.0,
|
||||||
|
ax: 1.0,
|
||||||
|
ay: 1.0,
|
||||||
|
delta: 0.0,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
path.duration(),
|
||||||
|
4.0,
|
||||||
|
"Lissajous duration should be 4.0 seconds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cubic_bezier_duration_is_5_seconds() {
|
||||||
|
let path = PathType::CubicBezier {
|
||||||
|
p0: Vec3::ZERO,
|
||||||
|
p1: Vec3::ZERO,
|
||||||
|
p2: Vec3::ZERO,
|
||||||
|
p3: Vec3::ZERO,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
path.duration(),
|
||||||
|
5.0,
|
||||||
|
"CubicBezier duration should be 5.0 seconds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
141
src/flight_paths.rs
Normal file
141
src/flight_paths.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum PathType {
|
||||||
|
Lissajous {
|
||||||
|
center_x: f32,
|
||||||
|
center_y: f32,
|
||||||
|
amplitude_x: f32,
|
||||||
|
amplitude_y: f32,
|
||||||
|
ax: f32,
|
||||||
|
ay: f32,
|
||||||
|
delta: f32,
|
||||||
|
},
|
||||||
|
CubicBezier {
|
||||||
|
p0: Vec3,
|
||||||
|
p1: Vec3,
|
||||||
|
p2: Vec3,
|
||||||
|
p3: Vec3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PathType {
|
||||||
|
pub fn duration(&self) -> f32 {
|
||||||
|
match self {
|
||||||
|
PathType::Lissajous { .. } => 4.0,
|
||||||
|
PathType::CubicBezier { .. } => 5.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn evaluate(path_type: &PathType, t: f32) -> Vec3 {
|
||||||
|
match path_type {
|
||||||
|
PathType::Lissajous {
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
amplitude_x,
|
||||||
|
amplitude_y,
|
||||||
|
ax,
|
||||||
|
ay,
|
||||||
|
delta,
|
||||||
|
} => {
|
||||||
|
let x = *center_x + *amplitude_x * (2.0 * std::f32::consts::PI * ax * t + delta).sin();
|
||||||
|
let y = *center_y + *amplitude_y * (2.0 * std::f32::consts::PI * ay * t).sin();
|
||||||
|
Vec3::new(x, y, 0.0)
|
||||||
|
}
|
||||||
|
PathType::CubicBezier { p0, p1, p2, p3 } => {
|
||||||
|
let u = 1.0 - t;
|
||||||
|
let u3 = u * u * u;
|
||||||
|
let u2t = u * u * t;
|
||||||
|
let ut2 = u * t * t;
|
||||||
|
let t3 = t * t * t;
|
||||||
|
u3 * *p0 + 3.0 * u2t * *p1 + 3.0 * ut2 * *p2 + t3 * *p3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lissajous_y_is_non_monotonic() {
|
||||||
|
let path = PathType::Lissajous {
|
||||||
|
center_x: 0.0,
|
||||||
|
center_y: 100.0,
|
||||||
|
amplitude_x: 120.0,
|
||||||
|
amplitude_y: 300.0,
|
||||||
|
ax: 2.0,
|
||||||
|
ay: 1.0,
|
||||||
|
delta: PI / 2.0,
|
||||||
|
};
|
||||||
|
let y0 = evaluate(&path, 0.0).y;
|
||||||
|
let y_mid = evaluate(&path, 0.25).y;
|
||||||
|
let y_end = evaluate(&path, 0.5).y;
|
||||||
|
// y rises then falls, proving the curve is not a straight line
|
||||||
|
assert!(
|
||||||
|
y_mid > y0,
|
||||||
|
"Lissajous y at t=0.25 ({}) should be above y at t=0 ({})",
|
||||||
|
y_mid,
|
||||||
|
y0
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
y_end < y_mid,
|
||||||
|
"Lissajous y at t=0.5 ({}) should be below y at t=0.25 ({})",
|
||||||
|
y_end,
|
||||||
|
y_mid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bezier_control_points_deviate_from_linear() {
|
||||||
|
let path = PathType::CubicBezier {
|
||||||
|
p0: Vec3::new(0.0, 450.0, 0.0),
|
||||||
|
p1: Vec3::new(0.0, 200.0, 0.0),
|
||||||
|
p2: Vec3::new(0.0, 100.0, 0.0),
|
||||||
|
p3: Vec3::new(0.0, -450.0, 0.0),
|
||||||
|
};
|
||||||
|
let y_bezier = evaluate(&path, 0.25).y;
|
||||||
|
// Linear interpolation between p0.y=450 and p3.y=-450 at t=0.25
|
||||||
|
let y_linear = 450.0 + (-450.0 - 450.0) * 0.25; // = 225
|
||||||
|
assert!(
|
||||||
|
y_bezier > y_linear,
|
||||||
|
"Bezier y ({}) should be strictly greater than linear interpolation y ({})",
|
||||||
|
y_bezier,
|
||||||
|
y_linear
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bezier_at_t1_equals_p3() {
|
||||||
|
let path = PathType::CubicBezier {
|
||||||
|
p0: Vec3::ZERO,
|
||||||
|
p1: Vec3::ZERO,
|
||||||
|
p2: Vec3::ZERO,
|
||||||
|
p3: Vec3::new(0.0, -450.0, 0.0),
|
||||||
|
};
|
||||||
|
let result = evaluate(&path, 1.0);
|
||||||
|
assert!(
|
||||||
|
(result.y - (-450.0)).abs() < 1e-4,
|
||||||
|
"Bezier y at t=1.0 ({}) should equal p3.y (-450)",
|
||||||
|
result.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bezier_at_t0_equals_p0() {
|
||||||
|
let path = PathType::CubicBezier {
|
||||||
|
p0: Vec3::new(0.0, 450.0, 0.0),
|
||||||
|
p1: Vec3::ZERO,
|
||||||
|
p2: Vec3::ZERO,
|
||||||
|
p3: Vec3::ZERO,
|
||||||
|
};
|
||||||
|
let result = evaluate(&path, 0.0);
|
||||||
|
assert!(
|
||||||
|
(result.y - 450.0).abs() < 1e-4,
|
||||||
|
"Bezier y at t=0.0 ({}) should equal p0.y (450)",
|
||||||
|
result.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::components::{Bullet, Enemy, EnemyBullet, GameOverUI, RestartMessage, StartButton, StartMenuUI};
|
use crate::components::{
|
||||||
|
Bullet, Enemy, EnemyBullet, GameOverUI, RestartMessage, StartButton, StartMenuUI,
|
||||||
|
};
|
||||||
use crate::resources::RestartPressed;
|
use crate::resources::RestartPressed;
|
||||||
|
|
||||||
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
|
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ pub mod bullet;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod enemy;
|
pub mod enemy;
|
||||||
|
pub mod flight_paths;
|
||||||
pub mod game_state;
|
pub mod game_state;
|
||||||
pub mod player;
|
pub mod player;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
|
|
@ -15,8 +16,7 @@ pub mod starfield;
|
||||||
pub mod systems;
|
pub mod systems;
|
||||||
|
|
||||||
use bullet::{
|
use bullet::{
|
||||||
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets,
|
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets, move_enemy_bullets,
|
||||||
move_enemy_bullets,
|
|
||||||
};
|
};
|
||||||
use components::TractorBeam;
|
use components::TractorBeam;
|
||||||
use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH};
|
use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH};
|
||||||
|
|
|
||||||
|
|
@ -58,10 +58,10 @@ pub fn move_player(
|
||||||
|
|
||||||
transform.translation.x += direction * player.speed * time.delta_secs();
|
transform.translation.x += direction * player.speed * time.delta_secs();
|
||||||
let half_width = PLAYER_SIZE.x / 2.0;
|
let half_width = PLAYER_SIZE.x / 2.0;
|
||||||
transform.translation.x = transform
|
transform.translation.x = transform.translation.x.clamp(
|
||||||
.translation
|
-WINDOW_WIDTH / 2.0 + half_width,
|
||||||
.x
|
WINDOW_WIDTH / 2.0 - half_width,
|
||||||
.clamp(-WINDOW_WIDTH / 2.0 + half_width, WINDOW_WIDTH / 2.0 - half_width);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_captured_player(
|
pub fn handle_captured_player(
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use crate::constants::{
|
||||||
ENEMY_SHOOT_INTERVAL, FORMATION_BASE_Y, FORMATION_COLS, FORMATION_ENEMY_COUNT,
|
ENEMY_SHOOT_INTERVAL, FORMATION_BASE_Y, FORMATION_COLS, FORMATION_ENEMY_COUNT,
|
||||||
FORMATION_X_SPACING, FORMATION_Y_SPACING, SPECIAL_STAGE_INTERVAL, WINDOW_WIDTH,
|
FORMATION_X_SPACING, FORMATION_Y_SPACING, SPECIAL_STAGE_INTERVAL, WINDOW_WIDTH,
|
||||||
};
|
};
|
||||||
|
use crate::flight_paths::PathType;
|
||||||
|
|
||||||
/// Returns `true` when `stage_number` is a special stage (every 3rd stage).
|
/// Returns `true` when `stage_number` is a special stage (every 3rd stage).
|
||||||
pub fn is_special_stage(stage_number: u32) -> bool {
|
pub fn is_special_stage(stage_number: u32) -> bool {
|
||||||
|
|
@ -64,6 +65,7 @@ pub struct StageConfig {
|
||||||
pub struct StageConfigurations {
|
pub struct StageConfigurations {
|
||||||
pub stages: Vec<StageConfig>,
|
pub stages: Vec<StageConfig>,
|
||||||
pub special_stage: StageConfig,
|
pub special_stage: StageConfig,
|
||||||
|
pub flight_patterns: Vec<PathType>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StageConfigurations {
|
impl StageConfigurations {
|
||||||
|
|
@ -111,9 +113,28 @@ impl Default for StageConfigurations {
|
||||||
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL,
|
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let flight_patterns = vec![
|
||||||
|
PathType::Lissajous {
|
||||||
|
ax: 2.0,
|
||||||
|
ay: 1.0,
|
||||||
|
delta: std::f32::consts::FRAC_PI_2,
|
||||||
|
amplitude_x: 120.0,
|
||||||
|
amplitude_y: 300.0,
|
||||||
|
center_x: 0.0,
|
||||||
|
center_y: 100.0,
|
||||||
|
},
|
||||||
|
PathType::CubicBezier {
|
||||||
|
p0: Vec3::new(-250.0, 450.0, 0.0),
|
||||||
|
p1: Vec3::new(-100.0, 200.0, 0.0),
|
||||||
|
p2: Vec3::new(100.0, 100.0, 0.0),
|
||||||
|
p3: Vec3::new(250.0, -450.0, 0.0),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
stages: vec![stage1, stage2],
|
stages: vec![stage1, stage2],
|
||||||
special_stage,
|
special_stage,
|
||||||
|
flight_patterns,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -167,3 +188,77 @@ pub struct AttackDiveTimer {
|
||||||
pub struct RestartPressed {
|
pub struct RestartPressed {
|
||||||
pub pressed: bool,
|
pub pressed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::f32::consts::FRAC_PI_2;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_has_at_least_two_flight_patterns() {
|
||||||
|
let cfg = StageConfigurations::default();
|
||||||
|
assert!(
|
||||||
|
cfg.flight_patterns.len() >= 2,
|
||||||
|
"default StageConfigurations should have at least 2 flight patterns, found {}",
|
||||||
|
cfg.flight_patterns.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_flight_patterns_have_different_path_types() {
|
||||||
|
let cfg = StageConfigurations::default();
|
||||||
|
let first = &cfg.flight_patterns[0];
|
||||||
|
let second = &cfg.flight_patterns[1];
|
||||||
|
// The two entries must be different PathType variants
|
||||||
|
match (first, second) {
|
||||||
|
(PathType::Lissajous { .. }, PathType::CubicBezier { .. }) => {}
|
||||||
|
(PathType::CubicBezier { .. }, PathType::Lissajous { .. }) => {}
|
||||||
|
_ => panic!(
|
||||||
|
"first and second flight patterns must have different PathType variants: {:?} vs {:?}",
|
||||||
|
first, second
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_default_pattern_is_lissajous_with_correct_params() {
|
||||||
|
let cfg = StageConfigurations::default();
|
||||||
|
let PathType::Lissajous {
|
||||||
|
ax,
|
||||||
|
ay,
|
||||||
|
delta,
|
||||||
|
amplitude_x,
|
||||||
|
amplitude_y,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
} = &cfg.flight_patterns[0]
|
||||||
|
else {
|
||||||
|
panic!(
|
||||||
|
"first flight pattern should be Lissajous, got {:?}",
|
||||||
|
cfg.flight_patterns[0]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
assert_eq!(*ax, 2.0, "Lissajous ax");
|
||||||
|
assert_eq!(*ay, 1.0, "Lissajous ay");
|
||||||
|
assert_eq!(*delta, FRAC_PI_2, "Lissajous delta");
|
||||||
|
assert_eq!(*amplitude_x, 120.0, "Lissajous amplitude_x");
|
||||||
|
assert_eq!(*amplitude_y, 300.0, "Lissajous amplitude_y");
|
||||||
|
assert_eq!(*center_x, 0.0, "Lissajous center_x");
|
||||||
|
assert_eq!(*center_y, 100.0, "Lissajous center_y");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_default_pattern_is_cubic_bezier_with_correct_control_points() {
|
||||||
|
let cfg = StageConfigurations::default();
|
||||||
|
let PathType::CubicBezier { p0, p1, p2, p3 } = &cfg.flight_patterns[1] else {
|
||||||
|
panic!(
|
||||||
|
"second flight pattern should be CubicBezier, got {:?}",
|
||||||
|
cfg.flight_patterns[1]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
assert_eq!(*p0, Vec3::new(-250.0, 450.0, 0.0), "CubicBezier p0");
|
||||||
|
assert_eq!(*p1, Vec3::new(-100.0, 200.0, 0.0), "CubicBezier p1");
|
||||||
|
assert_eq!(*p2, Vec3::new(100.0, 100.0, 0.0), "CubicBezier p2");
|
||||||
|
assert_eq!(*p3, Vec3::new(250.0, -450.0, 0.0), "CubicBezier p3");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ use bevy::prelude::*;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::components::{Explosion, Invincible, Player};
|
use crate::components::{Explosion, Invincible, Player};
|
||||||
use crate::constants::{EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE};
|
use crate::constants::{
|
||||||
|
EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE,
|
||||||
|
};
|
||||||
use crate::player::spawn_player_ship;
|
use crate::player::spawn_player_ship;
|
||||||
use crate::resources::{CurrentStage, PlayerLives, Score, is_special_stage};
|
use crate::resources::{is_special_stage, CurrentStage, PlayerLives, Score};
|
||||||
use crate::starfield::spawn_starfield;
|
use crate::starfield::spawn_starfield;
|
||||||
|
|
||||||
pub fn setup(mut commands: Commands) {
|
pub fn setup(mut commands: Commands) {
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,10 @@ fn for_stage_returns_normal_config_at_stage_4() {
|
||||||
fn special_stage_has_full_formation() {
|
fn special_stage_has_full_formation() {
|
||||||
let configs = StageConfigurations::default();
|
let configs = StageConfigurations::default();
|
||||||
let config = configs.for_stage(3);
|
let config = configs.for_stage(3);
|
||||||
assert_eq!(config.enemy_count, 32, "Special stage should have 32 enemies");
|
assert_eq!(
|
||||||
|
config.enemy_count, 32,
|
||||||
|
"Special stage should have 32 enemies"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.formation_layout.positions.len(),
|
config.formation_layout.positions.len(),
|
||||||
32,
|
32,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue