feat: Implement scoring and stage management systems

This commit is contained in:
Harald Hoyer 2025-04-05 00:20:40 +02:00
parent 0f4737fffd
commit fe5579727f
2 changed files with 181 additions and 60 deletions

View file

@ -47,14 +47,14 @@ nix develop --command bash -c "cargo build"
* ~~Use Bevy's `States` (e.g., `Playing`, `GameOver`).~~ * ~~Use Bevy's `States` (e.g., `Playing`, `GameOver`).~~
* ~~Transition to `GameOver` when `PlayerLives` reaches zero.~~ * ~~Transition to `GameOver` when `PlayerLives` reaches zero.~~
* ~~In the `GameOver` state: stop enemy spawning, stop player controls, display a "Game Over" message (using `bevy_ui`), potentially offer a restart option.~~ * ~~In the `GameOver` state: stop enemy spawning, stop player controls, display a "Game Over" message (using `bevy_ui`), potentially offer a restart option.~~
* **Scoring:** * ~~**Scoring:**~~ **(DONE)**
* Add a `Score` resource. * ~~Add a `Score` resource.~~
* Increment the score in `check_bullet_collisions` when an enemy is hit. * ~~Increment the score in `check_bullet_collisions` when an enemy is hit.~~
* Consider different point values for different enemy types or hitting enemies during dives later. * Consider different point values for different enemy types or hitting enemies during dives later.
* **Levels/Stages:** * ~~**Levels/Stages:**~~ **(DONE)**
* Add a `CurrentStage` resource. * ~~Add a `CurrentStage` resource.~~
* Define criteria for clearing a stage (e.g., destroying all enemies in a wave/formation). * ~~Define criteria for clearing a stage (e.g., destroying all enemies in a wave/formation).~~
* Implement logic to advance to the next stage, potentially increasing difficulty (enemy speed, firing rate, different formations). * ~~Implement logic to advance to the next stage, potentially increasing difficulty (enemy speed, firing rate, different formations).~~
**2. Enemy Behavior - Formations & Attack Patterns:** **2. Enemy Behavior - Formations & Attack Patterns:**

View file

@ -17,6 +17,13 @@ const PLAYER_SIZE: Vec2 = Vec2::new(30.0, 30.0);
const ENEMY_SIZE: Vec2 = Vec2::new(40.0, 40.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);
// 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 STARTING_LIVES: u32 = 3;
const PLAYER_RESPAWN_DELAY: f32 = 2.0; const PLAYER_RESPAWN_DELAY: f32 = 2.0;
const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0; const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
@ -40,9 +47,13 @@ struct Enemy;
#[derive(Component)] #[derive(Component)]
struct Invincible { struct Invincible {
timer: Timer, timer: Timer,
} }
#[derive(Component)]
struct FormationTarget {
position: Vec3,
}
// --- Resources --- // --- Resources ---
#[derive(Resource)] #[derive(Resource)]
struct EnemySpawnTimer { struct EnemySpawnTimer {
@ -56,9 +67,24 @@ struct PlayerLives {
#[derive(Resource)] #[derive(Resource)]
struct PlayerRespawnTimer { struct PlayerRespawnTimer {
timer: Timer, 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,
}
// --- Game Over UI --- // --- Game Over UI ---
#[derive(Component)] #[derive(Component)]
@ -112,7 +138,41 @@ fn cleanup_game_entities(
} }
// for entity in player_query.iter() { // for entity in player_query.iter() {
// commands.entity(entity).despawn(); // 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;
}
if let Some(mut spawn_timer) = world.get_resource_mut::<EnemySpawnTimer>() {
spawn_timer.timer.reset();
}
}
} }
fn main() { fn main() {
@ -121,7 +181,7 @@ fn main() {
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0))) .insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0)))
.add_plugins(DefaultPlugins.set(WindowPlugin { .add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window { primary_window: Some(Window {
title: "Galaga :: Lives: 3".into(), // Initial title title: "Galaga :: Stage: 1 Lives: 3 Score: 0".into(), // Initial title with stage
resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(), resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
..default() ..default()
}), }),
@ -134,9 +194,12 @@ fn main() {
timer: Timer::new(Duration::from_secs_f32(PLAYER_RESPAWN_DELAY), TimerMode::Once) timer: Timer::new(Duration::from_secs_f32(PLAYER_RESPAWN_DELAY), TimerMode::Once)
}) })
.insert_resource(EnemySpawnTimer { .insert_resource(EnemySpawnTimer {
timer: Timer::new(Duration::from_secs_f32(0.5), TimerMode::Repeating), timer: Timer::new(Duration::from_secs_f32(0.5), TimerMode::Repeating),
}) })
// Add Systems .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
// Add Systems
.add_systems(Startup, setup) .add_systems(Startup, setup)
// Systems running only when Playing // Systems running only when Playing
.add_systems(Update, ( .add_systems(Update, (
@ -148,9 +211,11 @@ fn main() {
check_bullet_collisions, check_bullet_collisions,
check_player_enemy_collisions.run_if(player_vulnerable), check_player_enemy_collisions.run_if(player_vulnerable),
respawn_player.run_if(should_respawn_player), respawn_player.run_if(should_respawn_player),
manage_invincibility, manage_invincibility,
// Game Over check is now implicit in check_player_enemy_collisions // check_stage_clear is now added separately below using .pipe(system)
).run_if(in_state(AppState::Playing))) // 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
// Systems running regardless of state (or managing state transitions) // Systems running regardless of state (or managing state transitions)
.add_systems(Update, ( .add_systems(Update, (
update_window_title, // Keep title updated update_window_title, // Keep title updated
@ -283,60 +348,112 @@ fn move_bullets(
fn spawn_enemies( fn spawn_enemies(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
mut timer: ResMut<EnemySpawnTimer>, mut timer: ResMut<EnemySpawnTimer>,
mut stage: ResMut<CurrentStage>,
mut formation_state: ResMut<FormationState>, // Add formation state resource
) { ) {
timer.timer.tick(time.delta()); timer.timer.tick(time.delta());
if timer.timer.just_finished() { // Only spawn if we haven't spawned the full formation for this stage yet
let x_pos = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x); // AND the timer is finished
if formation_state.total_spawned_this_stage < FORMATION_ENEMY_COUNT && timer.timer.tick(time.delta()).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;
commands.spawn(( // Calculate target position in formation
SpriteBundle { let target_x = (col as f32 - (FORMATION_COLS as f32 - 1.0) / 2.0) * FORMATION_X_SPACING;
sprite: Sprite { let target_y = FORMATION_BASE_Y - (row as f32 * FORMATION_Y_SPACING);
color: Color::rgb(1.0, 0.2, 0.2), let target_pos = Vec3::new(target_x, target_y, 0.0);
custom_size: Some(ENEMY_SIZE),
..default() // Spawn position (still random at the top for now)
}, let spawn_x = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x);
transform: Transform::from_translation(Vec3::new( let spawn_y = WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y / 2.0;
x_pos,
WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y / 2.0, commands.spawn((
0.0 SpriteBundle {
)), sprite: Sprite {
..default() color: Color::rgb(1.0, 0.2, 0.2),
}, custom_size: Some(ENEMY_SIZE),
Enemy, ..default()
)); },
} transform: Transform::from_translation(Vec3::new(spawn_x, spawn_y, 0.0)),
..default()
},
Enemy,
FormationTarget { position: target_pos }, // Add the target position
));
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( fn move_enemies(
mut query: Query<(Entity, &mut Transform), With<Enemy>>, // Query now includes FormationTarget
time: Res<Time>, mut query: Query<(Entity, &mut Transform, &FormationTarget), With<Enemy>>,
mut commands: Commands, time: Res<Time>,
mut commands: Commands,
stage: Res<CurrentStage>, // Add stage resource for speed calculation
) { ) {
for (entity, mut transform) in query.iter_mut() { // Increase speed based on stage number
transform.translation.y -= ENEMY_SPEED * time.delta_seconds(); 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
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y { for (entity, mut transform, target) in query.iter_mut() {
commands.entity(entity).despawn(); 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 so it stops moving
// TODO: Add an InFormation component/state later for attack dives
println!("Enemy {} reached formation target.", entity.index());
} else {
// Move towards target
let move_delta = direction.normalize() * current_speed * time.delta_seconds();
transform.translation += move_delta;
}
// Still despawn if somehow it goes way off screen (shouldn't happen with target seeking)
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y * 2.0 {
println!("Despawning enemy {} that went off screen.", entity.index());
commands.entity(entity).despawn();
}
}
} }
fn check_bullet_collisions( fn check_bullet_collisions(
mut commands: Commands, mut commands: Commands,
bullet_query: Query<(Entity, &Transform), With<Bullet>>, bullet_query: Query<(Entity, &Transform), With<Bullet>>,
enemy_query: Query<(Entity, &Transform), With<Enemy>>, enemy_query: Query<(Entity, &Transform), With<Enemy>>,
mut score: ResMut<Score>, // Add Score resource
) { ) {
for (bullet_entity, bullet_transform) in bullet_query.iter() { for (bullet_entity, bullet_transform) in bullet_query.iter() {
for (enemy_entity, enemy_transform) in enemy_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 { if distance < BULLET_ENEMY_COLLISION_THRESHOLD {
commands.entity(bullet_entity).despawn(); commands.entity(bullet_entity).despawn();
commands.entity(enemy_entity).despawn(); commands.entity(enemy_entity).despawn();
break; score.value += 100; // Increment score
println!("Enemy hit! Score: {}", score.value); // Log score update
break;
} }
} }
} }
@ -424,18 +541,22 @@ fn manage_invincibility(
// Ensure player is visible when invincibility ends // Ensure player is visible when invincibility ends
if let Some(ref mut vis) = visibility { if let Some(ref mut vis) = visibility {
**vis = Visibility::Visible; **vis = Visibility::Visible;
} }
} }
} }
} }
// New System: Update Window Title with Lives // New System: Update Window Title with Lives
fn update_window_title( fn update_window_title(
lives: Res<PlayerLives>, lives: Res<PlayerLives>,
mut windows: Query<&mut Window>, score: Res<Score>,
stage: Res<CurrentStage>, // Add CurrentStage resource
mut windows: Query<&mut Window>,
) { ) {
if lives.is_changed() { // Update if lives or score changed
let mut window = windows.single_mut(); // Update if lives, score, or stage changed
window.title = format!("Galaga :: Lives: {}", lives.count); 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);
} }
} }