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
This commit is contained in:
parent
db061820b9
commit
52b0919d3f
4 changed files with 375 additions and 162 deletions
|
|
@ -45,6 +45,9 @@ pub struct TractorBeam {
|
||||||
pub active: bool, // Whether the beam is currently active
|
pub active: bool, // Whether the beam is currently active
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Component)]
|
||||||
|
pub struct TractorBeamSprite;
|
||||||
|
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct FormationTarget {
|
pub struct FormationTarget {
|
||||||
pub position: Vec3,
|
pub position: Vec3,
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ 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 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; // How long the player stays captured
|
||||||
|
// Tractor beam visual constants
|
||||||
|
pub const BEAM_GLOW_WIDTH: f32 = 40.0;
|
||||||
|
pub const BEAM_GLOW_COLOR: Color = Color::rgba(0.3, 0.0, 0.5, 0.25);
|
||||||
|
pub const BEAM_CORE_COLOR: Color = Color::rgba(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 constants
|
||||||
pub const STAR_COUNT: usize = 150;
|
pub const STAR_COUNT: usize = 150;
|
||||||
|
|
|
||||||
525
src/enemy.rs
525
src/enemy.rs
|
|
@ -1,14 +1,28 @@
|
||||||
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
|
Captured, Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget,
|
||||||
ENEMY_BULLET_SIZE, ENEMY_SIZE, ENEMY_SPEED, WINDOW_HEIGHT, WINDOW_WIDTH,
|
OriginalFormationPosition, 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,
|
||||||
|
// Only keeping used constants
|
||||||
|
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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn spawn_enemies(
|
pub fn spawn_enemies(
|
||||||
|
|
@ -29,18 +43,20 @@ pub fn spawn_enemies(
|
||||||
|
|
||||||
// Only spawn if we haven't spawned the full formation for this stage yet
|
// Only spawn if we haven't spawned the full formation for this stage yet
|
||||||
// AND the timer just finished this frame
|
// AND the timer just finished this frame
|
||||||
if formation_state.total_spawned_this_stage < stage_enemy_count
|
if formation_state.total_spawned_this_stage < stage_enemy_count && timer.timer.just_finished() {
|
||||||
&& timer.timer.just_finished()
|
|
||||||
{
|
|
||||||
let slot_index = formation_state.next_slot_index;
|
let slot_index = formation_state.next_slot_index;
|
||||||
|
|
||||||
// Ensure slot_index is within bounds of the formation layout
|
// Ensure slot_index is within bounds of the formation layout
|
||||||
if slot_index >= current_config.formation_layout.positions.len() {
|
if slot_index >= current_config.formation_layout.positions.len() {
|
||||||
println!("Warning: slot_index {} out of bounds for formation layout '{}' (size {})",
|
println!(
|
||||||
slot_index, current_config.formation_layout.name, current_config.formation_layout.positions.len());
|
"Warning: slot_index {} out of bounds for formation layout '{}' (size {})",
|
||||||
// Optionally, reset the timer and skip spawning this frame, or handle differently
|
slot_index,
|
||||||
timer.timer.reset();
|
current_config.formation_layout.name,
|
||||||
return;
|
current_config.formation_layout.positions.len()
|
||||||
|
);
|
||||||
|
// Optionally, reset the timer and skip spawning this frame, or handle differently
|
||||||
|
timer.timer.reset();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get target position from the stage's formation layout
|
// Get target position from the stage's formation layout
|
||||||
|
|
@ -84,7 +100,9 @@ pub fn spawn_enemies(
|
||||||
TimerMode::Once,
|
TimerMode::Once,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
FormationTarget { position: target_pos },
|
FormationTarget {
|
||||||
|
position: target_pos,
|
||||||
|
},
|
||||||
EnemyState::Entering,
|
EnemyState::Entering,
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
@ -113,7 +131,13 @@ 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>,
|
||||||
|
), // Add mutable state and original position
|
||||||
(With<Enemy>, Without<FormationTarget>),
|
(With<Enemy>, Without<FormationTarget>),
|
||||||
>, // Query potential attackers
|
>, // Query potential attackers
|
||||||
time: Res<Time>,
|
time: Res<Time>,
|
||||||
|
|
@ -147,10 +171,12 @@ pub fn move_enemies(
|
||||||
transform.translation = target_pos;
|
transform.translation = target_pos;
|
||||||
commands.entity(entity).remove::<FormationTarget>(); // Remove target component
|
commands.entity(entity).remove::<FormationTarget>(); // Remove target component
|
||||||
*state = EnemyState::InFormation; // Change state
|
*state = EnemyState::InFormation; // Change state
|
||||||
|
|
||||||
// Store the original formation position for potential return
|
// Store the original formation position for potential return
|
||||||
commands.entity(entity).insert(OriginalFormationPosition { position: target_pos });
|
commands.entity(entity).insert(OriginalFormationPosition {
|
||||||
|
position: target_pos,
|
||||||
|
});
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"Enemy {:?} reached formation target and is now InFormation.",
|
"Enemy {:?} reached formation target and is now InFormation.",
|
||||||
entity
|
entity
|
||||||
|
|
@ -168,133 +194,147 @@ pub fn move_enemies(
|
||||||
// Check what state the enemy is in
|
// Check what state the enemy is in
|
||||||
match state.as_ref() {
|
match state.as_ref() {
|
||||||
EnemyState::Attacking(attack_pattern) => {
|
EnemyState::Attacking(attack_pattern) => {
|
||||||
// Apply different movement based on enemy type
|
// Apply different movement based on enemy type
|
||||||
match enemy.enemy_type {
|
match enemy.enemy_type {
|
||||||
EnemyType::Grunt => {
|
EnemyType::Grunt => {
|
||||||
// Basic enemies follow their attack pattern
|
// Basic enemies follow their attack pattern
|
||||||
match attack_pattern {
|
match attack_pattern {
|
||||||
// ... existing patterns ...
|
// ... existing patterns ...
|
||||||
AttackPattern::SwoopDive => {
|
AttackPattern::SwoopDive => {
|
||||||
// ... existing code ...
|
// ... existing code ...
|
||||||
let vertical_movement = attack_speed * time.delta_seconds();
|
let vertical_movement = attack_speed * time.delta_seconds();
|
||||||
let horizontal_speed_factor = 0.5;
|
let horizontal_speed_factor = 0.5;
|
||||||
let horizontal_movement = if transform.translation.x < 0.0 {
|
let horizontal_movement = if transform.translation.x < 0.0 {
|
||||||
attack_speed * horizontal_speed_factor * time.delta_seconds()
|
attack_speed * horizontal_speed_factor * time.delta_seconds()
|
||||||
} else if transform.translation.x > 0.0 {
|
} else if transform.translation.x > 0.0 {
|
||||||
-attack_speed * horizontal_speed_factor * time.delta_seconds()
|
-attack_speed * horizontal_speed_factor * time.delta_seconds()
|
||||||
} else { 0.0 };
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
transform.translation.y -= vertical_movement;
|
transform.translation.y -= vertical_movement;
|
||||||
transform.translation.x += horizontal_movement;
|
transform.translation.x += horizontal_movement;
|
||||||
|
|
||||||
// Prevent overshooting center
|
// Prevent overshooting center
|
||||||
if (transform.translation.x > 0.0 && transform.translation.x + horizontal_movement < 0.0) ||
|
if (transform.translation.x > 0.0
|
||||||
(transform.translation.x < 0.0 && transform.translation.x + horizontal_movement > 0.0) {
|
&& transform.translation.x + horizontal_movement < 0.0)
|
||||||
transform.translation.x = 0.0;
|
|| (transform.translation.x < 0.0
|
||||||
|
&& transform.translation.x + horizontal_movement > 0.0)
|
||||||
|
{
|
||||||
|
transform.translation.x = 0.0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
AttackPattern::DirectDive => {
|
||||||
AttackPattern::DirectDive => {
|
transform.translation.y -= attack_speed * time.delta_seconds();
|
||||||
transform.translation.y -= attack_speed * time.delta_seconds();
|
}
|
||||||
}
|
AttackPattern::Kamikaze(target) => {
|
||||||
AttackPattern::Kamikaze(target) => {
|
// Copy the target value rather than dereferencing
|
||||||
// Copy the target value rather than dereferencing
|
// since target should actually be a Vec3 in this context
|
||||||
// since target should actually be a Vec3 in this context
|
let target_pos = *target; // Dereference here
|
||||||
let target_pos = *target; // Dereference here
|
let direction = target_pos - transform.translation;
|
||||||
let direction = target_pos - transform.translation;
|
let distance = direction.length();
|
||||||
let distance = direction.length();
|
let kamikaze_threshold = attack_speed * time.delta_seconds() * 1.1; // Threshold to stop near target
|
||||||
let kamikaze_threshold = attack_speed * time.delta_seconds() * 1.1; // Threshold to stop near target
|
|
||||||
|
if distance > kamikaze_threshold {
|
||||||
if distance > kamikaze_threshold {
|
let move_delta =
|
||||||
let move_delta = direction.normalize() * attack_speed * time.delta_seconds();
|
direction.normalize() * attack_speed * time.delta_seconds();
|
||||||
transform.translation += move_delta;
|
transform.translation += move_delta;
|
||||||
} else {
|
} else {
|
||||||
// Optionally stop or continue past target - for now, just stop moving towards it
|
// Optionally stop or continue past target - for now, just stop moving towards it
|
||||||
// Could also despawn here if desired upon reaching target
|
// 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();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// 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 => {
|
||||||
EnemyType::Boss => {
|
// Boss has special behavior, especially for CaptureBeam
|
||||||
// Boss has special behavior, especially for CaptureBeam
|
match attack_pattern {
|
||||||
match attack_pattern {
|
AttackPattern::CaptureBeam => {
|
||||||
AttackPattern::CaptureBeam => {
|
// Boss moves down to a position above the player area
|
||||||
// Boss moves down to a position above the player area
|
let target_y = -WINDOW_HEIGHT / 4.0; // Position at lower quarter of the screen
|
||||||
let target_y = -WINDOW_HEIGHT / 4.0; // Position at lower quarter of the screen
|
|
||||||
|
if transform.translation.y > target_y {
|
||||||
if transform.translation.y > target_y {
|
// Move down to position
|
||||||
// Move down to position
|
transform.translation.y -=
|
||||||
transform.translation.y -= attack_speed * 0.8 * time.delta_seconds();
|
attack_speed * 0.8 * time.delta_seconds();
|
||||||
} else {
|
} else {
|
||||||
// Once in position, stay there briefly before activating beam
|
// Once in position, stay there briefly before activating beam
|
||||||
// Check if this boss already has a TractorBeam component
|
// Check if this boss already has a TractorBeam component
|
||||||
if has_beam_query.get(entity).is_err() {
|
if has_beam_query.get(entity).is_err() {
|
||||||
// Spawn tractor beam component on this boss
|
// Spawn tractor beam component on this boss
|
||||||
commands.entity(entity).insert(TractorBeam {
|
commands.entity(entity).insert(TractorBeam {
|
||||||
target: None, // Will be filled in by the boss_capture_attack
|
target: None, // Will be filled in by the boss_capture_attack
|
||||||
timer: Timer::new(Duration::from_secs_f32(TRACTOR_BEAM_DURATION), TimerMode::Once),
|
timer: Timer::new(
|
||||||
width: TRACTOR_BEAM_WIDTH,
|
Duration::from_secs_f32(TRACTOR_BEAM_DURATION),
|
||||||
active: false,
|
TimerMode::Once,
|
||||||
});
|
),
|
||||||
|
width: TRACTOR_BEAM_WIDTH,
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttackPattern::SwoopDive => {
|
||||||
|
// ... existing code for swoop dive ...
|
||||||
|
let center_x = 0.0;
|
||||||
|
let bottom_y = -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y;
|
||||||
|
|
||||||
|
// First move towards center-bottom
|
||||||
|
let target = Vec3::new(center_x, bottom_y, 0.0);
|
||||||
|
// target is directly created as Vec3, not a reference
|
||||||
|
let direction = target - transform.translation;
|
||||||
|
|
||||||
|
// Normalize and move
|
||||||
|
if direction.length() > 0.0 {
|
||||||
|
let normalized_dir = direction.normalize();
|
||||||
|
transform.translation +=
|
||||||
|
normalized_dir * attack_speed * time.delta_seconds();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AttackPattern::DirectDive => {
|
||||||
|
transform.translation.y -= attack_speed * time.delta_seconds();
|
||||||
|
}
|
||||||
|
AttackPattern::Kamikaze(target) => {
|
||||||
|
// Convert the target to a value type
|
||||||
|
let target_pos = *target; // Dereference here
|
||||||
|
let direction = target_pos - transform.translation;
|
||||||
|
|
||||||
|
// If very close to target, just move straight down
|
||||||
|
if direction.length() < 50.0 {
|
||||||
|
transform.translation.y -= attack_speed * time.delta_seconds();
|
||||||
|
} else {
|
||||||
|
// Move toward target
|
||||||
|
let normalized_dir = direction.normalize();
|
||||||
|
transform.translation +=
|
||||||
|
normalized_dir * attack_speed * time.delta_seconds();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AttackPattern::SwoopDive => {
|
|
||||||
// ... existing code for swoop dive ...
|
|
||||||
let center_x = 0.0;
|
|
||||||
let bottom_y = -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y;
|
|
||||||
|
|
||||||
// First move towards center-bottom
|
|
||||||
let target = Vec3::new(center_x, bottom_y, 0.0);
|
|
||||||
// target is directly created as Vec3, not a reference
|
|
||||||
let direction = target - transform.translation;
|
|
||||||
|
|
||||||
// Normalize and move
|
|
||||||
if direction.length() > 0.0 {
|
|
||||||
let normalized_dir = direction.normalize();
|
|
||||||
transform.translation += normalized_dir * attack_speed * time.delta_seconds();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
AttackPattern::DirectDive => {
|
|
||||||
transform.translation.y -= attack_speed * time.delta_seconds();
|
|
||||||
}
|
|
||||||
AttackPattern::Kamikaze(target) => {
|
|
||||||
// Convert the target to a value type
|
|
||||||
let target_pos = *target; // Dereference here
|
|
||||||
let direction = target_pos - transform.translation;
|
|
||||||
|
|
||||||
// If very close to target, just move straight down
|
|
||||||
if direction.length() < 50.0 {
|
|
||||||
transform.translation.y -= attack_speed * time.delta_seconds();
|
|
||||||
} else {
|
|
||||||
// Move toward target
|
|
||||||
let normalized_dir = direction.normalize();
|
|
||||||
transform.translation += normalized_dir * attack_speed * time.delta_seconds();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
EnemyState::ReturningWithCaptive => {
|
EnemyState::ReturningWithCaptive => {
|
||||||
// Boss returning to formation with captured player
|
// Boss returning to formation with captured player
|
||||||
if let Some(original_pos) = original_pos {
|
if let Some(original_pos) = original_pos {
|
||||||
let direction = original_pos.position - transform.translation;
|
let direction = original_pos.position - transform.translation;
|
||||||
let distance = direction.length();
|
let distance = direction.length();
|
||||||
let return_speed = ENEMY_SPEED * speed_multiplier * 0.7; // Slightly slower when carrying captive
|
let return_speed = ENEMY_SPEED * speed_multiplier * 0.7; // Slightly slower when carrying captive
|
||||||
|
|
||||||
if distance < 5.0 { // Close enough to formation position
|
if distance < 5.0 {
|
||||||
|
// Close enough to formation position
|
||||||
// Return to formation
|
// Return to formation
|
||||||
transform.translation = original_pos.position;
|
transform.translation = original_pos.position;
|
||||||
*state = EnemyState::InFormation;
|
*state = EnemyState::InFormation;
|
||||||
println!("Boss {:?} returned to formation with captive!", entity);
|
println!("Boss {:?} returned to formation with captive!", entity);
|
||||||
} else {
|
} else {
|
||||||
// Move towards formation position
|
// Move towards formation position
|
||||||
let move_delta = direction.normalize() * return_speed * time.delta_seconds();
|
let move_delta =
|
||||||
|
direction.normalize() * return_speed * time.delta_seconds();
|
||||||
transform.translation += move_delta;
|
transform.translation += move_delta;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +359,7 @@ 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>, // Need current stage
|
||||||
stage_configs: Res<StageConfigurations>, // Need stage configs
|
stage_configs: Res<StageConfigurations>, // Need stage configs
|
||||||
) {
|
) {
|
||||||
// Only run the check if the formation isn't already marked as complete
|
// Only run the check if the formation isn't already marked as complete
|
||||||
|
|
@ -343,7 +383,7 @@ pub fn check_formation_complete(
|
||||||
|
|
||||||
// If all spawned and none are entering, formation is complete
|
// If all spawned and none are entering, formation is complete
|
||||||
if !any_entering {
|
if !any_entering {
|
||||||
println!(
|
println!(
|
||||||
"Formation complete for Stage {}! Setting attack timer. (Spawned={})",
|
"Formation complete for Stage {}! Setting attack timer. (Spawned={})",
|
||||||
stage.number, formation_state.total_spawned_this_stage
|
stage.number, formation_state.total_spawned_this_stage
|
||||||
);
|
);
|
||||||
|
|
@ -354,7 +394,10 @@ pub fn check_formation_complete(
|
||||||
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);
|
println!(
|
||||||
|
"Attack timer set to {:?} duration, unpaused and reset.",
|
||||||
|
dive_interval
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -367,8 +410,8 @@ pub fn trigger_attack_dives(
|
||||||
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)>, // Added Enemy component to check type
|
||||||
formation_state: Res<FormationState>,
|
formation_state: Res<FormationState>,
|
||||||
stage: Res<CurrentStage>, // Need current stage
|
stage: Res<CurrentStage>, // Need current stage
|
||||||
stage_configs: Res<StageConfigurations>, // Need stage configs
|
stage_configs: Res<StageConfigurations>, // Need stage configs
|
||||||
player_query: Query<&Transform, With<Player>>, // Need player position for Kamikaze
|
player_query: Query<&Transform, With<Player>>, // Need player position for Kamikaze
|
||||||
) {
|
) {
|
||||||
timer.timer.tick(time.delta());
|
timer.timer.tick(time.delta());
|
||||||
|
|
@ -397,16 +440,20 @@ pub fn trigger_attack_dives(
|
||||||
let mut selected_pattern = match enemy_type {
|
let mut selected_pattern = match enemy_type {
|
||||||
// For Boss enemies, occasionally use the CaptureBeam pattern
|
// For Boss enemies, occasionally use the CaptureBeam pattern
|
||||||
EnemyType::Boss => {
|
EnemyType::Boss => {
|
||||||
if fastrand::f32() < 0.8 { // 80% chance for Boss to use CaptureBeam (increased for testing)
|
if fastrand::f32() < 0.8 {
|
||||||
|
// 80% chance for Boss to use CaptureBeam (increased for testing)
|
||||||
println!("Boss {:?} selected CaptureBeam attack!", chosen_entity);
|
println!("Boss {:?} selected CaptureBeam attack!", chosen_entity);
|
||||||
AttackPattern::CaptureBeam
|
AttackPattern::CaptureBeam
|
||||||
} else {
|
} else {
|
||||||
// Otherwise use a random pattern from the stage config
|
// Otherwise use a random pattern from the stage config
|
||||||
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
|
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
|
||||||
println!("Boss {:?} selected {:?} attack!", chosen_entity, current_config.attack_patterns[pattern_index]);
|
println!(
|
||||||
|
"Boss {:?} selected {:?} attack!",
|
||||||
|
chosen_entity, current_config.attack_patterns[pattern_index]
|
||||||
|
);
|
||||||
current_config.attack_patterns[pattern_index]
|
current_config.attack_patterns[pattern_index]
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
// Regular enemies use patterns from the stage config
|
// Regular enemies use patterns from the stage config
|
||||||
EnemyType::Grunt => {
|
EnemyType::Grunt => {
|
||||||
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
|
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
|
||||||
|
|
@ -417,7 +464,7 @@ pub fn trigger_attack_dives(
|
||||||
// If Kamikaze, get player position (if player exists)
|
// If Kamikaze, get player position (if player exists)
|
||||||
if let AttackPattern::Kamikaze(_) = selected_pattern {
|
if let AttackPattern::Kamikaze(_) = selected_pattern {
|
||||||
if let Ok(player_transform) = player_query.get_single() {
|
if let Ok(player_transform) = player_query.get_single() {
|
||||||
selected_pattern = AttackPattern::Kamikaze(player_transform.translation);
|
selected_pattern = AttackPattern::Kamikaze(player_transform.translation);
|
||||||
} else {
|
} else {
|
||||||
// Fallback if player doesn't exist (e.g., just died)
|
// Fallback if player doesn't exist (e.g., just died)
|
||||||
selected_pattern = AttackPattern::DirectDive; // Or SwoopDive
|
selected_pattern = AttackPattern::DirectDive; // Or SwoopDive
|
||||||
|
|
@ -427,9 +474,12 @@ pub fn trigger_attack_dives(
|
||||||
|
|
||||||
// Get the chosen enemy's state mutably and change it
|
// Get the chosen enemy's state mutably and change it
|
||||||
if let Ok((_, mut state, _)) = enemy_query.get_mut(chosen_entity) {
|
if let Ok((_, mut state, _)) = enemy_query.get_mut(chosen_entity) {
|
||||||
println!("Enemy {:?} starting attack dive with pattern {:?}!", chosen_entity, selected_pattern);
|
println!(
|
||||||
|
"Enemy {:?} starting attack dive with pattern {:?}!",
|
||||||
|
chosen_entity, selected_pattern
|
||||||
|
);
|
||||||
*state = EnemyState::Attacking(selected_pattern); // Set state with pattern
|
*state = EnemyState::Attacking(selected_pattern); // Set state with pattern
|
||||||
// Timer duration is handled elsewhere (e.g., check_formation_complete)
|
// Timer duration is handled elsewhere (e.g., check_formation_complete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -443,7 +493,8 @@ pub fn enemy_shoot(
|
||||||
) {
|
) {
|
||||||
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)
|
// Only shoot if in any Attacking state (pattern doesn't matter for shooting)
|
||||||
if matches!(state, EnemyState::Attacking(_)) { // Use matches! macro
|
if matches!(state, EnemyState::Attacking(_)) {
|
||||||
|
// Use matches! macro
|
||||||
enemy.shoot_cooldown.tick(time.delta());
|
enemy.shoot_cooldown.tick(time.delta());
|
||||||
if enemy.shoot_cooldown.finished() {
|
if enemy.shoot_cooldown.finished() {
|
||||||
// println!("Enemy {:?} firing!", transform.translation); // Less verbose logging
|
// println!("Enemy {:?} firing!", transform.translation); // Less verbose logging
|
||||||
|
|
@ -478,6 +529,16 @@ pub fn is_formation_complete(formation_state: Res<FormationState>) -> bool {
|
||||||
formation_state.formation_complete
|
formation_state.formation_complete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculate the beam height from the boss's Y position 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculate the pulsing alpha value for a beam layer.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// New system to handle the tractor beam attack from Boss enemies
|
// New system to handle the tractor beam attack from Boss enemies
|
||||||
pub fn boss_capture_attack(
|
pub fn boss_capture_attack(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
|
|
@ -489,54 +550,84 @@ pub fn boss_capture_attack(
|
||||||
for (boss_entity, boss_transform, mut tractor_beam) in boss_query.iter_mut() {
|
for (boss_entity, boss_transform, mut tractor_beam) in boss_query.iter_mut() {
|
||||||
// Tick the beam timer
|
// Tick the beam timer
|
||||||
tractor_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 player exists and beam is not active yet, set player as target
|
||||||
if !tractor_beam.active && tractor_beam.target.is_none() {
|
if !tractor_beam.active && tractor_beam.target.is_none() {
|
||||||
if let Ok((player_entity, _)) = player_query.get_single() {
|
if let Ok((player_entity, _)) = player_query.get_single() {
|
||||||
tractor_beam.target = Some(player_entity);
|
tractor_beam.target = Some(player_entity);
|
||||||
tractor_beam.active = true;
|
tractor_beam.active = true;
|
||||||
println!("Boss {:?} activated tractor beam targeting player!", boss_entity);
|
println!(
|
||||||
|
"Boss {:?} activated tractor beam targeting player!",
|
||||||
// Create visual beam effect (using a simple sprite for now)
|
boss_entity
|
||||||
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
|
let beam_height = calculate_beam_height(boss_transform.translation.y);
|
||||||
|
|
||||||
commands.entity(boss_entity).with_children(|parent| {
|
commands.entity(boss_entity).with_children(|parent| {
|
||||||
parent.spawn(SpriteBundle {
|
// Outer glow layer (wide, dim)
|
||||||
sprite: Sprite {
|
parent.spawn((
|
||||||
color: TRACTOR_BEAM_COLOR,
|
SpriteBundle {
|
||||||
custom_size: Some(Vec2::new(tractor_beam.width, beam_height)),
|
sprite: Sprite {
|
||||||
|
color: BEAM_GLOW_COLOR,
|
||||||
|
custom_size: Some(Vec2::new(BEAM_GLOW_WIDTH, beam_height)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
transform: Transform::from_xyz(0.0, -beam_height / 2.0, -0.5),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
transform: Transform::from_xyz(0.0, -beam_height/2.0, 0.0),
|
TractorBeamSprite,
|
||||||
..default()
|
));
|
||||||
});
|
// Inner core layer (narrow, bright, shorter)
|
||||||
|
let core_height = beam_height * 0.6;
|
||||||
|
parent.spawn((
|
||||||
|
SpriteBundle {
|
||||||
|
sprite: Sprite {
|
||||||
|
color: BEAM_CORE_COLOR,
|
||||||
|
custom_size: Some(Vec2::new(TRACTOR_BEAM_WIDTH, core_height)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
transform: Transform::from_xyz(
|
||||||
|
0.0,
|
||||||
|
-core_height / 2.0 - beam_height * 0.2,
|
||||||
|
-0.3,
|
||||||
|
),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TractorBeamSprite,
|
||||||
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If beam is active, check if player is in beam's path
|
// If beam is active, check if player is in beam's path
|
||||||
if tractor_beam.active {
|
if tractor_beam.active {
|
||||||
if let Ok((player_entity, player_transform)) = player_query.get_single() {
|
if let Ok((player_entity, player_transform)) = player_query.get_single() {
|
||||||
// Check if player is roughly under the boss
|
// Check if player is roughly under the boss
|
||||||
if (player_transform.translation.x - boss_transform.translation.x).abs() < tractor_beam.width / 2.0 {
|
if (player_transform.translation.x - boss_transform.translation.x).abs()
|
||||||
|
< tractor_beam.width / 2.0
|
||||||
|
{
|
||||||
// Player is in the beam! Capture them
|
// Player is in the beam! Capture them
|
||||||
println!("Player captured by boss {:?}!", boss_entity);
|
println!("Player captured by boss {:?}!", boss_entity);
|
||||||
|
|
||||||
// Add Captured component to player
|
// 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,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Boss returns to formation with captured player
|
// Boss returns to formation with captured player
|
||||||
commands.entity(boss_entity).remove::<TractorBeam>();
|
commands.entity(boss_entity).remove::<TractorBeam>();
|
||||||
|
|
||||||
// Change boss state to returning with captive
|
// Change boss state to returning with captive
|
||||||
if let Ok(enemy) = enemy_query.get(boss_entity) {
|
if let Ok(enemy) = enemy_query.get(boss_entity) {
|
||||||
if let EnemyState::Attacking(_) = enemy {
|
if let EnemyState::Attacking(_) = enemy {
|
||||||
commands.entity(boss_entity).insert(EnemyState::ReturningWithCaptive);
|
commands
|
||||||
|
.entity(boss_entity)
|
||||||
|
.insert(EnemyState::ReturningWithCaptive);
|
||||||
|
|
||||||
// Clean up the beam visual
|
// Clean up the beam visual
|
||||||
commands.entity(boss_entity).despawn_descendants();
|
commands.entity(boss_entity).despawn_descendants();
|
||||||
}
|
}
|
||||||
|
|
@ -545,21 +636,133 @@ pub fn boss_capture_attack(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If beam timer finishes and player wasn't captured, end the beam attack
|
// If beam timer finishes and player wasn't captured, end the beam attack
|
||||||
if tractor_beam.timer.finished() {
|
if tractor_beam.timer.finished() {
|
||||||
println!("Boss {:?} tractor beam expired", boss_entity);
|
println!("Boss {:?} tractor beam expired", boss_entity);
|
||||||
commands.entity(boss_entity).remove::<TractorBeam>();
|
commands.entity(boss_entity).remove::<TractorBeam>();
|
||||||
|
|
||||||
// Clean up the beam visual
|
// Clean up the beam visual
|
||||||
commands.entity(boss_entity).despawn_descendants();
|
commands.entity(boss_entity).despawn_descendants();
|
||||||
|
|
||||||
// Boss returns to formation after failed capture
|
// Boss returns to formation after failed capture
|
||||||
if let Ok(enemy_state) = enemy_query.get(boss_entity) {
|
if let Ok(enemy_state) = enemy_query.get(boss_entity) {
|
||||||
if let EnemyState::Attacking(_) = enemy_state {
|
if let EnemyState::Attacking(_) = enemy_state {
|
||||||
commands.entity(boss_entity).insert(EnemyState::ReturningWithCaptive);
|
commands
|
||||||
|
.entity(boss_entity)
|
||||||
|
.insert(EnemyState::ReturningWithCaptive);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Update tractor beam visual: pulse opacity and update beam height.
|
||||||
|
pub fn update_tractor_beam_visual(
|
||||||
|
time: Res<Time>,
|
||||||
|
boss_query: Query<(Entity, &Transform, &TractorBeam)>,
|
||||||
|
mut sprite_query: Query<(&mut Sprite, &mut Transform), With<TractorBeamSprite>>,
|
||||||
|
children: Query<&Children>,
|
||||||
|
) {
|
||||||
|
for (boss_entity, boss_transform, _tractor_beam) in boss_query.iter() {
|
||||||
|
let current_beam_height = calculate_beam_height(boss_transform.translation.y);
|
||||||
|
let time_secs = time.elapsed_seconds();
|
||||||
|
|
||||||
|
if let Ok(child_entities) = children.get(boss_entity) {
|
||||||
|
for child in child_entities {
|
||||||
|
if let Ok((mut sprite, mut transform)) = sprite_query.get_mut(*child) {
|
||||||
|
// Update color pulse
|
||||||
|
if let Color::Rgba {
|
||||||
|
red,
|
||||||
|
green,
|
||||||
|
blue,
|
||||||
|
mut alpha,
|
||||||
|
} = sprite.color
|
||||||
|
{
|
||||||
|
let base_alpha = alpha;
|
||||||
|
alpha = beam_pulse_alpha(
|
||||||
|
base_alpha,
|
||||||
|
time_secs,
|
||||||
|
BEAM_PULSE_FREQ,
|
||||||
|
BEAM_PULSE_AMPLITUDE,
|
||||||
|
);
|
||||||
|
sprite.color = Color::Rgba {
|
||||||
|
red,
|
||||||
|
green,
|
||||||
|
blue,
|
||||||
|
alpha,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update size and position
|
||||||
|
let current_width = sprite.custom_size.map(|s| s.x).unwrap_or(0.0);
|
||||||
|
if current_width == BEAM_GLOW_WIDTH {
|
||||||
|
let new_height = current_beam_height;
|
||||||
|
sprite.custom_size = Some(Vec2::new(current_width, new_height));
|
||||||
|
transform.translation.y = -new_height / 2.0;
|
||||||
|
} else if current_width == TRACTOR_BEAM_WIDTH {
|
||||||
|
let core_height = current_beam_height * 0.6;
|
||||||
|
sprite.custom_size = Some(Vec2::new(current_width, core_height));
|
||||||
|
transform.translation.y = -core_height / 2.0 - current_beam_height * 0.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod beam_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn beam_height_at_screen_center() {
|
||||||
|
assert_eq!(calculate_beam_height(0.0), WINDOW_HEIGHT / 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn beam_height_at_boss_position() {
|
||||||
|
assert_eq!(calculate_beam_height(-200.0), 200.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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() {
|
||||||
|
// sin(pi/2) = 1, so time = (pi/2) / freq
|
||||||
|
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() {
|
||||||
|
// sin(3pi/2) = -1, so time = (3pi/2) / freq = pi/2
|
||||||
|
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() {
|
||||||
|
// sin(3pi/2) = -1, so time = (3pi/2) / freq
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ use player::{
|
||||||
};
|
};
|
||||||
use enemy::{
|
use enemy::{
|
||||||
check_formation_complete, enemy_shoot, is_formation_complete, move_enemies, spawn_enemies,
|
check_formation_complete, enemy_shoot, is_formation_complete, move_enemies, spawn_enemies,
|
||||||
trigger_attack_dives, boss_capture_attack,
|
trigger_attack_dives, boss_capture_attack, update_tractor_beam_visual,
|
||||||
};
|
};
|
||||||
use bullet::{
|
use bullet::{
|
||||||
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets,
|
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets,
|
||||||
|
|
@ -103,6 +103,7 @@ fn main() {
|
||||||
move_enemies,
|
move_enemies,
|
||||||
enemy_shoot, // Consider run_if attacking state? (Handled internally for now)
|
enemy_shoot, // Consider run_if attacking state? (Handled internally for now)
|
||||||
boss_capture_attack, // New system for boss tractor beam
|
boss_capture_attack, // New system for boss tractor beam
|
||||||
|
update_tractor_beam_visual,
|
||||||
)
|
)
|
||||||
.run_if(in_state(AppState::Playing)),
|
.run_if(in_state(AppState::Playing)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue