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:
Harald Hoyer 2026-05-06 19:35:16 +02:00
parent db061820b9
commit 52b0919d3f
4 changed files with 375 additions and 162 deletions

View file

@ -45,6 +45,9 @@ pub struct TractorBeam {
pub active: bool, // Whether the beam is currently active
}
#[derive(Component)]
pub struct TractorBeamSprite;
#[derive(Component)]
pub struct FormationTarget {
pub position: Vec3,

View file

@ -39,6 +39,12 @@ 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
// 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
pub const STAR_COUNT: usize = 150;

View file

@ -1,14 +1,28 @@
use bevy::prelude::*;
use std::time::Duration;
use crate::components::{Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget, TractorBeam, Captured, OriginalFormationPosition};
use crate::constants::{ // Only keeping used constants
ENEMY_BULLET_SIZE, ENEMY_SIZE, ENEMY_SPEED, WINDOW_HEIGHT, WINDOW_WIDTH,
TRACTOR_BEAM_WIDTH, TRACTOR_BEAM_DURATION, TRACTOR_BEAM_COLOR, CAPTURE_DURATION,
use crate::components::{
Captured, Enemy, EnemyBullet, EnemyState, EnemyType, FormationTarget,
OriginalFormationPosition, TractorBeam, TractorBeamSprite,
};
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::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState,
StageConfigurations,
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations,
};
pub fn spawn_enemies(
@ -29,15 +43,17 @@ pub fn spawn_enemies(
// Only spawn if we haven't spawned the full formation for this stage yet
// AND the timer just finished this frame
if formation_state.total_spawned_this_stage < stage_enemy_count
&& timer.timer.just_finished()
{
if formation_state.total_spawned_this_stage < stage_enemy_count && timer.timer.just_finished() {
let slot_index = formation_state.next_slot_index;
// Ensure slot_index is within bounds of the formation layout
if slot_index >= current_config.formation_layout.positions.len() {
println!("Warning: slot_index {} out of bounds for formation layout '{}' (size {})",
slot_index, current_config.formation_layout.name, current_config.formation_layout.positions.len());
println!(
"Warning: slot_index {} out of bounds for formation layout '{}' (size {})",
slot_index,
current_config.formation_layout.name,
current_config.formation_layout.positions.len()
);
// Optionally, reset the timer and skip spawning this frame, or handle differently
timer.timer.reset();
return;
@ -84,7 +100,9 @@ pub fn spawn_enemies(
TimerMode::Once,
),
},
FormationTarget { position: target_pos },
FormationTarget {
position: target_pos,
},
EnemyState::Entering,
));
@ -113,7 +131,13 @@ pub fn move_enemies(
(With<Enemy>, With<FormationTarget>),
>,
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>),
>, // Query potential attackers
time: Res<Time>,
@ -149,7 +173,9 @@ pub fn move_enemies(
*state = EnemyState::InFormation; // Change state
// 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!(
"Enemy {:?} reached formation target and is now InFormation.",
@ -182,14 +208,19 @@ pub fn move_enemies(
attack_speed * horizontal_speed_factor * time.delta_seconds()
} else if transform.translation.x > 0.0 {
-attack_speed * horizontal_speed_factor * time.delta_seconds()
} else { 0.0 };
} else {
0.0
};
transform.translation.y -= vertical_movement;
transform.translation.x += horizontal_movement;
// Prevent overshooting center
if (transform.translation.x > 0.0 && transform.translation.x + horizontal_movement < 0.0) ||
(transform.translation.x < 0.0 && transform.translation.x + horizontal_movement > 0.0) {
if (transform.translation.x > 0.0
&& transform.translation.x + horizontal_movement < 0.0)
|| (transform.translation.x < 0.0
&& transform.translation.x + horizontal_movement > 0.0)
{
transform.translation.x = 0.0;
}
}
@ -205,7 +236,8 @@ pub fn move_enemies(
let kamikaze_threshold = attack_speed * time.delta_seconds() * 1.1; // Threshold to stop near target
if distance > kamikaze_threshold {
let move_delta = direction.normalize() * attack_speed * time.delta_seconds();
let move_delta =
direction.normalize() * attack_speed * time.delta_seconds();
transform.translation += move_delta;
} else {
// Optionally stop or continue past target - for now, just stop moving towards it
@ -228,7 +260,8 @@ pub fn move_enemies(
if transform.translation.y > target_y {
// Move down to position
transform.translation.y -= attack_speed * 0.8 * time.delta_seconds();
transform.translation.y -=
attack_speed * 0.8 * time.delta_seconds();
} else {
// Once in position, stay there briefly before activating beam
// Check if this boss already has a TractorBeam component
@ -236,7 +269,10 @@ pub fn move_enemies(
// Spawn tractor beam component on this boss
commands.entity(entity).insert(TractorBeam {
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(
Duration::from_secs_f32(TRACTOR_BEAM_DURATION),
TimerMode::Once,
),
width: TRACTOR_BEAM_WIDTH,
active: false,
});
@ -256,7 +292,8 @@ pub fn move_enemies(
// Normalize and move
if direction.length() > 0.0 {
let normalized_dir = direction.normalize();
transform.translation += normalized_dir * attack_speed * time.delta_seconds();
transform.translation +=
normalized_dir * attack_speed * time.delta_seconds();
}
}
AttackPattern::DirectDive => {
@ -273,7 +310,8 @@ pub fn move_enemies(
} else {
// Move toward target
let normalized_dir = direction.normalize();
transform.translation += normalized_dir * attack_speed * time.delta_seconds();
transform.translation +=
normalized_dir * attack_speed * time.delta_seconds();
}
}
}
@ -287,14 +325,16 @@ pub fn move_enemies(
let distance = direction.length();
let return_speed = ENEMY_SPEED * speed_multiplier * 0.7; // Slightly slower when carrying captive
if distance < 5.0 { // Close enough to formation position
if distance < 5.0 {
// Close enough to formation position
// Return to formation
transform.translation = original_pos.position;
*state = EnemyState::InFormation;
println!("Boss {:?} returned to formation with captive!", entity);
} else {
// Move towards formation position
let move_delta = direction.normalize() * return_speed * time.delta_seconds();
let move_delta =
direction.normalize() * return_speed * time.delta_seconds();
transform.translation += move_delta;
}
}
@ -354,7 +394,10 @@ pub fn check_formation_complete(
attack_dive_timer.timer.set_duration(dive_interval);
attack_dive_timer.timer.reset();
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
);
}
}
}
@ -397,16 +440,20 @@ pub fn trigger_attack_dives(
let mut selected_pattern = match enemy_type {
// For Boss enemies, occasionally use the CaptureBeam pattern
EnemyType::Boss => {
if fastrand::f32() < 0.8 { // 80% chance for Boss to use CaptureBeam (increased for testing)
if fastrand::f32() < 0.8 {
// 80% chance for Boss to use CaptureBeam (increased for testing)
println!("Boss {:?} selected CaptureBeam attack!", chosen_entity);
AttackPattern::CaptureBeam
} else {
// Otherwise use a random pattern from the stage config
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
println!("Boss {:?} selected {:?} attack!", chosen_entity, current_config.attack_patterns[pattern_index]);
println!(
"Boss {:?} selected {:?} attack!",
chosen_entity, current_config.attack_patterns[pattern_index]
);
current_config.attack_patterns[pattern_index]
}
},
}
// Regular enemies use patterns from the stage config
EnemyType::Grunt => {
let pattern_index = fastrand::usize(..current_config.attack_patterns.len());
@ -427,7 +474,10 @@ pub fn trigger_attack_dives(
// Get the chosen enemy's state mutably and change it
if let Ok((_, mut state, _)) = enemy_query.get_mut(chosen_entity) {
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
// 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() {
// 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());
if enemy.shoot_cooldown.finished() {
// 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
}
/// 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
pub fn boss_capture_attack(
mut commands: Commands,
@ -495,22 +556,45 @@ pub fn boss_capture_attack(
if let Ok((player_entity, _)) = player_query.get_single() {
tractor_beam.target = Some(player_entity);
tractor_beam.active = true;
println!("Boss {:?} activated tractor beam targeting player!", boss_entity);
println!(
"Boss {:?} activated tractor beam targeting player!",
boss_entity
);
// Create visual beam effect (using a simple sprite for now)
let beam_height = boss_transform.translation.y - (-WINDOW_HEIGHT / 2.0); // Height from boss to bottom of screen
let beam_height = calculate_beam_height(boss_transform.translation.y);
// Spawn the beam as a child of the boss
commands.entity(boss_entity).with_children(|parent| {
parent.spawn(SpriteBundle {
// Outer glow layer (wide, dim)
parent.spawn((
SpriteBundle {
sprite: Sprite {
color: TRACTOR_BEAM_COLOR,
custom_size: Some(Vec2::new(tractor_beam.width, beam_height)),
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.0),
transform: Transform::from_xyz(0.0, -beam_height / 2.0, -0.5),
..default()
});
},
TractorBeamSprite,
));
// 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,
));
});
}
}
@ -519,14 +603,19 @@ pub fn boss_capture_attack(
if tractor_beam.active {
if let Ok((player_entity, player_transform)) = player_query.get_single() {
// Check if player is roughly under the boss
if (player_transform.translation.x - boss_transform.translation.x).abs() < tractor_beam.width / 2.0 {
if (player_transform.translation.x - boss_transform.translation.x).abs()
< tractor_beam.width / 2.0
{
// Player is in the beam! Capture them
println!("Player captured by boss {:?}!", boss_entity);
// Add Captured component to player
commands.entity(player_entity).insert(Captured {
boss_entity,
timer: Timer::new(Duration::from_secs_f32(CAPTURE_DURATION), TimerMode::Once),
timer: Timer::new(
Duration::from_secs_f32(CAPTURE_DURATION),
TimerMode::Once,
),
});
// Boss returns to formation with captured player
@ -535,7 +624,9 @@ pub fn boss_capture_attack(
// Change boss state to returning with captive
if let Ok(enemy) = enemy_query.get(boss_entity) {
if let EnemyState::Attacking(_) = enemy {
commands.entity(boss_entity).insert(EnemyState::ReturningWithCaptive);
commands
.entity(boss_entity)
.insert(EnemyState::ReturningWithCaptive);
// Clean up the beam visual
commands.entity(boss_entity).despawn_descendants();
@ -557,9 +648,121 @@ pub fn boss_capture_attack(
// Boss returns to formation after failed capture
if let Ok(enemy_state) = enemy_query.get(boss_entity) {
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);
}
}

View file

@ -29,7 +29,7 @@ use player::{
};
use enemy::{
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::{
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets,
@ -103,6 +103,7 @@ fn main() {
move_enemies,
enemy_shoot, // Consider run_if attacking state? (Handled internally for now)
boss_capture_attack, // New system for boss tractor beam
update_tractor_beam_visual,
)
.run_if(in_state(AppState::Playing)),
)