Merge branch 'opencode/happy-rocket'
Brings in scripted flight paths for special stage enemies (GAL-40) on top of the high-score persistence work (GAL-56) already on main. Conflict resolution: - src/game_state.rs, src/systems.rs: kept HEAD's imports — they are a superset that includes HighScore/persistence; the incoming branch's changes were just import-formatting that HEAD had already adopted. - src/resources.rs: kept both — HEAD's HighScore resource and the incoming flight_patterns test module are independent additions.
This commit is contained in:
commit
fb132aea2a
7 changed files with 517 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
259
src/enemy.rs
259
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<Enemy>, With<FormationTarget>),
|
||||
>,
|
||||
mut path_query: Query<(Entity, &mut Transform, &mut EnemyState), With<ScriptedPath>>,
|
||||
mut attacking_query: Query<
|
||||
(
|
||||
Entity,
|
||||
|
|
@ -124,7 +193,7 @@ pub fn move_enemies(
|
|||
&Enemy,
|
||||
Option<&OriginalFormationPosition>,
|
||||
),
|
||||
(With<Enemy>, Without<FormationTarget>),
|
||||
(With<Enemy>, Without<FormationTarget>, Without<ScriptedPath>),
|
||||
>,
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
|
|
@ -133,6 +202,37 @@ pub fn move_enemies(
|
|||
has_beam_query: Query<&TractorBeam>,
|
||||
) {
|
||||
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 base_speed = ENEMY_SPEED * speed_multiplier;
|
||||
let attack_speed = base_speed * ATTACKER_SPEED_MULTIPLIER;
|
||||
|
|
@ -257,6 +357,9 @@ pub fn check_formation_complete(
|
|||
stage: Res<CurrentStage>,
|
||||
stage_configs: Res<StageConfigurations>,
|
||||
) {
|
||||
if is_special_stage(stage.number) {
|
||||
return;
|
||||
}
|
||||
if formation_state.formation_complete {
|
||||
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)]
|
||||
mod beam_tests {
|
||||
use super::*;
|
||||
|
|
@ -565,3 +743,80 @@ mod beam_tests {
|
|||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ pub mod bullet;
|
|||
pub mod components;
|
||||
pub mod constants;
|
||||
pub mod enemy;
|
||||
pub mod flight_paths;
|
||||
pub mod game_state;
|
||||
pub mod persistence;
|
||||
pub mod player;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ use crate::constants::{
|
|||
ENEMY_SHOOT_INTERVAL, FORMATION_BASE_Y, FORMATION_COLS, FORMATION_ENEMY_COUNT,
|
||||
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).
|
||||
pub fn is_special_stage(stage_number: u32) -> bool {
|
||||
|
|
@ -64,6 +65,7 @@ pub struct StageConfig {
|
|||
pub struct StageConfigurations {
|
||||
pub stages: Vec<StageConfig>,
|
||||
pub special_stage: StageConfig,
|
||||
pub flight_patterns: Vec<PathType>,
|
||||
}
|
||||
|
||||
impl StageConfigurations {
|
||||
|
|
@ -111,9 +113,28 @@ impl Default for StageConfigurations {
|
|||
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 {
|
||||
stages: vec![stage1, stage2],
|
||||
special_stage,
|
||||
flight_patterns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -173,3 +194,77 @@ pub struct HighScore {
|
|||
#[serde(rename = "high_score")]
|
||||
pub value: u32,
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue