feat(game): add special stage type every 3rd level

Every 3rd stage (3, 6, 9, ...) is now a special stage with no enemy
dive attacks and no enemy shooting. Enemies still spawn in the default
grid formation (32 enemies) but remain in formation without attacking.

Changes:
- Add SPECIAL_STAGE_INTERVAL constant and is_special_stage() function
- Add special_stage config to StageConfigurations with empty attack_patterns
- Modify for_stage() to route special stages to special config
- Guard enemy_shoot system to skip shooting during special stages
- Show '*' suffix in window title for special stages

Refs: GAL-39
This commit is contained in:
Harald Hoyer 2026-05-06 23:31:12 +02:00
parent eaff717054
commit 9e6d53867a
5 changed files with 182 additions and 6 deletions

View file

@ -47,6 +47,9 @@ pub const BEAM_CORE_COLOR: Color = Color::srgba(0.7, 0.2, 1.0, 0.7);
pub const BEAM_PULSE_FREQ: f32 = 3.0; pub const BEAM_PULSE_FREQ: f32 = 3.0;
pub const BEAM_PULSE_AMPLITUDE: f32 = 0.15; pub const BEAM_PULSE_AMPLITUDE: f32 = 0.15;
// Special stages
pub const SPECIAL_STAGE_INTERVAL: u32 = 3;
// Starfield // Starfield
pub const STAR_COUNT: usize = 150; pub const STAR_COUNT: usize = 150;
pub const STAR_MIN_SIZE: f32 = 1.0; pub const STAR_MIN_SIZE: f32 = 1.0;

View file

