Compare commits
14 commits
db061820b9
...
ad2037a7a5
| Author | SHA1 | Date | |
|---|---|---|---|
| ad2037a7a5 | |||
| b2b564f690 | |||
| 7a4305677b | |||
| e5cc1b310a | |||
| ef3b7389e7 | |||
| c6dcf9d728 | |||
| 68e3051f77 | |||
| 506a775b0c | |||
| 35300ec62b | |||
| 8e06a91dde | |||
| 52b0919d3f | |||
| 405c326e9c | |||
| 08838c3428 | |||
| 2ff561efb1 |
18 changed files with 3748 additions and 1929 deletions
3
.mcp.json
Normal file
3
.mcp.json
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"mcpServers": {}
|
||||
}
|
||||
3367
Cargo.lock
generated
3367
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,5 +4,5 @@ version = "0.1.0"
|
|||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
bevy = "0.13"
|
||||
bevy = "0.18"
|
||||
fastrand = "2.0.1"
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -34,3 +34,28 @@ nix develop --command bash -c "cargo build"
|
|||
2. Clone the repository.
|
||||
3. Navigate to the project directory.
|
||||
4. Run the game using the command: `nix develop --command bash -c "cargo run"`
|
||||
|
||||
## 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
14
TODO.md
|
|
@ -29,9 +29,12 @@
|
|||
* [x] GAL-22: Periodically trigger random enemies to start an attack dive.
|
||||
* [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-25: Enemy Variety**
|
||||
* [ ] **GAL-25: Enemy Variety**
|
||||
* [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**
|
||||
|
||||
|
|
@ -40,7 +43,8 @@
|
|||
* [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-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-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.
|
||||
|
|
@ -54,7 +58,9 @@
|
|||
|
||||
* [ ] **GAL-42: Visuals**
|
||||
* [ ] GAL-43: Replace placeholder geometric shapes with actual sprites.
|
||||
* [ ] GAL-44: Add explosion animations/effects.
|
||||
* [x] GAL-44: Add explosion animations/effects.
|
||||
* **Branch:** `gal-44-add-explosion-effects`
|
||||
* **Comment:** 2026-05-06 — Implementation complete with commit `2ff561e`
|
||||
* [x] GAL-45: Implement a scrolling starfield background.
|
||||
* [ ] **GAL-46: Audio**
|
||||
* [ ] GAL-47: Integrate `bevy_audio`.
|
||||
|
|
|
|||
98
flake.nix
98
flake.nix
|
|
@ -26,9 +26,102 @@
|
|||
libxi
|
||||
libxkbcommon
|
||||
libxcb
|
||||
wayland
|
||||
vulkan-loader
|
||||
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
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
|
|
@ -49,6 +142,11 @@
|
|||
|
||||
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
|
||||
};
|
||||
|
||||
apps.take-screenshots = {
|
||||
type = "app";
|
||||
program = "${takeScreenshots}/bin/take-screenshots";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
|||
106
src/bullet.rs
106
src/bullet.rs
|
|
@ -1,16 +1,17 @@
|
|||
use bevy::prelude::*;
|
||||
|
||||
use crate::components::{Bullet, Enemy, EnemyBullet, EnemyType};
|
||||
use crate::components::{Bullet, Enemy, EnemyBullet, EnemyType, Invincible, Player};
|
||||
use crate::constants::{
|
||||
BULLET_ENEMY_COLLISION_THRESHOLD, BULLET_SIZE, BULLET_SPEED, ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD,
|
||||
ENEMY_BULLET_SIZE, ENEMY_BULLET_SPEED, WINDOW_HEIGHT,
|
||||
BULLET_ENEMY_COLLISION_THRESHOLD, BULLET_SIZE, BULLET_SPEED,
|
||||
ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD, ENEMY_BULLET_SIZE, ENEMY_BULLET_SPEED, WINDOW_HEIGHT,
|
||||
};
|
||||
use crate::resources::{PlayerLives, PlayerRespawnTimer, Score};
|
||||
use crate::game_state::AppState;
|
||||
use crate::components::Player; // Needed for check_enemy_bullet_player_collisions
|
||||
use crate::components::Invincible; // Needed for check_enemy_bullet_player_collisions
|
||||
use crate::player::kill_player;
|
||||
use crate::resources::{PlayerLives, PlayerRespawnTimer, Score};
|
||||
use crate::systems::spawn_explosion;
|
||||
|
||||
// --- Player Bullet Systems ---
|
||||
const GRUNT_POINTS: u32 = 100;
|
||||
const BOSS_POINTS: u32 = 100; // TODO(GAL-27): differentiate Boss from Grunt scoring
|
||||
|
||||
pub fn move_bullets(
|
||||
mut query: Query<(Entity, &mut Transform), With<Bullet>>,
|
||||
|
|
@ -18,8 +19,7 @@ pub fn move_bullets(
|
|||
mut commands: Commands,
|
||||
) {
|
||||
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 {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
|
|
@ -29,85 +29,65 @@ pub fn move_bullets(
|
|||
pub fn check_bullet_collisions(
|
||||
mut commands: Commands,
|
||||
bullet_query: Query<(Entity, &Transform), With<Bullet>>,
|
||||
enemy_query: Query<(Entity, &Transform, &Enemy), With<Enemy>>, // Fetch Enemy component too
|
||||
mut score: ResMut<Score>, // Add Score resource
|
||||
enemy_query: Query<(Entity, &Transform, &Enemy)>,
|
||||
mut score: ResMut<Score>,
|
||||
) {
|
||||
for (bullet_entity, bullet_transform) in bullet_query.iter() {
|
||||
for (enemy_entity, enemy_transform, enemy) in enemy_query.iter() {
|
||||
// Get Enemy component
|
||||
let distance = bullet_transform
|
||||
.translation
|
||||
.distance(enemy_transform.translation);
|
||||
let hit = enemy_query.iter().find(|(_, t, _)| {
|
||||
bullet_transform.translation.distance(t.translation) < BULLET_ENEMY_COLLISION_THRESHOLD
|
||||
});
|
||||
|
||||
if distance < BULLET_ENEMY_COLLISION_THRESHOLD {
|
||||
commands.entity(bullet_entity).despawn();
|
||||
commands.entity(enemy_entity).despawn();
|
||||
// Increment score based on enemy type
|
||||
let points = match enemy.enemy_type {
|
||||
EnemyType::Grunt => 100,
|
||||
EnemyType::Boss => 100, // Same points as Grunt for now
|
||||
};
|
||||
score.value += points;
|
||||
println!("Enemy hit by player bullet! Score: {}", score.value); // Log score update
|
||||
break; // Bullet only hits one enemy
|
||||
}
|
||||
if let Some((enemy_entity, enemy_transform, enemy)) = hit {
|
||||
commands.entity(bullet_entity).despawn();
|
||||
spawn_explosion(&mut commands, enemy_transform.translation);
|
||||
commands.entity(enemy_entity).despawn();
|
||||
score.value += match enemy.enemy_type {
|
||||
EnemyType::Grunt => GRUNT_POINTS,
|
||||
EnemyType::Boss => BOSS_POINTS,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Enemy Bullet Systems ---
|
||||
|
||||
pub fn move_enemy_bullets(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut query: Query<(Entity, &mut Transform), With<EnemyBullet>>,
|
||||
) {
|
||||
for (entity, mut transform) in query.iter_mut() {
|
||||
transform.translation.y -= ENEMY_BULLET_SPEED * time.delta_seconds();
|
||||
|
||||
// Despawn if off screen (bottom)
|
||||
transform.translation.y -= ENEMY_BULLET_SPEED * time.delta_secs();
|
||||
if transform.translation.y < -WINDOW_HEIGHT / 2.0 - ENEMY_BULLET_SIZE.y {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check collisions between enemy bullets and the player
|
||||
pub fn check_enemy_bullet_player_collisions(
|
||||
mut commands: Commands,
|
||||
mut lives: ResMut<PlayerLives>,
|
||||
mut respawn_timer: ResMut<PlayerRespawnTimer>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
// Player query matching the run_if condition (player exists and is vulnerable)
|
||||
player_query: Query<(Entity, &Transform), (With<Player>, Without<Invincible>)>,
|
||||
enemy_bullet_query: Query<(Entity, &Transform), With<EnemyBullet>>,
|
||||
bullet_query: Query<(Entity, &Transform), With<EnemyBullet>>,
|
||||
) {
|
||||
if let Ok((player_entity, player_transform)) = player_query.get_single() {
|
||||
for (bullet_entity, bullet_transform) in enemy_bullet_query.iter() {
|
||||
let distance = player_transform
|
||||
.translation
|
||||
.distance(bullet_transform.translation);
|
||||
let Ok((player_entity, player_transform)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
let player_pos = player_transform.translation;
|
||||
|
||||
if distance < ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD {
|
||||
println!("Player hit by enemy bullet!");
|
||||
commands.entity(bullet_entity).despawn(); // Despawn bullet
|
||||
let Some((bullet_entity, _)) = bullet_query.iter().find(|(_, t)| {
|
||||
player_pos.distance(t.translation) < ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
lives.count = lives.count.saturating_sub(1);
|
||||
println!("Lives remaining: {}", lives.count);
|
||||
|
||||
commands.entity(player_entity).despawn(); // Despawn player
|
||||
|
||||
if lives.count > 0 {
|
||||
respawn_timer.timer.reset();
|
||||
respawn_timer.timer.unpause();
|
||||
println!("Respawn timer started.");
|
||||
} else {
|
||||
println!("GAME OVER!");
|
||||
next_state.set(AppState::GameOver);
|
||||
}
|
||||
// Break because the player can only be hit once per frame
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
commands.entity(bullet_entity).despawn();
|
||||
kill_player(
|
||||
&mut commands,
|
||||
&mut lives,
|
||||
&mut respawn_timer,
|
||||
&mut next_state,
|
||||
player_entity,
|
||||
player_pos,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
use bevy::prelude::*;
|
||||
|
||||
// --- Components ---
|
||||
#[derive(Component)]
|
||||
pub struct Player {
|
||||
pub speed: f32,
|
||||
|
|
@ -10,15 +9,15 @@ pub struct Player {
|
|||
#[derive(Component)]
|
||||
pub struct Bullet;
|
||||
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)] // Added derive for common traits
|
||||
#[derive(Component, Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum EnemyType {
|
||||
Grunt,
|
||||
Boss, // Added Boss type
|
||||
Boss,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct Enemy {
|
||||
pub enemy_type: EnemyType, // Add type field
|
||||
pub enemy_type: EnemyType,
|
||||
pub shoot_cooldown: Timer,
|
||||
}
|
||||
|
||||
|
|
@ -27,61 +26,57 @@ pub struct Invincible {
|
|||
pub timer: Timer,
|
||||
}
|
||||
|
||||
// New component to mark a player as captured by a Boss enemy
|
||||
#[derive(Component, Clone)] // Added Clone derive
|
||||
/// Marks a player held by a Boss's tractor beam.
|
||||
#[derive(Component)]
|
||||
pub struct Captured {
|
||||
// Reference to the capturing boss entity
|
||||
pub boss_entity: Entity,
|
||||
// Timer for how long the player remains captured
|
||||
pub timer: Timer,
|
||||
}
|
||||
|
||||
// New component for the tractor beam visual effect
|
||||
#[derive(Component)]
|
||||
pub struct TractorBeam {
|
||||
pub target: Option<Entity>, // The entity being targeted (usually player)
|
||||
pub timer: Timer, // How long the beam lasts
|
||||
pub width: f32, // Visual width of the beam
|
||||
pub active: bool, // Whether the beam is currently active
|
||||
pub target: Option<Entity>,
|
||||
pub timer: Timer,
|
||||
pub width: f32,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct TractorBeamSprite;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct FormationTarget {
|
||||
pub position: Vec3,
|
||||
}
|
||||
|
||||
// Component to store the original formation position for enemies that need to return
|
||||
/// Stored on enemies once they reach formation, so attackers can return to it.
|
||||
#[derive(Component)]
|
||||
pub struct OriginalFormationPosition {
|
||||
pub position: Vec3,
|
||||
}
|
||||
|
||||
// Enum defining different ways an enemy can attack
|
||||
#[derive(Component, Clone, Copy, PartialEq, Debug)]
|
||||
pub enum AttackPattern {
|
||||
SwoopDive, // Original pattern: dive towards center, then off screen
|
||||
DirectDive, // Dive straight down
|
||||
Kamikaze(Vec3), // Dive towards a specific target location (e.g., player's last known position) - Needs target Vec3
|
||||
CaptureBeam, // New pattern: Boss dives and attempts to capture the player with a tractor beam
|
||||
// Add more patterns later (e.g., FigureEight, Looping)
|
||||
SwoopDive,
|
||||
DirectDive,
|
||||
Kamikaze(Vec3),
|
||||
CaptureBeam,
|
||||
}
|
||||
|
||||
#[derive(Component, Clone, PartialEq, Debug)] // Added Debug derive
|
||||
#[derive(Component, Clone, PartialEq, Debug)]
|
||||
pub enum EnemyState {
|
||||
Entering, // Flying onto the screen towards formation target
|
||||
InFormation, // Holding position in the formation
|
||||
Attacking(AttackPattern), // Diving towards the player using a specific pattern
|
||||
ReturningWithCaptive, // Boss returning to formation with captured player
|
||||
Entering,
|
||||
InFormation,
|
||||
Attacking(AttackPattern),
|
||||
ReturningWithCaptive,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct EnemyBullet;
|
||||
|
||||
// Game Over UI Component (might move to ui.rs later if more UI exists)
|
||||
#[derive(Component)]
|
||||
pub struct GameOverUI;
|
||||
|
||||
// Start Menu UI Components
|
||||
#[derive(Component)]
|
||||
pub struct StartMenuUI;
|
||||
|
||||
|
|
@ -95,3 +90,8 @@ pub struct RestartMessage;
|
|||
pub struct Star {
|
||||
pub speed: f32,
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct Explosion {
|
||||
pub timer: Timer,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,62 @@
|
|||
use bevy::math::Vec2;
|
||||
use bevy::prelude::*;
|
||||
|
||||
// --- Constants ---
|
||||
// Window
|
||||
pub const WINDOW_WIDTH: f32 = 600.0;
|
||||
pub const WINDOW_HEIGHT: f32 = 800.0;
|
||||
|
||||
// Player
|
||||
pub const PLAYER_SPEED: f32 = 300.0;
|
||||
pub const BULLET_SPEED: f32 = 500.0;
|
||||
pub const ENEMY_SPEED: f32 = 100.0;
|
||||
pub const PLAYER_SIZE: Vec2 = Vec2::new(30.0, 30.0);
|
||||
pub const STARTING_LIVES: u32 = 3;
|
||||
pub const PLAYER_RESPAWN_DELAY: f32 = 2.0;
|
||||
pub const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
|
||||
|
||||
// Enemies
|
||||
pub const ENEMY_SPEED: f32 = 100.0;
|
||||
pub const ENEMY_SIZE: Vec2 = Vec2::new(40.0, 40.0);
|
||||
pub const BULLET_SIZE: Vec2 = Vec2::new(5.0, 15.0);
|
||||
// Player bullet
|
||||
pub const ENEMY_BULLET_SIZE: Vec2 = Vec2::new(8.0, 8.0);
|
||||
// Enemy bullet
|
||||
pub const ENEMY_BULLET_SPEED: f32 = 300.0;
|
||||
pub const ENEMY_SHOOT_INTERVAL: f32 = 1.5;
|
||||
// Formation constants
|
||||
|
||||
// Bullets
|
||||
pub const BULLET_SPEED: f32 = 500.0;
|
||||
pub const BULLET_SIZE: Vec2 = Vec2::new(5.0, 15.0);
|
||||
pub const ENEMY_BULLET_SIZE: Vec2 = Vec2::new(8.0, 8.0);
|
||||
pub const ENEMY_BULLET_SPEED: f32 = 300.0;
|
||||
|
||||
// Formation
|
||||
const FORMATION_ROWS: usize = 4;
|
||||
pub const FORMATION_COLS: usize = 8;
|
||||
pub const FORMATION_ENEMY_COUNT: usize = FORMATION_ROWS * FORMATION_COLS;
|
||||
pub const FORMATION_X_SPACING: f32 = 60.0;
|
||||
pub const FORMATION_Y_SPACING: f32 = 50.0;
|
||||
pub const FORMATION_BASE_Y: f32 = WINDOW_HEIGHT / 2.0 - 150.0;
|
||||
// Top area for formation
|
||||
pub const STARTING_LIVES: u32 = 3;
|
||||
pub const PLAYER_RESPAWN_DELAY: f32 = 2.0;
|
||||
pub const PLAYER_INVINCIBILITY_DURATION: f32 = 2.0;
|
||||
// Collision thresholds
|
||||
|
||||
// Collision thresholds (mean of half-widths)
|
||||
pub const BULLET_ENEMY_COLLISION_THRESHOLD: f32 = (BULLET_SIZE.x + ENEMY_SIZE.x) * 0.5;
|
||||
// 22.5
|
||||
pub const PLAYER_ENEMY_COLLISION_THRESHOLD: f32 = (PLAYER_SIZE.x + ENEMY_SIZE.x) * 0.5;
|
||||
// 35.0
|
||||
pub const ENEMY_BULLET_PLAYER_COLLISION_THRESHOLD: f32 =
|
||||
(ENEMY_BULLET_SIZE.x + PLAYER_SIZE.x) * 0.5;
|
||||
|
||||
// Tractor beam constants
|
||||
// Tractor beam
|
||||
pub const TRACTOR_BEAM_WIDTH: f32 = 20.0;
|
||||
pub const TRACTOR_BEAM_DURATION: f32 = 3.0;
|
||||
pub const TRACTOR_BEAM_COLOR: Color = Color::rgba(0.5, 0.0, 0.8, 0.6);
|
||||
pub const CAPTURE_DURATION: f32 = 10.0; // How long the player stays captured
|
||||
pub const CAPTURE_DURATION: f32 = 10.0;
|
||||
pub const BEAM_GLOW_WIDTH: f32 = 40.0;
|
||||
pub const BEAM_GLOW_COLOR: Color = Color::srgba(0.3, 0.0, 0.5, 0.25);
|
||||
pub const BEAM_CORE_COLOR: Color = Color::srgba(0.7, 0.2, 1.0, 0.7);
|
||||
pub const BEAM_PULSE_FREQ: f32 = 3.0;
|
||||
pub const BEAM_PULSE_AMPLITUDE: f32 = 0.15;
|
||||
|
||||
// Starfield constants
|
||||
// Starfield
|
||||
pub const STAR_COUNT: usize = 150;
|
||||
pub const STAR_MIN_SIZE: f32 = 1.0;
|
||||
pub const STAR_MAX_SIZE: f32 = 3.0;
|
||||
pub const STAR_MIN_SPEED: f32 = 20.0;
|
||||
pub const STAR_MAX_SPEED: f32 = 100.0;
|
||||
pub const STAR_Z_DEPTH: f32 = -10.0; // Behind all game entities
|
||||
pub const STAR_Z_DEPTH: f32 = -10.0;
|
||||
|
||||
// Explosion
|
||||
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);
|
||||
|
|
|
|||
901
src/enemy.rs
901
src/enemy.rs
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
use crate::components::{Bullet, Enemy, GameOverUI, RestartMessage, StartButton, StartMenuUI};
|
||||
use crate::resources::RestartPressed;
|
||||
use bevy::prelude::*;
|
||||
|
||||
// --- Game States ---
|
||||
use crate::components::{Bullet, Enemy, EnemyBullet, GameOverUI, RestartMessage, StartButton, StartMenuUI};
|
||||
use crate::resources::RestartPressed;
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
|
||||
pub enum AppState {
|
||||
#[default]
|
||||
|
|
@ -11,207 +11,159 @@ pub enum AppState {
|
|||
GameOver,
|
||||
}
|
||||
|
||||
const BUTTON_IDLE: Color = Color::srgb(0.1, 0.1, 0.5);
|
||||
const BUTTON_HOVER: Color = Color::srgb(0.2, 0.2, 0.7);
|
||||
|
||||
// --- Game Over UI ---
|
||||
|
||||
pub fn setup_game_over_ui(mut commands: Commands) {
|
||||
println!("Entering GameOver state. Setting up UI.");
|
||||
commands.spawn((
|
||||
TextBundle::from_section(
|
||||
"GAME OVER",
|
||||
TextStyle {
|
||||
font_size: 100.0,
|
||||
color: Color::WHITE,
|
||||
..default()
|
||||
},
|
||||
)
|
||||
.with_style(Style {
|
||||
Text::new("GAME OVER"),
|
||||
TextFont {
|
||||
font_size: 100.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
align_self: AlignSelf::Center,
|
||||
justify_self: JustifySelf::Center,
|
||||
top: Val::Percent(40.0),
|
||||
..default()
|
||||
}),
|
||||
},
|
||||
GameOverUI,
|
||||
));
|
||||
commands.spawn((
|
||||
TextBundle::from_section(
|
||||
"Press R to Restart",
|
||||
TextStyle {
|
||||
font_size: 32.0,
|
||||
color: Color::WHITE,
|
||||
..default()
|
||||
},
|
||||
)
|
||||
.with_style(Style {
|
||||
Text::new("Press R to Restart"),
|
||||
TextFont {
|
||||
font_size: 32.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
align_self: AlignSelf::Center,
|
||||
justify_self: JustifySelf::Center,
|
||||
top: Val::Percent(55.0),
|
||||
..default()
|
||||
}),
|
||||
},
|
||||
RestartMessage,
|
||||
));
|
||||
}
|
||||
|
||||
pub fn cleanup_game_over_ui(
|
||||
mut commands: Commands,
|
||||
query: Query<Entity, With<GameOverUI>>,
|
||||
restart_query: Query<Entity, With<RestartMessage>>,
|
||||
query: Query<Entity, Or<(With<GameOverUI>, With<RestartMessage>)>>,
|
||||
) {
|
||||
println!("Exiting GameOver state. Cleaning up UI.");
|
||||
for entity in query.iter() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
}
|
||||
for entity in restart_query.iter() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
for entity in &query {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cleanup ---
|
||||
// --- Cleanup on leaving Playing ---
|
||||
|
||||
// Cleanup system when exiting the Playing state
|
||||
pub fn cleanup_game_entities(
|
||||
mut commands: Commands,
|
||||
bullet_query: Query<Entity, With<Bullet>>,
|
||||
enemy_bullet_query: Query<Entity, With<crate::components::EnemyBullet>>,
|
||||
enemy_query: Query<Entity, With<Enemy>>,
|
||||
restart_message_query: Query<Entity, With<crate::components::RestartMessage>>,
|
||||
// Optionally despawn player too, or handle separately if needed for restart
|
||||
// player_query: Query<Entity, With<Player>>,
|
||||
query: Query<Entity, Or<(With<Bullet>, With<EnemyBullet>, With<Enemy>)>>,
|
||||
) {
|
||||
println!("Exiting Playing state. Cleaning up game entities.");
|
||||
for entity in bullet_query.iter() {
|
||||
for entity in &query {
|
||||
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 ---
|
||||
|
||||
pub fn setup_start_menu_ui(mut commands: Commands) {
|
||||
println!("Entering StartMenu state. Setting up UI.");
|
||||
|
||||
// Root UI container
|
||||
commands
|
||||
.spawn((
|
||||
NodeBundle {
|
||||
style: Style {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Center,
|
||||
flex_direction: FlexDirection::Column,
|
||||
..default()
|
||||
},
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Center,
|
||||
flex_direction: FlexDirection::Column,
|
||||
..default()
|
||||
},
|
||||
StartMenuUI,
|
||||
))
|
||||
.with_children(|parent| {
|
||||
// Title
|
||||
parent.spawn(
|
||||
TextBundle::from_section(
|
||||
"BGLGA",
|
||||
TextStyle {
|
||||
font_size: 120.0,
|
||||
color: Color::WHITE,
|
||||
..default()
|
||||
},
|
||||
)
|
||||
.with_style(Style {
|
||||
parent.spawn((
|
||||
Text::new("BGLGA"),
|
||||
TextFont {
|
||||
font_size: 120.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
Node {
|
||||
margin: UiRect::bottom(Val::Px(50.0)),
|
||||
..default()
|
||||
}),
|
||||
);
|
||||
|
||||
// Start Game Button
|
||||
},
|
||||
));
|
||||
parent
|
||||
.spawn((
|
||||
ButtonBundle {
|
||||
style: Style {
|
||||
width: Val::Px(250.0),
|
||||
height: Val::Px(80.0),
|
||||
border: UiRect::all(Val::Px(2.0)),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
border_color: BorderColor(Color::WHITE),
|
||||
background_color: BackgroundColor(Color::rgb(0.1, 0.1, 0.5)),
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Px(250.0),
|
||||
height: Val::Px(80.0),
|
||||
border: UiRect::all(Val::Px(2.0)),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
..default()
|
||||
},
|
||||
BorderColor::all(Color::WHITE),
|
||||
BackgroundColor(BUTTON_IDLE),
|
||||
StartButton,
|
||||
))
|
||||
.with_children(|parent| {
|
||||
parent.spawn(TextBundle::from_section(
|
||||
"Start Game",
|
||||
TextStyle {
|
||||
parent.spawn((
|
||||
Text::new("Start Game"),
|
||||
TextFont {
|
||||
font_size: 40.0,
|
||||
color: Color::WHITE,
|
||||
..default()
|
||||
},
|
||||
TextColor(Color::WHITE),
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
pub fn cleanup_start_menu_ui(mut commands: Commands, query: Query<Entity, With<StartMenuUI>>) {
|
||||
println!("Exiting StartMenu state. Cleaning up UI.");
|
||||
for entity in query.iter() {
|
||||
commands.entity(entity).despawn_recursive();
|
||||
for entity in &query {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_menu_button_system(
|
||||
mut interaction_query: Query<
|
||||
mut interactions: Query<
|
||||
(&Interaction, &mut BackgroundColor),
|
||||
(Changed<Interaction>, With<StartButton>),
|
||||
>,
|
||||
mut app_state: ResMut<NextState<AppState>>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
) {
|
||||
for (interaction, mut color) in &mut interaction_query {
|
||||
for (interaction, mut bg) in &mut interactions {
|
||||
match *interaction {
|
||||
Interaction::Pressed => {
|
||||
println!("Start button pressed! Transitioning to Playing state.");
|
||||
app_state.set(AppState::Playing);
|
||||
}
|
||||
Interaction::Hovered => {
|
||||
color.0 = Color::rgb(0.2, 0.2, 0.7);
|
||||
}
|
||||
Interaction::None => {
|
||||
color.0 = Color::rgb(0.1, 0.1, 0.5);
|
||||
}
|
||||
Interaction::Pressed => next_state.set(AppState::Playing),
|
||||
Interaction::Hovered => bg.0 = BUTTON_HOVER,
|
||||
Interaction::None => bg.0 = BUTTON_IDLE,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_restart_input(
|
||||
keyboard_input: Res<ButtonInput<KeyCode>>,
|
||||
mut restart_resource: ResMut<RestartPressed>,
|
||||
keyboard: Res<ButtonInput<KeyCode>>,
|
||||
mut restart: ResMut<RestartPressed>,
|
||||
) {
|
||||
if keyboard_input.just_pressed(KeyCode::KeyR) {
|
||||
restart_resource.pressed = true;
|
||||
if keyboard.just_pressed(KeyCode::KeyR) {
|
||||
restart.pressed = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restart_game_system(
|
||||
mut app_state: ResMut<NextState<AppState>>,
|
||||
mut restart_resource: ResMut<RestartPressed>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
mut restart: ResMut<RestartPressed>,
|
||||
) {
|
||||
if restart_resource.pressed {
|
||||
println!("Restart requested. Transitioning to Playing state.");
|
||||
restart_resource.pressed = false;
|
||||
app_state.set(AppState::Playing);
|
||||
if restart.pressed {
|
||||
restart.pressed = false;
|
||||
next_state.set(AppState::Playing);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
132
src/lib.rs
Normal file
132
src/lib.rs
Normal 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();
|
||||
}
|
||||
131
src/main.rs
131
src/main.rs
|
|
@ -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() {
|
||||
App::new()
|
||||
.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();
|
||||
bglga::run();
|
||||
}
|
||||
|
|
|
|||
310
src/player.rs
310
src/player.rs
|
|
@ -1,5 +1,4 @@
|
|||
use bevy::prelude::*;
|
||||
use bevy::ecs::system::ParamSet;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::components::{Bullet, Captured, Enemy, Invincible, Player};
|
||||
|
|
@ -9,28 +8,28 @@ use crate::constants::{
|
|||
};
|
||||
use crate::game_state::AppState;
|
||||
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) {
|
||||
commands.spawn((
|
||||
SpriteBundle {
|
||||
sprite: Sprite {
|
||||
color: Color::rgb(0.0, 0.5, 1.0),
|
||||
custom_size: Some(PLAYER_SIZE),
|
||||
..default()
|
||||
},
|
||||
transform: Transform::from_translation(Vec3::new(
|
||||
0.0,
|
||||
-WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0,
|
||||
0.0,
|
||||
)),
|
||||
Sprite {
|
||||
color: Color::srgb(0.0, 0.5, 1.0),
|
||||
custom_size: Some(PLAYER_SIZE),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, -WINDOW_HEIGHT / 2.0 + PLAYER_SIZE.y / 2.0 + 20.0, 0.0),
|
||||
Player {
|
||||
speed: PLAYER_SPEED,
|
||||
shoot_cooldown: Timer::new(Duration::from_secs_f32(0.3), TimerMode::Once),
|
||||
shoot_cooldown: Timer::new(
|
||||
Duration::from_secs_f32(PLAYER_SHOOT_COOLDOWN),
|
||||
TimerMode::Once,
|
||||
),
|
||||
},
|
||||
// Player starts invincible for a short time
|
||||
Invincible {
|
||||
timer: Timer::new(
|
||||
Duration::from_secs_f32(PLAYER_INVINCIBILITY_DURATION),
|
||||
|
|
@ -38,251 +37,194 @@ pub fn spawn_player_ship(commands: &mut Commands) {
|
|||
),
|
||||
},
|
||||
));
|
||||
println!("Player spawned!");
|
||||
}
|
||||
|
||||
pub fn move_player(
|
||||
keyboard_input: Res<ButtonInput<KeyCode>>,
|
||||
mut query: Query<(&mut Transform, &Player), Without<Captured>>, // Don't move captured players with controls
|
||||
keyboard: Res<ButtonInput<KeyCode>>,
|
||||
mut query: Query<(&mut Transform, &Player), Without<Captured>>,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
// Using get_single_mut handles the case where player might not exist yet (or was just destroyed)
|
||||
if let Ok((mut transform, player)) = query.get_single_mut() {
|
||||
let mut direction = 0.0;
|
||||
let Ok((mut transform, player)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if keyboard_input.pressed(KeyCode::KeyA) || keyboard_input.pressed(KeyCode::ArrowLeft) {
|
||||
direction -= 1.0;
|
||||
}
|
||||
|
||||
if keyboard_input.pressed(KeyCode::KeyD) || keyboard_input.pressed(KeyCode::ArrowRight) {
|
||||
direction += 1.0;
|
||||
}
|
||||
|
||||
transform.translation.x += direction * player.speed * time.delta_seconds();
|
||||
let half_player_width = PLAYER_SIZE.x / 2.0;
|
||||
transform.translation.x = transform.translation.x.clamp(
|
||||
-WINDOW_WIDTH / 2.0 + half_player_width,
|
||||
WINDOW_WIDTH / 2.0 - half_player_width,
|
||||
);
|
||||
let mut direction = 0.0;
|
||||
if keyboard.pressed(KeyCode::KeyA) || keyboard.pressed(KeyCode::ArrowLeft) {
|
||||
direction -= 1.0;
|
||||
}
|
||||
if keyboard.pressed(KeyCode::KeyD) || keyboard.pressed(KeyCode::ArrowRight) {
|
||||
direction += 1.0;
|
||||
}
|
||||
|
||||
transform.translation.x += direction * player.speed * time.delta_secs();
|
||||
let half_width = PLAYER_SIZE.x / 2.0;
|
||||
transform.translation.x = transform
|
||||
.translation
|
||||
.x
|
||||
.clamp(-WINDOW_WIDTH / 2.0 + half_width, WINDOW_WIDTH / 2.0 - half_width);
|
||||
}
|
||||
|
||||
// New system to handle captured player movement
|
||||
pub fn handle_captured_player(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut set: ParamSet<(
|
||||
Query<(Entity, &mut Transform, &mut Captured)>,
|
||||
Query<&Transform, (With<Enemy>, Without<Player>)>,
|
||||
)>,
|
||||
mut player_query: Query<(Entity, &mut Transform, &mut Captured), With<Player>>,
|
||||
boss_query: Query<&Transform, (With<Enemy>, Without<Player>)>,
|
||||
mut lives: ResMut<PlayerLives>,
|
||||
mut respawn_timer: ResMut<PlayerRespawnTimer>,
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
) {
|
||||
// First, collect data about captured players and their bosses
|
||||
let mut captured_data = Vec::new();
|
||||
|
||||
// Get player data
|
||||
for (entity, transform, captured) in set.p0().iter() {
|
||||
captured_data.push((entity, transform.translation, captured.boss_entity, captured.timer.clone()));
|
||||
}
|
||||
|
||||
// Process each captured player
|
||||
for (player_entity, _player_pos, boss_entity, mut timer) in captured_data {
|
||||
// Check if the boss exists and get its position
|
||||
let boss_pos = set.p1().get(boss_entity).map(|t| t.translation).ok();
|
||||
|
||||
// Tick the timer
|
||||
timer.tick(time.delta());
|
||||
|
||||
// Update the player
|
||||
if let Ok((entity, mut transform, mut captured)) = set.p0().get_mut(player_entity) {
|
||||
// Update the actual timer
|
||||
captured.timer.tick(time.delta());
|
||||
|
||||
match boss_pos {
|
||||
Some(boss_pos) => {
|
||||
// Boss exists, update player position
|
||||
let target_pos = boss_pos - Vec3::new(0.0, PLAYER_SIZE.y + 10.0, 0.0);
|
||||
transform.translation = transform.translation.lerp(target_pos, 0.2);
|
||||
}
|
||||
None => {
|
||||
// Boss is gone, release player but lose a life
|
||||
println!("Boss is gone, releasing captured player!");
|
||||
commands.entity(entity).remove::<Captured>();
|
||||
lose_life_and_respawn(
|
||||
&mut commands,
|
||||
&mut lives,
|
||||
&mut respawn_timer,
|
||||
&mut next_state,
|
||||
entity,
|
||||
);
|
||||
continue; // Skip the rest of processing for this player
|
||||
}
|
||||
}
|
||||
|
||||
// If capture duration expires, player escapes but loses a life
|
||||
if captured.timer.finished() {
|
||||
println!("Player escaped from capture after timer expired!");
|
||||
commands.entity(entity).remove::<Captured>();
|
||||
lose_life_and_respawn(
|
||||
&mut commands,
|
||||
&mut lives,
|
||||
&mut respawn_timer,
|
||||
&mut next_state,
|
||||
entity,
|
||||
);
|
||||
}
|
||||
for (player_entity, mut transform, mut captured) in player_query.iter_mut() {
|
||||
captured.timer.tick(time.delta());
|
||||
|
||||
let Ok(boss_transform) = boss_query.get(captured.boss_entity) else {
|
||||
// Boss was despawned mid-capture: release and penalize.
|
||||
commands.entity(player_entity).remove::<Captured>();
|
||||
kill_player(
|
||||
&mut commands,
|
||||
&mut lives,
|
||||
&mut respawn_timer,
|
||||
&mut next_state,
|
||||
player_entity,
|
||||
transform.translation,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
let target = boss_transform.translation - Vec3::Y * CAPTURE_OFFSET_BELOW_BOSS;
|
||||
transform.translation = transform.translation.lerp(target, CAPTURE_FOLLOW_LERP);
|
||||
|
||||
if captured.timer.is_finished() {
|
||||
commands.entity(player_entity).remove::<Captured>();
|
||||
kill_player(
|
||||
&mut commands,
|
||||
&mut lives,
|
||||
&mut respawn_timer,
|
||||
&mut next_state,
|
||||
player_entity,
|
||||
transform.translation,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function for player life loss and respawn logic
|
||||
fn lose_life_and_respawn(
|
||||
/// Despawn the player at `position`, decrement lives, and either start the respawn
|
||||
/// timer or transition to GameOver. Does not handle collateral entities (the caller
|
||||
/// despawns enemies/bullets/etc. as needed).
|
||||
pub(crate) fn kill_player(
|
||||
commands: &mut Commands,
|
||||
lives: &mut ResMut<PlayerLives>,
|
||||
respawn_timer: &mut ResMut<PlayerRespawnTimer>,
|
||||
next_state: &mut ResMut<NextState<AppState>>,
|
||||
lives: &mut PlayerLives,
|
||||
respawn_timer: &mut PlayerRespawnTimer,
|
||||
next_state: &mut NextState<AppState>,
|
||||
player_entity: Entity,
|
||||
position: Vec3,
|
||||
) {
|
||||
// Lose a life
|
||||
lives.count = lives.count.saturating_sub(1);
|
||||
println!("Lives remaining: {}", lives.count);
|
||||
|
||||
// Destroy player
|
||||
spawn_explosion(commands, position);
|
||||
commands.entity(player_entity).despawn();
|
||||
|
||||
if lives.count > 0 {
|
||||
respawn_timer.timer.reset();
|
||||
respawn_timer.timer.unpause();
|
||||
println!("Respawn timer started.");
|
||||
} else {
|
||||
println!("GAME OVER!");
|
||||
info!("GAME OVER");
|
||||
next_state.set(AppState::GameOver);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn player_shoot(
|
||||
keyboard_input: Res<ButtonInput<KeyCode>>,
|
||||
mut query: Query<(&Transform, &mut Player), Without<Captured>>, // Only non-captured players can shoot
|
||||
keyboard: Res<ButtonInput<KeyCode>>,
|
||||
mut query: Query<(&Transform, &mut Player), Without<Captured>>,
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
) {
|
||||
if let Ok((player_transform, mut player)) = query.get_single_mut() {
|
||||
player.shoot_cooldown.tick(time.delta());
|
||||
let Ok((transform, mut player)) = query.single_mut() else {
|
||||
return;
|
||||
};
|
||||
player.shoot_cooldown.tick(time.delta());
|
||||
|
||||
if (keyboard_input.just_pressed(KeyCode::Space)
|
||||
|| keyboard_input.just_pressed(KeyCode::ArrowUp))
|
||||
&& player.shoot_cooldown.finished()
|
||||
{
|
||||
player.shoot_cooldown.reset();
|
||||
|
||||
let bullet_start_pos = player_transform.translation
|
||||
+ Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
|
||||
|
||||
commands.spawn((
|
||||
SpriteBundle {
|
||||
sprite: Sprite {
|
||||
color: Color::rgb(1.0, 1.0, 1.0),
|
||||
custom_size: Some(BULLET_SIZE),
|
||||
..default()
|
||||
},
|
||||
transform: Transform::from_translation(bullet_start_pos),
|
||||
..default()
|
||||
},
|
||||
Bullet,
|
||||
));
|
||||
}
|
||||
let fire = keyboard.just_pressed(KeyCode::Space) || keyboard.just_pressed(KeyCode::ArrowUp);
|
||||
if !fire || !player.shoot_cooldown.is_finished() {
|
||||
return;
|
||||
}
|
||||
player.shoot_cooldown.reset();
|
||||
|
||||
let bullet_pos = transform.translation + Vec3::Y * (PLAYER_SIZE.y / 2.0 + BULLET_SIZE.y / 2.0);
|
||||
commands.spawn((
|
||||
Sprite {
|
||||
color: Color::WHITE,
|
||||
custom_size: Some(BULLET_SIZE),
|
||||
..default()
|
||||
},
|
||||
Transform::from_translation(bullet_pos),
|
||||
Bullet,
|
||||
));
|
||||
}
|
||||
|
||||
// Modified Collision Check for Player vs Enemy
|
||||
pub fn check_player_enemy_collisions(
|
||||
mut commands: Commands,
|
||||
mut lives: ResMut<PlayerLives>,
|
||||
mut respawn_timer: ResMut<PlayerRespawnTimer>,
|
||||
mut next_state: ResMut<NextState<AppState>>, // Resource to change state
|
||||
// Query player without Invincible component - relies on run_if condition too
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
player_query: Query<
|
||||
(Entity, &Transform),
|
||||
(With<Player>, Without<Invincible>, Without<Captured>),
|
||||
>, // Don't check collisions for captured players
|
||||
>,
|
||||
enemy_query: Query<(Entity, &Transform), With<Enemy>>,
|
||||
) {
|
||||
// This system only runs if player exists and is not invincible, due to run_if
|
||||
if let Ok((player_entity, player_transform)) = player_query.get_single() {
|
||||
for (enemy_entity, enemy_transform) in enemy_query.iter() {
|
||||
let distance = player_transform
|
||||
.translation
|
||||
.distance(enemy_transform.translation);
|
||||
let Ok((player_entity, player_transform)) = player_query.single() else {
|
||||
return;
|
||||
};
|
||||
let player_pos = player_transform.translation;
|
||||
|
||||
if distance < PLAYER_ENEMY_COLLISION_THRESHOLD {
|
||||
println!("Player hit by enemy!");
|
||||
commands.entity(enemy_entity).despawn(); // Despawn enemy
|
||||
let Some((enemy_entity, enemy_transform)) = enemy_query
|
||||
.iter()
|
||||
.find(|(_, t)| player_pos.distance(t.translation) < PLAYER_ENEMY_COLLISION_THRESHOLD)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
lives.count = lives.count.saturating_sub(1); // Decrement lives safely
|
||||
println!("Lives remaining: {}", lives.count);
|
||||
|
||||
commands.entity(player_entity).despawn(); // Despawn player
|
||||
|
||||
if lives.count > 0 {
|
||||
// Start the respawn timer
|
||||
respawn_timer.timer.reset();
|
||||
respawn_timer.timer.unpause();
|
||||
println!("Respawn timer started.");
|
||||
} else {
|
||||
println!("GAME OVER!");
|
||||
next_state.set(AppState::GameOver); // Updated for newer Bevy states API
|
||||
}
|
||||
// Important: Break after handling one collision per frame for the player
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
spawn_explosion(&mut commands, enemy_transform.translation);
|
||||
commands.entity(enemy_entity).despawn();
|
||||
kill_player(
|
||||
&mut commands,
|
||||
&mut lives,
|
||||
&mut respawn_timer,
|
||||
&mut next_state,
|
||||
player_entity,
|
||||
player_pos,
|
||||
);
|
||||
}
|
||||
|
||||
// New System: Respawn Player
|
||||
pub fn respawn_player(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut respawn_timer: ResMut<PlayerRespawnTimer>,
|
||||
// No player query needed here due to run_if condition
|
||||
) {
|
||||
// Tick the timer only if it's actually running
|
||||
if respawn_timer.timer.tick(time.delta()).just_finished() {
|
||||
println!("Respawn timer finished. Spawning player.");
|
||||
spawn_player_ship(&mut commands);
|
||||
respawn_timer.timer.pause(); // Pause timer until next death
|
||||
respawn_timer.timer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
// New System: Manage Invincibility
|
||||
pub fn manage_invincibility(
|
||||
mut commands: Commands,
|
||||
time: Res<Time>,
|
||||
mut query: Query<(Entity, &mut Invincible, Option<&mut Visibility>), With<Player>>,
|
||||
mut query: Query<(Entity, &mut Invincible, &mut Visibility), With<Player>>,
|
||||
) {
|
||||
for (entity, mut invincible, mut visibility) in query.iter_mut() {
|
||||
invincible.timer.tick(time.delta());
|
||||
|
||||
// Blinking effect (optional)
|
||||
if let Some(ref mut vis) = visibility {
|
||||
// Blink roughly 5 times per second
|
||||
let elapsed_secs = invincible.timer.elapsed_secs();
|
||||
**vis = if (elapsed_secs * 10.0).floor() % 2.0 == 0.0 {
|
||||
if invincible.timer.is_finished() {
|
||||
commands.entity(entity).remove::<Invincible>();
|
||||
*visibility = Visibility::Visible;
|
||||
} else {
|
||||
// Blink at INVINCIBILITY_BLINK_HZ / 2 (one full cycle = 2 floor steps).
|
||||
let phase = (invincible.timer.elapsed_secs() * INVINCIBILITY_BLINK_HZ) as u32;
|
||||
*visibility = if phase.is_multiple_of(2) {
|
||||
Visibility::Visible
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
}
|
||||
|
||||
if invincible.timer.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
123
src/resources.rs
123
src/resources.rs
|
|
@ -1,6 +1,11 @@
|
|||
use bevy::prelude::*;
|
||||
|
||||
// --- Resources ---
|
||||
use crate::components::AttackPattern;
|
||||
use crate::constants::{
|
||||
ENEMY_SHOOT_INTERVAL, FORMATION_BASE_Y, FORMATION_COLS, FORMATION_ENEMY_COUNT,
|
||||
FORMATION_X_SPACING, FORMATION_Y_SPACING, WINDOW_WIDTH,
|
||||
};
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct EnemySpawnTimer {
|
||||
pub timer: Timer,
|
||||
|
|
@ -16,59 +21,57 @@ pub struct PlayerRespawnTimer {
|
|||
pub timer: Timer,
|
||||
}
|
||||
|
||||
// New struct to define formation positions
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FormationLayout {
|
||||
pub name: String, // Optional name for debugging/identification
|
||||
pub name: String,
|
||||
pub positions: Vec<Vec3>,
|
||||
}
|
||||
|
||||
// Default implementation for easy initialization
|
||||
impl Default for FormationLayout {
|
||||
fn default() -> Self {
|
||||
// Default to the original grid formation for now
|
||||
let mut positions = Vec::with_capacity(crate::constants::FORMATION_ENEMY_COUNT);
|
||||
for i in 0..crate::constants::FORMATION_ENEMY_COUNT {
|
||||
let row = i / crate::constants::FORMATION_COLS;
|
||||
let col = i % crate::constants::FORMATION_COLS;
|
||||
let target_x = (col as f32 - (crate::constants::FORMATION_COLS as f32 - 1.0) / 2.0)
|
||||
* crate::constants::FORMATION_X_SPACING;
|
||||
let target_y = crate::constants::FORMATION_BASE_Y
|
||||
- (row as f32 * crate::constants::FORMATION_Y_SPACING);
|
||||
positions.push(Vec3::new(target_x, target_y, 0.0));
|
||||
}
|
||||
FormationLayout {
|
||||
let positions = (0..FORMATION_ENEMY_COUNT)
|
||||
.map(|i| {
|
||||
let row = i / FORMATION_COLS;
|
||||
let col = i % FORMATION_COLS;
|
||||
let x = (col as f32 - (FORMATION_COLS as f32 - 1.0) / 2.0) * FORMATION_X_SPACING;
|
||||
let y = FORMATION_BASE_Y - row as f32 * FORMATION_Y_SPACING;
|
||||
Vec3::new(x, y, 0.0)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
name: "Default Grid".to_string(),
|
||||
positions,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration for a single stage
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StageConfig {
|
||||
pub formation_layout: FormationLayout,
|
||||
pub enemy_count: usize, // Allow overriding enemy count per stage
|
||||
pub attack_patterns: Vec<crate::components::AttackPattern>, // Possible patterns for this stage
|
||||
pub attack_dive_interval: f32, // Time between attack dives for this stage
|
||||
pub enemy_speed_multiplier: f32, // Speed multiplier for this stage
|
||||
pub enemy_shoot_interval: f32, // Shoot interval for this stage
|
||||
pub enemy_count: usize,
|
||||
pub attack_patterns: Vec<AttackPattern>,
|
||||
pub attack_dive_interval: f32,
|
||||
pub enemy_speed_multiplier: f32,
|
||||
pub enemy_shoot_interval: f32,
|
||||
}
|
||||
|
||||
// Resource to hold all stage configurations
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct StageConfigurations {
|
||||
pub stages: Vec<StageConfig>,
|
||||
}
|
||||
|
||||
impl StageConfigurations {
|
||||
/// Cycles through configured stages once `stage_number` exceeds the count.
|
||||
pub fn for_stage(&self, stage_number: u32) -> &StageConfig {
|
||||
let idx = (stage_number.saturating_sub(1) as usize) % self.stages.len();
|
||||
&self.stages[idx]
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StageConfigurations {
|
||||
fn default() -> Self {
|
||||
use crate::components::AttackPattern;
|
||||
use crate::constants::*; // Import constants for default values
|
||||
|
||||
// Define configurations for a few stages
|
||||
let stage1 = StageConfig {
|
||||
formation_layout: FormationLayout::default(), // Use the default grid
|
||||
formation_layout: FormationLayout::default(),
|
||||
enemy_count: FORMATION_ENEMY_COUNT,
|
||||
attack_patterns: vec![AttackPattern::SwoopDive],
|
||||
attack_dive_interval: 3.0,
|
||||
|
|
@ -76,41 +79,36 @@ impl Default for StageConfigurations {
|
|||
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL,
|
||||
};
|
||||
|
||||
let stage2_layout = {
|
||||
let mut positions = Vec::new();
|
||||
let radius = WINDOW_WIDTH / 4.0;
|
||||
let center_y = FORMATION_BASE_Y - 50.0;
|
||||
let count = 16; // Example: Fewer enemies in a circle
|
||||
for i in 0..count {
|
||||
let angle = (i as f32 / count as f32) * 2.0 * std::f32::consts::PI;
|
||||
positions.push(Vec3::new(
|
||||
angle.cos() * radius,
|
||||
center_y + angle.sin() * radius,
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
FormationLayout {
|
||||
name: "Circle".to_string(),
|
||||
positions,
|
||||
}
|
||||
};
|
||||
let stage2_layout = circle_formation(16, WINDOW_WIDTH / 4.0, FORMATION_BASE_Y - 50.0);
|
||||
let stage2 = StageConfig {
|
||||
formation_layout: stage2_layout,
|
||||
enemy_count: 16,
|
||||
attack_patterns: vec![AttackPattern::SwoopDive, AttackPattern::DirectDive], // Add direct dive
|
||||
attack_dive_interval: 2.5, // Faster dives
|
||||
enemy_speed_multiplier: 1.2, // Faster enemies
|
||||
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8, // Faster shooting
|
||||
attack_patterns: vec![AttackPattern::SwoopDive, AttackPattern::DirectDive],
|
||||
attack_dive_interval: 2.5,
|
||||
enemy_speed_multiplier: 1.2,
|
||||
enemy_shoot_interval: ENEMY_SHOOT_INTERVAL * 0.8,
|
||||
};
|
||||
|
||||
// Add more stages here...
|
||||
|
||||
StageConfigurations {
|
||||
stages: vec![stage1, stage2], // Add more stages as needed
|
||||
Self {
|
||||
stages: vec![stage1, stage2],
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Resource)]
|
||||
|
||||
fn circle_formation(count: usize, radius: f32, center_y: f32) -> FormationLayout {
|
||||
let positions = (0..count)
|
||||
.map(|i| {
|
||||
let angle = (i as f32 / count as f32) * std::f32::consts::TAU;
|
||||
Vec3::new(angle.cos() * radius, center_y + angle.sin() * radius, 0.0)
|
||||
})
|
||||
.collect();
|
||||
FormationLayout {
|
||||
name: "Circle".to_string(),
|
||||
positions,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct Score {
|
||||
pub value: u32,
|
||||
}
|
||||
|
|
@ -118,14 +116,23 @@ pub struct Score {
|
|||
#[derive(Resource)]
|
||||
pub struct CurrentStage {
|
||||
pub number: u32,
|
||||
pub waiting_for_clear: bool, // Flag to check if we should check for stage clear
|
||||
pub waiting_for_clear: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
impl Default for CurrentStage {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
number: 1,
|
||||
waiting_for_clear: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct FormationState {
|
||||
pub next_slot_index: usize,
|
||||
pub total_spawned_this_stage: usize,
|
||||
pub formation_complete: bool, // Flag to indicate if all enemies are in position
|
||||
pub formation_complete: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
|
|
|
|||
44
src/stage.rs
44
src/stage.rs
|
|
@ -3,35 +3,19 @@ use bevy::prelude::*;
|
|||
use crate::components::Enemy;
|
||||
use crate::resources::{CurrentStage, EnemySpawnTimer, FormationState};
|
||||
|
||||
// Helper to access world directly in check_stage_clear
|
||||
pub fn check_stage_clear(world: &mut World) {
|
||||
// Use manual resource access because we need mutable access to multiple resources
|
||||
// Separate checks to manage borrows correctly
|
||||
let mut should_clear = false;
|
||||
if let Some(stage) = world.get_resource::<CurrentStage>() {
|
||||
if stage.waiting_for_clear {
|
||||
// Create the query *after* checking the flag
|
||||
let mut enemy_query = world.query_filtered::<Entity, With<Enemy>>();
|
||||
if enemy_query.iter(world).next().is_none() {
|
||||
should_clear = true;
|
||||
}
|
||||
}
|
||||
pub fn check_stage_clear(
|
||||
enemy_query: Query<(), With<Enemy>>,
|
||||
mut stage: ResMut<CurrentStage>,
|
||||
mut formation_state: ResMut<FormationState>,
|
||||
mut spawn_timer: ResMut<EnemySpawnTimer>,
|
||||
) {
|
||||
if !stage.waiting_for_clear || !enemy_query.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
if should_clear {
|
||||
// Get mutable resources only when needed
|
||||
if let Some(mut stage) = world.get_resource_mut::<CurrentStage>() {
|
||||
stage.number += 1;
|
||||
stage.waiting_for_clear = false;
|
||||
println!("Stage cleared! Starting Stage {}...", stage.number);
|
||||
}
|
||||
if let Some(mut formation_state) = world.get_resource_mut::<FormationState>() {
|
||||
formation_state.next_slot_index = 0;
|
||||
formation_state.total_spawned_this_stage = 0;
|
||||
formation_state.formation_complete = false; // Reset flag for new stage
|
||||
}
|
||||
if let Some(mut spawn_timer) = world.get_resource_mut::<EnemySpawnTimer>() {
|
||||
spawn_timer.timer.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
stage.number += 1;
|
||||
stage.waiting_for_clear = false;
|
||||
*formation_state = FormationState::default();
|
||||
spawn_timer.timer.reset();
|
||||
info!("Starting Stage {}", stage.number);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,47 +2,49 @@ use bevy::prelude::*;
|
|||
|
||||
use crate::components::Star;
|
||||
use crate::constants::{
|
||||
STAR_COUNT, STAR_MAX_SIZE, STAR_MAX_SPEED, STAR_MIN_SIZE, STAR_MIN_SPEED,
|
||||
STAR_Z_DEPTH, WINDOW_HEIGHT, WINDOW_WIDTH,
|
||||
STAR_COUNT, STAR_MAX_SIZE, STAR_MAX_SPEED, STAR_MIN_SIZE, STAR_MIN_SPEED, STAR_Z_DEPTH,
|
||||
WINDOW_HEIGHT, WINDOW_WIDTH,
|
||||
};
|
||||
|
||||
const PARALLAX_FACTOR: f32 = 0.01;
|
||||
|
||||
pub fn spawn_starfield(commands: &mut Commands) {
|
||||
for _ in 0..STAR_COUNT {
|
||||
let size = fastrand::f32() * (STAR_MAX_SIZE - STAR_MIN_SIZE) + STAR_MIN_SIZE;
|
||||
let speed = fastrand::f32() * (STAR_MAX_SPEED - STAR_MIN_SPEED) + STAR_MIN_SPEED;
|
||||
let brightness = fastrand::f32() * 0.5 + 0.5; // 0.5 to 1.0
|
||||
let size = lerp_random(STAR_MIN_SIZE, STAR_MAX_SIZE);
|
||||
let speed = lerp_random(STAR_MIN_SPEED, STAR_MAX_SPEED);
|
||||
let brightness = 0.5 + fastrand::f32() * 0.5;
|
||||
|
||||
commands.spawn((
|
||||
SpriteBundle {
|
||||
sprite: Sprite {
|
||||
color: Color::rgb(brightness, brightness, brightness),
|
||||
custom_size: Some(Vec2::new(size, size)),
|
||||
..default()
|
||||
},
|
||||
transform: Transform::from_translation(Vec3::new(
|
||||
fastrand::f32() * WINDOW_WIDTH - WINDOW_WIDTH / 2.0,
|
||||
fastrand::f32() * WINDOW_HEIGHT - WINDOW_HEIGHT / 2.0,
|
||||
STAR_Z_DEPTH,
|
||||
)),
|
||||
Sprite {
|
||||
color: Color::srgb(brightness, brightness, brightness),
|
||||
custom_size: Some(Vec2::splat(size)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(
|
||||
(fastrand::f32() - 0.5) * WINDOW_WIDTH,
|
||||
(fastrand::f32() - 0.5) * WINDOW_HEIGHT,
|
||||
STAR_Z_DEPTH,
|
||||
),
|
||||
Star { speed },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_starfield(mut star_query: Query<(&mut Transform, &Star)>, time: Res<Time>) {
|
||||
let dt = time.delta_seconds();
|
||||
let half_height = WINDOW_HEIGHT / 2.0;
|
||||
let half_width = WINDOW_WIDTH / 2.0;
|
||||
let dt = time.delta_secs();
|
||||
let half_h = WINDOW_HEIGHT / 2.0;
|
||||
|
||||
for (mut transform, star) in star_query.iter_mut() {
|
||||
let parallax = -transform.translation.y * 0.01;
|
||||
let parallax = -transform.translation.y * PARALLAX_FACTOR;
|
||||
transform.translation.y -= (star.speed + parallax) * dt;
|
||||
|
||||
if transform.translation.y < -half_height {
|
||||
transform.translation.y = half_height;
|
||||
transform.translation.x = fastrand::f32() * WINDOW_WIDTH - half_width;
|
||||
if transform.translation.y < -half_h {
|
||||
transform.translation.y = half_h;
|
||||
transform.translation.x = (fastrand::f32() - 0.5) * WINDOW_WIDTH;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lerp_random(min: f32, max: f32) -> f32 {
|
||||
min + fastrand::f32() * (max - min)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
use bevy::prelude::*;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::components::{Invincible, Player};
|
||||
use crate::resources::{CurrentStage, PlayerLives, Score};
|
||||
use crate::components::{Explosion, Invincible, Player};
|
||||
use crate::constants::{EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE};
|
||||
use crate::player::spawn_player_ship;
|
||||
use crate::resources::{CurrentStage, PlayerLives, Score};
|
||||
use crate::starfield::spawn_starfield;
|
||||
|
||||
// --- Setup ---
|
||||
pub fn setup(mut commands: Commands) {
|
||||
commands.spawn(Camera2dBundle::default());
|
||||
commands.spawn(Camera2d);
|
||||
spawn_starfield(&mut commands);
|
||||
spawn_player_ship(&mut commands);
|
||||
}
|
||||
|
||||
// --- Run Conditions ---
|
||||
// --- Run conditions ---
|
||||
|
||||
pub fn player_exists(query: Query<&Player>) -> bool {
|
||||
!query.is_empty()
|
||||
}
|
||||
|
|
@ -21,27 +23,61 @@ pub fn player_vulnerable(query: Query<&Player, Without<Invincible>>) -> bool {
|
|||
!query.is_empty()
|
||||
}
|
||||
|
||||
pub fn should_respawn_player(lives: Res<PlayerLives>, player_query: Query<&Player>) -> bool {
|
||||
player_query.is_empty() && lives.count > 0
|
||||
pub fn should_respawn_player(lives: Res<PlayerLives>, query: Query<&Player>) -> bool {
|
||||
query.is_empty() && lives.count > 0
|
||||
}
|
||||
|
||||
// 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(
|
||||
lives: Res<PlayerLives>,
|
||||
score: Res<Score>,
|
||||
stage: Res<CurrentStage>, // Add CurrentStage resource
|
||||
stage: Res<CurrentStage>,
|
||||
mut windows: Query<&mut Window>,
|
||||
) {
|
||||
// Update if lives, score, or stage changed
|
||||
if lives.is_changed() || score.is_changed() || stage.is_changed() {
|
||||
let mut window = windows.single_mut();
|
||||
if !(lives.is_changed() || score.is_changed() || stage.is_changed()) {
|
||||
return;
|
||||
}
|
||||
if let Ok(mut window) = windows.single_mut() {
|
||||
window.title = format!(
|
||||
"Galaga :: Stage: {} Lives: {} Score: {}",
|
||||
stage.number, lives.count, score.value
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue