chainmailer 0.2.1

If You Do Not Send This Letter To Ten Recipients You Will Regret It
Documentation
use std::time::Duration;

use bevy::{prelude::*, time::common_conditions::on_timer};
use bevy_ratatui_camera::RatatuiCamera;
use rand::distributions::uniform::SampleRange;

use crate::{constants::WORD_CUBE_LENGTH, letters::WordBag, rng::RngResource, states::GameStates};

pub fn plugin(app: &mut App) {
    app.add_systems(OnExit(GameStates::Loading), scene_setup_system)
        .add_systems(
            Update,
            (
                word_cube_spawn_system.run_if(on_timer(Duration::from_millis(1000))),
                word_cube_move_system,
                word_cube_despawn_system,
            )
                .run_if(in_state(GameStates::Playing)),
        );
}

#[derive(Component, Debug, Default, Clone)]
pub struct WordCube {
    pub word: String,
    pub color: Color,
    pub despawn_character: char,
}

impl WordCube {
    pub fn new(word: &str, color: Color, despawn_character: char) -> Self {
        Self {
            word: word.into(),
            color,
            despawn_character,
        }
    }
}

fn scene_setup_system(mut commands: Commands) {
    commands.spawn((PointLight::default(),));
}

fn word_cube_spawn_system(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
    mut word_bag: ResMut<WordBag>,
    word_cubes: Query<&WordCube>,
    mut rng: Local<RngResource>,
    camera: Single<(&Camera, &GlobalTransform), With<RatatuiCamera>>,
) {
    let (camera, camera_transform) = camera.into_inner();
    let Some(spawn_position) = get_spawn_position(camera, camera_transform, 2.0, 4.0, &mut rng)
    else {
        return;
    };

    let word_cube = word_bag.pick(&mut rng.0).clone();

    if word_cubes
        .iter()
        .any(|spawned_cube| word_cube.word == spawned_cube.word)
    {
        word_bag.shuffle_new_draft(&mut rng.0);
        return;
    }

    commands.spawn((
        Mesh3d(meshes.add(Cuboid::from_length(WORD_CUBE_LENGTH))),
        MeshMaterial3d(materials.add(word_cube.color)),
        Transform::from_translation(spawn_position),
        word_cube,
    ));
}

fn word_cube_move_system(time: Res<Time>, mut stars: Query<&mut Transform, With<WordCube>>) {
    for mut star in &mut stars {
        star.translation.y -= time.delta_secs() * 0.45;
        star.rotate_y(time.delta_secs());
        star.rotate_x(time.delta_secs() * 0.4);
    }
}

fn word_cube_despawn_system(
    mut commands: Commands,
    mut stars: Query<(Entity, &mut Transform), With<WordCube>>,
    camera: Single<(&Camera, &GlobalTransform), With<RatatuiCamera>>,
) {
    let (camera, camera_transform) = camera.into_inner();
    let Some(lowest_visible_y) = get_lowest_visible_y(camera, camera_transform, 4.0) else {
        return;
    };

    for (entity, transform) in &mut stars {
        if transform.translation.y < lowest_visible_y {
            commands.entity(entity).despawn();
        }
    }
}

fn get_spawn_position(
    camera: &Camera,
    camera_transform: &GlobalTransform,
    near_depth: f32,
    far_depth: f32,
    rng: &mut RngResource,
) -> Option<Vec3> {
    let viewport_size = camera.logical_viewport_size()?;

    let top_left = camera
        .viewport_to_world(camera_transform, Vec2::new(0.05, 0.0))
        .ok()?;

    let top_right = camera
        .viewport_to_world(camera_transform, Vec2::new(viewport_size.x - 0.05, 0.0))
        .ok()?;

    let z = (near_depth..far_depth).sample_single(&mut rng.0);

    let top_left_distance =
        top_left.intersect_plane(Vec3::new(0., 0., -z), InfinitePlane3d::new(Vec3::Z))?;
    let top_right_distance =
        top_left.intersect_plane(Vec3::new(0., 0., -z), InfinitePlane3d::new(Vec3::Z))?;

    let top_left_at_z = top_left.get_point(top_left_distance);
    let top_right_at_z = top_right.get_point(top_right_distance);

    let x = (top_left_at_z.x..top_right_at_z.x).sample_single(&mut rng.0);
    let y = top_left_at_z.y + WORD_CUBE_LENGTH;

    Some(Vec3::new(x, y, -z))
}

fn get_lowest_visible_y(
    camera: &Camera,
    camera_transform: &GlobalTransform,
    far_depth: f32,
) -> Option<f32> {
    let viewport_size = camera.logical_viewport_size()?;

    let bottom_left = camera
        .viewport_to_world(camera_transform, Vec2::new(0.0, viewport_size.y))
        .ok()?;

    let bottom_left_distance = bottom_left
        .intersect_plane(Vec3::new(0., 0., -far_depth), InfinitePlane3d::new(Vec3::Z))?;

    let bottom_left_at_depth = bottom_left.get_point(bottom_left_distance);

    Some(bottom_left_at_depth.y)
}