feat: Implement enemy attack dive mechanics and shooting behavior

This commit is contained in:
Harald Hoyer 2025-04-05 12:42:16 +02:00
parent 4256b0046c
commit 66fd1e8b1b
2 changed files with 509 additions and 287 deletions

View file

@ -63,11 +63,11 @@ nix develop --command bash -c "cargo build"
* ~~Give enemies an `Entering` state/component: They fly onto the screen following predefined paths (curves, waypoints).~~ **(DONE - Basic linear path implemented)**
* Give enemies a `Formation` state/component: Once they reach their target position, they stop and hold formation. **(DONE - Stop implemented by removing FormationTarget)**
* **Enemy Attack Dives:**
* Give enemies an `Attacking` state/component.
* Periodically trigger enemies in the formation to switch to the `Attacking` state.
* Define attack paths (swooping dives towards the player area).
* Make enemies fire bullets (downwards or towards the player) during their dives.
* After an attack dive, enemies could return to their formation position or fly off-screen.
* ~~Give enemies an `Attacking` state/component.~~ **(DONE)**
* ~~Periodically trigger enemies in the formation to switch to the `Attacking` state.~~ **(DONE - Random selection after formation complete)**
* Define attack paths (swooping dives towards the player area). **(Basic downward dive implemented)**
* ~~Make enemies fire bullets (downwards or towards the player) during their dives.~~ **(DONE - Downward)**
* After an attack dive, enemies could return to their formation position or fly off-screen. **(Fly off-screen implemented)**
* **Enemy Variety:**
* Introduce different types of enemies (e.g., using different components or an enum).
* Assign different behaviors, point values, and maybe sprites to each type.

View file

