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:
parent
0a5382187e
commit
557b38af79
2 changed files with 267 additions and 41 deletions
src
238
src/main.rs
238
src/main.rs
|
@ -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
|
||||
commands.spawn((
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue