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_AMPLITUDE: f32 = 0.15;
// Special stages
pub const SPECIAL_STAGE_INTERVAL: u32 = 3;
// Starfield
pub const STAR_COUNT: usize = 150;
pub const STAR_MIN_SIZE: f32 = 1.0;

View file

@ -12,6 +12,7 @@ use crate::constants::{
};
use crate::resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations,
is_special_stage,
};
const BOSS_BASE_CHANCE: f32 = 0.30;
@ -341,7 +342,11 @@ pub fn enemy_shoot(
mut commands: Commands,
time: Res<Time>,
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() {
if !matches!(state, EnemyState::Attacking(_)) {
continue;

View file

@ -3,9 +3,14 @@ use bevy::prelude::*;
use crate::components::AttackPattern;
use crate::constants::{
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)]
pub struct EnemySpawnTimer {
pub timer: Timer,
@ -58,13 +63,21 @@ pub struct StageConfig {
#[derive(Resource, Debug, Clone)]
pub struct StageConfigurations {
pub stages: Vec<StageConfig>,
pub special_stage: StageConfig,
}
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 {
let idx = (stage_number.saturating_sub(1) as usize) % self.stages.len();
&self.stages[idx]
if is_special_stage(stage_number) {
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,
};
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 {
stages: vec![stage1, stage2],
special_stage,
}
}
}

View file

@ -4,7 +4,7 @@ use std::time::Duration;
use crate::components::{Explosion, Invincible, Player};
use crate::constants::{EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE};
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;
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()) {
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() {
window.title = format!(
"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"
);
}