feat: Implement scoring and stage management systems
This commit is contained in:
parent
0f4737fffd
commit
fe5579727f
14
README.md
14
README.md
|
@ -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:**
|
||||
|
||||
|
|
227
src/main.rs
227
src/main.rs
|
@ -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;
|
||||
|
@ -40,9 +47,13 @@ struct Enemy;
|
|||
|
||||
#[derive(Component)]
|
||||
struct Invincible {
|
||||
timer: Timer,
|
||||
timer: Timer,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct FormationTarget {
|
||||
position: Vec3,
|
||||
}
|
||||
// --- Resources ---
|
||||
#[derive(Resource)]
|
||||
struct EnemySpawnTimer {
|
||||
|
@ -56,9 +67,24 @@ struct PlayerLives {
|
|||
|
||||
#[derive(Resource)]
|
||||
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 ---
|
||||
|
||||
#[derive(Component)]
|
||||
|
@ -112,7 +138,41 @@ fn cleanup_game_entities(
|
|||
}
|
||||
// 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;
|
||||
}
|
||||
if let Some(mut spawn_timer) = world.get_resource_mut::<EnemySpawnTimer>() {
|
||||
spawn_timer.timer.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
|
@ -121,7 +181,7 @@ fn main() {
|
|||
.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()
|
||||
}),
|
||||
|
@ -134,9 +194,12 @@ fn main() {
|
|||
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),
|
||||
})
|
||||
// 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 }) // Initialize formation state
|
||||
// Add Systems
|
||||
.add_systems(Startup, setup)
|
||||
// Systems running only when Playing
|
||||
.add_systems(Update, (
|
||||
|
@ -148,9 +211,11 @@ fn main() {
|
|||
check_bullet_collisions,
|
||||
check_player_enemy_collisions.run_if(player_vulnerable),
|
||||
respawn_player.run_if(should_respawn_player),
|
||||
manage_invincibility,
|
||||
// Game Over check is now implicit in check_player_enemy_collisions
|
||||
).run_if(in_state(AppState::Playing)))
|
||||
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
|
||||
|
@ -283,60 +348,112 @@ fn move_bullets(
|
|||
|
||||
fn spawn_enemies(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut timer: ResMut<EnemySpawnTimer>,
|
||||
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;
|
||||
|
||||
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(
|
||||
x_pos,
|
||||
WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y / 2.0,
|
||||
0.0
|
||||
)),
|
||||
..default()
|
||||
},
|
||||
Enemy,
|
||||
));
|
||||
}
|
||||
// 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 {
|
||||
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 }, // 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>>,
|
||||
time: Res<Time>,
|
||||
mut commands: Commands,
|
||||
// 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 {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_bullet_collisions(
|
||||
mut commands: Commands,
|
||||
bullet_query: Query<(Entity, &Transform), With<Bullet>>,
|
||||
enemy_query: Query<(Entity, &Transform), With<Enemy>>,
|
||||
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);
|
||||
|
||||
if distance < BULLET_ENEMY_COLLISION_THRESHOLD {
|
||||
commands.entity(bullet_entity).despawn();
|
||||
commands.entity(enemy_entity).despawn();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -424,18 +541,22 @@ fn manage_invincibility(
|
|||
// 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>,
|
||||
mut windows: Query<&mut Window>,
|
||||
lives: Res<PlayerLives>,
|
||||
score: Res<Score>,
|
||||
stage: Res<CurrentStage>, // Add CurrentStage resource
|
||||
mut windows: Query<&mut Window>,
|
||||
) {
|
||||
if lives.is_changed() {
|
||||
let mut window = windows.single_mut();
|
||||
window.title = format!("Galaga :: Lives: {}", lives.count);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue