Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Appendix - Project so Far

Here's our full file directory:

Tutorial-Project/
├── Cargo.toml                    
├── build.rs                      
└── src/
    ├── lib.rs                    
    │   ├── Components:           
    │   │   ├── Player
    │   │   ├── Enemy
    │   │   ├── Reticle
    │   │   ├── Projectile
    │   │   ├── LightSource
    │   │   ├── GridDot
    │   │   ├── GravitySource
    │   │   ├── Velocity
    │   │   └── Acceleration
    │   ├── Resources:            
    │   │   └── PlayerInput
    │   └── Systems:              
    │       ├── update_projectile_lifetime
    │       ├── sync_player_movement_to_config_consts
    │       ├── update_player_input
    │       ├── spawn_everything
    │       ├── apply_grid_lighting_system
    │       ├── apply_grid_gravity_system
    │       ├── update_player_acceleration_system
    │       ├── update_enemy_acceleration_system
    │       ├── apply_acceleration_system
    │       ├── apply_velocity_system
    │       ├── update_camera_position_system
    │       ├── update_reticle_position_system
    │       └── fire_projectile_system
    ├── config.rs                 
    ├── ffi.rs                   
    ├── grid_systems.rs          
    │   ├── apply_grid_lighting
    │   └── apply_grid_gravity
    ├── movement_systems.rs      
    │   ├── update_player_acceleration
    │   ├── update_enemy_acceleration
    │   ├── apply_acceleration
    │   └── apply_velocity
    ├── spawn_systems.rs          
    │   ├── spawn_grid_dots
    │   ├── spawn_camera
    │   ├── spawn_player
    │   ├── spawn_enemy
    │   └── spawn_reticle
    └── util.rs             
        └── color_to_hsla

Individual files from src:

lib.rs
srclib.rs
use engine::prelude::*;
mod config;
mod ffi;
mod grid_systems;
mod movement_systems;
mod spawn_systems;
mod util;
use crate::config::*;
use crate::grid_systems::*;
use crate::movement_systems::*;
use crate::spawn_systems::*;

#[derive(Default)]
#[resource]
struct PlayerInput {
    move_axes: Vec2,
    aim_position: Vec2,
    fire_projectile: bool,
}

#[component]
struct Player;

#[component]
struct Enemy;

#[component]
struct Reticle;

#[component]
struct Projectile {
    lifetime: f32,
}

#[component]
struct LightSource {
    falloff_distance: f32,
    strength: f32,
    falloff_power: f32,
}

#[derive(Default)]
#[component]
struct GridDot {
    home_position: Vec2,
    base_alpha: f32,
    current_displacement: Vec2,
    current_height: f32,
    lit_base_alpha: f32,
    color_buckets: [Option<ColorBucket>; MAX_BUCKETS],
}

#[derive(Clone, Copy, serde::Serialize, serde::Deserialize)]
struct ColorBucket {
    h: f32,
    s: f32,
    l: f32,
    current_strength: f32, 
    target_strength: f32, 
}
#[derive(Default)]
#[component]
struct GravitySource {
    strength: f32,
    falloff_distance: f32,
    falloff_power: f32,
}

#[derive(Default)]
#[component]
struct Velocity {
    current_velocity: Vec2,
    max_velocity: f32,
}

#[derive(Default)]
#[component]
struct Acceleration {
    acceleration_rate: f32,
    deceleration_rate: f32,
    target_velocity: Vec2,
}

#[system]
fn update_projectile_lifetime(
    mut projectiles: Query<(Mut<Projectile>, Ref<EntityId>)>,
    frame_constants: Ref<FrameConstants>,
    engine: Ref<Engine>,
) {
    projectiles.for_each(|(mut projectile, entity_id)| {
        projectile.lifetime -= frame_constants.delta_time;
        if projectile.lifetime < 0.0 {
            engine.despawn(*entity_id);
        }
    });
}

#[system]
fn sync_player_movement_to_config_consts(
    mut player: Query<(Mut<Acceleration>, Mut<Velocity>, Ref<Player>)>,
) {
    let Some((mut acceleration, mut velocity, _)) = player.get_mut(0) else {
        return;
    };
    velocity.max_velocity = PLAYER_MAX_VELOCITY;
    acceleration.acceleration_rate = PLAYER_ACCELERATION;
    acceleration.deceleration_rate = PLAYER_DECELERATION;
}

#[system]
fn update_player_input(
    mut player_input: Mut<PlayerInput>,
    input: Ref<InputState>,
    camera: Query<(Ref<Transform>, Ref<Camera>)>,
    window: Ref<Window>,
) {
    let key_to_f32 = |key_code| {
        if input.keys[key_code].pressed() {
            1.0
        } else {
            0.0
        }
    };

    player_input.move_axes = Vec2::new(
        key_to_f32(KeyCode::KeyD) - key_to_f32(KeyCode::KeyA),
        key_to_f32(KeyCode::KeyW) - key_to_f32(KeyCode::KeyS),
    )
    .clamp_length_max(1.);

    let Some((camera_transform, camera)) = camera.get(0) else {
        return;
    };

    let normalized_cursor_position = input.mouse.cursor_position / window.size - 0.5;

    let CameraAspect::FixedVirtualHeight { height } = camera.aspect else {
        return;
    };

    let scale = Vec2::new(window.size.x * (height / window.size.y), -height);

    let view_cursor_position = normalized_cursor_position * scale;
    player_input.aim_position = view_cursor_position + camera_transform.position;

    player_input.fire_projectile = input.mouse.buttons[MouseButton::Left].just_pressed();
}

#[system_once]
fn spawn_everything(engine: Ref<Engine>) {
    spawn_grid_dots(&engine);
    spawn_camera(&engine);
    spawn_player(&engine);
    spawn_enemy(&engine);
    spawn_reticle(&engine);
}

#[system]
fn apply_grid_lighting_system(
    grid_dots: Query<(Mut<Color>, Mut<GridDot>)>,
    light_sources: Query<(Ref<Transform>, Ref<Color>, Ref<LightSource>)>,
    frame_constants: Ref<FrameConstants>,
) {
    apply_grid_lighting(grid_dots, light_sources, frame_constants);
}
#[system]
fn apply_grid_gravity_system(
    grid_dots: Query<(Mut<Transform>, Mut<GridDot>, Mut<Color>)>,
    gravity_sources: Query<(Ref<Transform>, Ref<GravitySource>)>,
    frame_constants: Ref<FrameConstants>,
) {
    apply_grid_gravity(grid_dots, gravity_sources, frame_constants);
}

#[system]
fn update_player_acceleration_system(
    player_input: Ref<PlayerInput>,
    player_query: Query<(Mut<Acceleration>, Ref<Velocity>, Ref<Player>)>,
) {
    update_player_acceleration(player_input, player_query);
}

#[system]
fn update_enemy_acceleration_system(
    enemy_query: Query<(Mut<Acceleration>, Ref<Velocity>, Ref<Transform>, Ref<Enemy>)>,
    player_query: Query<(Ref<Transform>, Ref<Player>)>,
) {
    update_enemy_acceleration(enemy_query, player_query);
}

