Merge branch 'gal-44-add-explosion-effects'

This commit is contained in:
Harald Hoyer 2026-05-06 20:33:23 +02:00
commit 35300ec62b
8 changed files with 128 additions and 3 deletions

View file

@ -0,0 +1,48 @@
# Workflow Summary
**Run Timestamp:** 2026-05-06
**Issue:** GAL-44: Add explosion animations/effects
**Branch:** gal-44-add-explosion-effects
**Final Commits:**
- `2ff561e` feat: add explosion animations on entity destruction
- `08838c3` chore(todo): update GAL-44 status and progress
## Summary of Implementation
Added explosion visual effects triggered at all entity destruction points. Explosions are orange sprites that expand from 15x15 to 50x50 pixels over 0.4 seconds while fading from full opacity to transparent, then auto-despawn.
## Files Changed
| File | Change |
|------|--------|
| `src/components.rs` | Added `Explosion` component with timer |
| `src/constants.rs` | Added explosion constants (duration, sizes, color) |
| `src/systems.rs` | Added `spawn_explosion()` helper and `animate_explosion()` system |
| `src/main.rs` | Registered `animate_explosion` system |
| `src/bullet.rs` | Explosion on enemy kill + player hit by bullet |
| `src/player.rs` | Explosion on player-enemy collision + captured player release |
## TDD Evidence
No unit tests written — Bevy ECS game code with visual effects is not amenable to traditional unit testing. Implementation verified via `cargo check` (clean compilation, no warnings).
## Review Outcomes
**Plan Review (Phase 5):**
- `@check`: ACCEPTABLE (after revision addressing missing destruction points)
- `@simplify`: ACCEPTABLE (after consolidation into existing files, simplified animation)
**Final Review (Phase 9):**
- `@check`: NEEDS WORK → resolved (duplicate explosion concern was false positive — `check_player_enemy_collisions` handles life loss inline, not via `lose_life_and_respawn`)
- `@simplify`: ACCEPTABLE (minor color duplication fix applied)
## Unresolved Items
None.
## Design Decisions
- **No new module:** Consolidated into existing `components.rs`, `systems.rs`, `constants.rs` per @simplify recommendation
- **Scale + fade animation:** Simpler than 4-stage color interpolation, visually effective
- **All destruction points covered:** Enemy kills, player deaths, captured player release
- **Excluded:** Off-screen enemy despawns (invisible), state transition cleanup (not combat deaths)

View file

@ -55,7 +55,9 @@
* [ ] **GAL-42: Visuals** * [ ] **GAL-42: Visuals**
* [ ] GAL-43: Replace placeholder geometric shapes with actual sprites. * [ ] GAL-43: Replace placeholder geometric shapes with actual sprites.
* [ ] GAL-44: Add explosion animations/effects. * [x] GAL-44: Add explosion animations/effects.
* **Branch:** `gal-44-add-explosion-effects`
* **Comment:** 2026-05-06 — Implementation complete with commit `2ff561e`
* [x] GAL-45: Implement a scrolling starfield background. * [x] GAL-45: Implement a scrolling starfield background.
* [ ] **GAL-46: Audio** * [ ] **GAL-46: Audio**
* [ ] GAL-47: Integrate `bevy_audio`. * [ ] GAL-47: Integrate `bevy_audio`.

View file

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

View file

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

View file

@ -53,3 +53,9 @@ pub const STAR_MAX_SIZE: f32 = 3.0;
pub const STAR_MIN_SPEED: f32 = 20.0; pub const STAR_MIN_SPEED: f32 = 20.0;
pub const STAR_MAX_SPEED: f32 = 100.0; pub const STAR_MAX_SPEED: f32 = 100.0;
pub const STAR_Z_DEPTH: f32 = -10.0; // Behind all game entities 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, move_enemy_bullets,
}; };
use stage::check_stage_clear; 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; use starfield::scroll_starfield;
fn main() { fn main() {
@ -120,6 +120,8 @@ fn main() {
) )
// Starfield runs in all states // Starfield runs in all states
.add_systems(Update, scroll_starfield) .add_systems(Update, scroll_starfield)
// Explosion animation runs in all states
.add_systems(Update, animate_explosion)
// UI and state management systems // UI and state management systems
.add_systems(OnEnter(AppState::StartMenu), setup_start_menu_ui) .add_systems(OnEnter(AppState::StartMenu), setup_start_menu_ui)
.add_systems(OnExit(AppState::StartMenu), cleanup_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::game_state::AppState;
use crate::resources::{PlayerLives, PlayerRespawnTimer}; use crate::resources::{PlayerLives, PlayerRespawnTimer};
use crate::systems::spawn_explosion;
// Helper to spawn player (used in setup and respawn) // Helper to spawn player (used in setup and respawn)
pub fn spawn_player_ship(commands: &mut Commands) { pub fn spawn_player_ship(commands: &mut Commands) {
@ -116,6 +117,7 @@ pub fn handle_captured_player(
&mut respawn_timer, &mut respawn_timer,
&mut next_state, &mut next_state,
entity, entity,
_player_pos,
); );
continue; // Skip the rest of processing for this player continue; // Skip the rest of processing for this player
} }
@ -131,6 +133,7 @@ pub fn handle_captured_player(
&mut respawn_timer, &mut respawn_timer,
&mut next_state, &mut next_state,
entity, entity,
transform.translation,
); );
} }
} }
@ -144,11 +147,15 @@ fn lose_life_and_respawn(
respawn_timer: &mut ResMut<PlayerRespawnTimer>, respawn_timer: &mut ResMut<PlayerRespawnTimer>,
next_state: &mut ResMut<NextState<AppState>>, next_state: &mut ResMut<NextState<AppState>>,
player_entity: Entity, player_entity: Entity,
player_position: Vec3,
) { ) {
// Lose a life // Lose a life
lives.count = lives.count.saturating_sub(1); lives.count = lives.count.saturating_sub(1);
println!("Lives remaining: {}", lives.count); println!("Lives remaining: {}", lives.count);
// Spawn explosion at player position before destroying
spawn_explosion(commands, player_position);
// Destroy player // Destroy player
commands.entity(player_entity).despawn(); commands.entity(player_entity).despawn();
@ -218,11 +225,13 @@ pub fn check_player_enemy_collisions(
if distance < PLAYER_ENEMY_COLLISION_THRESHOLD { if distance < PLAYER_ENEMY_COLLISION_THRESHOLD {
println!("Player hit by enemy!"); println!("Player hit by enemy!");
spawn_explosion(&mut commands, enemy_transform.translation);
commands.entity(enemy_entity).despawn(); // Despawn enemy commands.entity(enemy_entity).despawn(); // Despawn enemy
lives.count = lives.count.saturating_sub(1); // Decrement lives safely lives.count = lives.count.saturating_sub(1); // Decrement lives safely
println!("Lives remaining: {}", lives.count); println!("Lives remaining: {}", lives.count);
spawn_explosion(&mut commands, player_transform.translation);
commands.entity(player_entity).despawn(); // Despawn player commands.entity(player_entity).despawn(); // Despawn player
if lives.count > 0 { if lives.count > 0 {

View file

@ -1,6 +1,7 @@
use bevy::prelude::*; 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::resources::{CurrentStage, PlayerLives, Score};
use crate::player::spawn_player_ship; use crate::player::spawn_player_ship;
use crate::starfield::spawn_starfield; use crate::starfield::spawn_starfield;
@ -45,3 +46,52 @@ pub fn update_window_title(
); );
} }
} }
// --- 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();
}
}
}