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`).~~
|
* ~~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:**
|
||||||
|
|
||||||
|
|
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 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue