Signed-off-by: Harald Hoyer <harald@hoyer.xyz>
This commit is contained in:
Harald Hoyer 2025-04-11 10:43:25 +02:00
parent ef055fe3c5
commit 3dbfb9dac1
11 changed files with 1085 additions and 867 deletions

3
.roo/mcp.json Normal file
View file

@ -0,0 +1,3 @@
{
"mcpServers": {}
}

113
src/bullet.rs Normal file
View file

@ -0,0 +1,113 @@
use bevy::prelude::*;
use crate::components::{Bullet, Enemy, EnemyBullet, EnemyType};
use crate::constants::{
BULLET_ENEMY_COLLISION_THRESHOLD, BULLET_SIZE, BULLET_SPEED, ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD,
ENEMY_BULLET_SIZE, ENEMY_BULLET_SPEED, WINDOW_HEIGHT,
};
use crate::resources::{PlayerLives, PlayerRespawnTimer, Score};
use crate::game_state::AppState;
use crate::components::Player; // Needed for check_enemy_bullet_player_collisions
use crate::components::Invincible; // Needed for check_enemy_bullet_player_collisions
// --- Player Bullet Systems ---
pub fn move_bullets(
mut query: Query<(Entity, &mut Transform), With<Bullet>>,
time: Res<Time>,
mut commands: Commands,
) {
for (entity, mut transform) in query.iter_mut() {
transform.translation.y += BULLET_SPEED * time.delta_seconds();
if transform.translation.y > WINDOW_HEIGHT / 2.0 + BULLET_SIZE.y {
commands.entity(entity).despawn();
}
}
}
pub fn check_bullet_collisions(
mut commands: Commands,
bullet_query: Query<(Entity, &Transform), With<Bullet>>,
enemy_query: Query<(Entity, &Transform, &Enemy), With<Enemy>>, // Fetch Enemy component too
mut score: ResMut<Score>, // Add Score resource
) {
for (bullet_entity, bullet_transform) in bullet_query.iter() {
for (enemy_entity, enemy_transform, enemy) in enemy_query.iter() {
// Get Enemy component
let distance = bullet_transform
.translation
.distance(enemy_transform.translation);
if distance < BULLET_ENEMY_COLLISION_THRESHOLD {
commands.entity(bullet_entity).despawn();
commands.entity(enemy_entity).despawn();
// Increment score based on enemy type
let points = match enemy.enemy_type {
EnemyType::Grunt => 100,
EnemyType::Boss => 100, // Same points as Grunt for now
};
score.value += points;
println!("Enemy hit by player bullet! Score: {}", score.value); // Log score update
break; // Bullet only hits one enemy
}
}
}
}
// --- Enemy Bullet Systems ---
pub fn move_enemy_bullets(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut Transform), With<EnemyBullet>>,
) {
for (entity, mut transform) in query.iter_mut() {
transform.translation.y -= ENEMY_BULLET_SPEED * time.delta_seconds();
// Despawn if off screen (bottom)
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_BULLET_SIZE.y {
commands.entity(entity).despawn();
}
}
}
// Check collisions between enemy bullets and the player
pub fn check_enemy_bullet_player_collisions(
mut commands: Commands,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>,
// Player query matching the run_if condition (player exists and is vulnerable)
player_query: Query<(Entity, &Transform), (With<Player>, Without<Invincible>)>,
enemy_bullet_query: Query<(Entity, &Transform), With<EnemyBullet>>,
) {
if let Ok((player_entity, player_transform)) = player_query.get_single() {
for (bullet_entity, bullet_transform) in enemy_bullet_query.iter() {
let distance = player_transform
.translation
.distance(bullet_transform.translation);
if distance < ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD {
println!("Player hit by enemy bullet!");
commands.entity(bullet_entity).despawn(); // Despawn bullet
lives.count = lives.count.saturating_sub(1);
println!("Lives remaining: {}", lives.count);
commands.entity(player_entity).despawn(); // Despawn player
if lives.count > 0 {
respawn_timer.timer.reset();
respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
next_state.set(AppState::GameOver);
}
// Break because the player can only be hit once per frame
break;
}
}
}
}

57
src/components.rs Normal file
View file

@ -0,0 +1,57 @@
use bevy::prelude::*;
use std::time::Duration; // Needed for Timer in Player
// --- Components ---
#[derive(Component)]
pub struct Player {
pub speed: f32,
pub shoot_cooldown: Timer,
}
#[derive(Component)]
pub struct Bullet;
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] // Added derive for common traits
pub enum EnemyType {
Grunt,
Boss, // Added Boss type
}
#[derive(Component)]
pub struct Enemy {
pub enemy_type: EnemyType, // Add type field
pub shoot_cooldown: Timer,
}
#[derive(Component)]
pub struct Invincible {
pub timer: Timer,
}
#[derive(Component)]
pub struct FormationTarget {
pub position: Vec3,
}
// Enum defining different ways an enemy can attack
#[derive(Component, Clone, Copy, PartialEq, Debug)]
pub enum AttackPattern {
SwoopDive, // Original pattern: dive towards center, then off screen
DirectDive, // Dive straight down
Kamikaze(Vec3), // Dive towards a specific target location (e.g., player's last known position) - Needs target Vec3
// Add more patterns later (e.g., FigureEight, Looping)
}
#[derive(Component, Clone, PartialEq, Debug)] // Added Debug derive
pub enum EnemyState {
Entering, // Flying onto the screen towards formation target
InFormation, // Holding position in the formation
Attacking(AttackPattern), // Diving towards the player using a specific pattern
}
#[derive(Component)]
pub struct EnemyBullet;
// Game Over UI Component (might move to ui.rs later if more UI exists)
#[derive(Component)]
pub struct GameOverUI;

33
src/constants.rs Normal file
View file

@ -0,0 +1,33 @@
use bevy::math::Vec2;
// --- Constants ---
pub const WINDOW_WIDTH: f32 = 600.0;
pub const WINDOW_HEIGHT: f32 = 800.0;
pub const PLAYER_SPEED: f32 = 300.0;
pub const BULLET_SPEED: f32 = 500.0;
pub const ENEMY_SPEED: f32 = 100.0;
pub const PLAYER_SIZE: Vec2 = Vec2::new(30.0, 30.0);
pub const ENEMY_SIZE: Vec2 = Vec2::new(40.0, 40.0);
pub const BULLET_SIZE: Vec2 = Vec2::new(5.0, 15.0);
// Player bullet
pub const ENEMY_BULLET_SIZE: Vec2 = Vec2::new(8.0, 8.0);
// Enemy bullet
pub const ENEMY_BULLET_SPEED: f32 = 300.0;
pub const ENEMY_SHOOT_INTERVAL: f32 = 1.5;
// Formation constants
const FORMATION_ROWS: usize = 4;
pub const FORMATION_COLS: usize = 8;
pub const FORMATION_ENEMY_COUNT: usize = FORMATION_ROWS * FORMATION_COLS;
pub const FORMATION_X_SPACING: f32 = 60.0;
pub const FORMATION_Y_SPACING: f32 = 50.0;
pub const FORMATION_BASE_Y: f32 = WINDOW_HEIGHT / 2.0 - 150.0;
// Top area for formation
pub const STARTING_LIVES: u32 = 3;
pub const PLAYER_RESPAWN_DELAY: f32 = 2.0;
pub const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
// Collision thresholds
pub const BULLET_ENEMY_COLLISION_THRESHOLD: f32 = (BULLET_SIZE.x + ENEMY_SIZE.x) * 0.5;
// 22.5
pub const PLAYER_ENEMY_COLLISION_THRESHOLD: f32 = (PLAYER_SIZE.x + ENEMY_SIZE.x) * 0.5;
// 35.0
pub const ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD: f32 = (ENEMY_BULLET_SIZE.x + PLAYER_SIZE.x) * 0.5;

356
src/enemy.rs Normal file
View file

