📄 src/bunny.rs
use std::time::Duration;

use bevy::{
    app::{App, Startup, Update},
    asset::{AssetServer, Assets},
    ecs::{
        component::Component,
        query::{With, Without},
        system::{Commands, Query, Res, ResMut, Single},
    },
    gltf::GltfAssetLabel,
    math::{Vec3, primitives::Cuboid},
    mesh::{Mesh, Mesh3d, MeshBuilder, Meshable},
    scene::SceneRoot,
    time::Time,
    transform::components::Transform,
};

use crate::{dog::Dog, obstacles::Obstacles};

pub trait BunnySystems {
    fn add_bunny_systems(&mut self) -> &mut Self;
}

impl BunnySystems for App {
    fn add_bunny_systems(&mut self) -> &mut Self {
        self.add_systems(Startup, setup)
            .add_systems(Update, control)
    }
}

#[derive(Component, Default)]
struct Bunny {
    jump_state: JumpState,
}

enum JumpState {
    Sitting,
    Jumping { from: Vec3, to: Vec3 },
    Cooldown { ready: Duration },
}

impl Default for JumpState {
    fn default() -> Self {
        JumpState::Sitting
    }
}

const DETECTION_DISTANCE: f32 = 3.0;
const SCARED_JUMP_DISTANCE: f32 = 2.0;
const JUMP_HEIGHT: f32 = 1.0;
const JUMP_COOLDOWN: f32 = 0.05;
const DESIRED_SPEED: f32 = 10.0;
const SPEED: f32 = SCARED_JUMP_DISTANCE / (SCARED_JUMP_DISTANCE / DESIRED_SPEED - JUMP_COOLDOWN);

fn setup(mut commands: Commands, asset_server: Res<AssetServer>, mut meshes: ResMut<Assets<Mesh>>) {
    let model =
        asset_server.load(GltfAssetLabel::Scene(0).from_asset("models/cube-pets/animal-bunny.glb"));
    let mesh = meshes.add(Cuboid::from_length(2.0).mesh().build());

    commands.spawn((
        SceneRoot(model.clone()),
        Transform::from_xyz(4.0, 0.0, 0.0),
        Mesh3d(mesh.clone()),
        Bunny::default(),
    ));

    commands.spawn((
        SceneRoot(model.clone()),
        Transform::from_xyz(-4.0, 0.0, 0.0),
        Mesh3d(mesh.clone()),
        Bunny::default(),
    ));
}

fn control(
    time: Res<Time>,
    dog: Single<&Transform, (With<Dog>, Without<Bunny>)>,
    bunnies: Query<(&mut Transform, &mut Bunny)>,
    mut obstacles: Obstacles,
) {
    for (mut bunny_transform, mut bunny) in bunnies {
        match &bunny.jump_state {
            JumpState::Sitting => {
                control_sitting(&dog, &mut bunny_transform, &mut bunny, &mut obstacles)
            }
            JumpState::Jumping { from, to } => {
                let from = *from;
                let to = *to;
                animate_jumping(&time, &mut bunny_transform, &mut bunny, from, to)
            }
            JumpState::Cooldown { ready } => {
                let ready = *ready;
                cooldown(&time, &mut bunny, ready);
            }
        };
    }
}

fn control_sitting(
    dog: &Transform,
    bunny_transform: &mut Transform,
    bunny: &mut Bunny,
    obstacles: &mut Obstacles,
) {
    let direction = bunny_transform.translation - dog.translation;
    if direction.length_squared() > DETECTION_DISTANCE * DETECTION_DISTANCE {
        return;
    }

    let dog_direction = direction.with_y(0.0).normalize();
    let Some(to) = obstacles.avoid(
        bunny_transform.translation,
        bunny_transform.translation + dog_direction * SCARED_JUMP_DISTANCE,
    ) else {
        return;
    };

    bunny.jump_state = JumpState::Jumping {
        from: bunny_transform.translation,
        to,
    };
    bunny_transform.look_to(-(to - bunny_transform.translation), Vec3::Y);
}

fn animate_jumping(
    time: &Time,
    bunny_transform: &mut Transform,
    bunny: &mut Bunny,
    from: Vec3,
    to: Vec3,
) {
    let current = bunny_transform.translation.with_y(to.y);
    let direction = to - current;
    let delta = SPEED * time.delta_secs();

    let (direction, length) = direction.normalize_and_length();
    if length <= delta {
        bunny_transform.translation = to;
        bunny.jump_state = JumpState::Cooldown {
            ready: time.elapsed() + Duration::from_secs_f32(JUMP_COOLDOWN),
        };
        return;
    }

    let next = current + direction.normalize() * delta;
    let total_distance = from.distance(to);
    bunny_transform.translation = next
        + Vec3::Y
            * JUMP_HEIGHT
            * (std::f32::consts::PI * from.distance(next) / total_distance).sin();
}

fn cooldown(time: &Time, bunny: &mut Bunny, ready: Duration) {
    if time.elapsed() >= ready {
        bunny.jump_state = JumpState::Sitting;
    }
}