Compare commits

...

10 commits

Author SHA1 Message Date
Harald Hoyer 008f9cc24a feat: add Captured and TractorBeam components, enhance enemy behavior with capture mechanics 2025-04-16 08:41:17 +02:00
Harald Hoyer 1d1b927007 chore: remove commit message instructions file 2025-04-15 12:50:29 +02:00
Harald Hoyer 037924a4f4 feat: add AGENT.md for project setup instructions and remove CLAUDE.md 2025-04-15 12:32:11 +02:00
Harald Hoyer 9aad8dd130 refactor: improve code formatting and readability in components and resources 2025-04-15 12:28:17 +02:00
Harald Hoyer 4727c370d2 feat: add commit message guidelines
- Introduce guidelines for writing commit messages.
- Emphasize the use of Conventional Commit format.
- Include tips for brevity and clarity in descriptions.
2025-04-15 12:26:10 +02:00
Harald Hoyer 4a64e12945 refactor: remove unused imports from multiple files
- Removed `std::time::Duration` import from `components.rs`, `resources.rs`, and `enemy.rs` as it was unnecessary.
- Cleaned up imports in `systems.rs` by removing `FormationState`.
2025-04-15 12:26:01 +02:00
Harald Hoyer 3c92823cb6 chore: add 'result' directory to .gitignore 2025-04-15 12:06:28 +02:00
Harald Hoyer 3520d5122b feat: add TODO list for core gameplay loop and enemy behavior mechanics 2025-04-15 12:06:22 +02:00
Harald Hoyer 54d1fd66d2 chore: remove obsolete .goosehints file 2025-04-15 12:06:09 +02:00
Harald Hoyer c61a28de29 feat: add cratedocs server configuration to mcp.json 2025-04-15 12:05:18 +02:00
13 changed files with 472 additions and 201 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
/.direnv
/logs
/.idea
result

11
.vscode/mcp.json vendored Normal file
View file

@ -0,0 +1,11 @@
{
"servers": {
"cratedocs": {
"type": "stdio",
"command": "/home/harald/.cargo/bin/cratedocs",
"args": [
"stdio"
]
}
}
}

View file

@ -1 +0,0 @@
.goosehints

View file

@ -34,73 +34,3 @@ nix develop --command bash -c "cargo build"
2. Clone the repository.
3. Navigate to the project directory.
4. Run the game using the command: `nix develop --command bash -c "cargo run"`
# TODO
**1. Core Gameplay Loop & State Management:**
* ~~**Player Lives:**~~ **(DONE)**
* ~~Add a `PlayerLives` resource (e.g., starting with 3).~~
* ~~Modify `check_player_enemy_collisions`: Instead of just printing, decrement the lives count.~~
* ~~Implement player destruction (despawn the player sprite) and respawn logic (maybe after a short delay, with temporary invincibility).~~
* ~~**Game Over State:**~~ **(DONE)**
* ~~Use Bevy's `States` (e.g., `Playing`, `GameOver`).~~
* ~~Transition to `GameOver` when `PlayerLives` reaches zero.~~
* ~~In the `GameOver` state: stop enemy spawning, stop player controls, display a "Game Over" message (using `bevy_ui`), potentially offer a restart option.~~
* ~~**Scoring:**~~ **(DONE)**
* ~~Add a `Score` resource.~~
* ~~Increment the score in `check_bullet_collisions` when an enemy is hit.~~
* Consider different point values for different enemy types or hitting enemies during dives later.
* ~~**Levels/Stages:**~~ **(DONE)**
* ~~Add a `CurrentStage` resource.~~
* ~~Define criteria for clearing a stage (e.g., destroying all enemies in a wave/formation).~~
* ~~Implement logic to advance to the next stage, potentially increasing difficulty (enemy speed, firing rate, different formations).~~
**2. Enemy Behavior - Formations & Attack Patterns:**
* **Enemy Formations:** This is a core Galaga feature.
* ~~Define target positions for the enemy formation on screen.~~ **(DONE)**
* ~~Give enemies an `Entering` state/component: They fly onto the screen following predefined paths (curves, waypoints).~~ **(DONE - Basic linear path implemented)**
* Give enemies a `Formation` state/component: Once they reach their target position, they stop and hold formation. **(DONE - Stop implemented by removing FormationTarget)**
* **Enemy Attack Dives:**
* ~~Give enemies an `Attacking` state/component.~~ **(DONE)**
* ~~Periodically trigger enemies in the formation to switch to the `Attacking` state.~~ **(DONE - Random selection after formation complete)**
* ~~Define attack paths (swooping dives towards the player area).~~ **(DONE - Basic swoop towards center implemented)**
* ~~Make enemies fire bullets (downwards or towards the player) during their dives.~~ **(DONE - Downward)**
* After an attack dive, enemies could return to their formation position or fly off-screen. **(Fly off-screen implemented)**
* **Enemy Variety:**
* ~~Introduce different types of enemies (e.g., using different components or an enum).~~ **(DONE - Added EnemyType enum and field)**
* ~~Assign different behaviors, point values, and maybe sprites to each type.~~ **(DONE - Behaviors, points & color based on type)**
**3. Advanced Galaga Mechanics:**
* **Boss Galaga & Capture Beam:**
* ~~Create a "Boss" enemy type.~~ **(DONE - Added to Enum, handled in matches)**
* Implement the tractor beam attack (visual effect, player capture logic).
* Player needs a `Captured` state.
* Logic for the Boss to return captured ships to the formation.
* **Dual Fighter (Rescuing Captured Ship):**
* Allow the player to shoot a Boss Galaga attempting to capture or one holding a captured ship.
* Implement the logic to free the captured ship.
* Implement the dual fighter mode (controlling two ships, firing two bullets). This involves significant changes to player control and shooting systems.
* **Challenging Stages:**
* Implement a special stage type (e.g., every few levels).
* Enemies fly in intricate patterns without shooting.
* Award bonus points for destroying all enemies in the stage.
**4. Polish and User Interface:**
* **Visuals:**
* Replace placeholder shapes with actual sprites using `SpriteBundle` or `SpriteSheetBundle`. Create distinct looks for the player, different enemy types, bullets.
* Add explosion effects/animations when enemies or the player are destroyed.
* Implement a scrolling starfield background for a more classic space feel.
* **Audio:**
* Integrate `bevy_audio`.
* Add sound effects for player shooting, enemy firing, explosions, player death, capturing, etc.
* Add background music that might change per stage or state (e.g., main gameplay vs. challenging stage).
* **UI:**
* Use `bevy_ui` to display the current Score, Lives remaining, and Stage number on screen during gameplay.
* Display High Score?
* Create a Start Menu state.
Starting with the Core Gameplay Loop (Lives, Game Over, Score, basic Stages) and then moving onto Enemy Formations and Attack Patterns would likely provide the biggest steps towards a Galaga feel.

