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:
parent
eaff717054
commit
9e6d53867a
5 changed files with 182 additions and 6 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
140
tests/special_stage.rs
Normal 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue