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:
Harald Hoyer 2026-05-09 14:07:28 +02:00
commit fb132aea2a
7 changed files with 517 additions and 9 deletions

View file

@ -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.

View file

@ -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

View file

@ -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;

View file

@ -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
View 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
);
}
}

View file

@ -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;

View file

@ -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");
}
}