69
TODO.md Normal file
View file

@ -0,0 +1,69 @@
# TODO
**1. Core Gameplay Loop & State Management:**
* ~~**Player Lives:**~~ **(DONE)**
* ~~Add a `PlayerLives` resource (e.g., starting with 3).~~
* ~~Modify `check_player_enemy_collisions`: Instead of just printing, decrement the lives count.~~
* ~~Implement player destruction (despawn the player sprite) and respawn logic (maybe after a short delay, with temporary invincibility).~~
* ~~**Game Over State:**~~ **(DONE)**
* ~~Use Bevy's `States` (e.g., `Playing`, `GameOver`).~~
* ~~Transition to `GameOver` when `PlayerLives` reaches zero.~~
* ~~In the `GameOver` state: stop enemy spawning, stop player controls, display a "Game Over" message (using `bevy_ui`), potentially offer a restart option.~~
* ~~**Scoring:**~~ **(DONE)**
* ~~Add a `Score` resource.~~
* ~~Increment the score in `check_bullet_collisions` when an enemy is hit.~~
* Consider different point values for different enemy types or hitting enemies during dives later.
* ~~**Levels/Stages:**~~ **(DONE)**
* ~~Add a `CurrentStage` resource.~~
* ~~Define criteria for clearing a stage (e.g., destroying all enemies in a wave/formation).~~
* ~~Implement logic to advance to the next stage, potentially increasing difficulty (enemy speed, firing rate, different formations).~~
**2. Enemy Behavior - Formations & Attack Patterns:**
* **Enemy Formations:** This is a core Galaga feature.
* ~~Define target positions for the enemy formation on screen.~~ **(DONE)**
* ~~Give enemies an `Entering` state/component: They fly onto the screen following predefined paths (curves, waypoints).~~ **(DONE - Basic linear path implemented)**
* Give enemies a `Formation` state/component: Once they reach their target position, they stop and hold formation. **(DONE - Stop implemented by removing FormationTarget)**
* **Enemy Attack Dives:**
* ~~Give enemies an `Attacking` state/component.~~ **(DONE)**
* ~~Periodically trigger enemies in the formation to switch to the `Attacking` state.~~ **(DONE - Random selection after formation complete)**
* ~~Define attack paths (swooping dives towards the player area).~~ **(DONE - Basic swoop towards center implemented)**
* ~~Make enemies fire bullets (downwards or towards the player) during their dives.~~ **(DONE - Downward)**
* After an attack dive, enemies could return to their formation position or fly off-screen. **(Fly off-screen implemented)**
* **Enemy Variety:**
* ~~Introduce different types of enemies (e.g., using different components or an enum).~~ **(DONE - Added EnemyType enum and field)**
* ~~Assign different behaviors, point values, and maybe sprites to each type.~~ **(DONE - Behaviors, points & color based on type)**
**3. Advanced Galaga Mechanics:**
* **Boss Galaga & Capture Beam:**
* ~~Create a "Boss" enemy type.~~ **(DONE - Added to Enum, handled in matches)**
* Implement the tractor beam attack (visual effect, player capture logic).
* Player needs a `Captured` state.
* Logic for the Boss to return captured ships to the formation.
* **Dual Fighter (Rescuing Captured Ship):**
* Allow the player to shoot a Boss Galaga attempting to capture or one holding a captured ship.
* Implement the logic to free the captured ship.
* Implement the dual fighter mode (controlling two ships, firing two bullets). This involves significant changes to player control and shooting systems.
* **Challenging Stages:**
* Implement a special stage type (e.g., every few levels).
* Enemies fly in intricate patterns without shooting.
* Award bonus points for destroying all enemies in the stage.
**4. Polish and User Interface:**
* **Visuals:**
* Replace placeholder shapes with actual sprites using `SpriteBundle` or `SpriteSheetBundle`. Create distinct looks for the player, different enemy types, bullets.
* Add explosion effects/animations when enemies or the player are destroyed.
* Implement a scrolling starfield background for a more classic space feel.
* **Audio:**
* Integrate `bevy_audio`.
* Add sound effects for player shooting, enemy firing, explosions, player death, capturing, etc.
* Add background music that might change per stage or state (e.g., main gameplay vs. challenging stage).
* **UI:**
* Use `bevy_ui` to display the current Score, Lives remaining, and Stage number on screen during gameplay.
* Display High Score?
* Create a Start Menu state.
Starting with the Core Gameplay Loop (Lives, Game Over, Score, basic Stages) and then moving onto Enemy Formations and Attack Patterns would likely provide the biggest steps towards a Galaga feel.

