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-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.
* [ ] **GAL-46: 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::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

@ -98,3 +98,8 @@ pub struct RestartMessage;
pub struct Star {
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_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() {
@ -120,6 +120,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();
}
}
}