@ -0,0 +1,356 @@
use bevy::prelude::*;
use std::time::Duration;
use crate::components::{Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget};
use crate::constants::{ // Added WINDOW_WIDTH
ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD, ENEMY_BULLET_SIZE, ENEMY_BULLET_SPEED,
ENEMY_SHOOT_INTERVAL, ENEMY_SIZE, ENEMY_SPEED, FORMATION_BASE_Y, FORMATION_COLS,
FORMATION_ENEMY_COUNT, FORMATION_X_SPACING, FORMATION_Y_SPACING, WINDOW_HEIGHT, WINDOW_WIDTH,
};
use crate::resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, PlayerLives,
PlayerRespawnTimer, StageConfigurations, // Make sure StageConfigurations is imported if not already
};
use crate::game_state::AppState;
pub fn spawn_enemies(
mut commands: Commands,
time: Res<Time>,
mut timer: ResMut<EnemySpawnTimer>,
mut stage: ResMut<CurrentStage>,
mut formation_state: ResMut<FormationState>,
stage_configs: Res<StageConfigurations>, // Use imported name
) {
// Get current stage config, looping if stage number exceeds defined configs
let config_index = (stage.number as usize - 1) % stage_configs.stages.len();
let current_config = &stage_configs.stages[config_index];
let stage_enemy_count = current_config.enemy_count;
// Tick the timer every frame
timer.timer.tick(time.delta());
// Only spawn if we haven't spawned the full formation for this stage yet
// AND the timer just finished this frame
if formation_state.total_spawned_this_stage < stage_enemy_count
&& timer.timer.just_finished()
{
let slot_index = formation_state.next_slot_index;
// Ensure slot_index is within bounds of the formation layout
if slot_index >= current_config.formation_layout.positions.len() {
println!("Warning: slot_index {} out of bounds for formation layout '{}' (size {})",
slot_index, current_config.formation_layout.name, current_config.formation_layout.positions.len());
// Optionally, reset the timer and skip spawning this frame, or handle differently
timer.timer.reset();
return;
}
// Get target position from the stage's formation layout
let target_pos = current_config.formation_layout.positions[slot_index];
// Spawn position (random at the top) - Corrected to use WINDOW_WIDTH
let spawn_x = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x);
let spawn_y = WINDOW_HEIGHT / 2.0 + ENEMY_SIZE.y / 2.0; // Spawn slightly above screen
// Determine enemy type (can be randomized or based on stage config later)
let enemy_type = EnemyType::Grunt;
// Determine sprite color based on type
let sprite_color = match enemy_type {
EnemyType::Grunt => Color::rgb(1.0, 0.2, 0.2),
EnemyType::Boss => Color::rgb(0.8, 0.2, 1.0),
};
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: sprite_color,
custom_size: Some(ENEMY_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(spawn_x, spawn_y, 0.0)),
..default()
},
Enemy {
enemy_type,
// Use shoot interval from stage config
shoot_cooldown: Timer::new(
Duration::from_secs_f32(current_config.enemy_shoot_interval),
TimerMode::Once,
),
},
FormationTarget { position: target_pos },
EnemyState::Entering,
));
// Use stage_enemy_count for cycling index
formation_state.next_slot_index = (slot_index + 1) % stage_enemy_count;
formation_state.total_spawned_this_stage += 1;
// Mark that we are now waiting for enemies to be cleared
stage.waiting_for_clear = true;
// Reset timer only if we are still spawning more enemies for the formation
if formation_state.total_spawned_this_stage < stage_enemy_count {
timer.timer.reset();
} else {
println!(
"Full formation ({}) spawned for Stage {}",
current_config.formation_layout.name, stage.number
);
}
}
}
pub fn move_enemies(
mut entering_query: Query<
(Entity, &mut Transform, &FormationTarget, &mut EnemyState),
(With<Enemy>, With<FormationTarget>),
>,
mut attacking_query: Query<
(Entity, &mut Transform, &EnemyState, &Enemy), // Add &Enemy here
(With<Enemy>, Without<FormationTarget>),
>, // Query potential attackers
time: Res<Time>,
mut commands: Commands,
stage: Res<CurrentStage>,
stage_configs: Res<StageConfigurations>, // Add stage configurations
) {
// Get current stage config for speed multiplier
let config_index = (stage.number as usize - 1) % stage_configs.stages.len();
let current_config = &stage_configs.stages[config_index];
let speed_multiplier = current_config.enemy_speed_multiplier;
// Calculate speeds for this frame
let base_speed = ENEMY_SPEED * speed_multiplier;
let attack_speed = base_speed * 1.5; // Attackers are faster
let arrival_threshold = base_speed * time.delta_seconds() * 1.1; // Threshold for reaching formation target
// --- Handle Entering Enemies ---
for (entity, mut transform, target, mut state) in entering_query.iter_mut() {
// Ensure we only process Entering state here, though query filters it mostly
if *state == EnemyState::Entering {
let current_pos = transform.translation;
let target_pos = target.position;
let direction = target_pos - current_pos;
let distance = direction.length();
if distance < arrival_threshold {
// Arrived at target
transform.translation = target_pos;
commands.entity(entity).remove::<FormationTarget>(); // Remove target component
*state = EnemyState::InFormation; // Change state
println!(
"Enemy {:?} reached formation target and is now InFormation.",
entity
);
} else {
// Move towards target using base_speed
let move_delta = direction.normalize() * base_speed * time.delta_seconds();
transform.translation += move_delta;
}
}
}
// --- Handle Attacking Enemies ---
// Note: attack_speed calculated above using multiplier
for (entity, mut transform, state, _enemy) in attacking_query.iter_mut() {
// Match on the specific attack pattern using matches! and then get the pattern
if matches!(state, EnemyState::Attacking(_)) {
if let EnemyState::Attacking(pattern) = state { // Get the pattern safely now
let delta_seconds = time.delta_seconds();
match pattern {
AttackPattern::SwoopDive => {
// Original Swooping Dive Logic
let vertical_movement = attack_speed * delta_seconds;
let horizontal_speed_factor = 0.5;
let horizontal_movement = if transform.translation.x < 0.0 {
attack_speed * horizontal_speed_factor * delta_seconds
} else if transform.translation.x > 0.0 {
-attack_speed * horizontal_speed_factor * delta_seconds
} else { 0.0 };
transform.translation.y -= vertical_movement;
transform.translation.x += horizontal_movement;
// Prevent overshooting center
if (transform.translation.x > 0.0 && transform.translation.x + horizontal_movement < 0.0) ||
(transform.translation.x < 0.0 && transform.translation.x + horizontal_movement > 0.0) {
transform.translation.x = 0.0;
}
}
AttackPattern::DirectDive => {
// Move straight down
transform.translation.y -= attack_speed * delta_seconds;
}
AttackPattern::Kamikaze(target_pos) => {
// Move towards the target position
let direction = *target_pos - transform.translation;
let distance = direction.length();
let kamikaze_threshold = attack_speed * delta_seconds * 1.1; // Threshold to stop near target
if distance > kamikaze_threshold {
let move_delta = direction.normalize() * attack_speed * delta_seconds;
transform.translation += move_delta;
} else {
// Optionally stop or continue past target - for now, just stop moving towards it
// Could also despawn here if desired upon reaching target
}
}
// Add cases for other patterns here
} // Close inner if let
} // Closes match enemy.enemy_type
} // Closes if *state == EnemyState::Attacking
// Despawn if off screen (This should be inside the loop)
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
println!(
"Despawning enemy {:?} that went off screen.", // Generic message as it could be InFormation or Attacking
entity
);
commands.entity(entity).despawn();
// TODO: Later, attacking enemies might return to formation or loop
}
} // Closes for loop
}
// System to check if all spawned enemies have reached their formation position
pub fn check_formation_complete(
mut formation_state: ResMut<FormationState>,
enemy_query: Query<&EnemyState, With<Enemy>>, // Query states directly
mut attack_dive_timer: ResMut<AttackDiveTimer>, // Add timer to reset it
) {
// Only run the check if the formation isn't already marked as complete
if !formation_state.formation_complete {
// Check if all enemies for the stage have been spawned
if formation_state.total_spawned_this_stage == FORMATION_ENEMY_COUNT {
// Check if any enemies are still in the Entering state
let mut any_entering = false;
for state in enemy_query.iter() {
if *state == EnemyState::Entering {
any_entering = true;
break;
}
}
// If all spawned and none are entering, formation is complete
if !any_entering {
println!(
"Formation complete! Resetting attack timer. (Spawned={})",
formation_state.total_spawned_this_stage
);
formation_state.formation_complete = true;
attack_dive_timer.timer.reset(); // Reset the dive timer
attack_dive_timer.timer.unpause(); // Start the timer now
println!("Formation complete! Attack timer unpaused and reset.");
}
}
}
}
use crate::components::AttackPattern; // Import the new enum
use crate::components::Player; // Import Player for Kamikaze target
pub fn trigger_attack_dives(
mut timer: ResMut<AttackDiveTimer>,
time: Res<Time>,
mut enemy_query: Query<(Entity, &mut EnemyState), With<Enemy>>, // Renamed for clarity
formation_state: Res<FormationState>,
stage: Res<CurrentStage>, // Need current stage
stage_configs: Res<StageConfigurations>, // Need stage configs
player_query: Query<&Transform, With<Player>>, // Need player position for Kamikaze
) {
timer.timer.tick(time.delta());
// Only proceed if the timer finished AND the formation is complete
if timer.timer.just_finished() && formation_state.formation_complete {
// Find all enemies currently in formation
let mut available_enemies: Vec<Entity> = Vec::new();
// Get the current stage config
let config_index = (stage.number as usize - 1) % stage_configs.stages.len();
let current_config = &stage_configs.stages[config_index];
// Find all enemies currently in formation
let mut available_enemies: Vec<Entity> = Vec::new();
for (entity, state) in enemy_query.iter() {
// Check the state correctly
if matches!(state, EnemyState::InFormation) {
available_enemies.push(entity);
}
}
// If there are enemies available, pick one randomly
if !available_enemies.is_empty() && !current_config.attack_patterns.is_empty() {
let random_index = fastrand::usize(..available_enemies.len());
let chosen_entity = available_enemies[random_index];
// Select a random attack pattern for this stage
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
let mut selected_pattern = current_config.attack_patterns[pattern_index]; // Copy the pattern
// If Kamikaze, get player position (if player exists)
if let AttackPattern::Kamikaze(_) = selected_pattern {
if let Ok(player_transform) = player_query.get_single() {
selected_pattern = AttackPattern::Kamikaze(player_transform.translation);
} else {
// Fallback if player doesn't exist (e.g., just died)
selected_pattern = AttackPattern::DirectDive; // Or SwoopDive
println!("Kamikaze target not found, falling back to DirectDive");
}
}
// Get the chosen enemy's state mutably and change it
if let Ok((_, mut state)) = enemy_query.get_mut(chosen_entity) {
println!("Enemy {:?} starting attack dive with pattern {:?}!", chosen_entity, selected_pattern);
*state = EnemyState::Attacking(selected_pattern); // Set state with pattern
// Timer duration is handled elsewhere (e.g., check_formation_complete)
}
}
}
}
pub fn enemy_shoot(
mut commands: Commands,
time: Res<Time>,
// Query attacking enemies: need their transform and mutable Enemy component for the timer
mut enemy_query: Query<(&Transform, &mut Enemy, &EnemyState), Without<FormationTarget>>, // Query remains the same
) {
for (transform, mut enemy, state) in enemy_query.iter_mut() {
// Only shoot if in any Attacking state (pattern doesn't matter for shooting)
if matches!(state, EnemyState::Attacking(_)) { // Use matches! macro
enemy.shoot_cooldown.tick(time.delta());
if enemy.shoot_cooldown.finished() {
// println!("Enemy {:?} firing!", transform.translation); // Less verbose logging
let bullet_start_pos = transform.translation
- Vec3::Y * (ENEMY_SIZE.y / 2.0 + ENEMY_BULLET_SIZE.y / 2.0);
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 0.5, 0.5),
custom_size: Some(ENEMY_BULLET_SIZE),
..default()
},
transform: Transform::from_translation(bullet_start_pos),
..default()
},
EnemyBullet,
));
// Reset the timer for the next shot
enemy.shoot_cooldown.reset();
}
}
}
}
// Check collisions between enemy bullets and the player
// Moved to player.rs as it affects player state directly
// New run condition: Check if the formation is complete
pub fn is_formation_complete(formation_state: Res<FormationState>) -> bool {
formation_state.formation_complete
}

68
src/game_state.rs Normal file
View file

@ -0,0 +1,68 @@
use bevy::prelude::*;
use crate::components::{Bullet, Enemy, GameOverUI}; // Import necessary components
// --- Game States ---
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
pub enum AppState {
#[default]
Playing,
GameOver,
}
// --- Game Over UI ---
pub fn setup_game_over_ui(mut commands: Commands) {
println!("Entering GameOver state. Setting up UI.");
commands.spawn((
TextBundle::from_section(
"GAME OVER",
TextStyle {
font_size: 100.0,
color: Color::WHITE,
..default()
},
)
.with_style(Style {
position_type: PositionType::Absolute,
align_self: AlignSelf::Center,
justify_self: JustifySelf::Center,
top: Val::Percent(40.0), // Center vertically roughly
..default()
}),
GameOverUI, // Tag the UI element
));
// TODO: Add "Press R to Restart" text later
}
pub fn cleanup_game_over_ui(mut commands: Commands, query: Query<Entity, With<GameOverUI>>) {
println!("Exiting GameOver state. Cleaning up UI.");
for entity in query.iter() {
commands.entity(entity).despawn_recursive();
}
}
// --- Cleanup ---
// Cleanup system when exiting the Playing state
pub fn cleanup_game_entities(
mut commands: Commands,
bullet_query: Query<Entity, With<Bullet>>,
enemy_bullet_query: Query<Entity, With<crate::components::EnemyBullet>>, // Need to specify crate::components
enemy_query: Query<Entity, With<Enemy>>,
// Optionally despawn player too, or handle separately if needed for restart
// player_query: Query<Entity, With<Player>>,
) {
println!("Exiting Playing state. Cleaning up game entities.");
for entity in bullet_query.iter() {
commands.entity(entity).despawn();
}
for entity in enemy_bullet_query.iter() { // Also despawn enemy bullets
commands.entity(entity).despawn();
}
for entity in enemy_query.iter() {
commands.entity(entity).despawn();
}
// for entity in player_query.iter() {
// commands.entity(entity).despawn();
// }
}

View file

@ -1,388 +1,46 @@
use bevy::prelude::*; // Removed unused AppExit
use bevy::prelude::*;
use std::time::Duration;
// --- Game States ---
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
enum AppState {
#[default]
Playing,
GameOver,
}
// --- Constants ---
const WINDOW_WIDTH: f32 = 600.0;
const WINDOW_HEIGHT: f32 = 800.0;
const PLAYER_SPEED: f32 = 300.0;
const BULLET_SPEED: f32 = 500.0;
const ENEMY_SPEED: f32 = 100.0;
const PLAYER_SIZE: Vec2 = Vec2::new(30.0, 30.0);
const ENEMY_SIZE: Vec2 = Vec2::new(40.0, 40.0);
const BULLET_SIZE: Vec2 = Vec2::new(5.0, 15.0); // Player bullet
const ENEMY_BULLET_SIZE: Vec2 = Vec2::new(8.0, 8.0); // Enemy bullet
const ENEMY_BULLET_SPEED: f32 = 300.0;
const ENEMY_SHOOT_INTERVAL: f32 = 1.5; // Seconds between shots for an attacking enemy
// Formation constants
const FORMATION_ROWS: usize = 4;
const FORMATION_COLS: usize = 8;
const FORMATION_ENEMY_COUNT: usize = FORMATION_ROWS * FORMATION_COLS;
const FORMATION_X_SPACING: f32 = 60.0;
const FORMATION_Y_SPACING: f32 = 50.0;
const FORMATION_BASE_Y: f32 = WINDOW_HEIGHT / 2.0 - 150.0; // Top area for formation
const STARTING_LIVES: u32 = 3;
const PLAYER_RESPAWN_DELAY: f32 = 2.0;
const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
pub mod components;
pub mod constants;
pub mod resources;
pub mod game_state;
pub mod player;
pub mod enemy;
pub mod bullet;
pub mod stage;
pub mod systems;
// Collision thresholds
const BULLET_ENEMY_COLLISION_THRESHOLD: f32 = (BULLET_SIZE.x + ENEMY_SIZE.x) * 0.5; // 22.5
const PLAYER_ENEMY_COLLISION_THRESHOLD: f32 = (PLAYER_SIZE.x + ENEMY_SIZE.x) * 0.5; // 35.0
const ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD: f32 = (ENEMY_BULLET_SIZE.x + PLAYER_SIZE.x) * 0.5; // ~19.0
// --- Components ---
#[derive(Component)]
struct Player {
speed: f32,
shoot_cooldown: Timer,
}
#[derive(Component)]
struct Bullet;
#[derive(Component)]
struct Enemy {
enemy_type: EnemyType, // Add type field
shoot_cooldown: Timer,
}
#[derive(Component)]
struct Invincible {
timer: Timer,
}
#[derive(Component)]
struct FormationTarget {
position: Vec3,
}
#[derive(Component, Clone, PartialEq)]
enum EnemyState {
Entering, // Flying onto the screen towards formation target
InFormation, // Holding position in the formation
Attacking, // Diving towards the player
}
#[derive(Component)]
struct EnemyBullet;
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] // Added derive for common traits
pub enum EnemyType {
Grunt,
Boss, // Added Boss type
}
// --- Resources ---
#[derive(Resource)]
struct EnemySpawnTimer {
timer: Timer,
}
#[derive(Resource)]
struct PlayerLives {
count: u32,
}
#[derive(Resource)]
struct PlayerRespawnTimer {
timer: Timer,
}
#[derive(Resource)]
struct Score {
value: u32,
}
#[derive(Resource)]
struct CurrentStage {
number: u32,
waiting_for_clear: bool, // Flag to check if we should check for stage clear
}
#[derive(Resource)]
struct FormationState {
next_slot_index: usize,
total_spawned_this_stage: usize,
formation_complete: bool, // Flag to indicate if all enemies are in position
}
#[derive(Resource)]
struct AttackDiveTimer {
timer: Timer,
}
// --- Game Over UI ---
#[derive(Component)]
struct GameOverUI;
fn setup_game_over_ui(mut commands: Commands) {
println!("Entering GameOver state. Setting up UI.");
commands.spawn((
TextBundle::from_section(
"GAME OVER",
TextStyle {
font_size: 100.0,
color: Color::WHITE,
..default()
},
)
.with_style(Style {
position_type: PositionType::Absolute,
align_self: AlignSelf::Center,
justify_self: JustifySelf::Center,
top: Val::Percent(40.0), // Center vertically roughly
..default()
}),
GameOverUI, // Tag the UI element
));
// TODO: Add "Press R to Restart" text later
}
fn cleanup_game_over_ui(mut commands: Commands, query: Query<Entity, With<GameOverUI>>) {
println!("Exiting GameOver state. Cleaning up UI.");
for entity in query.iter() {
commands.entity(entity).despawn_recursive();
}
}
// --- Cleanup ---
fn cleanup_game_entities(
mut commands: Commands,
bullet_query: Query<Entity, With<Bullet>>,
enemy_query: Query<Entity, With<Enemy>>,
// Optionally despawn player too, or handle separately if needed for restart
// player_query: Query<Entity, With<Player>>,
) {
println!("Exiting Playing state. Cleaning up game entities.");
for entity in bullet_query.iter() {
commands.entity(entity).despawn();
}
for entity in enemy_query.iter() {
commands.entity(entity).despawn();
}
// for entity in player_query.iter() {
// commands.entity(entity).despawn();
// }
}
// --- Stage Management ---
// Helper to access world directly in check_stage_clear
fn check_stage_clear(world: &mut World) {
// Use manual resource access because we need mutable access to multiple resources
// Separate checks to manage borrows correctly
let mut should_clear = false;
if let Some(stage) = world.get_resource::<CurrentStage>() {
if stage.waiting_for_clear {
// Create the query *after* checking the flag
let mut enemy_query = world.query_filtered::<Entity, With<Enemy>>();
if enemy_query.iter(world).next().is_none() {
should_clear = true;
}
}
}
if should_clear {
// Get mutable resources only when needed
if let Some(mut stage) = world.get_resource_mut::<CurrentStage>() {
stage.number += 1;
stage.waiting_for_clear = false;
println!("Stage cleared! Starting Stage {}...", stage.number);
}
if let Some(mut formation_state) = world.get_resource_mut::<FormationState>() {
formation_state.next_slot_index = 0;
formation_state.total_spawned_this_stage = 0;
formation_state.formation_complete = false; // Reset flag for new stage
}
if let Some(mut spawn_timer) = world.get_resource_mut::<EnemySpawnTimer>() {
spawn_timer.timer.reset();
}
}
}
// --- Enemy Attack Logic ---
fn trigger_attack_dives(
mut timer: ResMut<AttackDiveTimer>,
time: Res<Time>,
mut query: Query<(Entity, &mut EnemyState), With<Enemy>>,
formation_state: Res<FormationState>, // Read formation state
) {
timer.timer.tick(time.delta());
// Timer is ticked implicitly by the system scheduler when the run condition is met,
// or we can tick it explicitly *after* checking formation_complete.
// Let's rely on the run condition and internal check for now.
// timer.timer.tick(time.delta()); // Removed redundant tick
// Only proceed if the timer finished AND the formation is complete
if timer.timer.just_finished() && formation_state.formation_complete {
// Find all enemies currently in formation
let mut available_enemies: Vec<Entity> = Vec::new();
for (entity, state) in query.iter() {
if *state == EnemyState::InFormation {
available_enemies.push(entity);
}
}
// If there are enemies available, pick one randomly
if !available_enemies.is_empty() {
let random_index = fastrand::usize(..available_enemies.len());
let chosen_entity = available_enemies[random_index];
// Get the chosen enemy's state mutably and change it
if let Ok((_, mut state)) = query.get_mut(chosen_entity) {
println!("Enemy {:?} starting attack dive!", chosen_entity);
*state = EnemyState::Attacking;
// Timer will automatically repeat due to TimerMode::Repeating,
// no need to reset here unless we wanted a different interval after the first dive.
}
}
}
}
fn enemy_shoot(
mut commands: Commands,
time: Res<Time>,
// Query attacking enemies: need their transform and mutable Enemy component for the timer
mut enemy_query: Query<(&Transform, &mut Enemy, &EnemyState), Without<FormationTarget>>,
) {
for (transform, mut enemy, state) in enemy_query.iter_mut() {
// Only shoot if attacking
if *state == EnemyState::Attacking {
enemy.shoot_cooldown.tick(time.delta());
if enemy.shoot_cooldown.finished() {
println!("Enemy {:?} firing!", transform.translation); // Placeholder for entity ID if needed
let bullet_start_pos = transform.translation
- Vec3::Y * (ENEMY_SIZE.y / 2.0 + ENEMY_BULLET_SIZE.y / 2.0);
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 0.5, 0.5), // Different color for enemy bullets
custom_size: Some(ENEMY_BULLET_SIZE),
..default()
},
transform: Transform::from_translation(bullet_start_pos),
..default()
},
EnemyBullet,
));
// Reset the timer for the next shot
enemy.shoot_cooldown.reset();
}
}
}
}
fn move_enemy_bullets(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut Transform), With<EnemyBullet>>,
) {
for (entity, mut transform) in query.iter_mut() {
transform.translation.y -= ENEMY_BULLET_SPEED * time.delta_seconds();
// Despawn if off screen (bottom)
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_BULLET_SIZE.y {
commands.entity(entity).despawn();
}
}
}
// System to check if all spawned enemies have reached their formation position
fn check_formation_complete(
mut formation_state: ResMut<FormationState>,
enemy_query: Query<&EnemyState, With<Enemy>>, // Query states directly
mut attack_dive_timer: ResMut<AttackDiveTimer>, // Add timer to reset it
) {
// Only run the check if the formation isn't already marked as complete
if !formation_state.formation_complete {
// Check if all enemies for the stage have been spawned
if formation_state.total_spawned_this_stage == FORMATION_ENEMY_COUNT {
// Check if any enemies are still in the Entering state
let mut any_entering = false;
for state in enemy_query.iter() {
if *state == EnemyState::Entering {
any_entering = true;
break;
}
}
// If all spawned and none are entering, formation is complete
if !any_entering {
println!(
"Formation complete! Resetting attack timer. (Spawned={})",
formation_state.total_spawned_this_stage
);
formation_state.formation_complete = true;
attack_dive_timer.timer.reset(); // Reset the dive timer
attack_dive_timer.timer.unpause(); // Start the timer now
println!("Formation complete! Attack timer unpaused and reset.");
}
// else { // Optional log
// println!("Checking formation complete: Still entering...");
// }
}
// else { // Optional log
// println!("Checking formation complete: Not all spawned yet ({}/{})", formation_state.total_spawned_this_stage, FORMATION_ENEMY_COUNT);
// }
}
}
// Check collisions between enemy bullets and the player
fn check_enemy_bullet_player_collisions(
mut commands: Commands,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>,
// Player query matching the run_if condition
player_query: Query<(Entity, &Transform), (With<Player>, Without<Invincible>)>,
enemy_bullet_query: Query<(Entity, &Transform), With<EnemyBullet>>,
) {
if let Ok((player_entity, player_transform)) = player_query.get_single() {
for (bullet_entity, bullet_transform) in enemy_bullet_query.iter() {
let distance = player_transform
.translation
.distance(bullet_transform.translation);
if distance < ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD {
println!("Player hit by enemy bullet!");
commands.entity(bullet_entity).despawn(); // Despawn bullet
lives.count = lives.count.saturating_sub(1);
println!("Lives remaining: {}", lives.count);
commands.entity(player_entity).despawn(); // Despawn player
if lives.count > 0 {
respawn_timer.timer.reset();
respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
next_state.set(AppState::GameOver);
}
// Break because the player can only be hit once per frame
break;
}
}
}
}
use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH};
use resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, PlayerLives,
PlayerRespawnTimer, Score,
};
use game_state::{
cleanup_game_entities, cleanup_game_over_ui, setup_game_over_ui, AppState,
};
use player::{
check_player_enemy_collisions, manage_invincibility, move_player, player_shoot,
respawn_player,
};
use enemy::{
check_formation_complete, enemy_shoot, is_formation_complete, move_enemies, spawn_enemies,
trigger_attack_dives,
};
use bullet::{
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets,
move_enemy_bullets,
};
use stage::check_stage_clear;
use systems::{player_exists, player_vulnerable, setup, should_respawn_player, update_window_title};
fn main() {
App::new() // Start App builder
.init_state::<AppState>() // Initialize the AppState *after* App::new()
App::new()
.init_state::<AppState>() // Initialize the AppState
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0)))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Galaga :: Stage: 1 Lives: 3 Score: 0".into(), // Initial title with stage
title: "Galaga :: Stage: 1 Lives: 3 Score: 0".into(), // Initial title
resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
..default()
}),
@ -393,7 +51,6 @@ fn main() {
count: STARTING_LIVES,
})
.insert_resource(PlayerRespawnTimer {
// Start paused and finished
timer: Timer::new(
Duration::from_secs_f32(PLAYER_RESPAWN_DELAY),
TimerMode::Once,
@ -402,67 +59,64 @@ fn main() {
.insert_resource(EnemySpawnTimer {
timer: Timer::new(Duration::from_secs_f32(0.5), TimerMode::Repeating),
})
.insert_resource(Score { value: 0 }) // Initialize score
.insert_resource(Score { value: 0 })
.insert_resource(CurrentStage {
number: 1,
waiting_for_clear: false,
}) // Initialize stage and flag
})
.insert_resource(FormationState {
next_slot_index: 0,
total_spawned_this_stage: 0,
formation_complete: false,
}) // Initialize formation state with flag
})
.insert_resource(AttackDiveTimer {
timer: {
let mut timer = Timer::new(Duration::from_secs_f32(3.0), TimerMode::Repeating);
timer.pause(); // Start paused until formation is complete
timer.pause(); // Start paused
timer
},
}) // Initialize attack timer (e.g., every 3 seconds), initially paused
})
// Add Systems
.add_systems(Startup, setup)
// Systems running only when Playing
.add_systems(
Update,
(
// Player systems
move_player,
player_shoot.run_if(player_exists),
move_bullets,
move_enemies,
spawn_enemies,
check_bullet_collisions,
check_player_enemy_collisions.run_if(player_vulnerable),
respawn_player.run_if(should_respawn_player),
manage_invincibility,
// check_formation_complete and trigger_attack_dives added separately below
enemy_shoot, // Add enemy shooting system
// Bullet systems
move_bullets,
check_bullet_collisions,
move_enemy_bullets,
check_enemy_bullet_player_collisions.run_if(player_vulnerable), // Add enemy bullet collision check
// check_stage_clear is now added separately below using .pipe(system)
// Game Over check is now implicit in check_player_enemy_collisions
check_enemy_bullet_player_collisions.run_if(player_vulnerable),
// Enemy systems
spawn_enemies,
move_enemies,
enemy_shoot, // Consider run_if attacking state? (Handled internally for now)
)
.run_if(in_state(AppState::Playing)),
)
// Add formation check, dive trigger (with run condition), and stage clear separately
// Formation/Attack/Stage systems (some need specific ordering or run conditions)
.add_systems(
Update,
check_formation_complete.run_if(in_state(AppState::Playing)),
(
check_formation_complete,
trigger_attack_dives.run_if(is_formation_complete), // Run only when formation is complete
check_stage_clear, // Uses world access, run separately
)
.chain() // Ensure these run in order if needed, check_formation first
.run_if(in_state(AppState::Playing)),
)
.add_systems(
Update,
trigger_attack_dives
.run_if(in_state(AppState::Playing).and_then(is_formation_complete)),
)
.add_systems(
Update,
check_stage_clear.run_if(in_state(AppState::Playing)),
) // check_stage_clear uses world access
// Systems running regardless of state (or managing state transitions)
.add_systems(
Update,
(
update_window_title, // Keep title updated
// Add system to check for restart input in GameOver state later
// TODO: Add system to check for restart input in GameOver state
bevy::window::close_on_esc, // Allow closing anytime
),
)
@ -472,466 +126,3 @@ fn main() {
.add_systems(OnExit(AppState::Playing), cleanup_game_entities) // Cleanup when leaving Playing
.run();
}
// --- Run Conditions ---
fn player_exists(query: Query<&Player>) -> bool {
!query.is_empty()
}
fn player_vulnerable(query: Query<&Player, Without<Invincible>>) -> bool {
!query.is_empty()
}
fn should_respawn_player(lives: Res<PlayerLives>, player_query: Query<&Player>) -> bool {
player_query.is_empty() && lives.count > 0
}
// New run condition: Check if the formation is complete
fn is_formation_complete(formation_state: Res<FormationState>) -> bool {
formation_state.formation_complete
}
// --- Systems ---
fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
spawn_player_ship(&mut commands); // Use helper function
}
// Helper to spawn player (used in setup and respawn)
fn spawn_player_ship(commands: &mut Commands) {
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(0.0, 0.5, 1.0),
custom_size: Some(PLAYER_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(
0.0,
-WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0,
0.0,
)),
..default()
},
Player {
speed: PLAYER_SPEED,
shoot_cooldown: Timer::new(Duration::from_secs_f32(0.3), TimerMode::Once),
},
// Player starts invincible for a short time
Invincible {
timer: Timer::new(
Duration::from_secs_f32(PLAYER_INVINCIBILITY_DURATION),
TimerMode::Once,
),
},
));
println!("Player spawned!");
}
fn move_player(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Transform, &Player)>,
time: Res<Time>,
) {
// Using get_single_mut handles the case where player might not exist yet (or was just destroyed)
if let Ok((mut transform, player)) = query.get_single_mut() {
let mut direction = 0.0;
if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) {
direction -= 1.0;
}
if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
direction += 1.0;
}
transform.translation.x += direction * player.speed * time.delta_seconds();
let half_player_width = PLAYER_SIZE.x / 2.0;
transform.translation.x = transform.translation.x.clamp(
-WINDOW_WIDTH / 2.0 + half_player_width,
WINDOW_WIDTH / 2.0 - half_player_width,
);
}
}
fn player_shoot(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&Transform, &mut Player)>, // Should only run if player exists due to run_if
mut commands: Commands,
time: Res<Time>,
) {
if let Ok((player_transform, mut player)) = query.get_single_mut() {
player.shoot_cooldown.tick(time.delta());
if (keyboard_input.just_pressed(KeyCode::Space)
|| keyboard_input.just_pressed(KeyCode::ArrowUp))
&& player.shoot_cooldown.finished()
{
player.shoot_cooldown.reset();
let bullet_start_pos = player_transform.translation
+ Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 1.0, 1.0),
custom_size: Some(BULLET_SIZE),
..default()
},
transform: Transform::from_translation(bullet_start_pos),
..default()
},
Bullet,
));
}
}
}
fn move_bullets(
mut query: Query<(Entity, &mut Transform), With<Bullet>>,
time: Res<Time>,
mut commands: Commands,
) {
for (entity, mut transform) in query.iter_mut() {
transform.translation.y += BULLET_SPEED * time.delta_seconds();
if transform.translation.y > WINDOW_HEIGHT / 2.0 + BULLET_SIZE.y {
commands.entity(entity).despawn();
}
}
}
fn spawn_enemies(
mut commands: Commands,
time: Res<Time>,
mut timer: ResMut<EnemySpawnTimer>,
mut stage: ResMut<CurrentStage>,
mut formation_state: ResMut<FormationState>, // Add formation state resource
) {
// Tick the timer every frame
timer.timer.tick(time.delta());
// Only spawn if we haven't spawned the full formation for this stage yet
// AND the timer just finished this frame
if formation_state.total_spawned_this_stage < FORMATION_ENEMY_COUNT
&& timer.timer.just_finished()
{
// Calculate grid position
let slot_index = formation_state.next_slot_index;
let row = slot_index / FORMATION_COLS;
let col = slot_index % FORMATION_COLS;
// Calculate target position in formation
let target_x = (col as f32 - (FORMATION_COLS as f32 - 1.0) / 2.0) * FORMATION_X_SPACING;
let target_y = FORMATION_BASE_Y - (row as f32 * FORMATION_Y_SPACING);
let target_pos = Vec3::new(target_x, target_y, 0.0);
// Spawn position (still random at the top for now)
let spawn_x = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x);
let spawn_y = WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y / 2.0;
// Determine enemy type (currently always Grunt)
let enemy_type = EnemyType::Grunt;
// Determine sprite color based on type
let sprite_color = match enemy_type {
EnemyType::Grunt => Color::rgb(1.0, 0.2, 0.2), // Red for Grunts
EnemyType::Boss => Color::rgb(0.8, 0.2, 1.0), // Purple for Bosses
};
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: sprite_color, // Use determined color
custom_size: Some(ENEMY_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(spawn_x, spawn_y, 0.0)),
..default()
},
Enemy {
enemy_type, // Use the defined type
// Initialize cooldown, maybe slightly randomized later
shoot_cooldown: Timer::new(
Duration::from_secs_f32(ENEMY_SHOOT_INTERVAL),
TimerMode::Once,
),
},
FormationTarget {
position: target_pos,
},
EnemyState::Entering, // Start in Entering state
));
formation_state.next_slot_index = (slot_index + 1) % FORMATION_ENEMY_COUNT; // Cycle through slots if needed, though we stop spawning
formation_state.total_spawned_this_stage += 1;
// Mark that we are now waiting for enemies to be cleared
stage.waiting_for_clear = true;
// Reset timer only if we are still spawning more enemies for the formation
if formation_state.total_spawned_this_stage < FORMATION_ENEMY_COUNT {
timer.timer.reset();
} else {
println!("Full formation spawned for Stage {}", stage.number);
}
}
}
fn move_enemies(
// Query now includes FormationTarget
// Query now includes EnemyState, but FormationTarget is optional
// We need separate queries or logic paths for different states
mut entering_query: Query<
(Entity, &mut Transform, &FormationTarget, &mut EnemyState),
(With<Enemy>, With<FormationTarget>),
>,
mut attacking_query: Query<
(Entity, &mut Transform, &EnemyState, &Enemy), // Add &Enemy here
(With<Enemy>, Without<FormationTarget>),
>, // Query potential attackers
time: Res<Time>,
mut commands: Commands,
stage: Res<CurrentStage>, // Add stage resource for speed calculation
) {
// Increase speed based on stage number
let current_speed = ENEMY_SPEED + (stage.number - 1) as f32 * 10.0;
let arrival_threshold = current_speed * time.delta_seconds() * 1.1; // Slightly more than one frame's movement
// --- Handle Entering Enemies ---
for (entity, mut transform, target, mut state) in entering_query.iter_mut() {
// Ensure we only process Entering state here, though query filters it mostly
if *state == EnemyState::Entering {
let current_pos = transform.translation;
let target_pos = target.position;
let direction = target_pos - current_pos;
let distance = direction.length();
if distance < arrival_threshold {
// Arrived at target
transform.translation = target_pos;
commands.entity(entity).remove::<FormationTarget>(); // Remove target component
*state = EnemyState::InFormation; // Change state
println!(
"Enemy {:?} reached formation target and is now InFormation.",
entity
);
} else {
// Move towards target
let move_delta = direction.normalize() * current_speed * time.delta_seconds();
transform.translation += move_delta;
}
}
}
// --- Handle Attacking Enemies ---
let attack_speed = current_speed * 1.5; // Make them dive faster
for (entity, mut transform, state, enemy) in attacking_query.iter_mut() {
// Get state and enemy
// *** Explicitly check if the enemy is actually in the Attacking state ***
if *state == EnemyState::Attacking {
// --- Apply movement based on EnemyType ---
match enemy.enemy_type {
EnemyType::Grunt => {
// Grunt: Swooping Dive Logic
let delta_seconds = time.delta_seconds();
let vertical_movement = attack_speed * delta_seconds;
// Horizontal movement: Move towards the center (x=0)
let horizontal_speed_factor = 0.5; // Adjust this to control the swoop intensity
let horizontal_movement = if transform.translation.x < 0.0 {
attack_speed * horizontal_speed_factor * delta_seconds
} else if transform.translation.x > 0.0 {
-attack_speed * horizontal_speed_factor * delta_seconds
} else {
0.0 // No horizontal movement if exactly at center
};
// Apply movement
transform.translation.y -= vertical_movement;
transform.translation.x += horizontal_movement;
// Ensure enemy doesn't overshoot the center horizontally if moving towards it
if (transform.translation.x - horizontal_movement < 0.0
&& transform.translation.x > 0.0)
|| (transform.translation.x - horizontal_movement > 0.0
&& transform.translation.x < 0.0)
{
transform.translation.x = 0.0;
}
} // Add cases for other enemy types later
EnemyType::Boss => {
// Boss: Same Swooping Dive Logic for now
let delta_seconds = time.delta_seconds();
let vertical_movement = attack_speed * delta_seconds;
let horizontal_speed_factor = 0.5;
let horizontal_movement = if transform.translation.x < 0.0 {
attack_speed * horizontal_speed_factor * delta_seconds
} else if transform.translation.x > 0.0 {
-attack_speed * horizontal_speed_factor * delta_seconds
} else {
0.0
};
transform.translation.y -= vertical_movement;
transform.translation.x += horizontal_movement;
if (transform.translation.x - horizontal_movement < 0.0
&& transform.translation.x > 0.0)
|| (transform.translation.x - horizontal_movement > 0.0
&& transform.translation.x < 0.0)
{
transform.translation.x = 0.0;
}
}
} // Closes match enemy.enemy_type
} // Closes if *state == EnemyState::Attacking
// Enemies that are InFormation but Without<FormationTarget> will now be ignored by the movement logic above.
// Despawn if off screen (This should be inside the loop)
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
println!(
"Despawning enemy {:?} that went off screen.", // Generic message as it could be InFormation or Attacking
entity
);
commands.entity(entity).despawn();
// TODO: Later, attacking enemies might return to formation or loop
}
} // Closes for loop
}
fn check_bullet_collisions(
mut commands: Commands,
bullet_query: Query<(Entity, &Transform), With<Bullet>>,
enemy_query: Query<(Entity, &Transform, &Enemy), With<Enemy>>, // Fetch Enemy component too
mut score: ResMut<Score>, // Add Score resource
) {
for (bullet_entity, bullet_transform) in bullet_query.iter() {
for (enemy_entity, enemy_transform, enemy) in enemy_query.iter() {
// Get Enemy component
let distance = bullet_transform
.translation
.distance(enemy_transform.translation);
if distance < BULLET_ENEMY_COLLISION_THRESHOLD {
commands.entity(bullet_entity).despawn();
commands.entity(enemy_entity).despawn();
// Increment score based on enemy type
let points = match enemy.enemy_type {
EnemyType::Grunt => 100,
EnemyType::Boss => 100, // Same points as Grunt for now
};
score.value += points;
println!("Enemy hit! Score: {}", score.value); // Log score update
break;
}
}
}
}
// Modified Collision Check for Player
fn check_player_enemy_collisions(
mut commands: Commands,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>, // Resource to change state
// Query player without Invincible component - relies on run_if condition too
player_query: Query<(Entity, &Transform), (With<Player>, Without<Invincible>)>,
enemy_query: Query<(Entity, &Transform), With<Enemy>>,
) {
// This system only runs if player exists and is not invincible, due to run_if
if let Ok((player_entity, player_transform)) = player_query.get_single() {
for (enemy_entity, enemy_transform) in enemy_query.iter() {
let distance = player_transform
.translation
.distance(enemy_transform.translation);
if distance < PLAYER_ENEMY_COLLISION_THRESHOLD {
println!("Player hit!");
commands.entity(enemy_entity).despawn(); // Despawn enemy
lives.count = lives.count.saturating_sub(1); // Decrement lives safely
println!("Lives remaining: {}", lives.count);
commands.entity(player_entity).despawn(); // Despawn player
if lives.count > 0 {
// Start the respawn timer
respawn_timer.timer.reset();
respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
next_state.set(AppState::GameOver); // Transition to GameOver state
}
// Important: Break after handling one collision per frame for the player
break;
}
}
}
}
// New System: Respawn Player
fn respawn_player(
mut commands: Commands,
time: Res<Time>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
// No player query needed here due to run_if condition
) {
// Tick the timer only if it's actually running
if respawn_timer.timer.tick(time.delta()).just_finished() {
println!("Respawn timer finished. Spawning player.");
spawn_player_ship(&mut commands);
respawn_timer.timer.pause(); // Pause timer until next death
}
}
// New System: Manage Invincibility
fn manage_invincibility(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut Invincible, Option<&mut Visibility>), With<Player>>,
) {
for (entity, mut invincible, mut visibility) in query.iter_mut() {
invincible.timer.tick(time.delta());
// Blinking effect (optional)
if let Some(ref mut vis) = visibility {
// Blink roughly 5 times per second
let elapsed_secs = invincible.timer.elapsed_secs();
**vis = if (elapsed_secs * 10.0).floor() % 2.0 == 0.0 {
Visibility::Visible
} else {
Visibility::Hidden
};
}
if invincible.timer.finished() {
println!("Invincibility finished.");
commands.entity(entity).remove::<Invincible>();
// Ensure player is visible when invincibility ends
if let Some(ref mut vis) = visibility {
**vis = Visibility::Visible;
}
}
}
}
// New System: Update Window Title with Lives
fn update_window_title(
lives: Res<PlayerLives>,
score: Res<Score>,
stage: Res<CurrentStage>, // Add CurrentStage resource
mut windows: Query<&mut Window>,
) {
// Update if lives or score changed
// Update if lives, score, or stage changed
if lives.is_changed() || score.is_changed() || stage.is_changed() {
let mut window = windows.single_mut();
window.title = format!(
"Galaga :: Stage: {} Lives: {} Score: {}",
stage.number, lives.count, score.value
);
}
}

189
src/player.rs Normal file
View file

@ -0,0 +1,189 @@
use bevy::prelude::*;
use std::time::Duration;
use crate::components::{Bullet, Enemy, Invincible, Player};
use crate::constants::{
BULLET_SIZE, PLAYER_ENEMY_COLLISION_THRESHOLD, PLAYER_INVINCIBILITY_DURATION, PLAYER_SIZE,
PLAYER_SPEED, WINDOW_HEIGHT, WINDOW_WIDTH,
};
use crate::game_state::AppState;
use crate::resources::{PlayerLives, PlayerRespawnTimer};
// Helper to spawn player (used in setup and respawn)
pub fn spawn_player_ship(commands: &mut Commands) {
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(0.0, 0.5, 1.0),
custom_size: Some(PLAYER_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(
0.0,
-WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0,
0.0,
)),
..default()
},
Player {
speed: PLAYER_SPEED,
shoot_cooldown: Timer::new(Duration::from_secs_f32(0.3), TimerMode::Once),
},
// Player starts invincible for a short time
Invincible {
timer: Timer::new(
Duration::from_secs_f32(PLAYER_INVINCIBILITY_DURATION),
TimerMode::Once,
),
},
));
println!("Player spawned!");
}
pub fn move_player(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Transform, &Player)>,
time: Res<Time>,
) {
// Using get_single_mut handles the case where player might not exist yet (or was just destroyed)
if let Ok((mut transform, player)) = query.get_single_mut() {
let mut direction = 0.0;
if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) {
direction -= 1.0;
}
if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
direction += 1.0;
}
transform.translation.x += direction * player.speed * time.delta_seconds();
let half_player_width = PLAYER_SIZE.x / 2.0;
transform.translation.x = transform.translation.x.clamp(
-WINDOW_WIDTH / 2.0 + half_player_width,
WINDOW_WIDTH / 2.0 - half_player_width,
);
}
}
pub fn player_shoot(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&Transform, &mut Player)>, // Should only run if player exists due to run_if
mut commands: Commands,
time: Res<Time>,
) {
if let Ok((player_transform, mut player)) = query.get_single_mut() {
player.shoot_cooldown.tick(time.delta());
if (keyboard_input.just_pressed(KeyCode::Space)
|| keyboard_input.just_pressed(KeyCode::ArrowUp))
&& player.shoot_cooldown.finished()
{
player.shoot_cooldown.reset();
let bullet_start_pos = player_transform.translation
+ Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 1.0, 1.0),
custom_size: Some(BULLET_SIZE),
..default()
},
transform: Transform::from_translation(bullet_start_pos),
..default()
},
Bullet,
));
}
}
}
// Modified Collision Check for Player vs Enemy
pub fn check_player_enemy_collisions(
mut commands: Commands,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>, // Resource to change state
// Query player without Invincible component - relies on run_if condition too
player_query: Query<(Entity, &Transform), (With<Player>, Without<Invincible>)>,
enemy_query: Query<(Entity, &Transform), With<Enemy>>,
) {
// This system only runs if player exists and is not invincible, due to run_if
if let Ok((player_entity, player_transform)) = player_query.get_single() {
for (enemy_entity, enemy_transform) in enemy_query.iter() {
let distance = player_transform
.translation
.distance(enemy_transform.translation);
if distance < PLAYER_ENEMY_COLLISION_THRESHOLD {
println!("Player hit by enemy!");
commands.entity(enemy_entity).despawn(); // Despawn enemy
lives.count = lives.count.saturating_sub(1); // Decrement lives safely
println!("Lives remaining: {}", lives.count);
commands.entity(player_entity).despawn(); // Despawn player
if lives.count > 0 {
// Start the respawn timer
respawn_timer.timer.reset();
respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
next_state.set(AppState::GameOver); // Transition to GameOver state
}
// Important: Break after handling one collision per frame for the player
break;
}
}
}
}
// New System: Respawn Player
pub fn respawn_player(
mut commands: Commands,
time: Res<Time>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
// No player query needed here due to run_if condition
) {
// Tick the timer only if it's actually running
if respawn_timer.timer.tick(time.delta()).just_finished() {
println!("Respawn timer finished. Spawning player.");
spawn_player_ship(&mut commands);
respawn_timer.timer.pause(); // Pause timer until next death
}
}
// New System: Manage Invincibility
pub fn manage_invincibility(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut Invincible, Option<&mut Visibility>), With<Player>>,
) {
for (entity, mut invincible, mut visibility) in query.iter_mut() {
invincible.timer.tick(time.delta());
// Blinking effect (optional)
if let Some(ref mut vis) = visibility {
// Blink roughly 5 times per second
let elapsed_secs = invincible.timer.elapsed_secs();
**vis = if (elapsed_secs * 10.0).floor() % 2.0 == 0.0 {
Visibility::Visible
} else {
Visibility::Hidden
};
}
if invincible.timer.finished() {
println!("Invincibility finished.");
commands.entity(entity).remove::<Invincible>();
// Ensure player is visible when invincibility ends
if let Some(ref mut vis) = visibility {
**vis = Visibility::Visible;
}
}
}
}

