Compare commits

...

14 commits

Author SHA1 Message Date
ad2037a7a5 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>
2026-05-06 21:36:26 +02:00
b2b564f690 chore(deps): upgrade Bevy 0.13 → 0.18
Three major versions of breaking changes:
- Bundles → Required Components (SpriteBundle, NodeBundle, ButtonBundle,
  TextBundle, Camera2dBundle removed; spawn tuples of components instead)
- Style merged into Node; TextStyle split into TextFont + TextColor
- Color API: rgb/rgba → srgb/srgba; Color::Rgba pattern matching replaced
  with .alpha()/.set_alpha(); .r()/.g()/.b() → .to_srgba().red/green/blue
- Time: delta_seconds() → delta_secs(); elapsed_seconds() → elapsed_secs()
- Query: get_single()/get_single_mut() → single()/single_mut() (now Result)
- Timer::finished() → Timer::is_finished()
- despawn_recursive() removed (despawn() is now recursive); despawn_descendants()
  removed — replaced with Children query iteration
- BorderColor(c) → BorderColor::all(c)
- WindowResolution: From<(f32,f32)> removed → cast to (u32,u32)
- flake.nix: added wayland to runtime libs (default-on in 0.18)

Tests pass (8/8), clippy clean, headless render verified showing start
menu, button, starfield, and player ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:26:48 +02:00
7a4305677b chore(todo): uncheck GAL-25/GAL-27 — Boss points not differentiated
Audit revealed Boss and Grunt both award 100 points (bullet.rs:48-51),
contradicting GAL-27's "different points" requirement. Added breakdown
sub-items to track what's done (colors, behaviors) vs. what's missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 21:12:27 +02:00
e5cc1b310a add .mcp.json 2026-05-06 21:09:21 +02:00
ef3b7389e7 remove .opencode/workflow-summary.md 2026-05-06 21:07:41 +02:00
c6dcf9d728 feat: add take-screenshots flake app for headless capture
Expose a `nix run .#take-screenshots` app that runs a binary inside an
Xvfb display with lavapipe software Vulkan and captures PNG snapshots
via ImageMagick. Useful for smoke-testing the Bevy renderer in
environments without a GPU (CI, sandboxed shells, agents).

Usage:
  nix run .#take-screenshots -- EXE NUM DELAY_START PAUSE_INBETWEEN [OUTPUT_DIR]

The script picks the first free :N >= 99, locates the lavapipe ICD via
pkgs.mesa with a fallback to /run/opengl-driver (NixOS), reuses the
dev shell's runtimeLibs in LD_LIBRARY_PATH, and traps EXIT for cleanup.

README updated with usage and a worked example.
2026-05-06 21:01:14 +02:00
68e3051f77 refactor: split game logic into library + binary entry point
Move the App setup and module declarations from src/main.rs into a new
src/lib.rs that exposes a single pub fn run(). src/main.rs becomes a
three-line entry point that calls bglga::run(). This is the standard
Bevy-book pattern and lets the game logic be consumed as a library
(e.g. for integration tests, headless tooling, or alternate entry
points) without duplicating the App wiring.

Also gate update_tractor_beam_visual with
run_if(any_with_component::<TractorBeam>) so the system only runs while
a boss has an active tractor beam, instead of every Update tick.
2026-05-06 20:54:09 +02:00
506a775b0c fix: disjoint Transform queries in update_tractor_beam_visual
Add Without<TractorBeamSprite> filter to boss_query so Bevy's parallel
access checker can prove the &Transform read on bosses and the
&mut Transform write on beam-sprite children target disjoint entity
sets. Without it, the system panicked on first run with a B0001
"conflicts with a previous system parameter" error.
2026-05-06 20:54:02 +02:00
35300ec62b Merge branch 'gal-44-add-explosion-effects' 2026-05-06 20:33:23 +02:00
8e06a91dde chore(todo): update GAL-33 status and progress 2026-05-06 19:37:06 +02:00
52b0919d3f feat: improve tractor beam visual with 2-layer glow and pulse animation
Replace the single static rectangle with a 2-layer beam (outer glow +
inner core) and sinusoidal opacity pulse. Add per-frame beam height
tracking to follow boss position. Include 8 unit tests for pure math
functions (beam height calculation, pulse alpha).

Refs: GAL-33
2026-05-06 19:35:16 +02:00
405c326e9c chore(workflow): summary for GAL-44 2026-05-06 16:46:44 +02:00
08838c3428 chore(todo): update GAL-44 status and progress 2026-05-06 16:45:26 +02:00
2ff561efb1 feat: add explosion animations on entity destruction
Spawn expanding/fading orange explosion effects when enemies or
the player are destroyed. Explosions scale from 15x15 to 50x50
over 0.4s while fading from full opacity to transparent, then
auto-despawn.

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

Refs: GAL-44
2026-05-06 16:43:16 +02:00
18 changed files with 3748 additions and 1929 deletions

3
.mcp.json Normal file
View file

@ -0,0 +1,3 @@
{
"mcpServers": {}
}

3367
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -4,5 +4,5 @@ version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
bevy = "0.13" bevy = "0.18"
fastrand = "2.0.1" fastrand = "2.0.1"

View file

@ -34,3 +34,28 @@ nix develop --command bash -c "cargo build"
2. Clone the repository. 2. Clone the repository.
3. Navigate to the project directory. 3. Navigate to the project directory.
4. Run the game using the command: `nix develop --command bash -c "cargo run"` 4. Run the game using the command: `nix develop --command bash -c "cargo run"`
## Headless Screenshots
The flake exposes a `take-screenshots` app that launches a binary inside an
Xvfb display backed by lavapipe (software Vulkan), waits, and captures one or
more PNG screenshots. Useful for smoke-testing rendering without a real GPU.
```
nix run .#take-screenshots -- EXE NUM DELAY_START PAUSE_INBETWEEN [OUTPUT_DIR]
```
* `EXE` — path to the executable to launch
* `NUM` — number of screenshots to take
* `DELAY_START` — seconds to wait after launch before the first shot
* `PAUSE_INBETWEEN` — seconds between consecutive shots
* `OUTPUT_DIR` — where to write `shot-NNN.png` files (default: current directory)
Example, capturing three frames of the game one second apart after a six-second
warm-up (Bevy + software Vulkan needs roughly that long to render its first
frame):
```
cargo build
nix run .#take-screenshots -- ./target/debug/bglga 3 6 1 ./shots
```

14
TODO.md
View file

@ -29,9 +29,12 @@
* [x] GAL-22: Periodically trigger random enemies to start an attack dive. * [x] GAL-22: Periodically trigger random enemies to start an attack dive.
* [x] GAL-23: Enemies fire bullets during their dives. * [x] GAL-23: Enemies fire bullets during their dives.
* [x] GAL-24: Enemies are despawned when they fly off-screen after an attack. * [x] GAL-24: Enemies are despawned when they fly off-screen after an attack.
* [x] **GAL-25: Enemy Variety** * [ ] **GAL-25: Enemy Variety**
* [x] GAL-26: `EnemyType` enum (`Grunt`, `Boss`). * [x] GAL-26: `EnemyType` enum (`Grunt`, `Boss`).
* [x] GAL-27: Different behaviors, points, and colors based on type. * [ ] GAL-27: Different behaviors, points, and colors based on type.
* [x] Colors differ (Grunt red, Boss purple) — `enemy.rs:80-83`
* [x] Behaviors differ (Boss has `CaptureBeam`, distinct SwoopDive) — `enemy.rs:254-318`
* [ ] Points: Boss currently awards same 100 points as Grunt — see `bullet.rs:48-51` (`// Same points as Grunt for now`). Boss should award more.
**3. Advanced Galaga Mechanics** **3. Advanced Galaga Mechanics**
@ -40,7 +43,8 @@
* [x] GAL-30: Implement the tractor beam attack logic (`boss_capture_attack` system). * [x] GAL-30: Implement the tractor beam attack logic (`boss_capture_attack` system).
* [x] GAL-31: Player has a `Captured` component when hit by the beam. * [x] GAL-31: Player has a `Captured` component when hit by the beam.
* [x] GAL-32: Boss has a `ReturningWithCaptive` state to go back to the formation. * [x] GAL-32: Boss has a `ReturningWithCaptive` state to go back to the formation.
* [ ] GAL-33: Improve the tractor beam visual effect (currently a simple rectangle). * [x] GAL-33: Improve the tractor beam visual effect (currently a simple rectangle).
* Completed on branch gal-33-improve-tractor-beam-visual, commit 52b0919 — 2-layer glow beam with pulse animation, 8 unit tests
* [ ] **GAL-34: Dual Fighter (Rescuing Captured Ship)** * [ ] **GAL-34: Dual Fighter (Rescuing Captured Ship)**
* [ ] GAL-35: Allow the player to shoot a Boss that is holding a captured ship. * [ ] GAL-35: Allow the player to shoot a Boss that is holding a captured ship.
* [ ] GAL-36: Implement the logic to free the captured ship upon shooting the Boss. * [ ] GAL-36: Implement the logic to free the captured ship upon shooting the Boss.
@ -54,7 +58,9 @@
* [ ] **GAL-42: Visuals** * [ ] **GAL-42: Visuals**
* [ ] GAL-43: Replace placeholder geometric shapes with actual sprites. * [ ] GAL-43: Replace placeholder geometric shapes with actual sprites.
* [ ] GAL-44: Add explosion animations/effects. * [x] GAL-44: Add explosion animations/effects.
* **Branch:** `gal-44-add-explosion-effects`
* **Comment:** 2026-05-06 — Implementation complete with commit `2ff561e`
* [x] GAL-45: Implement a scrolling starfield background. * [x] GAL-45: Implement a scrolling starfield background.
* [ ] **GAL-46: Audio** * [ ] **GAL-46: Audio**
* [ ] GAL-47: Integrate `bevy_audio`. * [ ] GAL-47: Integrate `bevy_audio`.

View file