#[system]
fn apply_acceleration_system(
    velocities_to_adjust: Query<(Ref<Acceleration>, Mut<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    apply_acceleration(velocities_to_adjust, frame_constants);
}
#[system]
fn apply_velocity_system(
    entities_to_move: Query<(Mut<Transform>, Ref<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    apply_velocity(entities_to_move, frame_constants);
}

#[system]
fn update_camera_position_system(
    camera_query: Query<(Mut<Transform>, Ref<Camera>)>,
    player_query: Query<(Ref<Transform>, Ref<Player>)>,
) {
    update_camera_position(camera_query, player_query);
}

#[system]
fn update_reticle_position_system(
    mut reticle_query: Query<(Mut<Transform>, Ref<Reticle>)>,
    player_input: Ref<PlayerInput>,
) {
    let Some((mut transform, _)) = reticle_query.get_mut(0) else {
        return;
    };
    transform.position = player_input.aim_position;
}

#[system]
fn fire_projectile_system(
    player_input: Ref<PlayerInput>,
    player_query: Query<(Ref<Transform>, Ref<Player>)>,
    engine: Ref<Engine>,
) {
    let Some((player_transform, _)) = player_query.get(0) else {
        return;
    };

    if player_input.fire_projectile {
        fire_projectile(
            player_input.aim_position,
            player_transform.position,
            &engine,
        );
    }
}

fn fire_projectile(aim_position: Vec2, player_position: Vec2, engine: &Engine) {
    let direction = (aim_position - player_position).normalize();
    let transform = &Transform {
        position: player_position,
        layer: 10.,
        ..Default::default()
    };

    let color = &Color::RED;

    let circle_render = &CircleRender {
        num_sides: 20,
        size: Vec2::splat(15.0),
        visible: true,
    };

    let light = &LightSource {
        falloff_distance: 100.,
        strength: 100.,
        falloff_power: 1.5,
    };

    let grid_gravity = &GravitySource {
        strength: 1000.,
        falloff_distance: 100.,
        falloff_power: 1.5,
    };

    let velocity = &Velocity {
        current_velocity: direction * 2000.,
        max_velocity: 2000.,
    };
    let projectile = &Projectile { lifetime: 2.0 };

    engine.spawn(bundle!(
        transform,
        color,
        circle_render,
        light,
        grid_gravity,
        velocity,
        projectile,
    ));
}

config.rs
srcconfig.rs
pub const PLAYER_MAX_VELOCITY: f32 = 750.;
pub const PLAYER_ACCELERATION: f32 = 9000.;
pub const PLAYER_DECELERATION: f32 = 12000.;
pub const ZERO_VELOCITY_THRESHOLD: f32 = 0.1;

pub const ENEMY_MAX_VELOCITY: f32 = 300.;
pub const ENEMY_ACCELERATION: f32 = 1000.;
pub const ENEMY_DECELERATION: f32 = 5000.;

pub const ENEMY_CHASE_DISTANCE: f32 = 1000.;
pub const ENEMY_STOP_DISTANCE: f32 = 200.;

pub const GRID_DOT_SIZE: f32 = 6.;
pub const GRID_DOT_SIDES: u32 = 5;
pub const GRID_DOT_SPACING: f32 = 25.;
pub const GRID_ROWS: u32 = 200;
pub const GRID_COLS: u32 = 200;
pub const GRID_DOT_ALPHA_1: f32 = 0.7;
pub const GRID_DOT_ALPHA_2: f32 = 0.8;
pub const GRID_DOT_COLOR_SPACING: u32 = 5;

pub const PLAYER_GRAVITY_STRENGTH: f32 = 10000.0; 
pub const PLAYER_FALLOFF_POWER: f32 = 10.0;
pub const PLAYER_FALLOFF_DISTANCE: f32 = 1500.0;

pub const ENEMY_GRAVITY_STRENGTH: f32 = -10000.0;
pub const ENEMY_FALLOFF_POWER: f32 = 5.0;
pub const ENEMY_FALLOFF_DISTANCE: f32 = 250.0;

pub const MIN_GRAVITY_DISTANCE: f32 = 0.01; 
pub const INFLUENCE_CUTOFF: f32 = 0.00001;

pub const MAX_DISPLACEMENT: f32 = 100.0; 
pub const MAX_STRETCH_AMOUNT: f32 = 3.; 
pub const STRETCH_SENSITIVITY: f32 = 0.3;

pub const MAX_HEIGHT_RANGE: f32 = 3.0; 
pub const ALPHA_SENSITIVITY: f32 = 3.;
pub const MIN_ALPHA_PERCENTAGE: f32 = 0.11; 

pub const SMOOTHING_TIME: f32 = 0.1;

pub const PLAYER_LIGHT_RANGE: f32 = 1000.0;
pub const PLAYER_LIGHT_INTENSITY: f32 = 6.0; 
pub const PLAYER_LIGHT_FALLOFF_POWER: f32 = 3.5; 
pub const ENEMY_LIGHT_RANGE: f32 = 400.0;
pub const ENEMY_LIGHT_INTENSITY: f32 = 10.;
pub const ENEMY_LIGHT_FALLOFF_POWER: f32 = 2.5; 

pub const CHARGE_RATE: f32 = 2.; 
pub const DISCHARGE_RATE: f32 = 0.5; 
pub const LIGHT_ALPHA_BOOST: f32 = 0.75;
pub const MAX_LIT_ALPHA: f32 = 1.0;
pub const MAX_BUCKETS: usize = 8;

grid_systems.rs
srcgrid_systems.rs
use crate::config::*;
use crate::util::*;
use crate::{Color, ColorBucket, GravitySource, GridDot, LightSource, Transform};
use engine::prelude::*;

pub fn apply_grid_lighting(
    mut grid_dots: Query<(Mut<Color>, Mut<GridDot>)>,
    light_sources: Query<(Ref<Transform>, Ref<Color>, Ref<LightSource>)>,
    frame_constants: Ref<FrameConstants>,
) {
    let delta_time = frame_constants.delta_time;

    // Pre-process light sources
    let lights: Vec<_> = light_sources
        .iter()
        .map(|(transform, color, light)| {
            let (h, s, l, _) = color_to_hsla(&color);
            (
                transform.position,
                h,
                s,
                l,
                light.falloff_distance,
                light.strength,
                light.falloff_power,
            )
        })
        .collect();

    grid_dots.for_each(|(mut color, mut grid_dot)| {
        let dot_pos = grid_dot.home_position + grid_dot.current_displacement;

        // === CALCULATE CURRENT LIGHTING ===
        let mut current_colors = [(0.0, 0.0, 0.0, 0.0); MAX_BUCKETS];
        let mut current_count = 0;

        for (light_pos, h, s, l, range, intensity, falloff_power) in &lights {
            let distance = (*light_pos - dot_pos).length();

            let falloff = (1.0 - (distance / range).min(1.0)).powf(*falloff_power);

            let strength = falloff * intensity;

            if strength > 0.01 && current_count < MAX_BUCKETS {
                current_colors[current_count] = (*h, *s, *l, strength);
                current_count += 1;
            }
        }

        // === MATCH EXISTING BUCKETS WITH CURRENT COLORS ===
        // Track which current colors have been handled
        let mut handled_colors = [false; MAX_BUCKETS];

        // First, iterate over all existing buckets
        for i in 0..MAX_BUCKETS {
            if let Some(ref mut bucket) = grid_dot.color_buckets[i] {
                let mut found_match = false;

                // Check if this bucket matches any current color
                for j in 0..current_count {
                    if !handled_colors[j] {
                        let (h, s, l, strength) = current_colors[j];

                        if (bucket.h - h).abs() < 1.0 && // 1 degrees hue tolerance
                           (bucket.s - s).abs() < 0.1 &&
                           (bucket.l - l).abs() < 0.1
                        {
                            bucket.target_strength = strength;
                            handled_colors[j] = true;
                            found_match = true;
                            break;
                        }
                    }
                }

                // If no match found, this bucket should fade out
                if !found_match {
                    bucket.target_strength = 0.0;
                }
            }
        }

        // === FILL EMPTY BUCKETS WITH REMAINING CURRENT COLORS ===
        for i in 0..current_count {
            if !handled_colors[i] {
                let (h, s, l, strength) = current_colors[i];

                // Find empty slot for new bucket
                for j in 0..MAX_BUCKETS {
                    if grid_dot.color_buckets[j].is_none() {
                        grid_dot.color_buckets[j] = Some(ColorBucket {
                            h,
                            s,
                            l,
                            current_strength: 0.0,
                            target_strength: strength,
                        });
                        break;
                    }
                }
            }
        }

        // === UPDATE BUCKETS ===
        for i in 0..MAX_BUCKETS {
            if let Some(ref mut bucket) = grid_dot.color_buckets[i] {
                if bucket.current_strength < bucket.target_strength {
                    // Charge up towards target
                    let charge_amount = (bucket.target_strength - bucket.current_strength)
                        * CHARGE_RATE
                        * delta_time;
                    bucket.current_strength =
                        (bucket.current_strength + charge_amount).min(bucket.target_strength);
                } else if bucket.current_strength > bucket.target_strength {
                    // Discharge down towards target
                    let discharge_amount = (bucket.current_strength - bucket.target_strength)
                        * DISCHARGE_RATE
                        * delta_time;
                    bucket.current_strength =
                        (bucket.current_strength - discharge_amount).max(bucket.target_strength);
                }

                // Clear if too weak
                if bucket.current_strength < 0.00001 {
                    grid_dot.color_buckets[i] = None;
                }
            }
        }

        // === BLEND BUCKETS FOR DISPLAY ===
        let mut total_strength = 0.0;
        for i in 0..MAX_BUCKETS {
            if let Some(bucket) = &grid_dot.color_buckets[i] {
                total_strength += bucket.current_strength;
            }
        }

        if total_strength > 0.01 {
            // Weighted blend of all buckets
            let mut blended_h = 0.0;
            let mut blended_s = 0.0;
            let mut blended_l = 0.0;

            for i in 0..MAX_BUCKETS {
                if let Some(bucket) = &grid_dot.color_buckets[i] {
                    let weight = bucket.current_strength / total_strength;
                    blended_h += bucket.h * weight;
                    blended_s += bucket.s * weight;
                    blended_l += bucket.l * weight;
                }
            }

            // Mix with white based on total strength
            let intensity = total_strength.min(1.0);
            let final_s = blended_s * intensity;
            let final_l = blended_l * intensity + (1.0 - intensity);

            *color = Color::from_hsla(blended_h, final_s, final_l, color.a());

            // Alpha boost
            let alpha_boost = 1.0 + intensity * LIGHT_ALPHA_BOOST;
            grid_dot.lit_base_alpha = (grid_dot.base_alpha * alpha_boost).min(MAX_LIT_ALPHA);
        } else {
            // No buckets - pure white
            *color = Color::new(1.0, 1.0, 1.0, color.a());
            grid_dot.lit_base_alpha = grid_dot.base_alpha;
        }
    });
}

pub fn apply_grid_gravity(
    mut grid_dots: Query<(Mut<Transform>, Mut<GridDot>, Mut<Color>)>,
    gravity_sources: Query<(Ref<Transform>, Ref<GravitySource>)>,
    frame_constants: Ref<FrameConstants>,
) {
    let sources: Vec<_> = gravity_sources
        .iter()
        .map(|(transform, gravity)| {
            (
                transform.position,
                gravity.strength,
                gravity.falloff_distance,
                gravity.falloff_power,
            )
        })
        .collect();

    let dt = frame_constants.delta_time;
    let smoothing_factor = (1.0 - (-dt / SMOOTHING_TIME).exp()).min(1.0);

    grid_dots.for_each(|(mut transform, mut grid_dot, mut color)| {
        // Calculate combined influence from all sources
        let mut weighted_sum = Vec2::ZERO;
        let mut max_possible = 0.0;

        for (source_pos, strength, falloff_distance, falloff_power) in &sources {
            let to_source = *source_pos - grid_dot.home_position;
            let distance = to_source.length().max(MIN_GRAVITY_DISTANCE);
            let direction = to_source / distance;

            let falloff = (1.0 - (distance / falloff_distance).min(1.0)).powf(*falloff_power);
            let influence = strength * falloff;
            if influence.abs() > INFLUENCE_CUTOFF {
                weighted_sum += direction * influence;
                max_possible += strength.abs();
            }
        }

        // Calculate influence direction
        let influence_direction = weighted_sum.try_normalize().unwrap_or(Vec2::ZERO);

        // For non-displacement effects, normalize by max possible
        let influence_strength = if max_possible > 0.0 {
            (weighted_sum.length() / max_possible).min(1.0)
        } else {
            0.0
        };

        // For displacement, don't normalize - just clamp the final result
        // This preserves the actual push/pull strength
        let displacement_vector = (weighted_sum).clamp_length_max(MAX_DISPLACEMENT);
        let target_position = grid_dot.home_position + displacement_vector;

        let target_height = influence_strength * MAX_HEIGHT_RANGE;
        let stretch = 1. + (MAX_STRETCH_AMOUNT - 1.) * (influence_strength + STRETCH_SENSITIVITY);
        let target_scale = Vec2::new(stretch, 1.0 / stretch.sqrt());

        let target_rotation = influence_direction.y.atan2(influence_direction.x);

        // Smooth position (stored as displacement for lighting system compatibility)
        let new_position = transform.position.lerp(target_position, smoothing_factor);
        grid_dot.current_displacement = new_position - grid_dot.home_position;
        transform.position = new_position;

        grid_dot.current_height = target_height;

        // Smooth scale
        transform.scale = transform.scale.lerp(target_scale, smoothing_factor);

        // Smooth rotation with angle wrapping
        let current_rot = transform.rotation;
        let rotation_diff = {
            let diff = target_rotation - current_rot;
            if diff > std::f32::consts::PI {
                diff - 2.0 * std::f32::consts::PI
            } else if diff < -std::f32::consts::PI {
                diff + 2.0 * std::f32::consts::PI
            } else {
                diff
            }
        };
        transform.rotation = current_rot + rotation_diff * smoothing_factor;

        // Apply alpha based on height
        let height_factor = (grid_dot.current_height / MAX_HEIGHT_RANGE).clamp(0.0, 1.0);
        let min_alpha = grid_dot.base_alpha * MIN_ALPHA_PERCENTAGE;
        let alpha = grid_dot.base_alpha
            - (grid_dot.base_alpha - min_alpha) * height_factor * ALPHA_SENSITIVITY;
        *color = Color::new(color.r(), color.g(), color.b(), alpha);
    });
}

movement_systems.rs
srcmovement_systems.rs
use crate::config::*;
use crate::{Acceleration, Enemy, Player, PlayerInput, Velocity};
use engine::prelude::*;

pub fn update_player_acceleration(
    player_input: Ref<PlayerInput>,
    mut player_query: Query<(Mut<Acceleration>, Ref<Velocity>, Ref<Player>)>,
) {
    let Some((mut acceleration, velocity, _)) = player_query.get_mut(0) else {
        return;
    };

    acceleration.target_velocity = player_input.move_axes * velocity.max_velocity;
}

pub fn update_enemy_acceleration(
    mut enemy_query: Query<(Mut<Acceleration>, Ref<Velocity>, Ref<Transform>, Ref<Enemy>)>,
    player_query: Query<(Ref<Transform>, Ref<Player>)>,
) {
    let Some((player_transform, _)) = player_query.get(0) else {
        return;
    };

    enemy_query.for_each(|(mut acceleration, velocity, transform, _)| {
        let to_player = player_transform.position - transform.position;
        let distance = to_player.length();

        if distance > ENEMY_CHASE_DISTANCE {
            acceleration.target_velocity = Vec2::ZERO;
        } else {
            let ideal_position =
                player_transform.position - to_player.normalize() * ENEMY_STOP_DISTANCE;

            let to_ideal = ideal_position - transform.position;
            let distance_to_ideal = to_ideal.length();

            if distance_to_ideal < 20.0 {
                acceleration.target_velocity = Vec2::ZERO;
            } else {
                let speed_factor = if distance_to_ideal < 50.0 { 0.5 } else { 1.0 };
                acceleration.target_velocity =
                    to_ideal.normalize_or_zero() * (velocity.max_velocity * speed_factor);
            }
        }
    });
}

pub fn apply_acceleration(
    mut velocities_to_adjust: Query<(Ref<Acceleration>, Mut<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    velocities_to_adjust.for_each(|(acceleration, mut velocity)| {
        // Calculate the difference between current and target velocity
        let velocity_delta = acceleration.target_velocity - velocity.current_velocity;

        // Determine if we're accelerating or decelerating
        let is_decelerating = acceleration.target_velocity == Vec2::ZERO
            || acceleration.target_velocity.dot(velocity.current_velocity) < 0.0;

        let rate = if is_decelerating {
            acceleration.deceleration_rate
        } else {
            acceleration.acceleration_rate
        };

        // Calculate the maximum change allowed this frame
        let max_change = rate * frame_constants.delta_time;

        // Apply the change, clamped to the maximum rate
        let velocity_change = velocity_delta.clamp_length_max(max_change);
        velocity.current_velocity += velocity_change;

        // Check if we're trying to stop on each axis.
        if acceleration.target_velocity.x.abs() < ZERO_VELOCITY_THRESHOLD
            && velocity.current_velocity.x.abs() < ZERO_VELOCITY_THRESHOLD
        {
            velocity.current_velocity.x = 0.;
        }
        if acceleration.target_velocity.y.abs() < ZERO_VELOCITY_THRESHOLD
            && velocity.current_velocity.y.abs() < ZERO_VELOCITY_THRESHOLD
        {
            velocity.current_velocity.y = 0.;
        }

        // Clamp to max velocity
        velocity.current_velocity = velocity
            .current_velocity
            .clamp_length_max(velocity.max_velocity);
    });
}

pub fn apply_velocity(
    mut entities_to_move: Query<(Mut<Transform>, Ref<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    entities_to_move.for_each(|(mut transform, velocity)| {
        transform.position += velocity.current_velocity * frame_constants.delta_time;
    });
}

pub fn update_camera_position(
    mut camera_query: Query<(Mut<Transform>, Ref<Camera>)>,
    player_query: Query<(Ref<Transform>, Ref<Player>)>,
) {
    let Some((player_transform, _)) = player_query.get(0) else {
        return;
    };

    let Some((mut camera_transform, _)) = camera_query.get_mut(0) else {
        return;
    };

    camera_transform.position = player_transform.position;
}

spawn_systems.rs
srcspawn_systems.rs
use crate::{Acceleration, Enemy, Engine, GravitySource, GridDot, LightSource, Player, Velocity};
use crate::{Reticle, config::*};
use engine::prelude::*;

pub fn spawn_grid_dots(engine: &Engine) {
    // Calculate grid dimensions for centering at (0,0)
    let grid_width = (GRID_COLS - 1) as f32 * GRID_DOT_SPACING;
    let grid_height = (GRID_ROWS - 1) as f32 * GRID_DOT_SPACING;
    let start_x = -grid_width / 2.0;
    let start_y = -grid_height / 2.0;

    for row in 0..=GRID_ROWS {
        for col in 0..=GRID_COLS {
            // Calculate position for this grid dot
            let x = start_x + ((col) as f32 * GRID_DOT_SPACING);
            let y = start_y + ((row) as f32 * GRID_DOT_SPACING);

            let position = Vec2::new(x, y);

            let transform = &Transform {
                position,
                scale: Vec2::splat(1.0),
                ..Default::default()
            };

            // Determine if this is a grid line dot
            let is_grid_line =
                row % GRID_DOT_COLOR_SPACING == 0 || col % GRID_DOT_COLOR_SPACING == 0;
            let base_alpha = if is_grid_line {
                GRID_DOT_ALPHA_2
            } else {
                GRID_DOT_ALPHA_1
            };

            // All dots start as white with their respective alpha
            let color = &Color::new(1.0, 1.0, 1.0, base_alpha);

            let circle_render = &CircleRender {
                num_sides: GRID_DOT_SIDES,
                size: Vec2::splat(GRID_DOT_SIZE),
                visible: true,
            };

            let grid_dot = &GridDot {
                home_position: position,
                base_alpha,
                ..Default::default()
            };

            engine.spawn(bundle!(transform, color, circle_render, grid_dot));
        }
    }
}

pub fn spawn_player(engine: &Engine) {
    let transform = &Transform {
        position: Vec2::new(1000., 0.),
        scale: Vec2::splat(1.5),
        ..Default::default()
    };
    let color = &Color::new(0.96, 0.36, 0.9, 1.0);
    let circle_render = &CircleRender {
        num_sides: 20,
        ..Default::default()
    };

    let velocity = &Velocity {
        max_velocity: PLAYER_MAX_VELOCITY,
        ..Default::default()
    };
    let acceleration = &Acceleration {
        acceleration_rate: PLAYER_ACCELERATION,
        deceleration_rate: PLAYER_DECELERATION,
        ..Default::default()
    };

    let light = &LightSource {
        falloff_distance: PLAYER_LIGHT_RANGE,
        strength: PLAYER_LIGHT_INTENSITY,
        falloff_power: PLAYER_LIGHT_FALLOFF_POWER,
    };

    let grid_gravity = &GravitySource {
        strength: PLAYER_GRAVITY_STRENGTH,
        falloff_distance: PLAYER_FALLOFF_DISTANCE,
        falloff_power: PLAYER_FALLOFF_POWER,
    };
    engine.spawn(bundle!(
        transform,
        color,
        circle_render,
        &Player,
        velocity,
        acceleration,
        light,
        grid_gravity
    ));
}

pub fn spawn_camera(engine: &Engine) {
    let camera = &Camera {
        aspect: CameraAspect::FixedVirtualHeight { height: 1500. },
        ..Default::default()
    };

    engine.spawn(bundle!(&Transform::default(), camera));
}

pub fn spawn_enemy(engine: &Engine) {
    let transform = &Transform {
        position: Vec2::new(-110., 0.),
        scale: Vec2::splat(1.5),
        ..Default::default()
    };
    let color = &Color::new(0.2, 0.7, 0.7, 1.);
    let circle_render = &CircleRender {
        num_sides: 20,
        ..Default::default()
    };
    let velocity = &Velocity {
        max_velocity: ENEMY_MAX_VELOCITY,
        ..Default::default()
    };
    let acceleration = &Acceleration {
        acceleration_rate: ENEMY_ACCELERATION,
        deceleration_rate: ENEMY_DECELERATION,
        ..Default::default()
    };

    let light = &LightSource {
        falloff_distance: ENEMY_LIGHT_RANGE,
        strength: ENEMY_LIGHT_INTENSITY,
        falloff_power: ENEMY_LIGHT_FALLOFF_POWER,
    };

    let grid_gravity = &GravitySource {
        strength: ENEMY_GRAVITY_STRENGTH,
        falloff_distance: ENEMY_FALLOFF_DISTANCE,
        falloff_power: ENEMY_FALLOFF_POWER,
    };

    engine.spawn(bundle!(
        transform,
        color,
        circle_render,
        &Enemy,
        velocity,
        acceleration,
        light,
        grid_gravity
    ));
}

pub fn spawn_reticle(engine: &Engine) {
    let transform = &Transform {
        layer: 100.,
        ..Default::default()
    };
    let color = &Color::new(0.8, 0.6, 0.2, 1.0);

    let circle_render = &CircleRender {
        num_sides: 7,
        size: Vec2::splat(15.0),
        visible: true,
    };

    let light = &LightSource {
        falloff_distance: 100.,
        strength: 30.,
        falloff_power: 1.5,
    };

    let grid_gravity = &GravitySource {
        strength: -80.,
        falloff_distance: 50.,
        falloff_power: 0.5,
    };

    engine.spawn(bundle!(
        transform,
        color,
        circle_render,
        light,
        grid_gravity,
        &Reticle
    ));
}

util.rs
srcutil.rs
use engine::prelude::*;

// Helper function to convert Color to HSLA since it's not in the Color API
pub fn color_to_hsla(color: &Color) -> (f32, f32, f32, f32) {
    let r = color.r();
    let g = color.g();
    let b = color.b();
    let a = color.a();

    let max = r.max(g).max(b);
    let min = r.min(g).min(b);
    let delta = max - min;

    // Calculate lightness
    let l = (max + min) / 2.0;

    if delta == 0.0 {
        // Achromatic (gray)
        return (0.0, 0.0, l, a);
    }

    // Calculate saturation
    let s = if l < 0.5 {
        delta / (max + min)
    } else {
        delta / (2.0 - max - min)
    };

    // Calculate hue
    let h = if max == r {
        ((g - b) / delta + if g < b { 6.0 } else { 0.0 }) / 6.0
    } else if max == g {
        ((b - r) / delta + 2.0) / 6.0
    } else {
        ((r - g) / delta + 4.0) / 6.0
    };

    (h * 360.0, s, l, a)
}