feat: Implement player lives, destruction, and respawn

Adds a player lives system with respawning and temporary invincibility.

- Introduces  resource (starts at 3).
- Modifies player-enemy collision to decrement lives and despawn player.
- Implements  for delayed respawn.
- Adds  component with a timer for post-respawn protection, including a blinking visual effect.
- Updates window title dynamically to show remaining lives.

Reverted collision detection from  (due to build errors)
back to using .

Fixed several borrow checker (E0499, E0596) and type mismatch (E0308)
errors encountered during implementation, primarily within the
 system.

Added  conditions for systems like shooting, collision checking,
and respawning.

Updates README.md to reflect the current state and completed TODO item.
This commit is contained in:
Harald Hoyer 2025-03-29 13:45:43 +01:00
parent 0a5382187e
commit 557b38af79
2 changed files with 267 additions and 41 deletions

View file

@ -28,3 +28,73 @@ nix develop --command bash -c "cargo build"
2. Clone the repository.
3. Navigate to the project directory.
4. Run the game using the command: `nix develop --command bash -c "cargo run"`
# TODO
**1. Core Gameplay Loop & State Management:**
* **Player Lives:**
* Add a `PlayerLives` resource (e.g., starting with 3).
* Modify `check_player_enemy_collisions`: Instead of just printing, decrement the lives count.
* Implement player destruction (despawn the player sprite) and respawn logic (maybe after a short delay, with temporary invincibility).
* **Game Over State:**
* 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.
* 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).
**2. Enemy Behavior - Formations & Attack Patterns:**
* **Enemy Formations:** This is a core Galaga feature.
* Define target positions for the enemy formation on screen.
* Give enemies an `Entering` state/component: They fly onto the screen following predefined paths (curves, waypoints).
* Give enemies a `Formation` state/component: Once they reach their target position, they stop and hold formation.
* **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.
* **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.
**3. Advanced Galaga Mechanics:**
* **Boss Galaga & Capture Beam:**
* Create a "Boss" enemy type.
* Implement the tractor beam attack (visual effect, player capture logic).
* Player needs a `Captured` state.
* Logic for the Boss to return captured ships to the formation.
* **Dual Fighter (Rescuing Captured Ship):**
* Allow the player to shoot a Boss Galaga attempting to capture or one holding a captured ship.
* Implement the logic to free the captured ship.
* Implement the dual fighter mode (controlling two ships, firing two bullets). This involves significant changes to player control and shooting systems.
* **Challenging Stages:**
* Implement a special stage type (e.g., every few levels).
* Enemies fly in intricate patterns without shooting.
* Award bonus points for destroying all enemies in the stage.
**4. Polish and User Interface:**
* **Visuals:**
* Replace placeholder shapes with actual sprites using `SpriteBundle` or `SpriteSheetBundle`. Create distinct looks for the player, different enemy types, bullets.
* Add explosion effects/animations when enemies or the player are destroyed.
* Implement a scrolling starfield background for a more classic space feel.
* **Audio:**
* Integrate `bevy_audio`.
* Add sound effects for player shooting, enemy firing, explosions, player death, capturing, etc.
* Add background music that might change per stage or state (e.g., main gameplay vs. challenging stage).
* **UI:**
* Use `bevy_ui` to display the current Score, Lives remaining, and Stage number on screen during gameplay.
* Display High Score?
* Create a Start Menu state.
Starting with the Core Gameplay Loop (Lives, Game Over, Score, basic Stages) and then moving onto Enemy Formations and Attack Patterns would likely provide the biggest steps towards a Galaga feel.

View file

@ -1,12 +1,25 @@
use bevy::prelude::*;
use std::time::Duration;
// --- Constants ---
const WINDOW_WIDTH: f32 = 600.0;
const WINDOW_HEIGHT: f32 = 800.0;
const PLAYER_SPEED: f32 = 300.0;
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 STARTING_LIVES: u32 = 3;
const PLAYER_RESPAWN_DELAY: f32 = 2.0;
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
// --- Components ---
#[derive(Component)]
struct Player {
speed: f32,
@ -19,63 +32,121 @@ struct Bullet;
#[derive(Component)]
struct Enemy;
#[derive(Component)]
struct Invincible {
timer: Timer,
}
// --- Resources ---
#[derive(Resource)]
struct EnemySpawnTimer {
timer: Timer,
}
#[derive(Resource)]
struct PlayerLives {
count: u32,
}
#[derive(Resource)]
struct PlayerRespawnTimer {
timer: Timer,
}
fn main() {
App::new()
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0)))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Galaga".into(),
title: "Galaga :: Lives: 3".into(), // Initial title
resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
..default()
}),
..default()
}))
.add_systems(Startup, setup)
.add_systems(Update, (
move_player,
player_shoot,
move_bullets,
move_enemies,
check_bullet_collisions,
spawn_enemies,
))
// Add Resources
.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)
})
.insert_resource(EnemySpawnTimer {
timer: Timer::new(Duration::from_secs_f32(0.5), TimerMode::Repeating),
})
// Add Systems
.add_systems(Startup, setup)
.add_systems(Update, (
move_player,
player_shoot.run_if(player_exists), // Only shoot if player exists
move_bullets,
move_enemies,
spawn_enemies,
check_bullet_collisions,
check_player_enemy_collisions.run_if(player_vulnerable), // Only check if player exists and is not invincible
respawn_player.run_if(should_respawn_player), // Conditionally run respawn
manage_invincibility, // Handle invincibility timer and blinking
update_window_title, // Show lives in window title
// (Game Over check would go here later)
))
.run();
}
// --- Run Conditions ---
fn player_exists(query: Query<&Player>) -> bool {
!query.is_empty()
}
fn player_vulnerable(query: Query<&Player, Without<Invincible>>) -> bool {
!query.is_empty()
}
fn should_respawn_player(
lives: Res<PlayerLives>,
player_query: Query<&Player>,
) -> bool {
player_query.is_empty() && lives.count > 0
}
// --- Systems ---
fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default());
spawn_player_ship(&mut commands); // Use helper function
}
// Spawn player
// Helper to spawn player (used in setup and respawn)
fn spawn_player_ship(commands: &mut Commands) {
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(0.0, 0.5, 1.0),
custom_size: Some(Vec2::new(30.0, 30.0)),
custom_size: Some(PLAYER_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(0.0, -300.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 {
speed: PLAYER_SPEED,
shoot_cooldown: Timer::new(Duration::from_secs_f32(0.5), TimerMode::Once),
shoot_cooldown: Timer::new(Duration::from_secs_f32(0.3), TimerMode::Once),
},
));
// Player starts invincible for a short time
Invincible {
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)>,
time: Res<Time>,
) {
// Using get_single_mut handles the case where player might not exist yet (or was just destroyed)
if let Ok((mut transform, player)) = query.get_single_mut() {
let mut direction = 0.0;
@ -88,37 +159,34 @@ fn move_player(
}
transform.translation.x += direction * player.speed * time.delta_seconds();
transform.translation.x = transform.translation.x.clamp(-WINDOW_WIDTH / 2.0 + 15.0, WINDOW_WIDTH / 2.0 - 15.0);
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);
}
}
fn player_shoot(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&Transform, &mut Player)>,
mut query: Query<(&Transform, &mut Player)>, // Should only run if player exists due to run_if
mut commands: Commands,
time: Res<Time>,
) {
if let Ok((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() {
// Reset cooldown
player.shoot_cooldown.reset();
// Spawn bullet
let bullet_start_pos = player_transform.translation + Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 1.0, 1.0),
custom_size: Some(Vec2::new(5.0, 15.0)),
custom_size: Some(BULLET_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(
transform.translation.x,
transform.translation.y + 20.0,
0.0
)),
transform: Transform::from_translation(bullet_start_pos),
..default()
},
Bullet,
@ -135,8 +203,7 @@ fn move_bullets(
for (entity, mut transform) in query.iter_mut() {
transform.translation.y += BULLET_SPEED * time.delta_seconds();
// Despawn bullets that go out of screen
if transform.translation.y > WINDOW_HEIGHT / 2.0 {
if transform.translation.y > WINDOW_HEIGHT / 2.0 + BULLET_SIZE.y {
commands.entity(entity).despawn();
}
}
@ -146,24 +213,22 @@ fn spawn_enemies(
mut commands: Commands,
time: Res<Time>,
mut timer: ResMut<EnemySpawnTimer>,
enemy_query: Query<&Enemy>,
) {
timer.timer.tick(time.delta());
// Only spawn if timer finished and we don't have too many enemies
if timer.timer.just_finished() {
let x_pos = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - 40.0);
let x_pos = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x);
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: Color::rgb(1.0, 0.2, 0.2),
custom_size: Some(Vec2::new(40.0, 40.0)),
custom_size: Some(ENEMY_SIZE),
..default()
},
transform: Transform::from_translation(Vec3::new(
x_pos,
WINDOW_HEIGHT / 2.0 - 20.0,
WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y / 2.0,
0.0
)),
..default()
@ -181,8 +246,7 @@ fn move_enemies(
for (entity, mut transform) in query.iter_mut() {
transform.translation.y -= ENEMY_SPEED * time.delta_seconds();
// Despawn enemies that go out of screen
if transform.translation.y < -WINDOW_HEIGHT / 2.0 {
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
commands.entity(entity).despawn();
}
}
@ -195,13 +259,9 @@ fn check_bullet_collisions(
) {
for (bullet_entity, bullet_transform) in bullet_query.iter() {
for (enemy_entity, enemy_transform) in enemy_query.iter() {
let bullet_pos = bullet_transform.translation;
let enemy_pos = enemy_transform.translation;
let distance = bullet_transform.translation.distance(enemy_transform.translation);
// Simple collision detection using distance
let distance = bullet_pos.distance(enemy_pos);
if distance < 25.0 { // Approximate collision radius
// Despawn both bullet and enemy on collision
if distance < BULLET_ENEMY_COLLISION_THRESHOLD {
commands.entity(bullet_entity).despawn();
commands.entity(enemy_entity).despawn();
break;
@ -210,3 +270,99 @@ fn check_bullet_collisions(
}
}
// Modified Collision Check for Player
fn check_player_enemy_collisions(
mut commands: Commands,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
// Query player without Invincible component - relies on run_if condition too
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);
if distance < PLAYER_ENEMY_COLLISION_THRESHOLD {
println!("Player hit!");
commands.entity(enemy_entity).despawn(); // Despawn enemy
lives.count = lives.count.saturating_sub(1); // Decrement lives safely
println!("Lives remaining: {}", lives.count);
commands.entity(player_entity).despawn(); // Despawn player
if lives.count > 0 {
// Start the respawn timer
respawn_timer.timer.reset();
respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
// Game Over logic would go here - e.g., transition to a GameOver state
}
// Important: Break after handling one collision per frame for the player
break;
}
}
}
}
// New System: Respawn Player
fn respawn_player(
mut commands: Commands,
time: Res<Time>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
// No player query needed here due to run_if condition
) {
// 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
}
}
// New System: Manage Invincibility
fn manage_invincibility(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut Invincible, Option<&mut Visibility>), With<Player>>,
) {
for (entity, mut invincible, mut visibility) in query.iter_mut() {
invincible.timer.tick(time.delta());
// Blinking effect (optional)
if let Some(ref mut vis) = visibility {
// 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;
}
}
}
}
// New System: Update Window Title with Lives
fn update_window_title(
lives: Res<PlayerLives>,
mut windows: Query<&mut Window>,
) {
if lives.is_changed() {
let mut window = windows.single_mut();
window.title = format!("Galaga :: Lives: {}", lives.count);
}
}