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,18 +43,20 @@ 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());
// Optionally, reset the timer and skip spawning this frame, or handle differently
timer.timer.reset();
return;
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;
}
// Get target position from the stage's formation layout
@ -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>,
@ -147,10 +171,12 @@ pub fn move_enemies(
transform.translation = target_pos;
commands.entity(entity).remove::<FormationTarget>(); // Remove target component
*state = EnemyState::InFormation; // Change state
// Store the original formation position for potential return
commands.entity(entity).insert(OriginalFormationPosition { position: target_pos });
commands.entity(entity).insert(OriginalFormationPosition {
position: target_pos,
});
println!(
"Enemy {:?} reached formation target and is now InFormation.",
entity
@ -168,133 +194,147 @@ pub fn move_enemies(
// Check what state the enemy is in
match state.as_ref() {
EnemyState::Attacking(attack_pattern) => {
// Apply different movement based on enemy type
match enemy.enemy_type {
EnemyType::Grunt => {
// Basic enemies follow their attack pattern
match attack_pattern {
// ... existing patterns ...
AttackPattern::SwoopDive => {
// ... existing code ...
let vertical_movement = attack_speed * time.delta_seconds();
let horizontal_speed_factor = 0.5;
let horizontal_movement = if transform.translation.x < 0.0 {
attack_speed * horizontal_speed_factor * time.delta_seconds()
} else if transform.translation.x > 0.0 {
-attack_speed * horizontal_speed_factor * time.delta_seconds()
} else { 0.0 };
// Apply different movement based on enemy type
match enemy.enemy_type {
EnemyType::Grunt => {
// Basic enemies follow their attack pattern
match attack_pattern {
// ... existing patterns ...
AttackPattern::SwoopDive => {
// ... existing code ...
let vertical_movement = attack_speed * time.delta_seconds();
let horizontal_speed_factor = 0.5;
let horizontal_movement = if transform.translation.x < 0.0 {
attack_speed * horizontal_speed_factor * time.delta_seconds()
} else if transform.translation.x > 0.0 {
-attack_speed * horizontal_speed_factor * time.delta_seconds()
} else {
0.0
};
transform.translation.y -= vertical_movement;
transform.translation.x += horizontal_movement;
transform.translation.y -= vertical_movement;
transform.translation.x += horizontal_movement;
// Prevent overshooting center
if (transform.translation.x > 0.0 && transform.translation.x + horizontal_movement < 0.0) ||
(transform.translation.x < 0.0 && transform.translation.x + horizontal_movement > 0.0) {
transform.translation.x = 0.0;
// Prevent overshooting center
if (transform.translation.x > 0.0
&& transform.translation.x + horizontal_movement < 0.0)
|| (transform.translation.x < 0.0
&& transform.translation.x + horizontal_movement > 0.0)
{
transform.translation.x = 0.0;
}
}
}
AttackPattern::DirectDive => {
transform.translation.y -= attack_speed * time.delta_seconds();
}
AttackPattern::Kamikaze(target) => {
// Copy the target value rather than dereferencing
// since target should actually be a Vec3 in this context
let target_pos = *target; // Dereference here
let direction = target_pos - transform.translation;
let distance = direction.length();
let kamikaze_threshold = attack_speed * time.delta_seconds() * 1.1; // Threshold to stop near target
if distance > kamikaze_threshold {
let move_delta = direction.normalize() * attack_speed * time.delta_seconds();
transform.translation += move_delta;
} else {
// Optionally stop or continue past target - for now, just stop moving towards it
// Could also despawn here if desired upon reaching target
AttackPattern::DirectDive => {
transform.translation.y -= attack_speed * time.delta_seconds();
}
AttackPattern::Kamikaze(target) => {
// Copy the target value rather than dereferencing
// since target should actually be a Vec3 in this context
let target_pos = *target; // Dereference here
let direction = target_pos - transform.translation;
let distance = direction.length();
let kamikaze_threshold = attack_speed * time.delta_seconds() * 1.1; // Threshold to stop near target
if distance > kamikaze_threshold {
let move_delta =
direction.normalize() * attack_speed * time.delta_seconds();
transform.translation += move_delta;
} else {
// Optionally stop or continue past target - for now, just stop moving towards it
// Could also despawn here if desired upon reaching target
}
}
// New CaptureBeam pattern - Bosses behave differently
AttackPattern::CaptureBeam => {
// For Grunt enemies, just do a direct dive (fallback)
transform.translation.y -= attack_speed * time.delta_seconds();
}
}
// New CaptureBeam pattern - Bosses behave differently
AttackPattern::CaptureBeam => {
// For Grunt enemies, just do a direct dive (fallback)
transform.translation.y -= attack_speed * time.delta_seconds();
}
}
}
EnemyType::Boss => {
// Boss has special behavior, especially for CaptureBeam
match attack_pattern {
AttackPattern::CaptureBeam => {
// Boss moves down to a position above the player area
let target_y = -WINDOW_HEIGHT / 4.0; // Position at lower quarter of the screen
if transform.translation.y > target_y {
// Move down to position
transform.translation.y -= attack_speed * 0.8 * time.delta_seconds();
} else {
// Once in position, stay there briefly before activating beam
// Check if this boss already has a TractorBeam component
if has_beam_query.get(entity).is_err() {
// Spawn tractor beam component on this boss
commands.entity(entity).insert(TractorBeam {
target: None, // Will be filled in by the boss_capture_attack
timer: Timer::new(Duration::from_secs_f32(TRACTOR_BEAM_DURATION), TimerMode::Once),
width: TRACTOR_BEAM_WIDTH,
active: false,
});
EnemyType::Boss => {
// Boss has special behavior, especially for CaptureBeam
match attack_pattern {
AttackPattern::CaptureBeam => {
// Boss moves down to a position above the player area
let target_y = -WINDOW_HEIGHT / 4.0; // Position at lower quarter of the screen
if transform.translation.y > target_y {
// Move down to position
transform.translation.y -=
attack_speed * 0.8 * time.delta_seconds();
} else {
// Once in position, stay there briefly before activating beam
// Check if this boss already has a TractorBeam component
if has_beam_query.get(entity).is_err() {
// Spawn tractor beam component on this boss
commands.entity(entity).insert(TractorBeam {
target: None, // Will be filled in by the boss_capture_attack
timer: Timer::new(
Duration::from_secs_f32(TRACTOR_BEAM_DURATION),
TimerMode::Once,
),
width: TRACTOR_BEAM_WIDTH,
active: false,
});
}
}
}
AttackPattern::SwoopDive => {
// ... existing code for swoop dive ...
let center_x = 0.0;
let bottom_y = -WINDOW_HEIGHT / 2.0 - ENEMY_SIZE.y;
// First move towards center-bottom
let target = Vec3::new(center_x, bottom_y, 0.0);
// target is directly created as Vec3, not a reference
let direction = target - transform.translation;
// Normalize and move
if direction.length() > 0.0 {
let normalized_dir = direction.normalize();
transform.translation +=
normalized_dir * attack_speed * time.delta_seconds();
}
}
AttackPattern::DirectDive => {
transform.translation.y -= attack_speed * time.delta_seconds();
}
AttackPattern::Kamikaze(target) => {
// Convert the target to a value type
let target_pos = *target; // Dereference here
let direction = target_pos - transform.translation;
// If very close to target, just move straight down
if direction.length() < 50.0 {
transform.translation.y -= attack_speed * time.delta_seconds();
} else {
// Move toward target
let normalized_dir = direction.normalize();
transform.translation +=
normalized_dir * attack_speed * time.delta_seconds();
}
}
}
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 => {
// Boss returning to formation with captured player
if let Some(original_pos) = original_pos {
let direction = original_pos.position - transform.translation;
let distance = direction.length();
let return_speed = ENEMY_SPEED * speed_multiplier * 0.7; // Slightly slower when carrying captive
if distance < 5.0 { // Close enough to formation position
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;
}
}
@ -319,7 +359,7 @@ pub fn check_formation_complete(
mut formation_state: ResMut<FormationState>,
enemy_query: Query<&EnemyState, With<Enemy>>,
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
) {
// 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 !any_entering {
println!(
println!(
"Formation complete for Stage {}! Setting attack timer. (Spawned={})",
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.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
);
}
}
}
@ -367,8 +410,8 @@ pub fn trigger_attack_dives(
time: Res<Time>,
mut enemy_query: Query<(Entity, &mut EnemyState, &Enemy)>, // Added Enemy component to check type
formation_state: Res<FormationState>,
stage: Res<CurrentStage>, // Need current stage
stage_configs: Res<StageConfigurations>, // Need stage configs
stage: Res<CurrentStage>, // Need current stage
stage_configs: Res<StageConfigurations>, // Need stage configs
player_query: Query<&Transform, With<Player>>, // Need player position for Kamikaze
) {
timer.timer.tick(time.delta());
@ -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());
@ -417,7 +464,7 @@ pub fn trigger_attack_dives(
// If Kamikaze, get player position (if player exists)
if let AttackPattern::Kamikaze(_) = selected_pattern {
if let Ok(player_transform) = player_query.get_single() {
selected_pattern = AttackPattern::Kamikaze(player_transform.translation);
selected_pattern = AttackPattern::Kamikaze(player_transform.translation);
} else {
// Fallback if player doesn't exist (e.g., just died)
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
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)
// 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,
@ -489,54 +550,84 @@ pub fn boss_capture_attack(
for (boss_entity, boss_transform, mut tractor_beam) in boss_query.iter_mut() {
// Tick the beam timer
tractor_beam.timer.tick(time.delta());
// If player exists and beam is not active yet, set player as target
if !tractor_beam.active && tractor_beam.target.is_none() {
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);
// Create visual beam effect (using a simple sprite for now)
let beam_height = boss_transform.translation.y - (-WINDOW_HEIGHT / 2.0); // Height from boss to bottom of screen
// Spawn the beam as a child of the boss
println!(
"Boss {:?} activated tractor beam targeting player!",
boss_entity
);
let beam_height = calculate_beam_height(boss_transform.translation.y);
commands.entity(boss_entity).with_children(|parent| {
parent.spawn(SpriteBundle {
sprite: Sprite {
color: TRACTOR_BEAM_COLOR,
custom_size: Some(Vec2::new(tractor_beam.width, beam_height)),
// Outer glow layer (wide, dim)
parent.spawn((
SpriteBundle {
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()
},
transform: Transform::from_xyz(0.0, -beam_height/2.0, 0.0),
..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,
));
});
}
}
// If beam is active, check if player is in beam's path
if tractor_beam.active {
if let Ok((player_entity, player_transform)) = player_query.get_single() {
// Check if player is roughly under the boss
if (player_transform.translation.x - boss_transform.translation.x).abs() < tractor_beam.width / 2.0 {
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
commands.entity(boss_entity).remove::<TractorBeam>();
// 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();
}
@ -545,21 +636,133 @@ pub fn boss_capture_attack(
}
}
}
// If beam timer finishes and player wasn't captured, end the beam attack
if tractor_beam.timer.finished() {
println!("Boss {:?} tractor beam expired", boss_entity);
commands.entity(boss_entity).remove::<TractorBeam>();
// Clean up the beam visual
commands.entity(boss_entity).despawn_descendants();
// 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)),
)