nyanko 0.4.0

Pure stateless library for handling game quirks, animations, and data from The Battle Cats
Documentation
use crate::graphics::data::maanim::Animation;
use crate::graphics::data::mamodel::Model;
use crate::graphics::game::timeline;
use crate::graphics::game::transform::{self, Vector};
use crate::graphics::data::imgcut::SpriteSheet;

#[derive(Clone, Copy, Debug)]
pub struct BoundingBox {
    pub min_x: f32,
    pub min_y: f32,
    pub max_x: f32,
    pub max_y: f32,
}

impl BoundingBox {
    pub fn width(&self) -> f32 {
        self.max_x - self.min_x
    }

    pub fn height(&self) -> f32 {
        self.max_y - self.min_y
    }

    #[allow(dead_code)]
    pub fn center(&self) -> Vector {
        Vector {
            x: (self.min_x + self.max_x) / 2.0,
            y: (self.min_y + self.max_y) / 2.0,
        }
    }

    pub fn union(&self, other: &BoundingBox) -> BoundingBox {
        BoundingBox {
            min_x: self.min_x.min(other.min_x),
            min_y: self.min_y.min(other.min_y),
            max_x: self.max_x.max(other.max_x),
            max_y: self.max_y.max(other.max_y),
        }
    }
}

#[derive(Clone, Copy, Debug)]
pub struct Tolerance {
    pub minimum_opacity: f32,
    pub minimum_glow_opacity: f32,
    pub maximum_scale: f32,
    pub scale_opacity_threshold: f32,
    pub maximum_vertical_stretch: f32,
    pub maximum_height_threshold: f32,
    pub minimum_y_bound: f32,
}

impl Tolerance {
    pub fn new(level: f32) -> Self {
        let clamped_level = level.clamp(0.0, 1.0);
        let inverse_level = 1.0 - clamped_level;

        Self {
            minimum_opacity: 0.01 + (0.24 * clamped_level),
            minimum_glow_opacity: 0.75 * clamped_level,
            maximum_scale: 3.0 + (inverse_level * 100.0),
            scale_opacity_threshold: 0.95 * clamped_level,
            maximum_vertical_stretch: 2.0 + (inverse_level * 50.0),
            maximum_height_threshold: 1000.0 + (inverse_level * 10000.0),
            minimum_y_bound: -1200.0 - (inverse_level * 10000.0),
        }
    }
}

pub fn calculate_animation_bounds(
    model: &Model,
    sheet: &SpriteSheet,
    animations: &[&Animation],
    tolerance: Tolerance,
) -> Option<BoundingBox> {
    let mut master_bounds: Option<BoundingBox> = None;

    for loaded_animation in animations {
        let new_bounds_option = scan_bounds(model, Some(*loaded_animation), sheet, tolerance, None);

        let Some(new_bounds) = new_bounds_option else {
            continue;
        };

        master_bounds = Some(match master_bounds {
            Some(existing_bounds) => existing_bounds.union(&new_bounds),
            None => new_bounds,
        });
    }

    master_bounds
}

pub fn scan_bounds(
    model: &Model,
    animation: Option<&Animation>,
    sheet: &SpriteSheet,
    tolerance: Tolerance,
    override_range: Option<(i32, i32)>,
) -> Option<BoundingBox> {
    let mut minimum_x = f32::MAX;
    let mut minimum_y = f32::MAX;
    let mut maximum_x = f32::MIN;
    let mut maximum_y = f32::MIN;
    let mut found_any_valid_parts = false;

    let (start_frame, end_frame) = match override_range {
        Some(range) => range,
        None => match animation {
            Some(loaded_anim) => (0, loaded_anim.max_frame),
            None => (0, 0),
        },
    };

    let mut state_buffer = model.parts.clone();

    for frame_index in start_frame..=end_frame {
        let current_frame = frame_index as f32;

        let posed_parts = match animation {
            Some(loaded_anim) => {
                let _ = timeline::animate(model, loaded_anim, current_frame, &mut state_buffer);
                &state_buffer
            }
            None => &model.parts,
        };

        let world_parts = transform::solve_hierarchy(posed_parts, model);

        for part in world_parts {
            if part.opacity <= 0.01 || part.hidden {
                continue;
            }
            
            if part.opacity < tolerance.minimum_opacity {
                continue;
            }

            if part.glow > 0 && part.opacity < tolerance.minimum_glow_opacity {
                continue;
            }

            let scale_x = (part.matrix[0].powi(2) + part.matrix[1].powi(2)).sqrt();
            let scale_y = (part.matrix[3].powi(2) + part.matrix[4].powi(2)).sqrt();
            let maximum_scale = scale_x.max(scale_y);

            if maximum_scale > tolerance.maximum_scale && (part.opacity < tolerance.scale_opacity_threshold || part.glow > 0) {
                continue;
            }
            
            let Some(cut) = sheet.cuts_map.get(&part.sprite_index) else {
                continue;
            };

            let sprite_width = cut.original_size.x;
            let sprite_height = cut.original_size.y;
            let pivot_x = part.pivot.x;
            let pivot_y = part.pivot.y;

            let local_corners = [
                Vector { x: -pivot_x, y: -pivot_y },
                Vector { x: sprite_width - pivot_x, y: -pivot_y },
                Vector { x: sprite_width - pivot_x, y: sprite_height - pivot_y },
                Vector { x: -pivot_x, y: sprite_height - pivot_y },
            ];

            let transform_matrix = part.matrix;
            let mut part_minimum_x = f32::MAX;
            let mut part_minimum_y = f32::MAX;
            let mut part_maximum_x = f32::MIN;
            let mut part_maximum_y = f32::MIN;

            for point in local_corners {
                let world_x = point.x * transform_matrix[0] + point.y * transform_matrix[3] + transform_matrix[6];
                let world_y = point.x * transform_matrix[1] + point.y * transform_matrix[4] + transform_matrix[7];

                part_minimum_x = part_minimum_x.min(world_x);
                part_maximum_x = part_maximum_x.max(world_x);
                part_minimum_y = part_minimum_y.min(world_y);
                part_maximum_y = part_maximum_y.max(world_y);
            }
            
            let part_total_height = part_maximum_y - part_minimum_y;
            let part_total_width = part_maximum_x - part_minimum_x;

            if part_total_height > tolerance.maximum_height_threshold && part_total_height > part_total_width * tolerance.maximum_vertical_stretch {
                continue;
            }

            if part_maximum_y < tolerance.minimum_y_bound {
                continue;
            }
            
            minimum_x = minimum_x.min(part_minimum_x);
            maximum_x = maximum_x.max(part_maximum_x);
            minimum_y = minimum_y.min(part_minimum_y);
            maximum_y = maximum_y.max(part_maximum_y);

            found_any_valid_parts = true;
        }
    }

    if found_any_valid_parts {
        Some(BoundingBox {
            min_x: minimum_x,
            min_y: minimum_y,
            max_x: maximum_x,
            max_y: maximum_y,
        })
    } else {
        None
    }
}