View file

@ -1,5 +1,4 @@
use bevy::prelude::*;
use std::time::Duration; // Needed for Timer in Player
// --- Components ---
#[derive(Component)]
@ -28,6 +27,24 @@ pub struct Invincible {
pub timer: Timer,
}
// New component to mark a player as captured by a Boss enemy
#[derive(Component, Clone)] // Added Clone derive
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: 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
}
#[derive(Component)]
pub struct FormationTarget {
pub position: Vec3,
@ -36,16 +53,17 @@ pub struct FormationTarget {
// 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
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
// Add more patterns later (e.g., FigureEight, Looping)
CaptureBeam, // New pattern: Boss dives and attempts to capture the player with a tractor beam
// Add more patterns later (e.g., FigureEight, Looping)
}
#[derive(Component, Clone, PartialEq, Debug)] // Added Debug derive
pub enum EnemyState {
Entering, // Flying onto the screen towards formation target
InFormation, // Holding position in the formation
Entering, // Flying onto the screen towards formation target
InFormation, // Holding position in the formation
Attacking(AttackPattern), // Diving towards the player using a specific pattern
}

View file

@ -1,4 +1,5 @@
use bevy::math::Vec2;
use bevy::prelude::*;
// --- Constants ---
pub const WINDOW_WIDTH: f32 = 600.0;
@ -31,3 +32,9 @@ pub const BULLET_ENEMY_COLLISION_THRESHOLD: f32 = (BULLET_SIZE.x + ENEMY_SIZE.x)
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
pub const TRACTOR_BEAM_WIDTH: f32 = 20.0;
pub const TRACTOR_BEAM_DURATION: f32 = 3.0;
pub const TRACTOR_BEAM_COLOR: Color = Color::rgba(0.5, 0.0, 0.8, 0.6);
pub const CAPTURE_DURATION: f32 = 10.0; // How long the player stays captured

View file

@ -1,17 +1,15 @@
use bevy::prelude::*;
use std::time::Duration;
use crate::components::{Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget};
use crate::constants::{ // Added WINDOW_WIDTH
ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD, ENEMY_BULLET_SIZE, ENEMY_BULLET_SPEED,
ENEMY_SHOOT_INTERVAL, ENEMY_SIZE, ENEMY_SPEED, FORMATION_BASE_Y, FORMATION_COLS,
FORMATION_ENEMY_COUNT, FORMATION_X_SPACING, FORMATION_Y_SPACING, WINDOW_HEIGHT, WINDOW_WIDTH,
use crate::components::{Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget, TractorBeam, Captured};
use crate::constants::{ // Only keeping used constants
ENEMY_BULLET_SIZE, ENEMY_SIZE, ENEMY_SPEED, WINDOW_HEIGHT, WINDOW_WIDTH,
TRACTOR_BEAM_WIDTH, TRACTOR_BEAM_DURATION, TRACTOR_BEAM_COLOR, CAPTURE_DURATION,
};
use crate::resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, PlayerLives,
PlayerRespawnTimer, StageConfigurations, // Make sure StageConfigurations is imported if not already
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState,
StageConfigurations,
};
use crate::game_state::AppState;
pub fn spawn_enemies(
mut commands: Commands,
@ -52,8 +50,15 @@ pub fn spawn_enemies(
let spawn_x = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x);
let spawn_y = WINDOW_HEIGHT / 2.0 + ENEMY_SIZE.y / 2.0; // Spawn slightly above screen
// Determine enemy type (can be randomized or based on stage config later)
let enemy_type = EnemyType::Grunt;
// Determine enemy type - now with a chance to spawn Boss enemies
// Higher stages have a slightly higher boss chance
let boss_chance = 0.05 + (stage.number as f32 * 0.01).min(0.15);
let enemy_type = if fastrand::f32() < boss_chance {
println!("Spawning a Boss enemy!");
EnemyType::Boss
} else {
EnemyType::Grunt
};
// Determine sprite color based on type
let sprite_color = match enemy_type {
@ -115,6 +120,7 @@ pub fn move_enemies(
mut commands: Commands,
stage: Res<CurrentStage>,
stage_configs: Res<StageConfigurations>, // Add stage configurations
has_beam_query: Query<&TractorBeam>,
) {
// Get current stage config for speed multiplier
let config_index = (stage.number as usize - 1) % stage_configs.stages.len();
@ -132,6 +138,7 @@ pub fn move_enemies(
if *state == EnemyState::Entering {
let current_pos = transform.translation;
let target_pos = target.position;
// Using target_pos which is already a Vec3, not a reference
let direction = target_pos - current_pos;
let distance = direction.length();
@ -153,56 +160,121 @@ pub fn move_enemies(
}
// --- Handle Attacking Enemies ---
// Note: attack_speed calculated above using multiplier
for (entity, mut transform, state, _enemy) in attacking_query.iter_mut() {
for (entity, mut transform, state, enemy) in attacking_query.iter_mut() {
// Check what state the enemy is in
if let EnemyState::Attacking(attack_pattern) = state {
// Apply different movement based on enemy type
match enemy.enemy_type {
EnemyType::Grunt => {
// Basic enemies follow their attack pattern
match attack_pattern {
// ... existing patterns ...
AttackPattern::SwoopDive => {
// ... existing code ...
let vertical_movement = attack_speed * time.delta_seconds();
let horizontal_speed_factor = 0.5;
let horizontal_movement = if transform.translation.x < 0.0 {
attack_speed * horizontal_speed_factor * time.delta_seconds()
} else if transform.translation.x > 0.0 {
-attack_speed * horizontal_speed_factor * time.delta_seconds()
} else { 0.0 };
// Match on the specific attack pattern using matches! and then get the pattern
if matches!(state, EnemyState::Attacking(_)) {
if let EnemyState::Attacking(pattern) = state { // Get the pattern safely now
let delta_seconds = time.delta_seconds();
transform.translation.y -= vertical_movement;
transform.translation.x += horizontal_movement;
match pattern {
AttackPattern::SwoopDive => {
// Original Swooping Dive Logic
let vertical_movement = attack_speed * delta_seconds;
let horizontal_speed_factor = 0.5;
let horizontal_movement = if transform.translation.x < 0.0 {
attack_speed * horizontal_speed_factor * delta_seconds
} else if transform.translation.x > 0.0 {
-attack_speed * horizontal_speed_factor * delta_seconds
} else { 0.0 };
// Prevent overshooting center
if (transform.translation.x > 0.0 && transform.translation.x + horizontal_movement < 0.0) ||
(transform.translation.x < 0.0 && transform.translation.x + horizontal_movement > 0.0) {
transform.translation.x = 0.0;
}
}
AttackPattern::DirectDive => {
transform.translation.y -= attack_speed * time.delta_seconds();
}
AttackPattern::Kamikaze(target) => {
// Copy the target value rather than dereferencing
// since target should actually be a Vec3 in this context
let target_pos = *target; // Dereference here
let direction = target_pos - transform.translation;
let distance = direction.length();
let kamikaze_threshold = attack_speed * time.delta_seconds() * 1.1; // Threshold to stop near target
transform.translation.y -= vertical_movement;
transform.translation.x += horizontal_movement;
// Prevent overshooting center
if (transform.translation.x > 0.0 && transform.translation.x + horizontal_movement < 0.0) ||
(transform.translation.x < 0.0 && transform.translation.x + horizontal_movement > 0.0) {
transform.translation.x = 0.0;
if distance > kamikaze_threshold {
let move_delta = direction.normalize() * attack_speed * time.delta_seconds();
transform.translation += move_delta;
} else {
// Optionally stop or continue past target - for now, just stop moving towards it
// Could also despawn here if desired upon reaching target
}
}
// New CaptureBeam pattern - Bosses behave differently
AttackPattern::CaptureBeam => {
// For Grunt enemies, just do a direct dive (fallback)
transform.translation.y -= attack_speed * time.delta_seconds();
}
}
}
AttackPattern::DirectDive => {
// Move straight down
transform.translation.y -= attack_speed * delta_seconds;
}
AttackPattern::Kamikaze(target_pos) => {
// Move towards the target position
let direction = *target_pos - transform.translation;
let distance = direction.length();
let kamikaze_threshold = attack_speed * delta_seconds * 1.1; // Threshold to stop near target
EnemyType::Boss => {
// Boss has special behavior, especially for CaptureBeam
match attack_pattern {
AttackPattern::CaptureBeam => {
// Boss moves down to a position above the player area
let target_y = -WINDOW_HEIGHT / 4.0; // Position at lower quarter of the screen
if distance > kamikaze_threshold {
let move_delta = direction.normalize() * attack_speed * delta_seconds;
transform.translation += move_delta;
} else {
// Optionally stop or continue past target - for now, just stop moving towards it
// Could also despawn here if desired upon reaching target
if transform.translation.y > target_y {
// Move down to position
transform.translation.y -= attack_speed * 0.8 * time.delta_seconds();
} else {
// Once in position, stay there briefly before activating beam
// Check if this boss already has a TractorBeam component
if has_beam_query.get(entity).is_err() {
// Spawn tractor beam component on this boss
commands.entity(entity).insert(TractorBeam {
target: Entity::PLACEHOLDER, // Will be filled in by the boss_capture_attack
timer: Timer::new(Duration::from_secs_f32(TRACTOR_BEAM_DURATION), TimerMode::Once),
width: TRACTOR_BEAM_WIDTH,
active: false,
});
}
}
}
AttackPattern::SwoopDive => {
// ... existing code for swoop dive ...
let center_x = 0.0;
let bottom_y = -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y;
// First move towards center-bottom
let target = Vec3::new(center_x, bottom_y, 0.0);
// target is directly created as Vec3, not a reference
let direction = target - transform.translation;
// Normalize and move
if direction.length() > 0.0 {
let normalized_dir = direction.normalize();
transform.translation += normalized_dir * attack_speed * time.delta_seconds();
}
}
AttackPattern::DirectDive => {
transform.translation.y -= attack_speed * time.delta_seconds();
}
AttackPattern::Kamikaze(target) => {
// Convert the target to a value type
let target_pos = *target; // Dereference here
let direction = target_pos - transform.translation;
// If very close to target, just move straight down
if direction.length() < 50.0 {
transform.translation.y -= attack_speed * time.delta_seconds();
} else {
// Move toward target
let normalized_dir = direction.normalize();
transform.translation += normalized_dir * attack_speed * time.delta_seconds();
}
}
}
}
// Add cases for other patterns here
} // Close inner if let
} // Closes match enemy.enemy_type
} // Closes if *state == EnemyState::Attacking
}
}
// Despawn if off screen (This should be inside the loop)
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
@ -262,13 +334,12 @@ pub fn check_formation_complete(
}
}
use crate::components::AttackPattern; // Import the new enum
use crate::components::Player; // Import Player for Kamikaze target
use crate::components::{AttackPattern, Player}; // Import the new enum and Player
pub fn trigger_attack_dives(
mut timer: ResMut<AttackDiveTimer>,
time: Res<Time>,
mut enemy_query: Query<(Entity, &mut EnemyState), With<Enemy>>, // Renamed for clarity
mut enemy_query: Query<(Entity, &mut EnemyState, &Enemy)>, // Added Enemy component to check type
formation_state: Res<FormationState>,
stage: Res<CurrentStage>, // Need current stage
stage_configs: Res<StageConfigurations>, // Need stage configs
@ -278,29 +349,42 @@ pub fn trigger_attack_dives(
// Only proceed if the timer finished AND the formation is complete
if timer.timer.just_finished() && formation_state.formation_complete {
// Find all enemies currently in formation
let mut available_enemies: Vec<Entity> = Vec::new();
// Get the current stage config
let config_index = (stage.number as usize - 1) % stage_configs.stages.len();
let current_config = &stage_configs.stages[config_index];
// Find all enemies currently in formation
let mut available_enemies: Vec<Entity> = Vec::new();
for (entity, state) in enemy_query.iter() {
// Check the state correctly
let mut available_enemies: Vec<(Entity, EnemyType)> = Vec::new();
for (entity, state, enemy) in enemy_query.iter() {
// Check the state correctly and store enemy type
if matches!(state, EnemyState::InFormation) {
available_enemies.push(entity);
available_enemies.push((entity, enemy.enemy_type));
}
}
// If there are enemies available, pick one randomly
if !available_enemies.is_empty() && !current_config.attack_patterns.is_empty() {
let random_index = fastrand::usize(..available_enemies.len());
let chosen_entity = available_enemies[random_index];
let (chosen_entity, enemy_type) = available_enemies[random_index];
// Select a random attack pattern for this stage
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
let mut selected_pattern = current_config.attack_patterns[pattern_index]; // Copy the pattern
// Select an attack pattern based on enemy type
let mut selected_pattern = match enemy_type {
// For Boss enemies, occasionally use the CaptureBeam pattern
EnemyType::Boss => {
if fastrand::f32() < 0.4 { // 40% chance for Boss to use CaptureBeam
AttackPattern::CaptureBeam
} else {
// Otherwise use a random pattern from the stage config
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
current_config.attack_patterns[pattern_index]
}
},
// Regular enemies use patterns from the stage config
EnemyType::Grunt => {
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
current_config.attack_patterns[pattern_index]
}
};
// If Kamikaze, get player position (if player exists)
if let AttackPattern::Kamikaze(_) = selected_pattern {
@ -313,9 +397,8 @@ pub fn trigger_attack_dives(
}
}
// Get the chosen enemy's state mutably and change it
if let Ok((_, mut state)) = enemy_query.get_mut(chosen_entity) {
if let Ok((_, mut state, _)) = enemy_query.get_mut(chosen_entity) {
println!("Enemy {:?} starting attack dive with pattern {:?}!", chosen_entity, selected_pattern);
*state = EnemyState::Attacking(selected_pattern); // Set state with pattern
// Timer duration is handled elsewhere (e.g., check_formation_complete)
@ -366,3 +449,80 @@ pub fn enemy_shoot(
pub fn is_formation_complete(formation_state: Res<FormationState>) -> bool {
formation_state.formation_complete
}
// New system to handle the tractor beam attack from Boss enemies
pub fn boss_capture_attack(
mut commands: Commands,
time: Res<Time>,
mut boss_query: Query<(Entity, &Transform, &mut TractorBeam)>,
player_query: Query<(Entity, &Transform), (With<Player>, Without<Captured>)>, // Only target non-captured players
has_beam_query: Query<&TractorBeam>,
) {
for (boss_entity, boss_transform, mut tractor_beam) in boss_query.iter_mut() {
// Tick the beam timer
tractor_beam.timer.tick(time.delta());
// If player exists and beam is not active yet, set player as target
if !tractor_beam.active && tractor_beam.target == Entity::PLACEHOLDER {
if let Ok((player_entity, _)) = player_query.get_single() {
tractor_beam.target = player_entity;
tractor_beam.active = true;
println!("Boss {:?} activated tractor beam targeting player!", boss_entity);
// Create visual beam effect (using a simple sprite for now)
let beam_height = boss_transform.translation.y - (-WINDOW_HEIGHT / 2.0); // Height from boss to bottom of screen
// Spawn the beam as a child of the boss
commands.entity(boss_entity).with_children(|parent| {
parent.spawn(SpriteBundle {
sprite: Sprite {
color: TRACTOR_BEAM_COLOR,
custom_size: Some(Vec2::new(tractor_beam.width, beam_height)),
..default()
},
transform: Transform::from_xyz(0.0, -beam_height/2.0, 0.0),
..default()
});
});
}
}
// If beam is active, check if player is in beam's path
if tractor_beam.active {
if let Ok((player_entity, player_transform)) = player_query.get_single() {
// Check if player is roughly under the boss
if (player_transform.translation.x - boss_transform.translation.x).abs() < tractor_beam.width / 2.0 {
// Player is in the beam! Capture them
println!("Player captured by boss {:?}!", boss_entity);
// Add Captured component to player
commands.entity(player_entity).insert(Captured {
boss_entity,
timer: Timer::new(Duration::from_secs_f32(CAPTURE_DURATION), TimerMode::Once),
});
// Boss returns to formation with captured player
commands.entity(boss_entity).remove::<TractorBeam>();
// TODO: Implement logic for boss to return to formation
// For now, just despawn the boss to simplify
commands.entity(boss_entity).despawn_recursive();
break;
}
}
}
// If beam timer finishes and player wasn't captured, end the beam attack
if tractor_beam.timer.finished() {
println!("Boss {:?} tractor beam expired", boss_entity);
commands.entity(boss_entity).remove::<TractorBeam>();
// If we had a proper visual beam, we'd despawn it here
// For now, just make sure we clean up by despawning all children
commands.entity(boss_entity).despawn_descendants();
// For simplicity, after beam attack the boss flies off-screen
// In a more complete implementation, it might return to formation
}
}
}

View file

@ -21,11 +21,11 @@ use game_state::{
};
use player::{
check_player_enemy_collisions, manage_invincibility, move_player, player_shoot,
respawn_player,
respawn_player, handle_captured_player,
};
use enemy::{
check_formation_complete, enemy_shoot, is_formation_complete, move_enemies, spawn_enemies,
trigger_attack_dives,
trigger_attack_dives, boss_capture_attack,
};
use bullet::{
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets,
@ -36,75 +36,67 @@ use systems::{player_exists, player_vulnerable, setup, should_respawn_player, up
fn main() {
App::new()
.init_state::<AppState>() // Initialize the AppState
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.0)))
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.1)))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "Galaga :: Stage: 1 Lives: 3 Score: 0".into(), // Initial title
title: "BGLGA".into(),
resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
resizable: false,
..default()
}),
..default()
}))
// Add Resources
.insert_resource(PlayerLives {
count: STARTING_LIVES,
// Add states
.init_state::<AppState>() // Changed from add_state to init_state
// Initialize game resources
.insert_resource(EnemySpawnTimer {
timer: Timer::new(Duration::from_secs_f32(1.0), TimerMode::Once),
})
.insert_resource(PlayerLives { count: STARTING_LIVES })
.insert_resource(PlayerRespawnTimer {
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),
})
.insert_resource(Score { value: 0 })
.insert_resource(CurrentStage {
number: 1,
waiting_for_clear: false,
})
.insert_resource(FormationState {
next_slot_index: 0,
total_spawned_this_stage: 0,
formation_complete: false,
})
.insert_resource(StageConfigurations::default()) // Add stage configurations
.insert_resource(AttackDiveTimer {
timer: { // Correctly assign the block expression to the timer field
let mut timer = Timer::new(Duration::from_secs_f32(3.0), TimerMode::Repeating); // Default duration, will be overwritten by stage config
timer.pause(); // Start paused
timer
}
total_spawned_this_stage: 0,
next_slot_index: 0,
})
.insert_resource(AttackDiveTimer {
timer: {
let mut timer = Timer::new(Duration::from_secs_f32(3.0), TimerMode::Repeating);
timer.pause(); // Start paused
timer
},
timer: Timer::new(Duration::from_secs_f32(3.0), TimerMode::Once),
})
// Add Systems
.insert_resource(StageConfigurations::default()) // Use default stages for now
// Add startup systems
.add_systems(Startup, setup)
// Systems running only when Playing
// Core game systems
.add_systems(
Update,
(
// Player systems
move_player,
update_window_title,
// Enemy and player systems
spawn_enemies,
move_player.run_if(player_exists),
player_shoot.run_if(player_exists),
check_player_enemy_collisions.run_if(player_vulnerable),
respawn_player.run_if(should_respawn_player),
manage_invincibility,
handle_captured_player, // New system for handling captured player
// Bullet systems
move_bullets,
check_bullet_collisions,
move_enemy_bullets,
check_enemy_bullet_player_collisions.run_if(player_vulnerable),
// Enemy systems
spawn_enemies,
move_enemies,
enemy_shoot, // Consider run_if attacking state? (Handled internally for now)
boss_capture_attack, // New system for boss tractor beam
)
.run_if(in_state(AppState::Playing)),
)
@ -119,18 +111,9 @@ fn main() {
.chain() // Ensure these run in order if needed, check_formation first
.run_if(in_state(AppState::Playing)),
)
// Systems running regardless of state (or managing state transitions)
.add_systems(
Update,
(
update_window_title, // Keep title updated
// TODO: Add system to check for restart input in GameOver state
bevy::window::close_on_esc, // Allow closing anytime
),
)
// Systems for entering/exiting states
// UI and state management systems
.add_systems(OnEnter(AppState::GameOver), setup_game_over_ui)
.add_systems(OnExit(AppState::Playing), cleanup_game_entities)
.add_systems(OnExit(AppState::GameOver), cleanup_game_over_ui)
.add_systems(OnExit(AppState::Playing), cleanup_game_entities) // Cleanup when leaving Playing
.run();
}

View file

@ -1,7 +1,7 @@
use bevy::prelude::*;
use std::time::Duration;
use crate::components::{Bullet, Enemy, Invincible, Player};
use crate::components::{Bullet, Enemy, Invincible, Player, Captured};
use crate::constants::{
BULLET_SIZE, PLAYER_ENEMY_COLLISION_THRESHOLD, PLAYER_INVINCIBILITY_DURATION, PLAYER_SIZE,
PLAYER_SPEED, WINDOW_HEIGHT, WINDOW_WIDTH,
@ -42,7 +42,7 @@ pub fn spawn_player_ship(commands: &mut Commands) {
pub fn move_player(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Transform, &Player)>,
mut query: Query<(&mut Transform, &Player), Without<Captured>>, // Don't move captured players with controls
time: Res<Time>,
) {
// Using get_single_mut handles the case where player might not exist yet (or was just destroyed)
@ -66,9 +66,94 @@ pub fn move_player(
}
}
// New system to handle captured player movement
pub fn handle_captured_player(
mut commands: Commands,
time: Res<Time>,
player_query: Query<(Entity, &Transform, &Captured)>,
mut player_mut_query: Query<(&mut Transform, &mut Captured)>,
enemy_query: Query<&Transform, With<Enemy>>,
mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>,
) {
// First, collect data from all captured players
let mut to_process = Vec::new();
for (entity, transform, captured) in player_query.iter() {
// Check if the boss exists
let boss_exists = enemy_query.get(captured.boss_entity).is_ok();
let boss_pos = if boss_exists {
enemy_query.get(captured.boss_entity).map(|t| t.translation).ok()
} else {
None
};
// Create a copy of the timer to check if it would finish
let mut timer_copy = captured.timer.clone();
timer_copy.tick(time.delta());
to_process.push((entity, transform.translation, boss_pos, timer_copy.finished()));
}
// Now process each player separately
for (entity, current_pos, boss_pos_opt, timer_would_finish) in to_process {
if let Ok((mut transform, mut captured)) = player_mut_query.get_mut(entity) {
// Tick the real timer
captured.timer.tick(time.delta());
match boss_pos_opt {
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 = current_pos.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);
}
}
// If capture duration expires, player escapes but loses a life
if timer_would_finish || captured.timer.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);
}
}
}
}
// Helper function for player life loss and respawn logic
fn lose_life_and_respawn(
commands: &mut Commands,
lives: &mut ResMut<PlayerLives>,
respawn_timer: &mut ResMut<PlayerRespawnTimer>,
next_state: &mut ResMut<NextState<AppState>>,
player_entity: Entity,
) {
// Lose a life
lives.count = lives.count.saturating_sub(1);
println!("Lives remaining: {}", lives.count);
// Destroy player
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!");
next_state.set(AppState::GameOver);
}
}
pub fn player_shoot(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut query: Query<(&Transform, &mut Player)>, // Should only run if player exists due to run_if
mut query: Query<(&Transform, &mut Player), Without<Captured>>, // Only non-captured players can shoot
mut commands: Commands,
time: Res<Time>,
) {
@ -107,7 +192,7 @@ pub fn check_player_enemy_collisions(
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
player_query: Query<(Entity, &Transform), (With<Player>, Without<Invincible>)>,
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
@ -133,7 +218,7 @@ pub fn check_player_enemy_collisions(
println!("Respawn timer started.");
} else {
println!("GAME OVER!");
next_state.set(AppState::GameOver); // Transition to GameOver state
next_state.set(AppState::GameOver); // Updated for newer Bevy states API
}
// Important: Break after handling one collision per frame for the player
break;

View file

@ -1,5 +1,4 @@
use bevy::prelude::*;
use std::time::Duration; // Needed for Timer
// --- Resources ---
#[derive(Resource)]
@ -32,8 +31,10 @@ impl Default for FormationLayout {
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);
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 {
@ -82,15 +83,22 @@ impl Default for StageConfigurations {
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));
positions.push(Vec3::new(
angle.cos() * radius,
center_y + angle.sin() * radius,
0.0,
));
}
FormationLayout {
name: "Circle".to_string(),
positions,
}
FormationLayout { name: "Circle".to_string(), positions }
};
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
attack_dive_interval: 2.5, // Faster dives
enemy_speed_multiplier: 1.2, // Faster enemies
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8, // Faster shooting
};

View file

@ -1,7 +1,7 @@
use bevy::prelude::*;
use crate::components::{Invincible, Player};
use crate::resources::{CurrentStage, FormationState, PlayerLives, Score};
use crate::resources::{CurrentStage, PlayerLives, Score};
use crate::player::spawn_player_ship; // Import the helper function
// --- Setup ---