refactor: idiomatic cleanup across the codebase

Net -311 lines (1075 → 764). Build clean, clippy clean, 8 tests pass,
headless render verified.

Highlights:
- player.rs: bug fix in handle_captured_player — was cloning captured.timer
  and ticking the clone, never the real timer. Consolidated kill_player as
  pub(crate) helper (was duplicated across 3 sites). Switched from ParamSet
  to two disjoint Queries.
- enemy.rs: extracted step_attacker(), spawn_beam_visual(), end_beam(),
  pick_pattern() helpers — move_enemies is no longer 3-deep nested matches,
  beam cleanup no longer duplicated. Fixed SwoopDive overshoot check (was
  using already-applied movement). Mid-file `use` hoisted to top.
- resources.rs: added StageConfigurations::for_stage() helper (was repeated
  4×); FormationState/Score/CurrentStage now Default; extracted
  circle_formation() helper.
- stage.rs: replaced raw world: &mut World access with ordinary ResMut
  system params (no need — resource types are disjoint).
- game_state.rs: cleanup queries collapsed via Or<…> filters; dropped dead
  RestartMessage cleanup from cleanup_game_entities; button colors
  extracted as constants.
- bullet.rs: reuses kill_player; introduced GRUNT_POINTS/BOSS_POINTS with
  TODO referencing GAL-27.
- lib.rs: init_resource::<T>() for Default-implementing resources.
- Removed unused TRACTOR_BEAM_COLOR constant.
- Replaced per-frame println! debug spam with targeted info!/warn!.
- Stripped noise comments that restated what the code does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Harald Hoyer 2026-05-06 21:36:26 +02:00
parent b2b564f690
commit ad2037a7a5
11 changed files with 762 additions and 1073 deletions

View file

