feat: add explosion animations on entity destruction

Spawn expanding/fading orange explosion effects when enemies or
the player are destroyed. Explosions scale from 15x15 to 50x50
over 0.4s while fading from full opacity to transparent, then
auto-despawn.

Integration points:
- Enemy killed by player bullet (bullet.rs)
- Player hit by enemy bullet (bullet.rs)
- Player collides with enemy (player.rs) - both explode
- Captured player released (player.rs)

Refs: GAL-44
This commit is contained in:
Harald Hoyer 2026-05-06 16:43:16 +02:00
parent db061820b9
commit 2ff561efb1
6 changed files with 77 additions and 2 deletions

View file

@ -9,6 +9,7 @@ use crate::resources::{PlayerLives, PlayerRespawnTimer, Score};
use crate::game_state::AppState;
use crate::components::Player; // Needed for check_enemy_bullet_player_collisions
use crate::components::Invincible; // Needed for check_enemy_bullet_player_collisions
use crate::systems::spawn_explosion;
// --- Player Bullet Systems ---
@ -41,6 +42,7 @@ pub fn check_bullet_collisions(
if distance < BULLET_ENEMY_COLLISION_THRESHOLD {
commands.entity(bullet_entity).despawn();
spawn_explosion(&mut commands, enemy_transform.translation);
commands.entity(enemy_entity).despawn();
// Increment score based on enemy type
let points = match enemy.enemy_type {
@ -90,6 +92,7 @@ pub fn check_enemy_bullet_player_collisions(
if distance < ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD {
println!("Player hit by enemy bullet!");
spawn_explosion(&mut commands, player_transform.translation);
commands.entity(bullet_entity).despawn(); // Despawn bullet
lives.count = lives.count.saturating_sub(1);

View file

@ -95,3 +95,8 @@ pub struct RestartMessage;
pub struct Star {
pub speed: f32,
}
#[derive(Component)]
pub struct Explosion {
pub timer: Timer,
}

View file

@ -47,3 +47,9 @@ pub const STAR_MAX_SIZE: f32 = 3.0;
pub const STAR_MIN_SPEED: f32 = 20.0;
pub const STAR_MAX_SPEED: f32 = 100.0;
pub const STAR_Z_DEPTH: f32 = -10.0; // Behind all game entities
// Explosion constants
pub const EXPLOSION_DURATION: f32 = 0.4;
pub const EXPLOSION_BASE_SIZE: Vec2 = Vec2::new(15.0, 15.0);
pub const EXPLOSION_MAX_SIZE: Vec2 = Vec2::new(50.0, 50.0);
pub const EXPLOSION_COLOR: Color = Color::rgba(1.0, 0.6, 0.1, 1.0);

View file

@ -36,7 +36,7 @@ use bullet::{
move_enemy_bullets,
};
use stage::check_stage_clear;
use systems::{player_exists, player_vulnerable, setup, should_respawn_player, update_window_title};
use systems::{player_exists, player_vulnerable, setup, should_respawn_player, update_window_title, animate_explosion};
use starfield::scroll_starfield;
fn main() {
@ -119,6 +119,8 @@ fn main() {
)
// Starfield runs in all states
.add_systems(Update, scroll_starfield)
// Explosion animation runs in all states
.add_systems(Update, animate_explosion)
// UI and state management systems
.add_systems(OnEnter(AppState::StartMenu), setup_start_menu_ui)
.add_systems(OnExit(AppState::StartMenu), cleanup_start_menu_ui)

View file

@ -9,6 +9,7 @@ use crate::constants::{
};
use crate::game_state::AppState;
use crate::resources::{PlayerLives, PlayerRespawnTimer};
use crate::systems::spawn_explosion;
// Helper to spawn player (used in setup and respawn)
pub fn spawn_player_ship(commands: &mut Commands) {
@ -116,6 +117,7 @@ pub fn handle_captured_player(
&mut respawn_timer,
&mut next_state,
entity,
_player_pos,
);
continue; // Skip the rest of processing for this player
}
@ -131,6 +133,7 @@ pub fn handle_captured_player(
&mut respawn_timer,
&mut next_state,
entity,
transform.translation,
);
}
}
@ -144,11 +147,15 @@ fn lose_life_and_respawn(
respawn_timer: &mut ResMut<PlayerRespawnTimer>,
next_state: &mut ResMut<NextState<AppState>>,
player_entity: Entity,
player_position: Vec3,
) {
// Lose a life
lives.count = lives.count.saturating_sub(1);
println!("Lives remaining: {}", lives.count);
// Spawn explosion at player position before destroying
spawn_explosion(commands, player_position);
// Destroy player
commands.entity(player_entity).despawn();
@ -218,11 +225,13 @@ pub fn check_player_enemy_collisions(
if distance < PLAYER_ENEMY_COLLISION_THRESHOLD {
println!("Player hit by enemy!");
spawn_explosion(&mut commands, enemy_transform.translation);
commands.entity(enemy_entity).despawn(); // Despawn enemy
lives.count = lives.count.saturating_sub(1); // Decrement lives safely
println!("Lives remaining: {}", lives.count);
spawn_explosion(&mut commands, player_transform.translation);
commands.entity(player_entity).despawn(); // Despawn player
if lives.count > 0 {

View file

@ -1,6 +1,7 @@
use bevy::prelude::*;
use crate::components::{Invincible, Player};
use crate::components::{Explosion, Invincible, Player};
use crate::constants::{EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE};
use crate::resources::{CurrentStage, PlayerLives, Score};
use crate::player::spawn_player_ship;
use crate::starfield::spawn_starfield;
@ -44,4 +45,53 @@ pub fn update_window_title(
stage.number, lives.count, score.value
);
}
}
// --- Explosion Systems ---
pub fn spawn_explosion(commands: &mut Commands, position: Vec3) {
commands.spawn((
SpriteBundle {
sprite: Sprite {
color: EXPLOSION_COLOR,
custom_size: Some(EXPLOSION_BASE_SIZE),
..default()
},
transform: Transform {
translation: position,
..default()
},
..default()
},
Explosion {
timer: Timer::new(std::time::Duration::from_secs_f32(EXPLOSION_DURATION), TimerMode::Once),
},
));
}
pub fn animate_explosion(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut Explosion, &mut Sprite)>,
) {
for (entity, mut explosion, mut sprite) in query.iter_mut() {
explosion.timer.tick(time.delta());
let progress = explosion.timer.elapsed_secs() / EXPLOSION_DURATION;
let clamped_progress = progress.min(1.0);
// Scale from base to max size
let scale_x = EXPLOSION_BASE_SIZE.x + (EXPLOSION_MAX_SIZE.x - EXPLOSION_BASE_SIZE.x) * clamped_progress;
let scale_y = EXPLOSION_BASE_SIZE.y + (EXPLOSION_MAX_SIZE.y - EXPLOSION_BASE_SIZE.y) * clamped_progress;
sprite.custom_size = Some(Vec2::new(scale_x, scale_y));
// Fade alpha from 1.0 to 0.0
let alpha = 1.0 - clamped_progress;
let base = EXPLOSION_COLOR;
sprite.color = Color::rgba(base.r(), base.g(), base.b(), alpha);
if explosion.timer.finished() {
commands.entity(entity).despawn();
}
}
}