126
src/resources.rs Normal file
View file

@ -0,0 +1,126 @@
use bevy::prelude::*;
use std::time::Duration; // Needed for Timer
// --- Resources ---
#[derive(Resource)]
pub struct EnemySpawnTimer {
pub timer: Timer,
}
#[derive(Resource)]
pub struct PlayerLives {
pub count: u32,
}
#[derive(Resource)]
pub struct PlayerRespawnTimer {
pub timer: Timer,
}
// New struct to define formation positions
#[derive(Clone, Debug)]
pub struct FormationLayout {
pub name: String, // Optional name for debugging/identification
pub positions: Vec<Vec3>,
}
// Default implementation for easy initialization
impl Default for FormationLayout {
fn default() -> Self {
// Default to the original grid formation for now
let mut positions = Vec::with_capacity(crate::constants::FORMATION_ENEMY_COUNT);
for i in 0..crate::constants::FORMATION_ENEMY_COUNT {
let row = i / crate::constants::FORMATION_COLS;
let col = i % crate::constants::FORMATION_COLS;
let target_x = (col as f32 - (crate::constants::FORMATION_COLS as f32 - 1.0) / 2.0) * crate::constants::FORMATION_X_SPACING;
let target_y = crate::constants::FORMATION_BASE_Y - (row as f32 * crate::constants::FORMATION_Y_SPACING);
positions.push(Vec3::new(target_x, target_y, 0.0));
}
FormationLayout {
name: "Default Grid".to_string(),
positions,
}
}
}
// Configuration for a single stage
#[derive(Clone, Debug)]
pub struct StageConfig {
pub formation_layout: FormationLayout,
pub enemy_count: usize, // Allow overriding enemy count per stage
pub attack_patterns: Vec<crate::components::AttackPattern>, // Possible patterns for this stage
pub attack_dive_interval: f32, // Time between attack dives for this stage
pub enemy_speed_multiplier: f32, // Speed multiplier for this stage
pub enemy_shoot_interval: f32, // Shoot interval for this stage
}
// Resource to hold all stage configurations
#[derive(Resource, Debug, Clone)]
pub struct StageConfigurations {
pub stages: Vec<StageConfig>,
}
impl Default for StageConfigurations {
fn default() -> Self {
use crate::components::AttackPattern;
use crate::constants::*; // Import constants for default values
// Define configurations for a few stages
let stage1 = StageConfig {
formation_layout: FormationLayout::default(), // Use the default grid
enemy_count: FORMATION_ENEMY_COUNT,
attack_patterns: vec![AttackPattern::SwoopDive],
attack_dive_interval: 3.0,
enemy_speed_multiplier: 1.0,
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL,
};
let stage2_layout = {
let mut positions = Vec::new();
let radius = WINDOW_WIDTH / 4.0;
let center_y = FORMATION_BASE_Y - 50.0;
let count = 16; // Example: Fewer enemies in a circle
for i in 0..count {
let angle = (i as f32 / count as f32) * 2.0 * std::f32::consts::PI;
positions.push(Vec3::new(angle.cos() * radius, center_y + angle.sin() * radius, 0.0));
}
FormationLayout { name: "Circle".to_string(), positions }
};
let stage2 = StageConfig {
formation_layout: stage2_layout,
enemy_count: 16,
attack_patterns: vec![AttackPattern::SwoopDive, AttackPattern::DirectDive], // Add direct dive
attack_dive_interval: 2.5, // Faster dives
enemy_speed_multiplier: 1.2, // Faster enemies
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8, // Faster shooting
};
// Add more stages here...
StageConfigurations {
stages: vec![stage1, stage2], // Add more stages as needed
}
}
}
#[derive(Resource)]
pub struct Score {
pub value: u32,
}
#[derive(Resource)]
pub struct CurrentStage {
pub number: u32,
pub waiting_for_clear: bool, // Flag to check if we should check for stage clear
}
#[derive(Resource)]
pub struct FormationState {
pub next_slot_index: usize,
pub total_spawned_this_stage: usize,
pub formation_complete: bool, // Flag to indicate if all enemies are in position
}
#[derive(Resource)]
pub struct AttackDiveTimer {
pub timer: Timer,
}

