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

Sidequest 2-1: Defining Gravity

After making the grid in 2.3 Add a Camera, I got inspired to see just how far we can push our ECS architecture to do wild things with it. The first thought I had was: "What if the grid was bent by our actors like one of those spacetime distortion grids?". If things are affected by gravity, they stretch out towards it (like a black hole), rotate towards the source, get darker the closer they are (to mimic being lower in height), and probably most importantly, they move towards it. That's enough different effects to sell what I'm looking for and is a great stress test for our ECS engine.

New Components and Config

To do this, first we'll need to add another couple components to lib.rs and update our spawn_systems.rs to use them, as well as update our config.rs.

We're introducing a GridDot component to make it easier to query them, and store information about how they're getting displaced/altered by our gravity effect. If we planned to have other things get pulled by gravity, then it'd make more sense to separate this out into another component, but for our scope this keeps things from getting too confusingly spread out. We'll also add a GravitySource component to put on our player, enemy, and maybe things in the future like projectiles.

srclib.rs
#[derive(Default)]
#[component]
struct GridDot {
    home_position: Vec2,
    current_displacement: Vec2,
    base_alpha: f32,
    current_height: f32,
}

#[derive(Default)]
#[component]
struct GravitySource {
    strength: f32,
    falloff_distance: f32,
    falloff_power: f32,
}

Our components that use GravitySource now need some config for what that amount will be. We'll also add a handful of global consts for use in our gravity function.

srcconfig.rs
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; // Minimum distance to avoid division issues
pub const INFLUENCE_CUTOFF: f32 = 0.00001; // At what point is gravity "too low" to matter.

pub const MAX_DISPLACEMENT: f32 = 100.0; // Maximum distance dots move toward gravity source
pub const MAX_STRETCH_AMOUNT: f32 = 3.; // How much grid dots can get stretched towards gravity source
pub const STRETCH_SENSITIVITY: f32 = 0.3; // A bonus to stretch amounts for better feel.

pub const MAX_HEIGHT_RANGE: f32 = 3.0; // How much "height" a dot can gain - we use this to then change its alpha
pub const ALPHA_SENSITIVITY: f32 = 3.; // How sensitive a dot is to height changes.
pub const MIN_ALPHA_PERCENTAGE: f32 = 0.11; // Minimum opacity as fraction of base alpha (0.1 = 10%)

pub const SMOOTHING_TIME: f32 = 0.1; // How long in s it takes to apply gravity effects.

Of course we need to update our spawning functions to use the new config and components. I also went ahead and removed the camera's clear_color for now, as the effect looks better on a black background I find.

srcspawn_systems.rs
use crate::{Acceleration, Enemy, GravitySource, GridDot, Player, Velocity};

pub fn spawn_grid_dots() {
    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 {
            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()
            };

            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
            };

            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));
        }
    }
}

//In spawn_player:

 let gravity_source = &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,
        gravity_source
    ));

//In spawn_enemy:

    let gravity_source = &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,
        gravity_source
    ));

Grid_Systems

We plan to do more than just gravity, so we're adding in a grid_systems.rs file to hold all our stuff that operates on the grid.

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

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>,
) {

    // First we gather all the gravity sources.
    let sources: Vec<_> = gravity_sources
        .iter()
        .map(|(transform, gravity)| {
            (
                transform.position,
                gravity.strength,
                gravity.falloff_distance,
                gravity.falloff_power,
            )
        })
        .collect();

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

    // Iterate over the entire grid.
    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
        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);
    });
}
srclib.rs
#[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);
}

With that all in place you get something like this...

Defining Gravity

It looks better in motion, of course!

But it would also look better with pretty colors. So let's add those in next.