@ -15,7 +15,10 @@ 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);
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;
@ -31,6 +34,7 @@ const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
// 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)]
@ -43,24 +47,29 @@ struct Player {
struct Bullet;
#[derive(Component)]
struct Enemy;
struct Enemy {
shoot_cooldown: Timer,
}
#[derive(Component)]
struct Invincible {
timer: Timer,
timer: Timer,
}
#[derive(Component)]
struct FormationTarget {
position: Vec3,
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 (to be implemented)
Entering, // Flying onto the screen towards formation target
InFormation, // Holding position in the formation
Attacking, // Diving towards the player
}
#[derive(Component)]
struct EnemyBullet;
// --- Resources ---
#[derive(Resource)]
struct EnemySpawnTimer {
@ -74,28 +83,29 @@ struct PlayerLives {
#[derive(Resource)]
struct PlayerRespawnTimer {
timer: Timer,
timer: Timer,
}
#[derive(Resource)]
struct Score {
value: u32,
value: u32,
}
#[derive(Resource)]
struct CurrentStage {
number: u32,
waiting_for_clear: bool, // Flag to check if we should check for stage clear
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,
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,
timer: Timer,
}
// --- Game Over UI ---
@ -103,88 +113,256 @@ struct AttackDiveTimer {
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
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();
}
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>>,
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();
// }
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;
}
}
}
// 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;
}
if let Some(mut spawn_timer) = world.get_resource_mut::<EnemySpawnTimer>() {
spawn_timer.timer.reset();
}
}
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());
// Tick the timer regardless
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();
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 to ensure a delay
}
// 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;
}
}
}
}
fn main() {
@ -193,49 +371,86 @@ fn main() {
.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 with stage
resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
..default()
}),
..default()
}))
// Add Resources
.insert_resource(PlayerLives { count: STARTING_LIVES })
.insert_resource(PlayerLives {
count: STARTING_LIVES,
})
.insert_resource(PlayerRespawnTimer {
// Start paused and finished
timer: Timer::new(Duration::from_secs_f32(PLAYER_RESPAWN_DELAY), TimerMode::Once)
// Start paused and finished
timer: Timer::new(
Duration::from_secs_f32(PLAYER_RESPAWN_DELAY),
TimerMode::Once,
),
})
.insert_resource(EnemySpawnTimer {
timer: Timer::new(Duration::from_secs_f32(0.5), TimerMode::Repeating),
})
.insert_resource(Score { value: 0 }) // Initialize score
.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 }) // Initialize formation state
.insert_resource(AttackDiveTimer { timer: Timer::new(Duration::from_secs_f32(3.0), TimerMode::Repeating)}) // Initialize attack timer (e.g., every 3 seconds)
// Add Systems
timer: Timer::new(Duration::from_secs_f32(0.5), TimerMode::Repeating),
})
.insert_resource(Score { value: 0 }) // Initialize score
.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: Timer::new(Duration::from_secs_f32(3.0), TimerMode::Repeating),
}) // Initialize attack timer (e.g., every 3 seconds)
// Add Systems
.add_systems(Startup, setup)
// Systems running only when Playing
.add_systems(Update, (
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,
trigger_attack_dives, // Add system to trigger dives
// check_stage_clear is now added separately below using .pipe(system)
// Game Over check is now implicit in check_player_enemy_collisions
).run_if(in_state(AppState::Playing)))
.add_systems(Update, check_stage_clear.run_if(in_state(AppState::Playing))) // Add world-based system directly
.add_systems(
Update,
(
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
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
)
.run_if(in_state(AppState::Playing)),
)
// Add formation check, dive trigger (with run condition), and stage clear separately
.add_systems(
Update,
check_formation_complete.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
bevy::window::close_on_esc, // Allow closing anytime
))
.add_systems(
Update,
(
update_window_title, // Keep title updated
// Add system to check for restart input in GameOver state later
bevy::window::close_on_esc, // Allow closing anytime
),
)
// Systems for entering/exiting states
.add_systems(OnEnter(AppState::GameOver), setup_game_over_ui)
.add_systems(OnExit(AppState::GameOver), cleanup_game_over_ui)
@ -252,13 +467,14 @@ fn player_vulnerable(query: Query<&Player, Without<Invincible>>) -> bool {
!query.is_empty()
}
fn should_respawn_player(
lives: Res<PlayerLives>,
player_query: Query<&Player>,
) -> bool {
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 ---
@ -269,14 +485,18 @@ fn setup(mut commands: Commands) {
// Helper to spawn player (used in setup and respawn)
fn spawn_player_ship(commands: &mut Commands) {
commands.spawn((
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)),
transform: Transform::from_translation(Vec3::new(
0.0,
-WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0,
0.0,
)),
..default()
},
Player {
@ -285,13 +505,15 @@ fn spawn_player_ship(commands: &mut Commands) {
},
// Player starts invincible for a short time
Invincible {
timer: Timer::new(Duration::from_secs_f32(PLAYER_INVINCIBILITY_DURATION), TimerMode::Once)
}
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)>,
@ -311,7 +533,10 @@ fn move_player(
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);
transform.translation.x = transform.translation.x.clamp(
-WINDOW_WIDTH / 2.0 + half_player_width,
WINDOW_WIDTH / 2.0 - half_player_width,
);
}
}
@ -321,14 +546,17 @@ fn player_shoot(
mut commands: Commands,
time: Res<Time>,
) {
if let Ok((player_transform, mut player)) = query.get_single_mut() {
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() {
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);
let bullet_start_pos = player_transform.translation
+ Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
commands.spawn((
SpriteBundle {
@ -362,135 +590,159 @@ fn move_bullets(
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
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());
// 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;
// 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);
// 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;
// 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;
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 0.2, 0.2),
custom_size: Some(ENEMY_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(spawn_x, spawn_y, 0.0)),
..default()
},
Enemy,
FormationTarget { position: target_pos },
EnemyState::Entering, // Start in Entering state
));
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 0.2, 0.2),
custom_size: Some(ENEMY_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(spawn_x, spawn_y, 0.0)),
..default()
},
Enemy {
// 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;
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;
// 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 {
// 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), (With<Enemy>, Without<FormationTarget>, With<EnemyState>)>, // Query attacking enemies separately
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;
println!("Full formation spawned for Stage {}", stage.number);
}
}
}
// --- Handle Attacking Enemies ---
let attack_speed = current_speed * 1.5; // Make them dive faster
for (entity, mut transform) in attacking_query.iter_mut() {
// Check state again in case the query picks up something unintended
// (Though With<EnemyState> and Without<FormationTarget> should be sufficient)
// We need a way to get the state here if we want to be super safe,
// maybe query state separately or adjust the query structure.
// For now, assume this query correctly gets Attacking enemies.
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),
(With<Enemy>, Without<FormationTarget>, With<EnemyState>),
>, // Query attacking enemies separately
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
// Move straight down
transform.translation.y -= attack_speed * time.delta_seconds();
// --- 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();
// Despawn if off screen
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
println!("Despawning attacking enemy {:?} that went off screen.", entity);
commands.entity(entity).despawn();
// TODO: Later, attacking enemies might return to formation or loop
}
}
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) in attacking_query.iter_mut() {
// Check state again in case the query picks up something unintended
// (Though With<EnemyState> and Without<FormationTarget> should be sufficient)
// We need a way to get the state here if we want to be super safe,
// maybe query state separately or adjust the query structure.
// For now, assume this query correctly gets Attacking enemies.
// Move straight down
transform.translation.y -= attack_speed * time.delta_seconds();
// Despawn if off screen
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
println!(
"Despawning attacking enemy {:?} that went off screen.",
entity
);
commands.entity(entity).despawn();
// TODO: Later, attacking enemies might return to formation or loop
}
}
}
fn check_bullet_collisions(
mut commands: Commands,
bullet_query: Query<(Entity, &Transform), With<Bullet>>,
enemy_query: Query<(Entity, &Transform), With<Enemy>>,
mut score: ResMut<Score>, // Add Score resource
bullet_query: Query<(Entity, &Transform), With<Bullet>>,
enemy_query: Query<(Entity, &Transform), With<Enemy>>,
mut score: ResMut<Score>, // Add Score resource
) {
for (bullet_entity, bullet_transform) in bullet_query.iter() {
for (enemy_entity, enemy_transform) in enemy_query.iter() {
let distance = bullet_transform.translation.distance(enemy_transform.translation);
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();
score.value += 100; // Increment score
println!("Enemy hit! Score: {}", score.value); // Log score update
break;
commands.entity(bullet_entity).despawn();
commands.entity(enemy_entity).despawn();
score.value += 100; // Increment score
println!("Enemy hit! Score: {}", score.value); // Log score update
break;
}
}
}
@ -503,13 +755,15 @@ fn check_player_enemy_collisions(
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>)>,
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);
let distance = player_transform
.translation
.distance(enemy_transform.translation);
if distance < PLAYER_ENEMY_COLLISION_THRESHOLD {
println!("Player hit!");
@ -545,9 +799,9 @@ fn respawn_player(
) {
// 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
println!("Respawn timer finished. Spawning player.");
spawn_player_ship(&mut commands);
respawn_timer.timer.pause(); // Pause timer until next death
}
}
@ -562,72 +816,40 @@ fn manage_invincibility(
// Blinking effect (optional)
if let Some(ref mut vis) = visibility {
// Blink roughly 5 times per second
// 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;
}
}
// --- Enemy Attack Logic ---
fn trigger_attack_dives(
mut timer: ResMut<AttackDiveTimer>,
time: Res<Time>,
mut query: Query<(Entity, &mut EnemyState), With<Enemy>>, // Query enemies and their states
) {
timer.timer.tick(time.delta());
if timer.timer.just_finished() {
// 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
}
// 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>,
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);
// 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
);
}
}