@ -26,9 +26,102 @@
libxi libxi
libxkbcommon libxkbcommon
libxcb libxcb
wayland
vulkan-loader vulkan-loader
glfw glfw
]; ];
takeScreenshots = pkgs.writeShellApplication {
name = "take-screenshots";
runtimeInputs = with pkgs; [
xorg-server
imagemagick
coreutils
];
text = ''
set -euo pipefail
if [ "$#" -lt 4 ]; then
cat >&2 <<'USAGE'
Usage: take-screenshots EXE NUM DELAY_START PAUSE_INBETWEEN [OUTPUT_DIR]
EXE path to executable to launch
NUM number of screenshots to take
DELAY_START seconds to wait after launching EXE before first shot
PAUSE_INBETWEEN seconds between consecutive shots
OUTPUT_DIR output directory (default: current directory)
USAGE
exit 1
fi
EXE=$1
NUM=$2
DELAY_START=$3
PAUSE=$4
OUTDIR=''${5:-.}
mkdir -p "$OUTDIR"
# Locate lavapipe (software Vulkan) ICD; needed because Xvfb has no GPU.
LVP_ICD=
for c in \
"${pkgs.mesa}/share/vulkan/icd.d/lvp_icd.x86_64.json" \
/run/opengl-driver/share/vulkan/icd.d/lvp_icd.x86_64.json
do
if [ -f "$c" ]; then LVP_ICD=$c; break; fi
done
if [ -z "$LVP_ICD" ]; then
echo "take-screenshots: could not locate lavapipe Vulkan ICD" >&2
exit 1
fi
# Pick a free X display.
DISPLAY_NUM=99
while [ -e "/tmp/.X$DISPLAY_NUM-lock" ] || [ -e "/tmp/.X11-unix/X$DISPLAY_NUM" ]; do
DISPLAY_NUM=$((DISPLAY_NUM + 1))
done
Xvfb ":$DISPLAY_NUM" -screen 0 800x900x24 \
+extension GLX +extension RANDR +render -ac &
XVFB_PID=$!
GAME_PID=
cleanup() {
if [ -n "$GAME_PID" ]; then
kill "$GAME_PID" 2>/dev/null || true
wait "$GAME_PID" 2>/dev/null || true
fi
kill "$XVFB_PID" 2>/dev/null || true
wait "$XVFB_PID" 2>/dev/null || true
rm -f "/tmp/.X$DISPLAY_NUM-lock"
}
trap cleanup EXIT
sleep 0.5
export DISPLAY=":$DISPLAY_NUM"
export VK_ICD_FILENAMES=$LVP_ICD
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
"$EXE" &
GAME_PID=$!
sleep "$DELAY_START"
for i in $(seq 1 "$NUM"); do
if ! kill -0 "$GAME_PID" 2>/dev/null; then
echo "take-screenshots: process exited before screenshot $i" >&2
exit 1
fi
out=$(printf "%s/shot-%03d.png" "$OUTDIR" "$i")
import -window root "$out"
echo "$out"
if [ "$i" -lt "$NUM" ]; then
sleep "$PAUSE"
fi
done
'';
};
in in
{ {
devShells.default = pkgs.mkShell { devShells.default = pkgs.mkShell {
@ -49,6 +142,11 @@
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
}; };
apps.take-screenshots = {
type = "app";
program = "${takeScreenshots}/bin/take-screenshots";
};
} }
); );
} }

View file

