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`).~~
* ~~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.~~
* **Scoring:**
* Add a `Score` resource.
* Increment the score in `check_bullet_collisions` when an enemy is hit.
* ~~**Scoring:**~~ **(DONE)**
* ~~Add a `Score` resource.~~
* ~~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.
* **Levels/Stages:**
* Add a `CurrentStage` resource.
* 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).
* ~~**Levels/Stages:**~~ **(DONE)**
* ~~Add a `CurrentStage` resource.~~
* ~~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).~~
**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 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 PLAYER_RESPAWN_DELAY: f32 = 2.0;
const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
@ -43,6 +50,10 @@ struct Invincible {
timer: Timer,
}
#[derive(Component)]
struct FormationTarget {
position: Vec3,
}
// --- Resources ---
#[derive(Resource)]
struct EnemySpawnTimer {
@ -58,7 +69,22 @@ struct PlayerLives {
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,
}
// --- Game Over UI ---
#[derive(Component)]
@ -115,13 +141,47 @@ fn cleanup_game_entities(
// }
}
// --- 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() {
App::new() // Start App builder
.init_state::<AppState>() // Initialize the AppState *after* App::new()
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0)))
.add_plugins(DefaultPlugins.set(WindowPlugin {
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(),
..default()
}),
@ -136,6 +196,9 @@ 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(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)
// Systems running only when Playing
@ -149,8 +212,10 @@ fn main() {
check_player_enemy_collisions.run_if(player_vulnerable),
respawn_player.run_if(should_respawn_player),
manage_invincibility,
// 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
// Systems running regardless of state (or managing state transitions)
.add_systems(Update, (
update_window_title, // Keep title updated
@ -285,11 +350,27 @@ 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
) {
timer.timer.tick(time.delta());
if timer.timer.just_finished() {
let x_pos = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x);
// Only spawn if we haven't spawned the full formation for this stage yet
// 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;
// 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;
commands.spawn((
SpriteBundle {
@ -298,27 +379,60 @@ fn spawn_enemies(
custom_size: Some(ENEMY_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(
x_pos,
WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y / 2.0,
0.0
)),
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(
mut query: Query<(Entity, &mut Transform), With<Enemy>>,
// Query now includes FormationTarget
mut query: Query<(Entity, &mut Transform, &FormationTarget), With<Enemy>>,
time: Res<Time>,
mut commands: Commands,
stage: Res<CurrentStage>, // Add stage resource for speed calculation
) {
for (entity, mut transform) in query.iter_mut() {
transform.translation.y -= ENEMY_SPEED * time.delta_seconds();
// 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
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
for (entity, mut transform, target) in query.iter_mut() {
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();
}
}
@ -328,6 +442,7 @@ 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
) {
for (bullet_entity, bullet_transform) in bullet_query.iter() {
for (enemy_entity, enemy_transform) in enemy_query.iter() {
@ -336,6 +451,8 @@ fn check_bullet_collisions(
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;
}
}
@ -432,10 +549,14 @@ fn manage_invincibility(
// 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>,
) {
if lives.is_changed() {
// 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 :: Lives: {}", lives.count);
window.title = format!("Galaga :: Stage: {} Lives: {} Score: {}", stage.number, lives.count, score.value);
}
}