37
src/stage.rs Normal file
View file

@ -0,0 +1,37 @@
use bevy::prelude::*;
use crate::components::Enemy;
use crate::resources::{CurrentStage, EnemySpawnTimer, FormationState};
// Helper to access world directly in check_stage_clear
pub fn check_stage_clear(world: &mut World) {
// Use manual resource access because we need mutable access to multiple resources
// Separate checks to manage borrows correctly
let mut should_clear = false;
if let Some(stage) = world.get_resource::<CurrentStage>() {
if stage.waiting_for_clear {
// Create the query *after* checking the flag
let mut enemy_query = world.query_filtered::<Entity, With<Enemy>>();
if enemy_query.iter(world).next().is_none() {
should_clear = true;
}
}
}
if should_clear {
// Get mutable resources only when needed
if let Some(mut stage) = world.get_resource_mut::<CurrentStage>() {
stage.number += 1;
stage.waiting_for_clear = false;
println!("Stage cleared! Starting Stage {}...", stage.number);
}
if let Some(mut formation_state) = world.get_resource_mut::<FormationState>() {
formation_state.next_slot_index = 0;
formation_state.total_spawned_this_stage = 0;
formation_state.formation_complete = false; // Reset flag for new stage
}
if let Some(mut spawn_timer) = world.get_resource_mut::<EnemySpawnTimer>() {
spawn_timer.timer.reset();
}
}
}

