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