@ -12,6 +12,7 @@ use crate::constants::{
}; };
use crate::resources::{ use crate::resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations, AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations,
is_special_stage,
}; };
const BOSS_BASE_CHANCE: f32 = 0.30; const BOSS_BASE_CHANCE: f32 = 0.30;
@ -341,7 +342,11 @@ pub fn enemy_shoot(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
mut enemy_query: Query<(&Transform, &mut Enemy, &EnemyState), Without<FormationTarget>>, mut enemy_query: Query<(&Transform, &mut Enemy, &EnemyState), Without<FormationTarget>>,
stage: Res<CurrentStage>,
) { ) {
if is_special_stage(stage.number) {
return;
}
for (transform, mut enemy, state) in enemy_query.iter_mut() { for (transform, mut enemy, state) in enemy_query.iter_mut() {
if !matches!(state, EnemyState::Attacking(_)) { if !matches!(state, EnemyState::Attacking(_)) {
continue; continue;

View file

@ -3,9 +3,14 @@ use bevy::prelude::*;
use crate::components::AttackPattern; use crate::components::AttackPattern;
use crate::constants::{ 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, WINDOW_WIDTH, FORMATION_X_SPACING, FORMATION_Y_SPACING, SPECIAL_STAGE_INTERVAL, WINDOW_WIDTH,
}; };
/// Returns `true` when `stage_number` is a special stage (every 3rd stage).
pub fn is_special_stage(stage_number: u32) -> bool {
stage_number > 0 && stage_number.is_multiple_of(SPECIAL_STAGE_INTERVAL)
}
#[derive(Resource)] #[derive(Resource)]
pub struct EnemySpawnTimer { pub struct EnemySpawnTimer {
pub timer: Timer, pub timer: Timer,
@ -58,13 +63,21 @@ pub struct StageConfig {
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct StageConfigurations { pub struct StageConfigurations {
pub stages: Vec<StageConfig>, pub stages: Vec<StageConfig>,
pub special_stage: StageConfig,
} }
impl StageConfigurations { impl StageConfigurations {
/// Cycles through configured stages once `stage_number` exceeds the count. /// Returns the config for the given stage, using the special stage config
/// when `stage_number` is a multiple of `SPECIAL_STAGE_INTERVAL`.
pub fn for_stage(&self, stage_number: u32) -> &StageConfig { pub fn for_stage(&self, stage_number: u32) -> &StageConfig {
let idx = (stage_number.saturating_sub(1) as usize) % self.stages.len(); if is_special_stage(stage_number) {
&self.stages[idx] return &self.special_stage;
}
// Count how many special stages come before this one
let special_before = stage_number / SPECIAL_STAGE_INTERVAL;
let normal_idx =
(stage_number.saturating_sub(1) - special_before) as usize % self.stages.len();
&self.stages[normal_idx]
} }
} }
@ -89,8 +102,18 @@ impl Default for StageConfigurations {
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8, enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8,
}; };
let special_stage = StageConfig {
formation_layout: FormationLayout::default(),
enemy_count: FORMATION_ENEMY_COUNT,
attack_patterns: vec![],
attack_dive_interval: f32::MAX,
enemy_speed_multiplier: 1.0,
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL,
};
Self { Self {
stages: vec![stage1, stage2], stages: vec![stage1, stage2],
special_stage,
} }
} }
} }

View file

@ -4,7 +4,7 @@ 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}; use crate::resources::{CurrentStage, PlayerLives, Score, is_special_stage};
use crate::starfield::spawn_starfield; use crate::starfield::spawn_starfield;
pub fn setup(mut commands: Commands) { pub fn setup(mut commands: Commands) {
@ -38,10 +38,15 @@ pub fn update_window_title(
if !(lives.is_changed() || score.is_changed() || stage.is_changed()) { if !(lives.is_changed() || score.is_changed() || stage.is_changed()) {
return; return;
} }
let stage_label = if is_special_stage(stage.number) {
format!("{}*", stage.number)
} else {
stage.number.to_string()
};
if let Ok(mut window) = windows.single_mut() { if let Ok(mut window) = windows.single_mut() {
window.title = format!( window.title = format!(
"Galaga :: Stage: {} Lives: {} Score: {}", "Galaga :: Stage: {} Lives: {} Score: {}",
stage.number, lives.count, score.value stage_label, lives.count, score.value
); );
} }
} }

140
tests/special_stage.rs Normal file
View file

@ -0,0 +1,140 @@
use bglga::constants::SPECIAL_STAGE_INTERVAL;
use bglga::resources::is_special_stage;
use bglga::resources::StageConfigurations;
// ── SPECIAL_STAGE_INTERVAL constant ──
#[test]
fn special_stage_interval_is_three() {
assert_eq!(SPECIAL_STAGE_INTERVAL, 3);
}
// ── is_special_stage function ──
#[test]
fn stage_zero_is_not_special() {
assert!(!is_special_stage(0));
}
#[test]
fn stage_1_is_not_special() {
assert!(!is_special_stage(1));
}
#[test]
fn stage_2_is_not_special() {
assert!(!is_special_stage(2));
}
#[test]
fn stage_3_is_special() {
assert!(is_special_stage(3));
}
#[test]
fn stage_4_is_not_special() {
assert!(!is_special_stage(4));
}
#[test]
fn stage_5_is_not_special() {
assert!(!is_special_stage(5));
}
#[test]
fn stage_6_is_special() {
assert!(is_special_stage(6));
}
#[test]
fn stage_9_is_special() {
assert!(is_special_stage(9));
}
// ── for_stage returns special config for special stages ──
#[test]
fn for_stage_returns_special_config_at_stage_3() {
let configs = StageConfigurations::default();
let config = configs.for_stage(3);
// Special stages have no dive attacks
assert!(
config.attack_patterns.is_empty(),
"Stage 3 (special) should have empty attack_patterns"
);
}
#[test]
fn for_stage_returns_special_config_at_stage_6() {
let configs = StageConfigurations::default();
let config = configs.for_stage(6);
assert!(
config.attack_patterns.is_empty(),
"Stage 6 (special) should have empty attack_patterns"
);
}
#[test]
fn for_stage_returns_normal_config_at_stage_1() {
let configs = StageConfigurations::default();
let config = configs.for_stage(1);
assert!(
!config.attack_patterns.is_empty(),
"Stage 1 (normal) should have attack patterns"
);
}
#[test]
fn for_stage_returns_normal_config_at_stage_2() {
let configs = StageConfigurations::default();
let config = configs.for_stage(2);
assert!(
!config.attack_patterns.is_empty(),
"Stage 2 (normal) should have attack patterns"
);
}
#[test]
fn for_stage_returns_normal_config_at_stage_4() {
let configs = StageConfigurations::default();
let config = configs.for_stage(4);
assert!(
!config.attack_patterns.is_empty(),
"Stage 4 (normal) should have attack patterns"
);
}
// ── Special stage config properties ──
#[test]
fn special_stage_has_full_formation() {
let configs = StageConfigurations::default();
let config = configs.for_stage(3);
assert_eq!(config.enemy_count, 32, "Special stage should have 32 enemies");
assert_eq!(
config.formation_layout.positions.len(),
32,
"Special stage formation should have 32 positions"
);
}
#[test]
fn special_stage_has_max_dive_interval() {
let configs = StageConfigurations::default();
let config = configs.for_stage(3);
assert_eq!(
config.attack_dive_interval,
f32::MAX,
"Special stage should have max dive interval"
);
}
#[test]
fn special_stage_has_base_speed() {
let configs = StageConfigurations::default();
let config = configs.for_stage(3);
assert_eq!(
config.enemy_speed_multiplier, 1.0,
"Special stage should have 1.0x speed multiplier"
);
}