45
src/systems.rs Normal file
View file

@ -0,0 +1,45 @@
use bevy::prelude::*;
use crate::components::{Invincible, Player};
use crate::resources::{CurrentStage, FormationState, PlayerLives, Score};
use crate::player::spawn_player_ship; // Import the helper function
// --- Setup ---
pub fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
spawn_player_ship(&mut commands); // Use helper function from player module
}
// --- Run Conditions ---
pub fn player_exists(query: Query<&Player>) -> bool {
!query.is_empty()
}
pub fn player_vulnerable(query: Query<&Player, Without<Invincible>>) -> bool {
!query.is_empty()
}
pub fn should_respawn_player(lives: Res<PlayerLives>, player_query: Query<&Player>) -> bool {
player_query.is_empty() && lives.count > 0
}
// Moved is_formation_complete to enemy.rs as it's closely related to enemy logic
// --- General Systems ---
// Update Window Title with Lives, Score, and Stage
pub fn update_window_title(
lives: Res<PlayerLives>,
score: Res<Score>,
stage: Res<CurrentStage>, // Add CurrentStage resource
mut windows: Query<&mut Window>,
) {
// Update if lives, score, or stage changed
if lives.is_changed() || score.is_changed() || stage.is_changed() {
let mut window = windows.single_mut();
window.title = format!(
"Galaga :: Stage: {} Lives: {} Score: {}",
stage.number, lives.count, score.value
);
}
}