@ -1,16 +1,17 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::components::{Bullet, Enemy, EnemyBullet, EnemyType}; use crate::components::{Bullet, Enemy, EnemyBullet, EnemyType, Invincible, Player};
use crate::constants::{ use crate::constants::{
BULLET_ENEMY_COLLISION_THRESHOLD, BULLET_SIZE, BULLET_SPEED, ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD, BULLET_ENEMY_COLLISION_THRESHOLD, BULLET_SIZE, BULLET_SPEED,
ENEMY_BULLET_SIZE, ENEMY_BULLET_SPEED, WINDOW_HEIGHT, 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::game_state::AppState;
use crate::components::Player; // Needed for check_enemy_bullet_player_collisions use crate::player::kill_player;
use crate::components::Invincible; // Needed for check_enemy_bullet_player_collisions 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( pub fn move_bullets(
mut query: Query<(Entity, &mut Transform), With<Bullet>>, mut query: Query<(Entity, &mut Transform), With<Bullet>>,
@ -18,8 +19,7 @@ pub fn move_bullets(
mut commands: Commands, mut commands: Commands,
) { ) {
for (entity, mut transform) in query.iter_mut() { for (entity, mut transform) in query.iter_mut() {
transform.translation.y += BULLET_SPEED * time.delta_seconds(); transform.translation.y += BULLET_SPEED * time.delta_secs();
if transform.translation.y > WINDOW_HEIGHT / 2.0 + BULLET_SIZE.y { if transform.translation.y > WINDOW_HEIGHT / 2.0 + BULLET_SIZE.y {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
@ -29,33 +29,25 @@ pub fn move_bullets(
pub fn check_bullet_collisions( pub fn check_bullet_collisions(
mut commands: Commands, mut commands: Commands,
bullet_query: Query<(Entity, &Transform), With<Bullet>>, bullet_query: Query<(Entity, &Transform), With<Bullet>>,
enemy_query: Query<(Entity, &Transform, &Enemy), With<Enemy>>, // Fetch Enemy component too enemy_query: Query<(Entity, &Transform, &Enemy)>,
mut score: ResMut<Score>, // Add Score resource mut score: ResMut<Score>,
) { ) {
for (bullet_entity, bullet_transform) in bullet_query.iter() { for (bullet_entity, bullet_transform) in bullet_query.iter() {
for (enemy_entity, enemy_transform, enemy) in enemy_query.iter() { let hit = enemy_query.iter().find(|(_, t, _)| {
// Get Enemy component bullet_transform.translation.distance(t.translation) < BULLET_ENEMY_COLLISION_THRESHOLD
let distance = bullet_transform });
.translation
.distance(enemy_transform.translation);
if distance < BULLET_ENEMY_COLLISION_THRESHOLD { if let Some((enemy_entity, enemy_transform, enemy)) = hit {
commands.entity(bullet_entity).despawn(); commands.entity(bullet_entity).despawn();
spawn_explosion(&mut commands, enemy_transform.translation);
commands.entity(enemy_entity).despawn(); commands.entity(enemy_entity).despawn();
// Increment score based on enemy type score.value += match enemy.enemy_type {
let points = match enemy.enemy_type { EnemyType::Grunt => GRUNT_POINTS,
EnemyType::Grunt => 100, EnemyType::Boss => BOSS_POINTS,
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
} }
} }
} }
}
// --- Enemy Bullet Systems ---
pub fn move_enemy_bullets( pub fn move_enemy_bullets(
mut commands: Commands, mut commands: Commands,
@ -63,51 +55,39 @@ pub fn move_enemy_bullets(
mut query: Query<(Entity, &mut Transform), With<EnemyBullet>>, mut query: Query<(Entity, &mut Transform), With<EnemyBullet>>,
) { ) {
for (entity, mut transform) in query.iter_mut() { for (entity, mut transform) in query.iter_mut() {
transform.translation.y -= ENEMY_BULLET_SPEED * time.delta_seconds(); 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 { if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_BULLET_SIZE.y {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
} }
} }
// Check collisions between enemy bullets and the player
pub fn check_enemy_bullet_player_collisions( pub fn check_enemy_bullet_player_collisions(
mut commands: Commands, mut commands: Commands,
mut lives: ResMut<PlayerLives>, mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>, mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>, 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>)>, 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.get_single() { let Ok((player_entity, player_transform)) = player_query.single() else {
for (bullet_entity, bullet_transform) in enemy_bullet_query.iter() { return;
let distance = player_transform };
.translation let player_pos = player_transform.translation;
.distance(bullet_transform.translation);
if distance < ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD { let Some((bullet_entity, _)) = bullet_query.iter().find(|(_, t)| {
println!("Player hit by enemy bullet!"); player_pos.distance(t.translation) < ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD
commands.entity(bullet_entity).despawn(); // Despawn bullet }) else {
return;
};
lives.count = lives.count.saturating_sub(1); commands.entity(bullet_entity).despawn();
println!("Lives remaining: {}", lives.count); kill_player(
&mut commands,
commands.entity(player_entity).despawn(); // Despawn player &mut lives,
&mut respawn_timer,
if lives.count > 0 { &mut next_state,
respawn_timer.timer.reset(); player_entity,
respawn_timer.timer.unpause(); player_pos,
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;
}
}
}
} }

View file

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

View file

@ -1,49 +1,62 @@
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::*; use bevy::prelude::*;
// --- Constants --- // Window
pub const WINDOW_WIDTH: f32 = 600.0; pub const WINDOW_WIDTH: f32 = 600.0;
pub const WINDOW_HEIGHT: f32 = 800.0; pub const WINDOW_HEIGHT: f32 = 800.0;
// Player
pub const PLAYER_SPEED: f32 = 300.0; 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 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 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; 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; const FORMATION_ROWS: usize = 4;
pub const FORMATION_COLS: usize = 8; pub const FORMATION_COLS: usize = 8;
pub const FORMATION_ENEMY_COUNT: usize = FORMATION_ROWS * FORMATION_COLS; pub const FORMATION_ENEMY_COUNT: usize = FORMATION_ROWS * FORMATION_COLS;
pub const FORMATION_X_SPACING: f32 = 60.0; pub const FORMATION_X_SPACING: f32 = 60.0;
pub const FORMATION_Y_SPACING: f32 = 50.0; pub const FORMATION_Y_SPACING: f32 = 50.0;
pub const FORMATION_BASE_Y: f32 = WINDOW_HEIGHT / 2.0 - 150.0; pub const FORMATION_BASE_Y: f32 = WINDOW_HEIGHT / 2.0 - 150.0;
// Top area for formation
pub const STARTING_LIVES: u32 = 3; // Collision thresholds (mean of half-widths)
pub const PLAYER_RESPAWN_DELAY: f32 = 2.0;
pub const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
// Collision thresholds
pub const BULLET_ENEMY_COLLISION_THRESHOLD: f32 = (BULLET_SIZE.x + ENEMY_SIZE.x) * 0.5; 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; 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 = pub const ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD: f32 =
(ENEMY_BULLET_SIZE.x + PLAYER_SIZE.x) * 0.5; (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_WIDTH: f32 = 20.0;
pub const TRACTOR_BEAM_DURATION: f32 = 3.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;
pub const CAPTURE_DURATION: f32 = 10.0; // How long the player stays captured 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_COUNT: usize = 150;
pub const STAR_MIN_SIZE: f32 = 1.0; pub const STAR_MIN_SIZE: f32 = 1.0;
pub const STAR_MAX_SIZE: f32 = 3.0; pub const STAR_MAX_SIZE: f32 = 3.0;
pub const STAR_MIN_SPEED: f32 = 20.0; pub const STAR_MIN_SPEED: f32 = 20.0;
pub const STAR_MAX_SPEED: f32 = 100.0; pub const STAR_MAX_SPEED: f32 = 100.0;
pub const STAR_Z_DEPTH: f32 = -10.0; // Behind all game entities pub const STAR_Z_DEPTH: f32 = -10.0;
// 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);
pub const EXPLOSION_COLOR: Color = Color::srgba(1.0, 0.6, 0.1, 1.0);

View file

@ -1,111 +1,114 @@
use bevy::prelude::*; use bevy::prelude::*;
use std::time::Duration; use std::time::Duration;
use crate::components::{Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget, TractorBeam, Captured, OriginalFormationPosition}; use crate::components::{
use crate::constants::{ // Only keeping used constants AttackPattern, Captured, Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget,
ENEMY_BULLET_SIZE, ENEMY_SIZE, ENEMY_SPEED, WINDOW_HEIGHT, WINDOW_WIDTH, OriginalFormationPosition, Player, TractorBeam, TractorBeamSprite,
TRACTOR_BEAM_WIDTH, TRACTOR_BEAM_DURATION, TRACTOR_BEAM_COLOR, CAPTURE_DURATION, };
use crate::constants::{
BEAM_CORE_COLOR, BEAM_GLOW_COLOR, BEAM_GLOW_WIDTH, BEAM_PULSE_AMPLITUDE, BEAM_PULSE_FREQ,
CAPTURE_DURATION, ENEMY_BULLET_SIZE, ENEMY_SIZE, ENEMY_SPEED, TRACTOR_BEAM_DURATION,
TRACTOR_BEAM_WIDTH, WINDOW_HEIGHT, WINDOW_WIDTH,
}; };
use crate::resources::{ use crate::resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations,
StageConfigurations,
}; };
const BOSS_BASE_CHANCE: f32 = 0.30;
const BOSS_CHANCE_PER_STAGE: f32 = 0.05;
const BOSS_CHANCE_BONUS_CAP: f32 = 0.50;
const BOSS_CAPTURE_BEAM_PROBABILITY: f32 = 0.80;
const ATTACKER_SPEED_MULTIPLIER: f32 = 1.5;
const RETURN_SPEED_MULTIPLIER: f32 = 0.7;
const SWOOP_HORIZONTAL_FACTOR: f32 = 0.5;
const KAMIKAZE_NEAR_DISTANCE: f32 = 50.0;
const RETURN_ARRIVAL_DISTANCE: f32 = 5.0;
const ARRIVAL_THRESHOLD_FACTOR: f32 = 1.1;
const BOSS_CAPTURE_BEAM_HOVER_Y_FACTOR: f32 = 0.25; // -WINDOW_HEIGHT * this
const BOSS_DESCENT_SPEED_FACTOR: f32 = 0.8;
pub fn spawn_enemies( pub fn spawn_enemies(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
mut timer: ResMut<EnemySpawnTimer>, mut timer: ResMut<EnemySpawnTimer>,
mut stage: ResMut<CurrentStage>, mut stage: ResMut<CurrentStage>,
mut formation_state: ResMut<FormationState>, mut formation_state: ResMut<FormationState>,
stage_configs: Res<StageConfigurations>, // Use imported name stage_configs: Res<StageConfigurations>,
) { ) {
// Get current stage config, looping if stage number exceeds defined configs let config = stage_configs.for_stage(stage.number);
let config_index = (stage.number as usize - 1) % stage_configs.stages.len();
let current_config = &stage_configs.stages[config_index];
let stage_enemy_count = current_config.enemy_count;
// Tick the timer every frame
timer.timer.tick(time.delta()); timer.timer.tick(time.delta());
// Only spawn if we haven't spawned the full formation for this stage yet if formation_state.total_spawned_this_stage >= config.enemy_count
// AND the timer just finished this frame || !timer.timer.just_finished()
if formation_state.total_spawned_this_stage < stage_enemy_count
&& timer.timer.just_finished()
{ {
let slot_index = formation_state.next_slot_index; return;
}
// Ensure slot_index is within bounds of the formation layout let slot = formation_state.next_slot_index;
if slot_index >= current_config.formation_layout.positions.len() { if slot >= config.formation_layout.positions.len() {
println!("Warning: slot_index {} out of bounds for formation layout '{}' (size {})", warn!(
slot_index, current_config.formation_layout.name, current_config.formation_layout.positions.len()); "slot_index {} out of bounds for formation '{}' (size {})",
// Optionally, reset the timer and skip spawning this frame, or handle differently slot,
config.formation_layout.name,
config.formation_layout.positions.len()
);
timer.timer.reset(); timer.timer.reset();
return; return;
} }
// Get target position from the stage's formation layout let target_pos = config.formation_layout.positions[slot];
let target_pos = current_config.formation_layout.positions[slot_index]; let spawn_pos = Vec3::new(
(fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x),
WINDOW_HEIGHT / 2.0 + ENEMY_SIZE.y / 2.0,
0.0,
);
// Spawn position (random at the top) - Corrected to use WINDOW_WIDTH let boss_chance = BOSS_BASE_CHANCE
let spawn_x = (fastrand::f32() - 0.5) * (WINDOW_WIDTH - ENEMY_SIZE.x); + (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP);
let spawn_y = WINDOW_HEIGHT / 2.0 + ENEMY_SIZE.y / 2.0; // Spawn slightly above screen
// Determine enemy type - now with a chance to spawn Boss enemies
// Higher stages have a slightly higher boss chance
let boss_chance = 0.30 + (stage.number as f32 * 0.05).min(0.50); // Increased for testing
let enemy_type = if fastrand::f32() < boss_chance { let enemy_type = if fastrand::f32() < boss_chance {
println!("Spawning a Boss enemy!");
EnemyType::Boss EnemyType::Boss
} else { } else {
EnemyType::Grunt EnemyType::Grunt
}; };
// Determine sprite color based on type let color = match enemy_type {
let sprite_color = match enemy_type { EnemyType::Grunt => Color::srgb(1.0, 0.2, 0.2),
EnemyType::Grunt => Color::rgb(1.0, 0.2, 0.2), EnemyType::Boss => Color::srgb(0.8, 0.2, 1.0),
EnemyType::Boss => Color::rgb(0.8, 0.2, 1.0),
}; };
commands.spawn(( commands.spawn((
SpriteBundle { Sprite {
sprite: Sprite { color,
color: sprite_color,
custom_size: Some(ENEMY_SIZE), custom_size: Some(ENEMY_SIZE),
..default() ..default()
}, },
transform: Transform::from_translation(Vec3::new(spawn_x, spawn_y, 0.0)), Transform::from_translation(spawn_pos),
..default()
},
Enemy { Enemy {
enemy_type, enemy_type,
// Use shoot interval from stage config
shoot_cooldown: Timer::new( shoot_cooldown: Timer::new(
Duration::from_secs_f32(current_config.enemy_shoot_interval), Duration::from_secs_f32(config.enemy_shoot_interval),
TimerMode::Once, TimerMode::Once,
), ),
}, },
FormationTarget { position: target_pos }, FormationTarget {
position: target_pos,
},
EnemyState::Entering, EnemyState::Entering,
)); ));
// Use stage_enemy_count for cycling index formation_state.next_slot_index = (slot + 1) % config.enemy_count;
formation_state.next_slot_index = (slot_index + 1) % stage_enemy_count;
formation_state.total_spawned_this_stage += 1; formation_state.total_spawned_this_stage += 1;
// Mark that we are now waiting for enemies to be cleared
stage.waiting_for_clear = true; stage.waiting_for_clear = true;
// Reset timer only if we are still spawning more enemies for the formation if formation_state.total_spawned_this_stage < config.enemy_count {
if formation_state.total_spawned_this_stage < stage_enemy_count {
timer.timer.reset(); timer.timer.reset();
} else { } else {
println!( info!(
"Full formation ({}) spawned for Stage {}", "Stage {} formation '{}' fully spawned",
current_config.formation_layout.name, stage.number stage.number, config.formation_layout.name
); );
} }
} }
}
pub fn move_enemies( pub fn move_enemies(
mut entering_query: Query< mut entering_query: Query<
@ -113,453 +116,447 @@ pub fn move_enemies(
(With<Enemy>, With<FormationTarget>), (With<Enemy>, With<FormationTarget>),
>, >,
mut attacking_query: Query< mut attacking_query: Query<
(Entity, &mut Transform, &mut EnemyState, &Enemy, Option<&OriginalFormationPosition>), // Add mutable state and original position (
Entity,
&mut Transform,
&mut EnemyState,
&Enemy,
Option<&OriginalFormationPosition>,
),
(With<Enemy>, Without<FormationTarget>), (With<Enemy>, Without<FormationTarget>),
>, // Query potential attackers >,
time: Res<Time>, time: Res<Time>,
mut commands: Commands, mut commands: Commands,
stage: Res<CurrentStage>, stage: Res<CurrentStage>,
stage_configs: Res<StageConfigurations>, // Add stage configurations stage_configs: Res<StageConfigurations>,
has_beam_query: Query<&TractorBeam>, has_beam_query: Query<&TractorBeam>,
) { ) {
// Get current stage config for speed multiplier let dt = time.delta_secs();
let config_index = (stage.number as usize - 1) % stage_configs.stages.len(); let speed_multiplier = stage_configs.for_stage(stage.number).enemy_speed_multiplier;
let current_config = &stage_configs.stages[config_index];
let speed_multiplier = current_config.enemy_speed_multiplier;
// Calculate speeds for this frame
let base_speed = ENEMY_SPEED * speed_multiplier; let base_speed = ENEMY_SPEED * speed_multiplier;
let attack_speed = base_speed * 1.5; // Attackers are faster let attack_speed = base_speed * ATTACKER_SPEED_MULTIPLIER;
let arrival_threshold = base_speed * time.delta_seconds() * 1.1; // Threshold for reaching formation target let arrival_threshold = base_speed * dt * ARRIVAL_THRESHOLD_FACTOR;
// --- Handle Entering Enemies ---
for (entity, mut transform, target, mut state) in entering_query.iter_mut() { for (entity, mut transform, target, mut state) in entering_query.iter_mut() {
// Ensure we only process Entering state here, though query filters it mostly if *state != EnemyState::Entering {
if *state == EnemyState::Entering { continue;
let current_pos = transform.translation; }
let target_pos = target.position; let direction = target.position - transform.translation;
// Using target_pos which is already a Vec3, not a reference if direction.length() < arrival_threshold {
let direction = target_pos - current_pos; transform.translation = target.position;
let distance = direction.length(); commands.entity(entity).remove::<FormationTarget>();
commands.entity(entity).insert(OriginalFormationPosition {
if distance < arrival_threshold { position: target.position,
// Arrived at target });
transform.translation = target_pos; *state = EnemyState::InFormation;
commands.entity(entity).remove::<FormationTarget>(); // Remove target component
*state = EnemyState::InFormation; // Change state
// Store the original formation position for potential return
commands.entity(entity).insert(OriginalFormationPosition { position: target_pos });
println!(
"Enemy {:?} reached formation target and is now InFormation.",
entity
);
} else { } else {
// Move towards target using base_speed transform.translation += direction.normalize() * base_speed * dt;
let move_delta = direction.normalize() * base_speed * time.delta_seconds();
transform.translation += move_delta;
}
} }
} }
// --- Handle Attacking and Returning Enemies ---
for (entity, mut transform, mut state, enemy, original_pos) in attacking_query.iter_mut() { for (entity, mut transform, mut state, enemy, original_pos) in attacking_query.iter_mut() {
// Check what state the enemy is in
match state.as_ref() { match state.as_ref() {
EnemyState::Attacking(attack_pattern) => { EnemyState::Attacking(pattern) => {
// Apply different movement based on enemy type step_attacker(
match enemy.enemy_type { &mut commands,
EnemyType::Grunt => { entity,
// Basic enemies follow their attack pattern &mut transform,
match attack_pattern { enemy.enemy_type,
// ... existing patterns ... *pattern,
AttackPattern::SwoopDive => { attack_speed,
// ... existing code ... dt,
let vertical_movement = attack_speed * time.delta_seconds(); &has_beam_query,
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 };
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;
} }
} EnemyState::ReturningWithCaptive => {
AttackPattern::DirectDive => { if let Some(home) = original_pos {
transform.translation.y -= attack_speed * time.delta_seconds(); let direction = home.position - transform.translation;
} let return_speed = base_speed * RETURN_SPEED_MULTIPLIER;
AttackPattern::Kamikaze(target) => { if direction.length() < RETURN_ARRIVAL_DISTANCE {
// Copy the target value rather than dereferencing transform.translation = home.position;
// since target should actually be a Vec3 in this context *state = EnemyState::InFormation;
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
if distance > kamikaze_threshold {
let move_delta = direction.normalize() * attack_speed * time.delta_seconds();
transform.translation += move_delta;
} else { } else {
// Optionally stop or continue past target - for now, just stop moving towards it transform.translation += direction.normalize() * return_speed * dt;
// 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();
} }
} }
} }
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 transform.translation.y > target_y { if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
// Move down to position commands.entity(entity).despawn();
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() { #[allow(clippy::too_many_arguments)]
// Spawn tractor beam component on this boss fn step_attacker(
commands: &mut Commands,
entity: Entity,
transform: &mut Transform,
enemy_type: EnemyType,
pattern: AttackPattern,
attack_speed: f32,
dt: f32,
has_beam_query: &Query<&TractorBeam>,
) {
match (enemy_type, pattern) {
(EnemyType::Boss, AttackPattern::CaptureBeam) => {
let hover_y = -WINDOW_HEIGHT * BOSS_CAPTURE_BEAM_HOVER_Y_FACTOR;
if transform.translation.y > hover_y {
transform.translation.y -= attack_speed * BOSS_DESCENT_SPEED_FACTOR * dt;
} else if has_beam_query.get(entity).is_err() {
commands.entity(entity).insert(TractorBeam { commands.entity(entity).insert(TractorBeam {
target: None, // Will be filled in by the boss_capture_attack target: None,
timer: Timer::new(Duration::from_secs_f32(TRACTOR_BEAM_DURATION), TimerMode::Once), timer: Timer::new(
Duration::from_secs_f32(TRACTOR_BEAM_DURATION),
TimerMode::Once,
),
width: TRACTOR_BEAM_WIDTH, width: TRACTOR_BEAM_WIDTH,
active: false, active: false,
}); });
} }
} }
} (EnemyType::Boss, AttackPattern::SwoopDive) => {
AttackPattern::SwoopDive => { let target = Vec3::new(0.0, -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y, 0.0);
// ... 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; let direction = target - transform.translation;
// Normalize and move
if direction.length() > 0.0 { if direction.length() > 0.0 {
let normalized_dir = direction.normalize(); transform.translation += direction.normalize() * attack_speed * dt;
transform.translation += normalized_dir * attack_speed * time.delta_seconds();
} }
} }
AttackPattern::DirectDive => { (_, AttackPattern::DirectDive) | (EnemyType::Grunt, AttackPattern::CaptureBeam) => {
transform.translation.y -= attack_speed * time.delta_seconds(); transform.translation.y -= attack_speed * dt;
} }
AttackPattern::Kamikaze(target) => { (EnemyType::Grunt, AttackPattern::SwoopDive) => {
// Convert the target to a value type transform.translation.y -= attack_speed * dt;
let target_pos = *target; // Dereference here let prev_x = transform.translation.x;
let direction = target_pos - transform.translation; let dx = -prev_x.signum() * attack_speed * SWOOP_HORIZONTAL_FACTOR * dt;
transform.translation.x += dx;
// If very close to target, just move straight down // Snap to center if we crossed it (prevents wobble)
if direction.length() < 50.0 { if prev_x != 0.0 && prev_x.signum() != transform.translation.x.signum() {
transform.translation.y -= attack_speed * time.delta_seconds(); transform.translation.x = 0.0;
} else { }
// Move toward target }
let normalized_dir = direction.normalize(); (_, AttackPattern::Kamikaze(target)) => {
transform.translation += normalized_dir * attack_speed * time.delta_seconds(); let direction = target - transform.translation;
let dist = direction.length();
if enemy_type == EnemyType::Boss && dist < KAMIKAZE_NEAR_DISTANCE {
transform.translation.y -= attack_speed * dt;
} else if dist > attack_speed * dt * ARRIVAL_THRESHOLD_FACTOR {
transform.translation += direction.normalize() * attack_speed * dt;
} }
} }
} }
} }
}
}
EnemyState::ReturningWithCaptive => {
// Boss returning to formation with captured player
if let Some(original_pos) = original_pos {
let direction = original_pos.position - transform.translation;
let distance = direction.length();
let return_speed = ENEMY_SPEED * speed_multiplier * 0.7; // Slightly slower when carrying captive
if distance < 5.0 { // Close enough to formation position
// Return to formation
transform.translation = original_pos.position;
*state = EnemyState::InFormation;
println!("Boss {:?} returned to formation with captive!", entity);
} else {
// Move towards formation position
let move_delta = direction.normalize() * return_speed * time.delta_seconds();
transform.translation += move_delta;
}
}
}
_ => {} // Handle other states if needed
}
// Despawn if off screen (This should be inside the loop)
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y {
println!(
"Despawning enemy {:?} that went off screen.", // Generic message as it could be InFormation or Attacking
entity
);
commands.entity(entity).despawn();
// TODO: Later, attacking enemies might return to formation or loop
}
} // Closes for loop
}
// System to check if all spawned enemies have reached their formation position
pub fn check_formation_complete( pub fn check_formation_complete(
mut formation_state: ResMut<FormationState>, mut formation_state: ResMut<FormationState>,
enemy_query: Query<&EnemyState, With<Enemy>>, enemy_query: Query<&EnemyState, With<Enemy>>,
mut attack_dive_timer: ResMut<AttackDiveTimer>, mut attack_dive_timer: ResMut<AttackDiveTimer>,
stage: Res<CurrentStage>, // Need current stage stage: Res<CurrentStage>,
stage_configs: Res<StageConfigurations>, // Need stage configs stage_configs: Res<StageConfigurations>,
) { ) {
// Only run the check if the formation isn't already marked as complete if formation_state.formation_complete {
if !formation_state.formation_complete { return;
// Get current stage config
let config_index = (stage.number as usize - 1) % stage_configs.stages.len();
let current_config = &stage_configs.stages[config_index];
let stage_enemy_count = current_config.enemy_count;
// Check if all enemies for *this stage* have been spawned
if formation_state.total_spawned_this_stage == stage_enemy_count {
// Check if any enemies are still in the Entering state
let mut any_entering = false;
for state in enemy_query.iter() {
// Use matches! macro for safety
if matches!(state, EnemyState::Entering) {
any_entering = true;
break;
} }
let config = stage_configs.for_stage(stage.number);
if formation_state.total_spawned_this_stage != config.enemy_count {
return;
}
let any_entering = enemy_query
.iter()
.any(|s| matches!(s, EnemyState::Entering));
if any_entering {
return;
} }
// If all spawned and none are entering, formation is complete info!("Stage {} formation complete", stage.number);
if !any_entering {
println!(
"Formation complete for Stage {}! Setting attack timer. (Spawned={})",
stage.number, formation_state.total_spawned_this_stage
);
formation_state.formation_complete = true; formation_state.formation_complete = true;
let dive_interval = Duration::from_secs_f32(config.attack_dive_interval);
// Set timer duration based on stage config
let dive_interval = Duration::from_secs_f32(current_config.attack_dive_interval);
attack_dive_timer.timer.set_duration(dive_interval); attack_dive_timer.timer.set_duration(dive_interval);
attack_dive_timer.timer.reset(); attack_dive_timer.timer.reset();
attack_dive_timer.timer.unpause(); attack_dive_timer.timer.unpause();
println!("Attack timer set to {:?} duration, unpaused and reset.", dive_interval);
} }
}
}
}
use crate::components::{AttackPattern, Player}; // Import the new enum and Player
pub fn trigger_attack_dives( pub fn trigger_attack_dives(
mut timer: ResMut<AttackDiveTimer>, mut timer: ResMut<AttackDiveTimer>,
time: Res<Time>, time: Res<Time>,
mut enemy_query: Query<(Entity, &mut EnemyState, &Enemy)>, // Added Enemy component to check type mut enemy_query: Query<(Entity, &mut EnemyState, &Enemy)>,
formation_state: Res<FormationState>, formation_state: Res<FormationState>,
stage: Res<CurrentStage>, // Need current stage stage: Res<CurrentStage>,
stage_configs: Res<StageConfigurations>, // Need stage configs stage_configs: Res<StageConfigurations>,
player_query: Query<&Transform, With<Player>>, // Need player position for Kamikaze player_query: Query<&Transform, With<Player>>,
) { ) {
timer.timer.tick(time.delta()); timer.timer.tick(time.delta());
if !timer.timer.just_finished() || !formation_state.formation_complete {
return;
}
// Only proceed if the timer finished AND the formation is complete let config = stage_configs.for_stage(stage.number);
if timer.timer.just_finished() && formation_state.formation_complete { if config.attack_patterns.is_empty() {
// Get the current stage config return;
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 candidates: Vec<(Entity, EnemyType)> = enemy_query
let mut available_enemies: Vec<(Entity, EnemyType)> = Vec::new(); .iter()
for (entity, state, enemy) in enemy_query.iter() { .filter(|(_, state, _)| matches!(state, EnemyState::InFormation))
// Check the state correctly and store enemy type .map(|(e, _, enemy)| (e, enemy.enemy_type))
if matches!(state, EnemyState::InFormation) { .collect();
available_enemies.push((entity, enemy.enemy_type)); if candidates.is_empty() {
return;
}
let (chosen, enemy_type) = candidates[fastrand::usize(..candidates.len())];
let pattern = pick_pattern(enemy_type, config.attack_patterns.as_slice(), &player_query);
if let Ok((_, mut state, _)) = enemy_query.get_mut(chosen) {
*state = EnemyState::Attacking(pattern);
} }
} }
// If there are enemies available, pick one randomly fn pick_pattern(
if !available_enemies.is_empty() && !current_config.attack_patterns.is_empty() { enemy_type: EnemyType,
let random_index = fastrand::usize(..available_enemies.len()); available: &[AttackPattern],
let (chosen_entity, enemy_type) = available_enemies[random_index]; player_query: &Query<&Transform, With<Player>>,
) -> AttackPattern {
// Select an attack pattern based on enemy type let pattern = match enemy_type {
let mut selected_pattern = match enemy_type { EnemyType::Boss if fastrand::f32() < BOSS_CAPTURE_BEAM_PROBABILITY => {
// For Boss enemies, occasionally use the CaptureBeam pattern
EnemyType::Boss => {
if fastrand::f32() < 0.8 { // 80% chance for Boss to use CaptureBeam (increased for testing)
println!("Boss {:?} selected CaptureBeam attack!", chosen_entity);
AttackPattern::CaptureBeam AttackPattern::CaptureBeam
} else {
// Otherwise use a random pattern from the stage config
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
println!("Boss {:?} selected {:?} attack!", chosen_entity, current_config.attack_patterns[pattern_index]);
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]
} }
_ => available[fastrand::usize(..available.len())],
}; };
// If Kamikaze, get player position (if player exists) // Resolve Kamikaze target (or fall back if player is gone).
if let AttackPattern::Kamikaze(_) = selected_pattern { if matches!(pattern, AttackPattern::Kamikaze(_)) {
if let Ok(player_transform) = player_query.get_single() { match player_query.single() {
selected_pattern = AttackPattern::Kamikaze(player_transform.translation); Ok(t) => AttackPattern::Kamikaze(t.translation),
Err(_) => AttackPattern::DirectDive,
}
} else { } else {
// Fallback if player doesn't exist (e.g., just died) pattern
selected_pattern = AttackPattern::DirectDive; // Or SwoopDive
println!("Kamikaze target not found, falling back to DirectDive");
}
}
// Get the chosen enemy's state mutably and change it
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)
}
}
} }
} }
pub fn enemy_shoot( pub fn enemy_shoot(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
// Query attacking enemies: need their transform and mutable Enemy component for the timer mut enemy_query: Query<(&Transform, &mut Enemy, &EnemyState), Without<FormationTarget>>,
mut enemy_query: Query<(&Transform, &mut Enemy, &EnemyState), Without<FormationTarget>>, // Query remains the same
) { ) {
for (transform, mut enemy, state) in enemy_query.iter_mut() { for (transform, mut enemy, state) in enemy_query.iter_mut() {
// Only shoot if in any Attacking state (pattern doesn't matter for shooting) if !matches!(state, EnemyState::Attacking(_)) {
if matches!(state, EnemyState::Attacking(_)) { // Use matches! macro continue;
}
enemy.shoot_cooldown.tick(time.delta()); enemy.shoot_cooldown.tick(time.delta());
if enemy.shoot_cooldown.finished() { if !enemy.shoot_cooldown.is_finished() {
// println!("Enemy {:?} firing!", transform.translation); // Less verbose logging continue;
let bullet_start_pos = transform.translation }
- Vec3::Y * (ENEMY_SIZE.y / 2.0 + ENEMY_BULLET_SIZE.y / 2.0);
let bullet_pos =
transform.translation - Vec3::Y * (ENEMY_SIZE.y / 2.0 + ENEMY_BULLET_SIZE.y / 2.0);
commands.spawn(( commands.spawn((
SpriteBundle { Sprite {
sprite: Sprite { color: Color::srgb(1.0, 0.5, 0.5),
color: Color::rgb(1.0, 0.5, 0.5),
custom_size: Some(ENEMY_BULLET_SIZE), custom_size: Some(ENEMY_BULLET_SIZE),
..default() ..default()
}, },
transform: Transform::from_translation(bullet_start_pos), Transform::from_translation(bullet_pos),
..default()
},
EnemyBullet, EnemyBullet,
)); ));
// Reset the timer for the next shot
enemy.shoot_cooldown.reset(); enemy.shoot_cooldown.reset();
} }
} }
}
}
// Check collisions between enemy bullets and the player
// Moved to player.rs as it affects player state directly
// New run condition: Check if the formation is complete
pub fn is_formation_complete(formation_state: Res<FormationState>) -> bool { pub fn is_formation_complete(formation_state: Res<FormationState>) -> bool {
formation_state.formation_complete formation_state.formation_complete
} }
// New system to handle the tractor beam attack from Boss enemies /// Beam spans from the boss down to the bottom of the screen.
pub(crate) fn calculate_beam_height(boss_y: f32) -> f32 {
(boss_y - (-WINDOW_HEIGHT / 2.0)).max(0.0)
}
/// Sinusoidal alpha pulse, clamped to [0, 1].
pub(crate) fn beam_pulse_alpha(base_alpha: f32, time_secs: f32, freq: f32, amplitude: f32) -> f32 {
(base_alpha + (time_secs * freq).sin() * amplitude).clamp(0.0, 1.0)
}
pub fn boss_capture_attack( pub fn boss_capture_attack(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
mut boss_query: Query<(Entity, &Transform, &mut TractorBeam)>, mut boss_query: Query<(Entity, &Transform, &mut TractorBeam)>,
player_query: Query<(Entity, &Transform), (With<Player>, Without<Captured>)>, // Only target non-captured players player_query: Query<(Entity, &Transform), (With<Player>, Without<Captured>)>,
enemy_query: Query<&EnemyState, With<Enemy>>, enemy_query: Query<&EnemyState, With<Enemy>>,
children_query: Query<&Children>,
) { ) {
for (boss_entity, boss_transform, mut tractor_beam) in boss_query.iter_mut() { for (boss_entity, boss_transform, mut beam) in boss_query.iter_mut() {
// Tick the beam timer beam.timer.tick(time.delta());
tractor_beam.timer.tick(time.delta());
// If player exists and beam is not active yet, set player as target if !beam.active && beam.target.is_none() {
if !tractor_beam.active && tractor_beam.target.is_none() { if let Ok((player_entity, _)) = player_query.single() {
if let Ok((player_entity, _)) = player_query.get_single() { beam.target = Some(player_entity);
tractor_beam.target = Some(player_entity); beam.active = true;
tractor_beam.active = true; spawn_beam_visual(&mut commands, boss_entity, boss_transform.translation.y);
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 beam.active {
if tractor_beam.active { if let Ok((player_entity, player_transform)) = player_query.single() {
if let Ok((player_entity, player_transform)) = player_query.get_single() { let dx = (player_transform.translation.x - boss_transform.translation.x).abs();
// Check if player is roughly under the boss if dx < beam.width / 2.0 {
if (player_transform.translation.x - boss_transform.translation.x).abs() < tractor_beam.width / 2.0 { info!("Player captured by boss {boss_entity:?}");
// 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 { commands.entity(player_entity).insert(Captured {
boss_entity, boss_entity,
timer: Timer::new(Duration::from_secs_f32(CAPTURE_DURATION), TimerMode::Once), timer: Timer::new(
Duration::from_secs_f32(CAPTURE_DURATION),
TimerMode::Once,
),
}); });
end_beam(&mut commands, boss_entity, &enemy_query, &children_query);
continue;
}
}
}
// Boss returns to formation with captured player if beam.timer.is_finished() {
end_beam(&mut commands, boss_entity, &enemy_query, &children_query);
}
}
}
fn spawn_beam_visual(commands: &mut Commands, boss_entity: Entity, boss_y: f32) {
let beam_height = calculate_beam_height(boss_y);
let core_height = beam_height * 0.6;
commands.entity(boss_entity).with_children(|parent| {
parent.spawn((
Sprite {
color: BEAM_GLOW_COLOR,
custom_size: Some(Vec2::new(BEAM_GLOW_WIDTH, beam_height)),
..default()
},
Transform::from_xyz(0.0, -beam_height / 2.0, -0.5),
TractorBeamSprite,
));
parent.spawn((
Sprite {
color: BEAM_CORE_COLOR,
custom_size: Some(Vec2::new(TRACTOR_BEAM_WIDTH, core_height)),
..default()
},
Transform::from_xyz(0.0, -core_height / 2.0 - beam_height * 0.2, -0.3),
TractorBeamSprite,
));
});
}
/// Tear down the beam component and visuals; if the boss was attacking, send it home.
fn end_beam(
commands: &mut Commands,
boss_entity: Entity,
enemy_query: &Query<&EnemyState, With<Enemy>>,
children_query: &Query<&Children>,
) {
commands.entity(boss_entity).remove::<TractorBeam>(); commands.entity(boss_entity).remove::<TractorBeam>();
if let Ok(children) = children_query.get(boss_entity) {
// Change boss state to returning with captive for child in children.iter() {
if let Ok(enemy) = enemy_query.get(boss_entity) { commands.entity(child).despawn();
if let EnemyState::Attacking(_) = enemy {
commands.entity(boss_entity).insert(EnemyState::ReturningWithCaptive);
// Clean up the beam visual
commands.entity(boss_entity).despawn_descendants();
} }
} }
break; if let Ok(EnemyState::Attacking(_)) = enemy_query.get(boss_entity) {
commands
.entity(boss_entity)
.insert(EnemyState::ReturningWithCaptive);
}
}
pub fn update_tractor_beam_visual(
time: Res<Time>,
boss_query: Query<(Entity, &Transform), (With<TractorBeam>, Without<TractorBeamSprite>)>,
mut sprite_query: Query<(&mut Sprite, &mut Transform), With<TractorBeamSprite>>,
children_query: Query<&Children>,
) {
let time_secs = time.elapsed_secs();
for (boss_entity, boss_transform) in boss_query.iter() {
let beam_height = calculate_beam_height(boss_transform.translation.y);
let Ok(children) = children_query.get(boss_entity) else {
continue;
};
for &child in children {
let Ok((mut sprite, mut transform)) = sprite_query.get_mut(child) else {
continue;
};
let alpha = beam_pulse_alpha(
sprite.color.alpha(),
time_secs,
BEAM_PULSE_FREQ,
BEAM_PULSE_AMPLITUDE,
);
sprite.color.set_alpha(alpha);
// Distinguish glow vs. core by their fixed widths.
let width = sprite.custom_size.map(|s| s.x).unwrap_or(0.0);
if (width - BEAM_GLOW_WIDTH).abs() < f32::EPSILON {
sprite.custom_size = Some(Vec2::new(width, beam_height));
transform.translation.y = -beam_height / 2.0;
} else if (width - TRACTOR_BEAM_WIDTH).abs() < f32::EPSILON {
let core_height = beam_height * 0.6;
sprite.custom_size = Some(Vec2::new(width, core_height));
transform.translation.y = -core_height / 2.0 - beam_height * 0.2;
}
} }
} }
} }
// If beam timer finishes and player wasn't captured, end the beam attack #[cfg(test)]
if tractor_beam.timer.finished() { mod beam_tests {
println!("Boss {:?} tractor beam expired", boss_entity); use super::*;
commands.entity(boss_entity).remove::<TractorBeam>();
// Clean up the beam visual #[test]
commands.entity(boss_entity).despawn_descendants(); fn beam_height_at_screen_center() {
assert_eq!(calculate_beam_height(0.0), WINDOW_HEIGHT / 2.0);
}
// Boss returns to formation after failed capture #[test]
if let Ok(enemy_state) = enemy_query.get(boss_entity) { fn beam_height_at_boss_position() {
if let EnemyState::Attacking(_) = enemy_state { assert_eq!(calculate_beam_height(-200.0), 200.0);
commands.entity(boss_entity).insert(EnemyState::ReturningWithCaptive); }
}
} #[test]
} fn beam_height_clamped_at_bottom() {
assert_eq!(calculate_beam_height(-WINDOW_HEIGHT / 2.0), 0.0);
}
#[test]
fn pulse_at_zero_returns_base() {
assert_eq!(beam_pulse_alpha(0.6, 0.0, 3.0, 0.15), 0.6);
}
#[test]
fn pulse_at_peak() {
let t = std::f32::consts::FRAC_PI_2 / 3.0;
let result = beam_pulse_alpha(0.5, t, 3.0, 0.15);
assert!((result - 0.65).abs() < 0.001);
}
#[test]
fn pulse_at_trough() {
let t = std::f32::consts::FRAC_PI_2;
let result = beam_pulse_alpha(0.5, t, 3.0, 0.15);
assert!((result - 0.35).abs() < 0.001);
}
#[test]
fn pulse_clamped_to_zero() {
let t = 3.0 * std::f32::consts::FRAC_PI_2 / 3.0;
let result = beam_pulse_alpha(0.05, t, 3.0, 1.0);
assert_eq!(result, 0.0);
}
#[test]
fn pulse_clamped_to_one() {
let t = std::f32::consts::FRAC_PI_2 / 3.0;
let result = beam_pulse_alpha(0.95, t, 3.0, 1.0);
assert_eq!(result, 1.0);
} }
} }

View file

@ -1,8 +1,8 @@
use crate::components::{Bullet, Enemy, GameOverUI, RestartMessage, StartButton, StartMenuUI};
use crate::resources::RestartPressed;
use bevy::prelude::*; 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)] #[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
pub enum AppState { pub enum AppState {
#[default] #[default]
@ -11,102 +11,72 @@ pub enum AppState {
GameOver, 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 --- // --- Game Over UI ---
pub fn setup_game_over_ui(mut commands: Commands) { pub fn setup_game_over_ui(mut commands: Commands) {
println!("Entering GameOver state. Setting up UI.");
commands.spawn(( commands.spawn((
TextBundle::from_section( Text::new("GAME OVER"),
"GAME OVER", TextFont {
TextStyle {
font_size: 100.0, font_size: 100.0,
color: Color::WHITE,
..default() ..default()
}, },
) TextColor(Color::WHITE),
.with_style(Style { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
align_self: AlignSelf::Center, align_self: AlignSelf::Center,
justify_self: JustifySelf::Center, justify_self: JustifySelf::Center,
top: Val::Percent(40.0), top: Val::Percent(40.0),
..default() ..default()
}), },
GameOverUI, GameOverUI,
)); ));
commands.spawn(( commands.spawn((
TextBundle::from_section( Text::new("Press R to Restart"),
"Press R to Restart", TextFont {
TextStyle {
font_size: 32.0, font_size: 32.0,
color: Color::WHITE,
..default() ..default()
}, },
) TextColor(Color::WHITE),
.with_style(Style { Node {
position_type: PositionType::Absolute, position_type: PositionType::Absolute,
align_self: AlignSelf::Center, align_self: AlignSelf::Center,
justify_self: JustifySelf::Center, justify_self: JustifySelf::Center,
top: Val::Percent(55.0), top: Val::Percent(55.0),
..default() ..default()
}), },
RestartMessage, RestartMessage,
)); ));
} }
pub fn cleanup_game_over_ui( pub fn cleanup_game_over_ui(
mut commands: Commands, mut commands: Commands,
query: Query<Entity, With<GameOverUI>>, query: Query<Entity, Or<(With<GameOverUI>, With<RestartMessage>)>>,
restart_query: Query<Entity, With<RestartMessage>>,
) { ) {
println!("Exiting GameOver state. Cleaning up UI."); for entity in &query {
for entity in query.iter() { commands.entity(entity).despawn();
commands.entity(entity).despawn_recursive();
}
for entity in restart_query.iter() {
commands.entity(entity).despawn_recursive();
} }
} }
// --- Cleanup --- // --- Cleanup on leaving Playing ---
// Cleanup system when exiting the Playing state
pub fn cleanup_game_entities( pub fn cleanup_game_entities(
mut commands: Commands, mut commands: Commands,
bullet_query: Query<Entity, With<Bullet>>, query: Query<Entity, Or<(With<Bullet>, With<EnemyBullet>, With<Enemy>)>>,
enemy_bullet_query: Query<Entity, With<crate::components::EnemyBullet>>,
enemy_query: Query<Entity, With<Enemy>>,
restart_message_query: Query<Entity, With<crate::components::RestartMessage>>,
// Optionally despawn player too, or handle separately if needed for restart
// player_query: Query<Entity, With<Player>>,
) { ) {
println!("Exiting Playing state. Cleaning up game entities."); for entity in &query {
for entity in bullet_query.iter() {
commands.entity(entity).despawn(); 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() {
commands.entity(entity).despawn();
}
// for entity in player_query.iter() {
// commands.entity(entity).despawn();
// }
} }
// --- Start Menu UI --- // --- Start Menu UI ---
pub fn setup_start_menu_ui(mut commands: Commands) { pub fn setup_start_menu_ui(mut commands: Commands) {
println!("Entering StartMenu state. Setting up UI.");
// Root UI container
commands commands
.spawn(( .spawn((
NodeBundle { Node {
style: Style {
width: Val::Percent(100.0), width: Val::Percent(100.0),
height: Val::Percent(100.0), height: Val::Percent(100.0),
align_items: AlignItems::Center, align_items: AlignItems::Center,
@ -114,32 +84,25 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
..default() ..default()
}, },
..default()
},
StartMenuUI, StartMenuUI,
)) ))
.with_children(|parent| { .with_children(|parent| {
// Title parent.spawn((
parent.spawn( Text::new("BGLGA"),
TextBundle::from_section( TextFont {
"BGLGA",
TextStyle {
font_size: 120.0, font_size: 120.0,
color: Color::WHITE,
..default() ..default()
}, },
) TextColor(Color::WHITE),
.with_style(Style { Node {
margin: UiRect::bottom(Val::Px(50.0)), margin: UiRect::bottom(Val::Px(50.0)),
..default() ..default()
}), },
); ));
// Start Game Button
parent parent
.spawn(( .spawn((
ButtonBundle { Button,
style: Style { Node {
width: Val::Px(250.0), width: Val::Px(250.0),
height: Val::Px(80.0), height: Val::Px(80.0),
border: UiRect::all(Val::Px(2.0)), border: UiRect::all(Val::Px(2.0)),
@ -147,71 +110,60 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
align_items: AlignItems::Center, align_items: AlignItems::Center,
..default() ..default()
}, },
border_color: BorderColor(Color::WHITE), BorderColor::all(Color::WHITE),
background_color: BackgroundColor(Color::rgb(0.1, 0.1, 0.5)), BackgroundColor(BUTTON_IDLE),
..default()
},
StartButton, StartButton,
)) ))
.with_children(|parent| { .with_children(|parent| {
parent.spawn(TextBundle::from_section( parent.spawn((
"Start Game", Text::new("Start Game"),
TextStyle { TextFont {
font_size: 40.0, font_size: 40.0,
color: Color::WHITE,
..default() ..default()
}, },
TextColor(Color::WHITE),
)); ));
}); });
}); });
} }
pub fn cleanup_start_menu_ui(mut commands: Commands, query: Query<Entity, With<StartMenuUI>>) { 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 {
for entity in query.iter() { commands.entity(entity).despawn();
commands.entity(entity).despawn_recursive();
} }
} }
pub fn start_menu_button_system( pub fn start_menu_button_system(
mut interaction_query: Query< mut interactions: Query<
(&Interaction, &mut BackgroundColor), (&Interaction, &mut BackgroundColor),
(Changed<Interaction>, With<StartButton>), (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 { match *interaction {
Interaction::Pressed => { Interaction::Pressed => next_state.set(AppState::Playing),
println!("Start button pressed! Transitioning to Playing state."); Interaction::Hovered => bg.0 = BUTTON_HOVER,
app_state.set(AppState::Playing); Interaction::None => bg.0 = BUTTON_IDLE,
}
Interaction::Hovered => {
color.0 = Color::rgb(0.2, 0.2, 0.7);
}
Interaction::None => {
color.0 = Color::rgb(0.1, 0.1, 0.5);
}
} }
} }
} }
pub fn handle_restart_input( pub fn handle_restart_input(
keyboard_input: Res<ButtonInput<KeyCode>>, keyboard: Res<ButtonInput<KeyCode>>,
mut restart_resource: ResMut<RestartPressed>, mut restart: ResMut<RestartPressed>,
) { ) {
if keyboard_input.just_pressed(KeyCode::KeyR) { if keyboard.just_pressed(KeyCode::KeyR) {
restart_resource.pressed = true; restart.pressed = true;
} }
} }
pub fn restart_game_system( pub fn restart_game_system(
mut app_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
mut restart_resource: ResMut<RestartPressed>, mut restart: ResMut<RestartPressed>,
) { ) {
if restart_resource.pressed { if restart.pressed {
println!("Restart requested. Transitioning to Playing state."); restart.pressed = false;
restart_resource.pressed = false; next_state.set(AppState::Playing);
app_state.set(AppState::Playing);
} }
} }

132
src/lib.rs Normal file
View file

@ -0,0 +1,132 @@
#![allow(clippy::type_complexity)]
use bevy::prelude::*;
use std::time::Duration;
pub mod bullet;
pub mod components;
pub mod constants;
pub mod enemy;
pub mod game_state;
pub mod player;
pub mod resources;
pub mod stage;
pub mod starfield;
pub mod systems;
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 starfield::scroll_starfield;
use systems::{
animate_explosion, player_exists, player_vulnerable, setup, should_respawn_player,
update_window_title,
};
pub fn run() {
App::new()
.insert_resource(ClearColor(Color::srgb(0.0, 0.0, 0.1)))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "BGLGA".into(),
resolution: (WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32).into(),
resizable: false,
..default()
}),
..default()
}))
.init_state::<AppState>()
.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(AttackDiveTimer {
timer: Timer::new(Duration::from_secs_f32(3.0), TimerMode::Once),
})
.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,
(
update_window_title,
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,
move_bullets,
check_bullet_collisions,
move_enemy_bullets,
check_enemy_bullet_player_collisions.run_if(player_vulnerable),
move_enemies,
enemy_shoot,
boss_capture_attack,
update_tractor_beam_visual.run_if(any_with_component::<TractorBeam>),
)
.run_if(in_state(AppState::Playing)),
)
.add_systems(
Update,
(
check_formation_complete,
trigger_attack_dives.run_if(is_formation_complete),
check_stage_clear,
)
.chain()
.run_if(in_state(AppState::Playing)),
)
// 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)),
)
// Game over.
.add_systems(OnEnter(AppState::GameOver), setup_game_over_ui)
.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,132 +1,3 @@
use bevy::prelude::*;
use std::time::Duration;
pub mod components;
pub mod constants;
pub mod resources;
pub mod game_state;
pub mod player;
pub mod enemy;
pub mod bullet;
pub mod stage;
pub mod systems;
pub mod starfield;
use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH};
use resources::{ // Added StageConfigurations
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,
};
use bullet::{
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets,
move_enemy_bullets,
};
use stage::check_stage_clear;
use systems::{player_exists, player_vulnerable, setup, should_respawn_player, update_window_title};
use starfield::scroll_starfield;
fn main() { fn main() {
App::new() bglga::run();
.insert_resource(ClearColor(Color::rgb(0.0, 0.0, 0.1)))
.add_plugins(DefaultPlugins.set(WindowPlugin {
primary_window: Some(Window {
title: "BGLGA".into(),
resolution: (WINDOW_WIDTH, WINDOW_HEIGHT).into(),
resizable: false,
..default()
}),
..default()
}))
// 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(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()) // Use default stages for now
.insert_resource(RestartPressed::default())
// Add startup systems
.add_systems(Startup, setup)
// Core game systems
.add_systems(
Update,
(
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
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)),
)
// Formation/Attack/Stage systems (some need specific ordering or run conditions)
.add_systems(
Update,
(
check_formation_complete,
trigger_attack_dives.run_if(is_formation_complete), // Run only when formation is complete
check_stage_clear, // Uses world access, run separately
)
.chain() // Ensure these run in order if needed, check_formation first
.run_if(in_state(AppState::Playing)),
)
// Starfield runs in all states
.add_systems(Update, scroll_starfield)
// UI and state management systems
.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(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)
.run();
} }

View file

@ -1,5 +1,4 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::ecs::system::ParamSet;
use std::time::Duration; use std::time::Duration;
use crate::components::{Bullet, Captured, Enemy, Invincible, Player}; use crate::components::{Bullet, Captured, Enemy, Invincible, Player};
@ -9,28 +8,28 @@ use crate::constants::{
}; };
use crate::game_state::AppState; use crate::game_state::AppState;
use crate::resources::{PlayerLives, PlayerRespawnTimer}; use crate::resources::{PlayerLives, PlayerRespawnTimer};
use crate::systems::spawn_explosion;
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;
// Helper to spawn player (used in setup and respawn)
pub fn spawn_player_ship(commands: &mut Commands) { pub fn spawn_player_ship(commands: &mut Commands) {
commands.spawn(( commands.spawn((
SpriteBundle { Sprite {
sprite: Sprite { color: Color::srgb(0.0, 0.5, 1.0),
color: Color::rgb(0.0, 0.5, 1.0),
custom_size: Some(PLAYER_SIZE), custom_size: Some(PLAYER_SIZE),
..default() ..default()
}, },
transform: Transform::from_translation(Vec3::new( Transform::from_xyz(0.0, -WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0, 0.0),
0.0,
-WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0,
0.0,
)),
..default()
},
Player { Player {
speed: PLAYER_SPEED, 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 { Invincible {
timer: Timer::new( timer: Timer::new(
Duration::from_secs_f32(PLAYER_INVINCIBILITY_DURATION), Duration::from_secs_f32(PLAYER_INVINCIBILITY_DURATION),
@ -38,251 +37,194 @@ pub fn spawn_player_ship(commands: &mut Commands) {
), ),
}, },
)); ));
println!("Player spawned!");
} }
pub fn move_player( pub fn move_player(
keyboard_input: Res<ButtonInput<KeyCode>>, keyboard: Res<ButtonInput<KeyCode>>,
mut query: Query<(&mut Transform, &Player), Without<Captured>>, // Don't move captured players with controls mut query: Query<(&mut Transform, &Player), Without<Captured>>,
time: Res<Time>, time: Res<Time>,
) { ) {
// Using get_single_mut handles the case where player might not exist yet (or was just destroyed) let Ok((mut transform, player)) = query.single_mut() else {
if let Ok((mut transform, player)) = query.get_single_mut() { return;
let mut direction = 0.0; };
if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) { let mut direction = 0.0;
if keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::ArrowLeft) {
direction -= 1.0; direction -= 1.0;
} }
if keyboard.pressed(KeyCode::KeyD) || keyboard.pressed(KeyCode::ArrowRight) {
if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
direction += 1.0; direction += 1.0;
} }
transform.translation.x += direction * player.speed * time.delta_seconds(); transform.translation.x += direction * player.speed * time.delta_secs();
let half_player_width = PLAYER_SIZE.x / 2.0; let half_width = PLAYER_SIZE.x / 2.0;
transform.translation.x = transform.translation.x.clamp( transform.translation.x = transform
-WINDOW_WIDTH / 2.0 + half_player_width, .translation
WINDOW_WIDTH / 2.0 - half_player_width, .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( pub fn handle_captured_player(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
mut set: ParamSet<( mut player_query: Query<(Entity, &mut Transform, &mut Captured), With<Player>>,
Query<(Entity, &mut Transform, &mut Captured)>, boss_query: Query<&Transform, (With<Enemy>, Without<Player>)>,
Query<&Transform, (With<Enemy>, Without<Player>)>,
)>,
mut lives: ResMut<PlayerLives>, mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>, mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>, mut next_state: ResMut<NextState<AppState>>,
) { ) {
// First, collect data about captured players and their bosses for (player_entity, mut transform, mut captured) in player_query.iter_mut() {
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()); captured.timer.tick(time.delta());
match boss_pos { let Ok(boss_transform) = boss_query.get(captured.boss_entity) else {
Some(boss_pos) => { // Boss was despawned mid-capture: release and penalize.
// Boss exists, update player position commands.entity(player_entity).remove::<Captured>();
let target_pos = boss_pos - Vec3::new(0.0, PLAYER_SIZE.y + 10.0, 0.0); kill_player(
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 commands,
&mut lives, &mut lives,
&mut respawn_timer, &mut respawn_timer,
&mut next_state, &mut next_state,
entity, player_entity,
transform.translation,
); );
continue; // Skip the rest of processing for this player continue;
} };
}
// If capture duration expires, player escapes but loses a life let target = boss_transform.translation - Vec3::Y * CAPTURE_OFFSET_BELOW_BOSS;
if captured.timer.finished() { transform.translation = transform.translation.lerp(target, CAPTURE_FOLLOW_LERP);
println!("Player escaped from capture after timer expired!");
commands.entity(entity).remove::<Captured>(); if captured.timer.is_finished() {
lose_life_and_respawn( commands.entity(player_entity).remove::<Captured>();
kill_player(
&mut commands, &mut commands,
&mut lives, &mut lives,
&mut respawn_timer, &mut respawn_timer,
&mut next_state, &mut next_state,
entity, player_entity,
transform.translation,
); );
} }
} }
} }
}
// Helper function for player life loss and respawn logic /// Despawn the player at `position`, decrement lives, and either start the respawn
fn lose_life_and_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, commands: &mut Commands,
lives: &mut ResMut<PlayerLives>, lives: &mut PlayerLives,
respawn_timer: &mut ResMut<PlayerRespawnTimer>, respawn_timer: &mut PlayerRespawnTimer,
next_state: &mut ResMut<NextState<AppState>>, next_state: &mut NextState<AppState>,
player_entity: Entity, player_entity: Entity,
position: Vec3,
) { ) {
// Lose a life
lives.count = lives.count.saturating_sub(1); lives.count = lives.count.saturating_sub(1);
println!("Lives remaining: {}", lives.count); spawn_explosion(commands, position);
// Destroy player
commands.entity(player_entity).despawn(); commands.entity(player_entity).despawn();
if lives.count > 0 { if lives.count > 0 {
respawn_timer.timer.reset(); respawn_timer.timer.reset();
respawn_timer.timer.unpause(); respawn_timer.timer.unpause();
println!("Respawn timer started.");
} else { } else {
println!("GAME OVER!"); info!("GAME OVER");
next_state.set(AppState::GameOver); next_state.set(AppState::GameOver);
} }
} }
pub fn player_shoot( pub fn player_shoot(
keyboard_input: Res<ButtonInput<KeyCode>>, keyboard: Res<ButtonInput<KeyCode>>,
mut query: Query<(&Transform, &mut Player), Without<Captured>>, // Only non-captured players can shoot mut query: Query<(&Transform, &mut Player), Without<Captured>>,
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
) { ) {
if let Ok((player_transform, mut player)) = query.get_single_mut() { let Ok((transform, mut player)) = query.single_mut() else {
return;
};
player.shoot_cooldown.tick(time.delta()); player.shoot_cooldown.tick(time.delta());
if (keyboard_input.just_pressed(KeyCode::Space) let fire = keyboard.just_pressed(KeyCode::Space) || keyboard.just_pressed(KeyCode::ArrowUp);
|| keyboard_input.just_pressed(KeyCode::ArrowUp)) if !fire || !player.shoot_cooldown.is_finished() {
&& player.shoot_cooldown.finished() return;
{ }
player.shoot_cooldown.reset(); player.shoot_cooldown.reset();
let bullet_start_pos = player_transform.translation let bullet_pos = transform.translation + Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
+ Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
commands.spawn(( commands.spawn((
SpriteBundle { Sprite {
sprite: Sprite { color: Color::WHITE,
color: Color::rgb(1.0, 1.0, 1.0),
custom_size: Some(BULLET_SIZE), custom_size: Some(BULLET_SIZE),
..default() ..default()
}, },
transform: Transform::from_translation(bullet_start_pos), Transform::from_translation(bullet_pos),
..default()
},
Bullet, Bullet,
)); ));
} }
}
}
// Modified Collision Check for Player vs Enemy
pub fn check_player_enemy_collisions( pub fn check_player_enemy_collisions(
mut commands: Commands, mut commands: Commands,
mut lives: ResMut<PlayerLives>, mut lives: ResMut<PlayerLives>,
mut respawn_timer: ResMut<PlayerRespawnTimer>, mut respawn_timer: ResMut<PlayerRespawnTimer>,
mut next_state: ResMut<NextState<AppState>>, // Resource to change state mut next_state: ResMut<NextState<AppState>>,
// Query player without Invincible component - relies on run_if condition too
player_query: Query< player_query: Query<
(Entity, &Transform), (Entity, &Transform),
(With<Player>, Without<Invincible>, Without<Captured>), (With<Player>, Without<Invincible>, Without<Captured>),
>, // Don't check collisions for captured players >,
enemy_query: Query<(Entity, &Transform), With<Enemy>>, enemy_query: Query<(Entity, &Transform), With<Enemy>>,
) { ) {
// This system only runs if player exists and is not invincible, due to run_if let Ok((player_entity, player_transform)) = player_query.single() else {
if let Ok((player_entity, player_transform)) = player_query.get_single() { return;
for (enemy_entity, enemy_transform) in enemy_query.iter() { };
let distance = player_transform let player_pos = player_transform.translation;
.translation
.distance(enemy_transform.translation);
if distance < PLAYER_ENEMY_COLLISION_THRESHOLD { let Some((enemy_entity, enemy_transform)) = enemy_query
println!("Player hit by enemy!"); .iter()
commands.entity(enemy_entity).despawn(); // Despawn enemy .find(|(_, t)| player_pos.distance(t.translation) < PLAYER_ENEMY_COLLISION_THRESHOLD)
else {
return;
};
lives.count = lives.count.saturating_sub(1); // Decrement lives safely spawn_explosion(&mut commands, enemy_transform.translation);
println!("Lives remaining: {}", lives.count); commands.entity(enemy_entity).despawn();
kill_player(
commands.entity(player_entity).despawn(); // Despawn player &mut commands,
&mut lives,
if lives.count > 0 { &mut respawn_timer,
// Start the respawn timer &mut next_state,
respawn_timer.timer.reset(); player_entity,
respawn_timer.timer.unpause(); player_pos,
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;
}
}
}
} }
// New System: Respawn Player
pub fn respawn_player( pub fn respawn_player(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, time: Res<Time>,
mut respawn_timer: ResMut<PlayerRespawnTimer>, 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() { if respawn_timer.timer.tick(time.delta()).just_finished() {
println!("Respawn timer finished. Spawning player.");
spawn_player_ship(&mut commands); 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( pub fn manage_invincibility(
mut commands: Commands, mut commands: Commands,
time: Res<Time>, 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() { for (entity, mut invincible, mut visibility) in query.iter_mut() {
invincible.timer.tick(time.delta()); invincible.timer.tick(time.delta());
// Blinking effect (optional) if invincible.timer.is_finished() {
if let Some(ref mut vis) = visibility { commands.entity(entity).remove::<Invincible>();
// Blink roughly 5 times per second *visibility = Visibility::Visible;
let elapsed_secs = invincible.timer.elapsed_secs(); } else {
**vis = if (elapsed_secs * 10.0).floor() % 2.0 == 0.0 { // 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 Visibility::Visible
} else { } else {
Visibility::Hidden Visibility::Hidden
}; };
} }
if invincible.timer.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::*; 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)] #[derive(Resource)]
pub struct EnemySpawnTimer { pub struct EnemySpawnTimer {
pub timer: Timer, pub timer: Timer,
@ -16,59 +21,57 @@ pub struct PlayerRespawnTimer {
pub timer: Timer, pub timer: Timer,
} }
// New struct to define formation positions
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct FormationLayout { pub struct FormationLayout {
pub name: String, // Optional name for debugging/identification pub name: String,
pub positions: Vec<Vec3>, pub positions: Vec<Vec3>,
} }
// Default implementation for easy initialization
impl Default for FormationLayout { impl Default for FormationLayout {
fn default() -> Self { fn default() -> Self {
// Default to the original grid formation for now let positions = (0..FORMATION_ENEMY_COUNT)
let mut positions = Vec::with_capacity(crate::constants::FORMATION_ENEMY_COUNT); .map(|i| {
for i in 0..crate::constants::FORMATION_ENEMY_COUNT { let row = i / FORMATION_COLS;
let row = i / crate::constants::FORMATION_COLS; let col = i % FORMATION_COLS;
let col = i % crate::constants::FORMATION_COLS; let x = (col as f32 - (FORMATION_COLS as f32 - 1.0) / 2.0) * FORMATION_X_SPACING;
let target_x = (col as f32 - (crate::constants::FORMATION_COLS as f32 - 1.0) / 2.0) let y = FORMATION_BASE_Y - row as f32 * FORMATION_Y_SPACING;
* crate::constants::FORMATION_X_SPACING; Vec3::new(x, y, 0.0)
let target_y = crate::constants::FORMATION_BASE_Y })
- (row as f32 * crate::constants::FORMATION_Y_SPACING); .collect();
positions.push(Vec3::new(target_x, target_y, 0.0)); Self {
}
FormationLayout {
name: "Default Grid".to_string(), name: "Default Grid".to_string(),
positions, positions,
} }
} }
} }
// Configuration for a single stage
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct StageConfig { pub struct StageConfig {
pub formation_layout: FormationLayout, pub formation_layout: FormationLayout,
pub enemy_count: usize, // Allow overriding enemy count per stage pub enemy_count: usize,
pub attack_patterns: Vec<crate::components::AttackPattern>, // Possible patterns for this stage pub attack_patterns: Vec<AttackPattern>,
pub attack_dive_interval: f32, // Time between attack dives for this stage pub attack_dive_interval: f32,
pub enemy_speed_multiplier: f32, // Speed multiplier for this stage pub enemy_speed_multiplier: f32,
pub enemy_shoot_interval: f32, // Shoot interval for this stage pub enemy_shoot_interval: f32,
} }
// Resource to hold all stage configurations
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
pub struct StageConfigurations { pub struct StageConfigurations {
pub stages: Vec<StageConfig>, 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 { impl Default for StageConfigurations {
fn default() -> Self { fn default() -> Self {
use crate::components::AttackPattern;
use crate::constants::*; // Import constants for default values
// Define configurations for a few stages
let stage1 = StageConfig { let stage1 = StageConfig {
formation_layout: FormationLayout::default(), // Use the default grid formation_layout: FormationLayout::default(),
enemy_count: FORMATION_ENEMY_COUNT, enemy_count: FORMATION_ENEMY_COUNT,
attack_patterns: vec![AttackPattern::SwoopDive], attack_patterns: vec![AttackPattern::SwoopDive],
attack_dive_interval: 3.0, attack_dive_interval: 3.0,
@ -76,41 +79,36 @@ impl Default for StageConfigurations {
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL, enemy_shoot_interval: ENEMY_SHOOT_INTERVAL,
}; };
let stage2_layout = { let stage2_layout = circle_formation(16, WINDOW_WIDTH / 4.0, FORMATION_BASE_Y - 50.0);
let mut positions = Vec::new(); let stage2 = StageConfig {
let radius = WINDOW_WIDTH / 4.0; formation_layout: stage2_layout,
let center_y = FORMATION_BASE_Y - 50.0; enemy_count: 16,
let count = 16; // Example: Fewer enemies in a circle attack_patterns: vec![AttackPattern::SwoopDive, AttackPattern::DirectDive],
for i in 0..count { attack_dive_interval: 2.5,
let angle = (i as f32 / count as f32) * 2.0 * std::f32::consts::PI; enemy_speed_multiplier: 1.2,
positions.push(Vec3::new( enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8,
angle.cos() * radius, };
center_y + angle.sin() * radius,
0.0, Self {
)); stages: vec![stage1, stage2],
} }
}
}
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 { FormationLayout {
name: "Circle".to_string(), name: "Circle".to_string(),
positions, 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
enemy_speed_multiplier: 1.2, // Faster enemies
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8, // Faster shooting
};
// Add more stages here... #[derive(Resource, Default)]
StageConfigurations {
stages: vec![stage1, stage2], // Add more stages as needed
}
}
}
#[derive(Resource)]
pub struct Score { pub struct Score {
pub value: u32, pub value: u32,
} }
@ -118,14 +116,23 @@ pub struct Score {
#[derive(Resource)] #[derive(Resource)]
pub struct CurrentStage { pub struct CurrentStage {
pub number: u32, 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 struct FormationState {
pub next_slot_index: usize, pub next_slot_index: usize,
pub total_spawned_this_stage: 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)] #[derive(Resource)]

View file

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

View file

@ -2,47 +2,49 @@ use bevy::prelude::*;
use crate::components::Star; use crate::components::Star;
use crate::constants::{ use crate::constants::{
STAR_COUNT, STAR_MAX_SIZE, STAR_MAX_SPEED, STAR_MIN_SIZE, STAR_MIN_SPEED, STAR_COUNT, STAR_MAX_SIZE, STAR_MAX_SPEED, STAR_MIN_SIZE, STAR_MIN_SPEED, STAR_Z_DEPTH,
STAR_Z_DEPTH, WINDOW_HEIGHT, WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_WIDTH,
}; };
const PARALLAX_FACTOR: f32 = 0.01;
pub fn spawn_starfield(commands: &mut Commands) { pub fn spawn_starfield(commands: &mut Commands) {
for _ in 0..STAR_COUNT { for _ in 0..STAR_COUNT {
let size = fastrand::f32() * (STAR_MAX_SIZE - STAR_MIN_SIZE) + STAR_MIN_SIZE; let size = lerp_random(STAR_MIN_SIZE, STAR_MAX_SIZE);
let speed = fastrand::f32() * (STAR_MAX_SPEED - STAR_MIN_SPEED) + STAR_MIN_SPEED; let speed = lerp_random(STAR_MIN_SPEED, STAR_MAX_SPEED);
let brightness = fastrand::f32() * 0.5 + 0.5; // 0.5 to 1.0 let brightness = 0.5 + fastrand::f32() * 0.5;
commands.spawn(( commands.spawn((
SpriteBundle { Sprite {
sprite: Sprite { color: Color::srgb(brightness, brightness, brightness),
color: Color::rgb(brightness, brightness, brightness), custom_size: Some(Vec2::splat(size)),
custom_size: Some(Vec2::new(size, size)),
..default() ..default()
}, },
transform: Transform::from_translation(Vec3::new( Transform::from_xyz(
fastrand::f32() * WINDOW_WIDTH - WINDOW_WIDTH / 2.0, (fastrand::f32() - 0.5) * WINDOW_WIDTH,
fastrand::f32() * WINDOW_HEIGHT - WINDOW_HEIGHT / 2.0, (fastrand::f32() - 0.5) * WINDOW_HEIGHT,
STAR_Z_DEPTH, STAR_Z_DEPTH,
)), ),
..default()
},
Star { speed }, Star { speed },
)); ));
} }
} }
pub fn scroll_starfield(mut star_query: Query<(&mut Transform, &Star)>, time: Res<Time>) { pub fn scroll_starfield(mut star_query: Query<(&mut Transform, &Star)>, time: Res<Time>) {
let dt = time.delta_seconds(); let dt = time.delta_secs();
let half_height = WINDOW_HEIGHT / 2.0; let half_h = WINDOW_HEIGHT / 2.0;
let half_width = WINDOW_WIDTH / 2.0;
for (mut transform, star) in star_query.iter_mut() { 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; transform.translation.y -= (star.speed + parallax) * dt;
if transform.translation.y < -half_height { if transform.translation.y < -half_h {
transform.translation.y = half_height; transform.translation.y = half_h;
transform.translation.x = fastrand::f32() * WINDOW_WIDTH - half_width; 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,18 +1,20 @@
use bevy::prelude::*; use bevy::prelude::*;
use std::time::Duration;
use crate::components::{Invincible, Player}; use crate::components::{Explosion, Invincible, Player};
use crate::resources::{CurrentStage, PlayerLives, Score}; use crate::constants::{EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE};
use crate::player::spawn_player_ship; use crate::player::spawn_player_ship;
use crate::resources::{CurrentStage, PlayerLives, Score};
use crate::starfield::spawn_starfield; use crate::starfield::spawn_starfield;
// --- Setup ---
pub fn setup(mut commands: Commands) { pub fn setup(mut commands: Commands) {
commands.spawn(Camera2dBundle::default()); commands.spawn(Camera2d);
spawn_starfield(&mut commands); spawn_starfield(&mut commands);
spawn_player_ship(&mut commands); spawn_player_ship(&mut commands);
} }
// --- Run Conditions --- // --- Run conditions ---
pub fn player_exists(query: Query<&Player>) -> bool { pub fn player_exists(query: Query<&Player>) -> bool {
!query.is_empty() !query.is_empty()
} }
@ -21,27 +23,61 @@ pub fn player_vulnerable(query: Query<&Player, Without<Invincible>>) -> bool {
!query.is_empty() !query.is_empty()
} }
pub fn should_respawn_player(lives: Res<PlayerLives>, player_query: Query<&Player>) -> bool { pub fn should_respawn_player(lives: Res<PlayerLives>, query: Query<&Player>) -> bool {
player_query.is_empty() && lives.count > 0 query.is_empty() && lives.count > 0
} }
// Moved is_formation_complete to enemy.rs as it's closely related to enemy logic // --- HUD ---
// --- General Systems ---
// Update Window Title with Lives, Score, and Stage
pub fn update_window_title( pub fn update_window_title(
lives: Res<PlayerLives>, lives: Res<PlayerLives>,
score: Res<Score>, score: Res<Score>,
stage: Res<CurrentStage>, // Add CurrentStage resource stage: Res<CurrentStage>,
mut windows: Query<&mut Window>, mut windows: Query<&mut Window>,
) { ) {
// Update if lives, score, or stage changed if !(lives.is_changed() || score.is_changed() || stage.is_changed()) {
if lives.is_changed() || score.is_changed() || stage.is_changed() { return;
let mut window = windows.single_mut(); }
if let Ok(mut window) = windows.single_mut() {
window.title = format!( window.title = format!(
"Galaga :: Stage: {} Lives: {} Score: {}", "Galaga :: Stage: {} Lives: {} Score: {}",
stage.number, lives.count, score.value stage.number, lives.count, score.value
); );
} }
} }
// --- Explosions ---
pub fn spawn_explosion(commands: &mut Commands, position: Vec3) {
commands.spawn((
Sprite {
color: EXPLOSION_COLOR,
custom_size: Some(EXPLOSION_BASE_SIZE),
..default()
},
Transform::from_translation(position),
Explosion {
timer: Timer::new(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)>,
) {
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 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();
}
}
}