@ -1,17 +1,17 @@
use bevy::prelude::*;
use crate::components::{Bullet, Enemy, EnemyBullet, EnemyType};
use crate::components::{Bullet, Enemy, EnemyBullet, EnemyType, Invincible, Player};
use crate::constants::{
BULLET_ENEMY_COLLISION_THRESHOLD, BULLET_SIZE, BULLET_SPEED, ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD,
ENEMY_BULLET_SIZE, ENEMY_BULLET_SPEED, WINDOW_HEIGHT,
BULLET_ENEMY_COLLISION_THRESHOLD, BULLET_SIZE, BULLET_SPEED,
ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD, ENEMY_BULLET_SIZE, ENEMY_BULLET_SPEED, WINDOW_HEIGHT,
};
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::player::kill_player;
use crate::resources::{PlayerLives, PlayerRespawnTimer, Score};
use crate::systems::spawn_explosion;
// --- Player Bullet Systems ---
const GRUNT_POINTS: u32 = 100;
const BOSS_POINTS: u32 = 100; // TODO(GAL-27): differentiate Boss from Grunt scoring
pub fn move_bullets(
mut query: Query<(Entity, &mut Transform), With<Bullet>>,
@ -20,7 +20,6 @@ pub fn move_bullets(
) {
for (entity, mut transform) in query.iter_mut() {
transform.translation.y += BULLET_SPEED * time.delta_secs();
if transform.translation.y > WINDOW_HEIGHT / 2.0 + BULLET_SIZE.y {
commands.entity(entity).despawn();
}
@ -30,35 +29,26 @@ pub fn move_bullets(
pub fn check_bullet_collisions(
mut commands: Commands,
bullet_query: Query<(Entity, &Transform), With<Bullet>>,
enemy_query: Query<(Entity, &Transform, &Enemy), With<Enemy>>, // Fetch Enemy component too
mut score: ResMut<Score>, // Add Score resource
enemy_query: Query<(Entity, &Transform, &Enemy)>,
mut score: ResMut<Score>,
) {
for (bullet_entity, bullet_transform) in bullet_query.iter() {
for (enemy_entity, enemy_transform, enemy) in enemy_query.iter() {
// Get Enemy component
let distance = bullet_transform
.translation
.distance(enemy_transform.translation);
let hit = enemy_query.iter().find(|(_, t, _)| {
bullet_transform.translation.distance(t.translation) < BULLET_ENEMY_COLLISION_THRESHOLD
});
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 {
EnemyType::Grunt => 100,
EnemyType::Boss => 100, // Same points as Grunt for now
};
score.value += points;
println!("Enemy hit by player bullet! Score: {}", score.value); // Log score update
break; // Bullet only hits one enemy
}
if let Some((enemy_entity, enemy_transform, enemy)) = hit {
commands.entity(bullet_entity).despawn();
spawn_explosion(&mut commands, enemy_transform.translation);
commands.entity(enemy_entity).despawn();
score.value += match enemy.enemy_type {
EnemyType::Grunt => GRUNT_POINTS,
EnemyType::Boss => BOSS_POINTS,
};
}
}
}
// --- Enemy Bullet Systems ---
pub fn move_enemy_bullets(
mut commands: Commands,
time: Res<Time>,
@ -66,51 +56,38 @@ pub fn move_enemy_bullets(
) {
for (entity, mut transform) in query.iter_mut() {
transform.translation.y -= ENEMY_BULLET_SPEED * time.delta_secs();
// Despawn if off screen (bottom)
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_BULLET_SIZE.y {
commands.entity(entity).despawn();
}
}
}
// Check collisions between enemy bullets and the player
pub fn check_enemy_bullet_player_collisions(
mut commands: Commands,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>,
// Player query matching the run_if condition (player exists and is vulnerable)
player_query: Query<(Entity, &Transform), (With<Player>, Without<Invincible>)>,
enemy_bullet_query: Query<(Entity, &Transform), With<EnemyBullet>>,
bullet_query: Query<(Entity, &Transform), With<EnemyBullet>>,
) {
if let Ok((player_entity, player_transform)) = player_query.single() {
for (bullet_entity, bullet_transform) in enemy_bullet_query.iter() {
let distance = player_transform
.translation
.distance(bullet_transform.translation);
let Ok((player_entity, player_transform)) = player_query.single() else {
return;
};
let player_pos = player_transform.translation;
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
let Some((bullet_entity, _)) = bullet_query.iter().find(|(_, t)| {
player_pos.distance(t.translation) < ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD
}) else {
return;
};
lives.count = lives.count.saturating_sub(1);
println!("Lives remaining: {}", lives.count);
commands.entity(player_entity).despawn(); // Despawn player
if lives.count > 0 {
respawn_timer.timer.reset();
respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
next_state.set(AppState::GameOver);
}
// Break because the player can only be hit once per frame
break;
}
}
}
}
commands.entity(bullet_entity).despawn();
kill_player(
&mut commands,
&mut lives,
&mut respawn_timer,
&mut next_state,
player_entity,
player_pos,
);
}

View file

@ -1,6 +1,5 @@
use bevy::prelude::*;
// --- Components ---
#[derive(Component)]
pub struct Player {
pub speed: f32,
@ -10,15 +9,15 @@ pub struct Player {
#[derive(Component)]
pub struct Bullet;
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] // Added derive for common traits
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
pub enum EnemyType {
Grunt,
Boss, // Added Boss type
Boss,
}
#[derive(Component)]
pub struct Enemy {
pub enemy_type: EnemyType, // Add type field
pub enemy_type: EnemyType,
pub shoot_cooldown: Timer,
}
@ -27,22 +26,19 @@ pub struct Invincible {
pub timer: Timer,
}
// New component to mark a player as captured by a Boss enemy
#[derive(Component, Clone)] // Added Clone derive
/// Marks a player held by a Boss's tractor beam.
#[derive(Component)]
pub struct Captured {
// Reference to the capturing boss entity
pub boss_entity: Entity,
// Timer for how long the player remains captured
pub timer: Timer,
}
// New component for the tractor beam visual effect
#[derive(Component)]
pub struct TractorBeam {
pub target: Option<Entity>, // The entity being targeted (usually player)
pub timer: Timer, // How long the beam lasts
pub width: f32, // Visual width of the beam
pub active: bool, // Whether the beam is currently active
pub target: Option<Entity>,
pub timer: Timer,
pub width: f32,
pub active: bool,
}
#[derive(Component)]
@ -53,38 +49,34 @@ pub struct FormationTarget {
pub position: Vec3,
}
// Component to store the original formation position for enemies that need to return
/// Stored on enemies once they reach formation, so attackers can return to it.
#[derive(Component)]
pub struct OriginalFormationPosition {
pub position: Vec3,
}
// Enum defining different ways an enemy can attack
#[derive(Component, Clone, Copy, PartialEq, Debug)]
pub enum AttackPattern {
SwoopDive, // Original pattern: dive towards center, then off screen
DirectDive, // Dive straight down
Kamikaze(Vec3), // Dive towards a specific target location (e.g., player's last known position) - Needs target Vec3
CaptureBeam, // New pattern: Boss dives and attempts to capture the player with a tractor beam
// Add more patterns later (e.g., FigureEight, Looping)
SwoopDive,
DirectDive,
Kamikaze(Vec3),
CaptureBeam,
}
#[derive(Component, Clone, PartialEq, Debug)] // Added Debug derive
#[derive(Component, Clone, PartialEq, Debug)]
pub enum EnemyState {
Entering, // Flying onto the screen towards formation target
InFormation, // Holding position in the formation
Attacking(AttackPattern), // Diving towards the player using a specific pattern
ReturningWithCaptive, // Boss returning to formation with captured player
Entering,
InFormation,
Attacking(AttackPattern),
ReturningWithCaptive,
}
#[derive(Component)]
pub struct EnemyBullet;
// Game Over UI Component (might move to ui.rs later if more UI exists)
#[derive(Component)]
pub struct GameOverUI;
// Start Menu UI Components
#[derive(Component)]
pub struct StartMenuUI;

View file

@ -1,60 +1,61 @@
use bevy::math::Vec2;
use bevy::prelude::*;
// --- Constants ---
// Window
pub const WINDOW_WIDTH: f32 = 600.0;
pub const WINDOW_HEIGHT: f32 = 800.0;
// Player
pub const PLAYER_SPEED: f32 = 300.0;
pub const BULLET_SPEED: f32 = 500.0;
pub const ENEMY_SPEED: f32 = 100.0;
pub const PLAYER_SIZE: Vec2 = Vec2::new(30.0, 30.0);
pub const STARTING_LIVES: u32 = 3;
pub const PLAYER_RESPAWN_DELAY: f32 = 2.0;
pub const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
// Enemies
pub const ENEMY_SPEED: f32 = 100.0;
pub const ENEMY_SIZE: Vec2 = Vec2::new(40.0, 40.0);
pub const BULLET_SIZE: Vec2 = Vec2::new(5.0, 15.0);
// Player bullet
pub const ENEMY_BULLET_SIZE: Vec2 = Vec2::new(8.0, 8.0);
// Enemy bullet
pub const ENEMY_BULLET_SPEED: f32 = 300.0;
pub const ENEMY_SHOOT_INTERVAL: f32 = 1.5;
// Formation constants
// Bullets
pub const BULLET_SPEED: f32 = 500.0;
pub const BULLET_SIZE: Vec2 = Vec2::new(5.0, 15.0);
pub const ENEMY_BULLET_SIZE: Vec2 = Vec2::new(8.0, 8.0);
pub const ENEMY_BULLET_SPEED: f32 = 300.0;
// Formation
const FORMATION_ROWS: usize = 4;
pub const FORMATION_COLS: usize = 8;
pub const FORMATION_ENEMY_COUNT: usize = FORMATION_ROWS * FORMATION_COLS;
pub const FORMATION_X_SPACING: f32 = 60.0;
pub const FORMATION_Y_SPACING: f32 = 50.0;
pub const FORMATION_BASE_Y: f32 = WINDOW_HEIGHT / 2.0 - 150.0;
// Top area for formation
pub const STARTING_LIVES: u32 = 3;
pub const PLAYER_RESPAWN_DELAY: f32 = 2.0;
pub const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
// Collision thresholds
// Collision thresholds (mean of half-widths)
pub const BULLET_ENEMY_COLLISION_THRESHOLD: f32 = (BULLET_SIZE.x + ENEMY_SIZE.x) * 0.5;
// 22.5
pub const PLAYER_ENEMY_COLLISION_THRESHOLD: f32 = (PLAYER_SIZE.x + ENEMY_SIZE.x) * 0.5;
// 35.0
pub const ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD: f32 =
(ENEMY_BULLET_SIZE.x + PLAYER_SIZE.x) * 0.5;
// Tractor beam constants
// Tractor beam
pub const TRACTOR_BEAM_WIDTH: f32 = 20.0;
pub const TRACTOR_BEAM_DURATION: f32 = 3.0;
pub const TRACTOR_BEAM_COLOR: Color = Color::srgba(0.5, 0.0, 0.8, 0.6);
pub const CAPTURE_DURATION: f32 = 10.0; // How long the player stays captured
// Tractor beam visual constants
pub const CAPTURE_DURATION: f32 = 10.0;
pub const BEAM_GLOW_WIDTH: f32 = 40.0;
pub const BEAM_GLOW_COLOR: Color = Color::srgba(0.3, 0.0, 0.5, 0.25);
pub const BEAM_CORE_COLOR: Color = Color::srgba(0.7, 0.2, 1.0, 0.7);
pub const BEAM_PULSE_FREQ: f32 = 3.0;
pub const BEAM_PULSE_AMPLITUDE: f32 = 0.15;
// Starfield constants
// Starfield
pub const STAR_COUNT: usize = 150;
pub const STAR_MIN_SIZE: f32 = 1.0;
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
pub const STAR_Z_DEPTH: f32 = -10.0;
// Explosion constants
// Explosion
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);

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
use crate::components::{Bullet, Enemy, GameOverUI, RestartMessage, StartButton, StartMenuUI};
use crate::resources::RestartPressed;
use bevy::prelude::*;
// --- Game States ---
use crate::components::{Bullet, Enemy, EnemyBullet, GameOverUI, RestartMessage, StartButton, StartMenuUI};
use crate::resources::RestartPressed;
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
pub enum AppState {
#[default]
@ -11,10 +11,12 @@ pub enum AppState {
GameOver,
}
const BUTTON_IDLE: Color = Color::srgb(0.1, 0.1, 0.5);
const BUTTON_HOVER: Color = Color::srgb(0.2, 0.2, 0.7);
// --- Game Over UI ---
pub fn setup_game_over_ui(mut commands: Commands) {
println!("Entering GameOver state. Setting up UI.");
commands.spawn((
Text::new("GAME OVER"),
TextFont {
@ -51,39 +53,20 @@ pub fn setup_game_over_ui(mut commands: Commands) {
pub fn cleanup_game_over_ui(
mut commands: Commands,
query: Query<Entity, With<GameOverUI>>,
restart_query: Query<Entity, With<RestartMessage>>,
query: Query<Entity, Or<(With<GameOverUI>, With<RestartMessage>)>>,
) {
println!("Exiting GameOver state. Cleaning up UI.");
for entity in query.iter() {
commands.entity(entity).despawn();
}
for entity in restart_query.iter() {
for entity in &query {
commands.entity(entity).despawn();
}
}
// --- Cleanup ---
// --- Cleanup on leaving Playing ---
// Cleanup system when exiting the Playing state
pub fn cleanup_game_entities(
mut commands: Commands,
bullet_query: Query<Entity, With<Bullet>>,
enemy_bullet_query: Query<Entity, With<crate::components::EnemyBullet>>,
enemy_query: Query<Entity, With<Enemy>>,
restart_message_query: Query<Entity, With<crate::components::RestartMessage>>,
query: Query<Entity, Or<(With<Bullet>, With<EnemyBullet>, With<Enemy>)>>,
) {
println!("Exiting Playing state. Cleaning up game entities.");
for entity in bullet_query.iter() {
commands.entity(entity).despawn();
}
for entity in enemy_bullet_query.iter() {
commands.entity(entity).despawn();
}
for entity in enemy_query.iter() {
commands.entity(entity).despawn();
}
for entity in restart_message_query.iter() {
for entity in &query {
commands.entity(entity).despawn();
}
}
@ -91,8 +74,6 @@ pub fn cleanup_game_entities(
// --- Start Menu UI ---
pub fn setup_start_menu_ui(mut commands: Commands) {
println!("Entering StartMenu state. Setting up UI.");
commands
.spawn((
Node {
@ -106,7 +87,6 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
StartMenuUI,
))
.with_children(|parent| {
// Title
parent.spawn((
Text::new("BGLGA"),
TextFont {
@ -119,8 +99,6 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
..default()
},
));
// Start Game Button
parent
.spawn((
Button,
@ -133,7 +111,7 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
..default()
},
BorderColor::all(Color::WHITE),
BackgroundColor(Color::srgb(0.1, 0.1, 0.5)),
BackgroundColor(BUTTON_IDLE),
StartButton,
))
.with_children(|parent| {
@ -150,51 +128,42 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
}
pub fn cleanup_start_menu_ui(mut commands: Commands, query: Query<Entity, With<StartMenuUI>>) {
println!("Exiting StartMenu state. Cleaning up UI.");
for entity in query.iter() {
for entity in &query {
commands.entity(entity).despawn();
}
}
pub fn start_menu_button_system(
mut interaction_query: Query<
mut interactions: Query<
(&Interaction, &mut BackgroundColor),
(Changed<Interaction>, With<StartButton>),
>,
mut app_state: ResMut<NextState<AppState>>,
mut next_state: ResMut<NextState<AppState>>,
) {
for (interaction, mut color) in &mut interaction_query {
for (interaction, mut bg) in &mut interactions {
match *interaction {
Interaction::Pressed => {
println!("Start button pressed! Transitioning to Playing state.");
app_state.set(AppState::Playing);
}
Interaction::Hovered => {
color.0 = Color::srgb(0.2, 0.2, 0.7);
}
Interaction::None => {
color.0 = Color::srgb(0.1, 0.1, 0.5);
}
Interaction::Pressed => next_state.set(AppState::Playing),
Interaction::Hovered => bg.0 = BUTTON_HOVER,
Interaction::None => bg.0 = BUTTON_IDLE,
}
}
}
pub fn handle_restart_input(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut restart_resource: ResMut<RestartPressed>,
keyboard: Res<ButtonInput<KeyCode>>,
mut restart: ResMut<RestartPressed>,
) {
if keyboard_input.just_pressed(KeyCode::KeyR) {
restart_resource.pressed = true;
if keyboard.just_pressed(KeyCode::KeyR) {
restart.pressed = true;
}
}
pub fn restart_game_system(
mut app_state: ResMut<NextState<AppState>>,
mut restart_resource: ResMut<RestartPressed>,
mut next_state: ResMut<NextState<AppState>>,
mut restart: ResMut<RestartPressed>,
) {
if restart_resource.pressed {
println!("Restart requested. Transitioning to Playing state.");
restart_resource.pressed = false;
app_state.set(AppState::Playing);
if restart.pressed {
restart.pressed = false;
next_state.set(AppState::Playing);
}
}

View file

@ -3,44 +3,46 @@
use bevy::prelude::*;
use std::time::Duration;
pub mod bullet;
pub mod components;
pub mod constants;
pub mod resources;
pub mod enemy;
pub mod game_state;
pub mod player;
pub mod enemy;
pub mod bullet;
pub mod resources;
pub mod stage;
pub mod systems;
pub mod starfield;
pub mod systems;
use components::TractorBeam;
use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH};
use resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, PlayerLives,
PlayerRespawnTimer, Score, StageConfigurations,
};
use game_state::{
cleanup_game_entities, cleanup_game_over_ui, setup_game_over_ui, AppState,
setup_start_menu_ui, cleanup_start_menu_ui, start_menu_button_system,
handle_restart_input, restart_game_system,
};
use resources::RestartPressed;
use player::{
check_player_enemy_collisions, manage_invincibility, move_player, player_shoot,
respawn_player, handle_captured_player,
};
use enemy::{
check_formation_complete, enemy_shoot, is_formation_complete, move_enemies, spawn_enemies,
trigger_attack_dives, boss_capture_attack, update_tractor_beam_visual,
};
use bullet::{
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets,
move_enemy_bullets,
};
use components::TractorBeam;
use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH};
use enemy::{
boss_capture_attack, check_formation_complete, enemy_shoot, is_formation_complete,
move_enemies, spawn_enemies, trigger_attack_dives, update_tractor_beam_visual,
};
use game_state::{
cleanup_game_entities, cleanup_game_over_ui, cleanup_start_menu_ui, handle_restart_input,
restart_game_system, setup_game_over_ui, setup_start_menu_ui, start_menu_button_system,
AppState,
};
use player::{
check_player_enemy_collisions, handle_captured_player, manage_invincibility, move_player,
player_shoot, respawn_player,
};
use resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, PlayerLives,
PlayerRespawnTimer, RestartPressed, Score, StageConfigurations,
};
use stage::check_stage_clear;
use systems::{player_exists, player_vulnerable, setup, should_respawn_player, update_window_title, animate_explosion};
use starfield::scroll_starfield;
use systems::{
animate_explosion, player_exists, player_vulnerable, setup, should_respawn_player,
update_window_title,
};
pub fn run() {
App::new()
@ -58,29 +60,25 @@ pub fn run() {
.insert_resource(EnemySpawnTimer {
timer: Timer::new(Duration::from_secs_f32(1.0), TimerMode::Once),
})
.insert_resource(PlayerLives { count: STARTING_LIVES })
.insert_resource(PlayerLives {
count: STARTING_LIVES,
})
.insert_resource(PlayerRespawnTimer {
timer: Timer::new(
Duration::from_secs_f32(PLAYER_RESPAWN_DELAY),
TimerMode::Once,
),
})
.insert_resource(Score { value: 0 })
.insert_resource(CurrentStage {
number: 1,
waiting_for_clear: false,
})
.insert_resource(FormationState {
formation_complete: false,
total_spawned_this_stage: 0,
next_slot_index: 0,
})
.insert_resource(AttackDiveTimer {
timer: Timer::new(Duration::from_secs_f32(3.0), TimerMode::Once),
})
.insert_resource(StageConfigurations::default())
.insert_resource(RestartPressed::default())
.init_resource::<Score>()
.init_resource::<CurrentStage>()
.init_resource::<FormationState>()
.init_resource::<StageConfigurations>()
.init_resource::<RestartPressed>()
.add_systems(Startup, setup)
// Systems active only while Playing.
.add_systems(
Update,
(
@ -113,15 +111,22 @@ pub fn run() {
.chain()
.run_if(in_state(AppState::Playing)),
)
.add_systems(Update, scroll_starfield)
.add_systems(Update, animate_explosion)
// Always-on background systems.
.add_systems(Update, (scroll_starfield, animate_explosion))
// Start menu.
.add_systems(OnEnter(AppState::StartMenu), setup_start_menu_ui)
.add_systems(OnExit(AppState::StartMenu), cleanup_start_menu_ui)
.add_systems(Update, start_menu_button_system.run_if(in_state(AppState::StartMenu)))
.add_systems(
Update,
start_menu_button_system.run_if(in_state(AppState::StartMenu)),
)
// Game over.
.add_systems(OnEnter(AppState::GameOver), setup_game_over_ui)
.add_systems(Update, handle_restart_input.run_if(in_state(AppState::GameOver)))
.add_systems(Update, restart_game_system.run_if(in_state(AppState::GameOver)))
.add_systems(OnExit(AppState::Playing), cleanup_game_entities)
.add_systems(OnExit(AppState::GameOver), cleanup_game_over_ui)
.add_systems(
Update,
(handle_restart_input, restart_game_system).run_if(in_state(AppState::GameOver)),
)
.add_systems(OnExit(AppState::Playing), cleanup_game_entities)
.run();
}

View file

@ -1,5 +1,4 @@
use bevy::prelude::*;
use bevy::ecs::system::ParamSet;
use std::time::Duration;
use crate::components::{Bullet, Captured, Enemy, Invincible, Player};
@ -11,7 +10,11 @@ use crate::game_state::AppState;
use crate::resources::{PlayerLives, PlayerRespawnTimer};
use crate::systems::spawn_explosion;
// Helper to spawn player (used in setup and respawn)
const PLAYER_SHOOT_COOLDOWN: f32 = 0.3;
const CAPTURE_FOLLOW_LERP: f32 = 0.2;
const CAPTURE_OFFSET_BELOW_BOSS: f32 = PLAYER_SIZE.y + 10.0;
const INVINCIBILITY_BLINK_HZ: f32 = 10.0;
pub fn spawn_player_ship(commands: &mut Commands) {
commands.spawn((
Sprite {
@ -19,16 +22,14 @@ pub fn spawn_player_ship(commands: &mut Commands) {
custom_size: Some(PLAYER_SIZE),
..default()
},
Transform::from_translation(Vec3::new(
0.0,
-WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0,
0.0,
)),
Transform::from_xyz(0.0, -WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0, 0.0),
Player {
speed: PLAYER_SPEED,
shoot_cooldown: Timer::new(Duration::from_secs_f32(0.3), TimerMode::Once),
shoot_cooldown: Timer::new(
Duration::from_secs_f32(PLAYER_SHOOT_COOLDOWN),
TimerMode::Once,
),
},
// Player starts invincible for a short time
Invincible {
timer: Timer::new(
Duration::from_secs_f32(PLAYER_INVINCIBILITY_DURATION),
@ -36,256 +37,194 @@ pub fn spawn_player_ship(commands: &mut Commands) {
),
},
));
println!("Player spawned!");
}
pub fn move_player(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Transform, &Player), Without<Captured>>, // Don't move captured players with controls
keyboard: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Transform, &Player), Without<Captured>>,
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.single_mut() {
let mut direction = 0.0;
let Ok((mut transform, player)) = query.single_mut() else {
return;
};
if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) {
direction -= 1.0;
}
if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
direction += 1.0;
}
transform.translation.x += direction * player.speed * time.delta_secs();
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,
);
let mut direction = 0.0;
if keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::ArrowLeft) {
direction -= 1.0;
}
if keyboard.pressed(KeyCode::KeyD) || keyboard.pressed(KeyCode::ArrowRight) {
direction += 1.0;
}
transform.translation.x += direction * player.speed * time.delta_secs();
let half_width = PLAYER_SIZE.x / 2.0;
transform.translation.x = transform
.translation
.x
.clamp(-WINDOW_WIDTH / 2.0 + half_width, WINDOW_WIDTH / 2.0 - half_width);
}
// New system to handle captured player movement
pub fn handle_captured_player(
mut commands: Commands,
time: Res<Time>,
mut set: ParamSet<(
Query<(Entity, &mut Transform, &mut Captured)>,
Query<&Transform, (With<Enemy>, Without<Player>)>,
)>,
mut player_query: Query<(Entity, &mut Transform, &mut Captured), With<Player>>,
boss_query: Query<&Transform, (With<Enemy>, Without<Player>)>,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>,
) {
// First, collect data about captured players and their bosses
let mut captured_data = Vec::new();
// Get player data
for (entity, transform, captured) in set.p0().iter() {
captured_data.push((entity, transform.translation, captured.boss_entity, captured.timer.clone()));
}
// Process each captured player
for (player_entity, _player_pos, boss_entity, mut timer) in captured_data {
// Check if the boss exists and get its position
let boss_pos = set.p1().get(boss_entity).map(|t| t.translation).ok();
// Tick the timer
timer.tick(time.delta());
// Update the player
if let Ok((entity, mut transform, mut captured)) = set.p0().get_mut(player_entity) {
// Update the actual timer
captured.timer.tick(time.delta());
match boss_pos {
Some(boss_pos) => {
// Boss exists, update player position
let target_pos = boss_pos - Vec3::new(0.0, PLAYER_SIZE.y + 10.0, 0.0);
transform.translation = transform.translation.lerp(target_pos, 0.2);
}
None => {
// Boss is gone, release player but lose a life
println!("Boss is gone, releasing captured player!");
commands.entity(entity).remove::<Captured>();
lose_life_and_respawn(
&mut commands,
&mut lives,
&mut respawn_timer,
&mut next_state,
entity,
_player_pos,
);
continue; // Skip the rest of processing for this player
}
}
// If capture duration expires, player escapes but loses a life
if captured.timer.is_finished() {
println!("Player escaped from capture after timer expired!");
commands.entity(entity).remove::<Captured>();
lose_life_and_respawn(
&mut commands,
&mut lives,
&mut respawn_timer,
&mut next_state,
entity,
transform.translation,
);
}
for (player_entity, mut transform, mut captured) in player_query.iter_mut() {
captured.timer.tick(time.delta());
let Ok(boss_transform) = boss_query.get(captured.boss_entity) else {
// Boss was despawned mid-capture: release and penalize.
commands.entity(player_entity).remove::<Captured>();
kill_player(
&mut commands,
&mut lives,
&mut respawn_timer,
&mut next_state,
player_entity,
transform.translation,
);
continue;
};
let target = boss_transform.translation - Vec3::Y * CAPTURE_OFFSET_BELOW_BOSS;
transform.translation = transform.translation.lerp(target, CAPTURE_FOLLOW_LERP);
if captured.timer.is_finished() {
commands.entity(player_entity).remove::<Captured>();
kill_player(
&mut commands,
&mut lives,
&mut respawn_timer,
&mut next_state,
player_entity,
transform.translation,
);
}
}
}
// Helper function for player life loss and respawn logic
fn lose_life_and_respawn(
/// Despawn the player at `position`, decrement lives, and either start the respawn
/// timer or transition to GameOver. Does not handle collateral entities (the caller
/// despawns enemies/bullets/etc. as needed).
pub(crate) fn kill_player(
commands: &mut Commands,
lives: &mut ResMut<PlayerLives>,
respawn_timer: &mut ResMut<PlayerRespawnTimer>,
next_state: &mut ResMut<NextState<AppState>>,
lives: &mut PlayerLives,
respawn_timer: &mut PlayerRespawnTimer,
next_state: &mut NextState<AppState>,
player_entity: Entity,
player_position: Vec3,
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
spawn_explosion(commands, position);
commands.entity(player_entity).despawn();
if lives.count > 0 {
respawn_timer.timer.reset();
respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
info!("GAME OVER");
next_state.set(AppState::GameOver);
}
}
pub fn player_shoot(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&Transform, &mut Player), Without<Captured>>, // Only non-captured players can shoot
keyboard: Res<ButtonInput<KeyCode>>,
mut query: Query<(&Transform, &mut Player), Without<Captured>>,
mut commands: Commands,
time: Res<Time>,
) {
if let Ok((player_transform, mut player)) = query.single_mut() {
player.shoot_cooldown.tick(time.delta());
let Ok((transform, mut player)) = query.single_mut() else {
return;
};
player.shoot_cooldown.tick(time.delta());
if (keyboard_input.just_pressed(KeyCode::Space)
|| keyboard_input.just_pressed(KeyCode::ArrowUp))
&& player.shoot_cooldown.is_finished()
{
player.shoot_cooldown.reset();
let bullet_start_pos = player_transform.translation
+ Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
commands.spawn((
Sprite {
color: Color::srgb(1.0, 1.0, 1.0),
custom_size: Some(BULLET_SIZE),
..default()
},
Transform::from_translation(bullet_start_pos),
Bullet,
));
}
let fire = keyboard.just_pressed(KeyCode::Space) || keyboard.just_pressed(KeyCode::ArrowUp);
if !fire || !player.shoot_cooldown.is_finished() {
return;
}
player.shoot_cooldown.reset();
let bullet_pos = transform.translation + Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
commands.spawn((
Sprite {
color: Color::WHITE,
custom_size: Some(BULLET_SIZE),
..default()
},
Transform::from_translation(bullet_pos),
Bullet,
));
}
// Modified Collision Check for Player vs Enemy
pub fn check_player_enemy_collisions(
mut commands: Commands,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>, // Resource to change state
// Query player without Invincible component - relies on run_if condition too
mut next_state: ResMut<NextState<AppState>>,
player_query: Query<
(Entity, &Transform),
(With<Player>, Without<Invincible>, Without<Captured>),
>, // Don't check collisions for captured players
>,
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.single() {
for (enemy_entity, enemy_transform) in enemy_query.iter() {
let distance = player_transform
.translation
.distance(enemy_transform.translation);
let Ok((player_entity, player_transform)) = player_query.single() else {
return;
};
let player_pos = player_transform.translation;
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
let Some((enemy_entity, enemy_transform)) = enemy_query
.iter()
.find(|(_, t)| player_pos.distance(t.translation) < PLAYER_ENEMY_COLLISION_THRESHOLD)
else {
return;
};
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 {
// Start the respawn timer
respawn_timer.timer.reset();
respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
next_state.set(AppState::GameOver); // Updated for newer Bevy states API
}
// Important: Break after handling one collision per frame for the player
break;
}
}
}
spawn_explosion(&mut commands, enemy_transform.translation);
commands.entity(enemy_entity).despawn();
kill_player(
&mut commands,
&mut lives,
&mut respawn_timer,
&mut next_state,
player_entity,
player_pos,
);
}
// New System: Respawn Player
pub 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
respawn_timer.timer.pause();
}
}
// New System: Manage Invincibility
pub fn manage_invincibility(
mut commands: Commands,
time: Res<Time>,
mut query: Query<(Entity, &mut Invincible, Option<&mut Visibility>), With<Player>>,
mut query: Query<(Entity, &mut Invincible, &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 {
if invincible.timer.is_finished() {
commands.entity(entity).remove::<Invincible>();
*visibility = Visibility::Visible;
} else {
// Blink at INVINCIBILITY_BLINK_HZ / 2 (one full cycle = 2 floor steps).
let phase = (invincible.timer.elapsed_secs() * INVINCIBILITY_BLINK_HZ) as u32;
*visibility = if phase.is_multiple_of(2) {
Visibility::Visible
} else {
Visibility::Hidden
};
}
if invincible.timer.is_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;
}
}
}
}

View file

@ -1,6 +1,11 @@
use bevy::prelude::*;
// --- Resources ---
use crate::components::AttackPattern;
use crate::constants::{
ENEMY_SHOOT_INTERVAL, FORMATION_BASE_Y, FORMATION_COLS, FORMATION_ENEMY_COUNT,
FORMATION_X_SPACING, FORMATION_Y_SPACING, WINDOW_WIDTH,
};
#[derive(Resource)]
pub struct EnemySpawnTimer {
pub timer: Timer,
@ -16,59 +21,57 @@ pub struct PlayerRespawnTimer {
pub timer: Timer,
}
// New struct to define formation positions
#[derive(Clone, Debug)]
pub struct FormationLayout {
pub name: String, // Optional name for debugging/identification
pub name: String,
pub positions: Vec<Vec3>,
}
// Default implementation for easy initialization
impl Default for FormationLayout {
fn default() -> Self {
// Default to the original grid formation for now
let mut positions = Vec::with_capacity(crate::constants::FORMATION_ENEMY_COUNT);
for i in 0..crate::constants::FORMATION_ENEMY_COUNT {
let row = i / crate::constants::FORMATION_COLS;
let col = i % crate::constants::FORMATION_COLS;
let target_x = (col as f32 - (crate::constants::FORMATION_COLS as f32 - 1.0) / 2.0)
* crate::constants::FORMATION_X_SPACING;
let target_y = crate::constants::FORMATION_BASE_Y
- (row as f32 * crate::constants::FORMATION_Y_SPACING);
positions.push(Vec3::new(target_x, target_y, 0.0));
}
FormationLayout {
let positions = (0..FORMATION_ENEMY_COUNT)
.map(|i| {
let row = i / FORMATION_COLS;
let col = i % FORMATION_COLS;
let x = (col as f32 - (FORMATION_COLS as f32 - 1.0) / 2.0) * FORMATION_X_SPACING;
let y = FORMATION_BASE_Y - row as f32 * FORMATION_Y_SPACING;
Vec3::new(x, y, 0.0)
})
.collect();
Self {
name: "Default Grid".to_string(),
positions,
}
}
}
// Configuration for a single stage
#[derive(Clone, Debug)]
pub struct StageConfig {
pub formation_layout: FormationLayout,
pub enemy_count: usize, // Allow overriding enemy count per stage
pub attack_patterns: Vec<crate::components::AttackPattern>, // Possible patterns for this stage
pub attack_dive_interval: f32, // Time between attack dives for this stage
pub enemy_speed_multiplier: f32, // Speed multiplier for this stage
pub enemy_shoot_interval: f32, // Shoot interval for this stage
pub enemy_count: usize,
pub attack_patterns: Vec<AttackPattern>,
pub attack_dive_interval: f32,
pub enemy_speed_multiplier: f32,
pub enemy_shoot_interval: f32,
}
// Resource to hold all stage configurations
#[derive(Resource, Debug, Clone)]
pub struct StageConfigurations {
pub stages: Vec<StageConfig>,
}
impl StageConfigurations {
/// Cycles through configured stages once `stage_number` exceeds the count.
pub fn for_stage(&self, stage_number: u32) -> &StageConfig {
let idx = (stage_number.saturating_sub(1) as usize) % self.stages.len();
&self.stages[idx]
}
}
impl Default for StageConfigurations {
fn default() -> Self {
use crate::components::AttackPattern;
use crate::constants::*; // Import constants for default values
// Define configurations for a few stages
let stage1 = StageConfig {
formation_layout: FormationLayout::default(), // Use the default grid
formation_layout: FormationLayout::default(),
enemy_count: FORMATION_ENEMY_COUNT,
attack_patterns: vec![AttackPattern::SwoopDive],
attack_dive_interval: 3.0,
@ -76,41 +79,36 @@ impl Default for StageConfigurations {
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL,
};
let stage2_layout = {
let mut positions = Vec::new();
let radius = WINDOW_WIDTH / 4.0;
let center_y = FORMATION_BASE_Y - 50.0;
let count = 16; // Example: Fewer enemies in a circle
for i in 0..count {
let angle = (i as f32 / count as f32) * 2.0 * std::f32::consts::PI;
positions.push(Vec3::new(
angle.cos() * radius,
center_y + angle.sin() * radius,
0.0,
));
}
FormationLayout {
name: "Circle".to_string(),
positions,
}
};
let stage2_layout = circle_formation(16, WINDOW_WIDTH / 4.0, FORMATION_BASE_Y - 50.0);
let stage2 = StageConfig {
formation_layout: stage2_layout,
enemy_count: 16,
attack_patterns: vec![AttackPattern::SwoopDive, AttackPattern::DirectDive], // Add direct dive
attack_dive_interval: 2.5, // Faster dives
enemy_speed_multiplier: 1.2, // Faster enemies
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8, // Faster shooting
attack_patterns: vec![AttackPattern::SwoopDive, AttackPattern::DirectDive],
attack_dive_interval: 2.5,
enemy_speed_multiplier: 1.2,
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8,
};
// Add more stages here...
StageConfigurations {
stages: vec![stage1, stage2], // Add more stages as needed
Self {
stages: vec![stage1, stage2],
}
}
}
#[derive(Resource)]
fn circle_formation(count: usize, radius: f32, center_y: f32) -> FormationLayout {
let positions = (0..count)
.map(|i| {
let angle = (i as f32 / count as f32) * std::f32::consts::TAU;
Vec3::new(angle.cos() * radius, center_y + angle.sin() * radius, 0.0)
})
.collect();
FormationLayout {
name: "Circle".to_string(),
positions,
}
}
#[derive(Resource, Default)]
pub struct Score {
pub value: u32,
}
@ -118,14 +116,23 @@ pub struct Score {
#[derive(Resource)]
pub struct CurrentStage {
pub number: u32,
pub waiting_for_clear: bool, // Flag to check if we should check for stage clear
pub waiting_for_clear: bool,
}
#[derive(Resource)]
impl Default for CurrentStage {
fn default() -> Self {
Self {
number: 1,
waiting_for_clear: false,
}
}
}
#[derive(Resource, Default)]
pub struct FormationState {
pub next_slot_index: usize,
pub total_spawned_this_stage: usize,
pub formation_complete: bool, // Flag to indicate if all enemies are in position
pub formation_complete: bool,
}
#[derive(Resource)]

View file

@ -3,35 +3,19 @@ use bevy::prelude::*;
use crate::components::Enemy;
use crate::resources::{CurrentStage, EnemySpawnTimer, FormationState};
// Helper to access world directly in check_stage_clear
pub fn check_stage_clear(world: &mut World) {
// Use manual resource access because we need mutable access to multiple resources
// Separate checks to manage borrows correctly
let mut should_clear = false;
if let Some(stage) = world.get_resource::<CurrentStage>() {
if stage.waiting_for_clear {
// Create the query *after* checking the flag
let mut enemy_query = world.query_filtered::<Entity, With<Enemy>>();
if enemy_query.iter(world).next().is_none() {
should_clear = true;
}
}
pub fn check_stage_clear(
enemy_query: Query<(), With<Enemy>>,
mut stage: ResMut<CurrentStage>,
mut formation_state: ResMut<FormationState>,
mut spawn_timer: ResMut<EnemySpawnTimer>,
) {
if !stage.waiting_for_clear || !enemy_query.is_empty() {
return;
}
if should_clear {
// Get mutable resources only when needed
if let Some(mut stage) = world.get_resource_mut::<CurrentStage>() {
stage.number += 1;
stage.waiting_for_clear = false;
println!("Stage cleared! Starting Stage {}...", stage.number);
}
if let Some(mut formation_state) = world.get_resource_mut::<FormationState>() {
formation_state.next_slot_index = 0;
formation_state.total_spawned_this_stage = 0;
formation_state.formation_complete = false; // Reset flag for new stage
}
if let Some(mut spawn_timer) = world.get_resource_mut::<EnemySpawnTimer>() {
spawn_timer.timer.reset();
}
}
}
stage.number += 1;
stage.waiting_for_clear = false;
*formation_state = FormationState::default();
spawn_timer.timer.reset();
info!("Starting Stage {}", stage.number);
}

View file

@ -2,27 +2,29 @@ use bevy::prelude::*;
use crate::components::Star;
use crate::constants::{
STAR_COUNT, STAR_MAX_SIZE, STAR_MAX_SPEED, STAR_MIN_SIZE, STAR_MIN_SPEED,
STAR_Z_DEPTH, WINDOW_HEIGHT, WINDOW_WIDTH,
STAR_COUNT, STAR_MAX_SIZE, STAR_MAX_SPEED, STAR_MIN_SIZE, STAR_MIN_SPEED, STAR_Z_DEPTH,
WINDOW_HEIGHT, WINDOW_WIDTH,
};
const PARALLAX_FACTOR: f32 = 0.01;
pub fn spawn_starfield(commands: &mut Commands) {
for _ in 0..STAR_COUNT {
let size = fastrand::f32() * (STAR_MAX_SIZE - STAR_MIN_SIZE) + STAR_MIN_SIZE;
let speed = fastrand::f32() * (STAR_MAX_SPEED - STAR_MIN_SPEED) + STAR_MIN_SPEED;
let brightness = fastrand::f32() * 0.5 + 0.5; // 0.5 to 1.0
let size = lerp_random(STAR_MIN_SIZE, STAR_MAX_SIZE);
let speed = lerp_random(STAR_MIN_SPEED, STAR_MAX_SPEED);
let brightness = 0.5 + fastrand::f32() * 0.5;
commands.spawn((
Sprite {
color: Color::srgb(brightness, brightness, brightness),
custom_size: Some(Vec2::new(size, size)),
custom_size: Some(Vec2::splat(size)),
..default()
},
Transform::from_translation(Vec3::new(
fastrand::f32() * WINDOW_WIDTH - WINDOW_WIDTH / 2.0,
fastrand::f32() * WINDOW_HEIGHT - WINDOW_HEIGHT / 2.0,
Transform::from_xyz(
(fastrand::f32() - 0.5) * WINDOW_WIDTH,
(fastrand::f32() - 0.5) * WINDOW_HEIGHT,
STAR_Z_DEPTH,
)),
),
Star { speed },
));
}
@ -30,16 +32,19 @@ pub fn spawn_starfield(commands: &mut Commands) {
pub fn scroll_starfield(mut star_query: Query<(&mut Transform, &Star)>, time: Res<Time>) {
let dt = time.delta_secs();
let half_height = WINDOW_HEIGHT / 2.0;
let half_width = WINDOW_WIDTH / 2.0;
let half_h = WINDOW_HEIGHT / 2.0;
for (mut transform, star) in star_query.iter_mut() {
let parallax = -transform.translation.y * 0.01;
let parallax = -transform.translation.y * PARALLAX_FACTOR;
transform.translation.y -= (star.speed + parallax) * dt;
if transform.translation.y < -half_height {
transform.translation.y = half_height;
transform.translation.x = fastrand::f32() * WINDOW_WIDTH - half_width;
if transform.translation.y < -half_h {
transform.translation.y = half_h;
transform.translation.x = (fastrand::f32() - 0.5) * WINDOW_WIDTH;
}
}
}
fn lerp_random(min: f32, max: f32) -> f32 {
min + fastrand::f32() * (max - min)
}

View file

@ -1,19 +1,20 @@
use bevy::prelude::*;
use std::time::Duration;
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::resources::{CurrentStage, PlayerLives, Score};
use crate::starfield::spawn_starfield;
// --- Setup ---
pub fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
spawn_starfield(&mut commands);
spawn_player_ship(&mut commands);
}
// --- Run Conditions ---
// --- Run conditions ---
pub fn player_exists(query: Query<&Player>) -> bool {
!query.is_empty()
}
@ -22,30 +23,30 @@ pub fn player_vulnerable(query: Query<&Player, Without<Invincible>>) -> bool {
!query.is_empty()
}
pub fn should_respawn_player(lives: Res<PlayerLives>, player_query: Query<&Player>) -> bool {
player_query.is_empty() && lives.count > 0
pub fn should_respawn_player(lives: Res<PlayerLives>, query: Query<&Player>) -> bool {
query.is_empty() && lives.count > 0
}
// --- General Systems ---
// --- HUD ---
// Update Window Title with Lives, Score, and Stage
pub fn update_window_title(
lives: Res<PlayerLives>,
score: Res<Score>,
stage: Res<CurrentStage>,
mut windows: Query<&mut Window>,
) {
if lives.is_changed() || score.is_changed() || stage.is_changed() {
if let Ok(mut window) = windows.single_mut() {
window.title = format!(
"Galaga :: Stage: {} Lives: {} Score: {}",
stage.number, lives.count, score.value
);
}
if !(lives.is_changed() || score.is_changed() || stage.is_changed()) {
return;
}
if let Ok(mut window) = windows.single_mut() {
window.title = format!(
"Galaga :: Stage: {} Lives: {} Score: {}",
stage.number, lives.count, score.value
);
}
}
// --- Explosion Systems ---
// --- Explosions ---
pub fn spawn_explosion(commands: &mut Commands, position: Vec3) {
commands.spawn((
@ -56,7 +57,7 @@ pub fn spawn_explosion(commands: &mut Commands, position: Vec3) {
},
Transform::from_translation(position),
Explosion {
timer: Timer::new(std::time::Duration::from_secs_f32(EXPLOSION_DURATION), TimerMode::Once),
timer: Timer::new(Duration::from_secs_f32(EXPLOSION_DURATION), TimerMode::Once),
},
));
}
@ -66,19 +67,14 @@ pub fn animate_explosion(
time: Res<Time>,
mut query: Query<(Entity, &mut Explosion, &mut Sprite)>,
) {
let base = EXPLOSION_COLOR.to_srgba();
for (entity, mut explosion, mut sprite) in query.iter_mut() {
explosion.timer.tick(time.delta());
let progress = (explosion.timer.elapsed_secs() / EXPLOSION_DURATION).min(1.0);
let progress = explosion.timer.elapsed_secs() / EXPLOSION_DURATION;
let clamped_progress = progress.min(1.0);
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));
let alpha = 1.0 - clamped_progress;
let base = EXPLOSION_COLOR.to_srgba();
sprite.color = Color::srgba(base.red, base.green, base.blue, alpha);
let size = EXPLOSION_BASE_SIZE.lerp(EXPLOSION_MAX_SIZE, progress);
sprite.custom_size = Some(size);
sprite.color = Color::srgba(base.red, base.green, base.blue, 1.0 - progress);
if explosion.timer.is_finished() {
commands.entity(entity).despawn();