bombhopper 0.1.2

Rust bindings for bombhopper.io.
Documentation
//! Rust bindings for [bombhopper.io](bombhoppper.io).

use std::{
    collections::HashMap,
    ops::{Add, Mul, Sub},
};

use serde::Serialize;

#[derive(Serialize, PartialEq, Default, Clone, Copy)]
pub struct Point {
    pub x: f32,
    pub y: f32,
}

impl Point {
    pub fn new(x: f32, y: f32) -> Self {
        Self { x, y }
    }
}

impl Add for Point {
    type Output = Self;

    fn add(self, other: Self) -> Self::Output {
        Self {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

impl Sub for Point {
    type Output = Self;

    fn sub(self, other: Self) -> Self::Output {
        Point {
            x: self.x - other.x,
            y: self.y - other.y,
        }
    }
}

impl Mul<f32> for Point {
    type Output = Self;

    fn mul(self, other: f32) -> Self::Output {
        Point {
            x: self.x * other,
            y: self.y * other,
        }
    }
}

#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "camelCase")]
pub enum AmmoType {
    Empty,
    #[serde(rename = "bullet")]
    Bomb,
    Grenade,
}

#[derive(Serialize, Clone)]
pub enum Ammo {
    #[serde(rename = "infiniteAmmo")]
    Infinite(AmmoType),

    /// Note that finite is reversed, so if an ammo is in front, it'll be fired last
    #[serde(rename = "magazine")]
    Finite(Vec<AmmoType>),
}

impl Ammo {
    pub fn finite_seq(s: &str) -> Result<Self, String> {
        let mut mag = vec![];
        for c in s.chars().rev() {
            mag.push(match c.to_ascii_lowercase() {
                'b' => AmmoType::Bomb,
                'g' => AmmoType::Grenade,
                'e' => AmmoType::Empty,
                // TODO potentially migrate to Error enum
                _ => return Err(String::from("Ammo Doesn't exist")),
            })
        }
        Ok(Self::Finite(mag))
    }
}

#[derive(Serialize, Clone)]
#[serde(untagged)]
pub enum Shape {
    Polygon { vertices: Vec<Point> },
    Circle { x: f32, y: f32, radius: f32 },
}

#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub enum TextAlign {
    Left,
    Center,
    Right,
    Justify,
}

macro_rules! define_entities {
    ( $( $material: ident),* ) => {
        #[derive(Serialize, Clone)]
        #[serde(rename_all = "camelCase", tag = "type", content = "params")]
        pub enum Entity {
            #[serde(rename_all = "camelCase")]
            Player {
                is_static: bool,
                angle: i32,
                x: f32,
                y: f32,
                #[serde(flatten)]
                ammo: Ammo,
            },
            #[serde(rename_all = "camelCase", rename = "endpoint")]
            Door {
                is_static: bool,
                angle: i32,
                x: f32,
                y: f32,
                right_facing: bool,
            },
            #[serde(rename_all = "camelCase")]
            Text {
                angle: i32,
                x: f32,
                y: f32,
                // TODO maybe use &str and deal with lifetimes
                #[serde(rename = "copy")]
                text: HashMap<String, String>,
                anchor: Point,
                align: TextAlign,
                fill_color: i32,
                opacity: f32,
            },
            #[serde(rename_all = "camelCase")]
            Paint {
                fill_color: i32,
                opacity: f32,
                vertices: Vec<Point>,
            },
            $(
            #[serde(rename_all = "camelCase")]
            $material {
                is_static: bool,
                #[serde(flatten)]
                shape: Shape,
            },
            )*
        }
    };
}

define_entities!(Normal, Ice, Breakable, Deadly, Bouncy);

impl Entity {
    pub fn new_text(pos: Point, text: &str) -> Self {
        Self::Text {
            angle: 0,
            x: pos.x,
            y: pos.y,
            text: HashMap::from([(String::from("en"), text.to_string())]),
            anchor: Point::new(0.5, 0.5),
            align: TextAlign::Left,
            fill_color: 16777215,
            opacity: 1.0,
        }
    }
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Level {
    pub name: String,
    pub timings: [i32; 2],
    pub entities: Vec<Entity>,
    format_version: u8,
}

impl Level {
    pub fn new(name: String, timings: [i32; 2]) -> Self {
        Self {
            name,
            timings,
            entities: vec![],
            format_version: 0,
        }
    }
    /// Pushes entity onto entities vector
    pub fn push(&mut self, entity: Entity) {
        self.entities.push(entity);
    }
    /// Clears all entities from entities vector
    pub fn clear(&mut self) {
        self.entities.clear();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json;

    #[test]
    fn ammo() {
        // Tests if infinte bombs goes correctly
        assert_eq!(
            r#"{"type":"player","params":{"isStatic":false,"angle":0,"x":0.0,"y":0.0,"infiniteAmmo":"bullet"}}"#,
            serde_json::to_string(&Entity::Player {
                is_static: false,
                angle: 0,
                x: 0.0,
                y: 0.0,
                ammo: Ammo::Infinite(AmmoType::Bomb)
            })
            .unwrap()
        );

        // Tests if finite seq works (and finite)
        assert_eq!(
            r#"{"type":"player","params":{"isStatic":false,"angle":0,"x":0.0,"y":0.0,"magazine":["grenade","empty","bullet","bullet"]}}"#,
            serde_json::to_string(&Entity::Player {
                is_static: false,
                angle: 0,
                x: 0.0,
                y: 0.0,
                ammo: Ammo::finite_seq("bbeg").unwrap()
            })
            .unwrap()
        )
    }

    #[test]
    fn default_level() {
        let mut level = Level::new(String::from("My level"), [0, 0]);
        level.push(Entity::new_text(
            Point::new(200.0, 520.0),
            "This is the default level!\nEdit to your liking",
        ));

        level.push(Entity::Normal {
            is_static: true,
            shape: Shape::Polygon {
                vertices: vec![
                    Point::new(400.0, 820.0),
                    Point::new(400.0, 880.0),
                    Point::new(520.0, 880.0),
                    Point::new(520.0, 820.0),
                ],
            },
        });

        level.push(Entity::Ice {
            is_static: true,
            shape: Shape::Polygon {
                vertices: vec![
                    Point::new(-260.0, 580.0),
                    Point::new(-260.0, 820.0),
                    Point::new(400.0, 820.0),
                    Point::new(400.0, 760.0),
                    Point::new(160.0, 760.0),
                    Point::new(-20.0, 640.0),
                    Point::new(-140.0, 640.0),
                ],
            },
        });

        level.push(Entity::Door {
            is_static: true,
            angle: 0,
            x: 550.0,
            y: 630.0,
            right_facing: true,
        });

        level.push(Entity::Player {
            is_static: false,
            angle: 0,
            x: -60.0,
            y: 620.0,
            ammo: Ammo::finite_seq("beg").unwrap(),
        });

        level.push(Entity::Normal {
            is_static: false,
            shape: Shape::Polygon {
                vertices: vec![
                    Point::new(-236.0, 292.0),
                    Point::new(-176.0, 292.0),
                    Point::new(-176.0, 352.0),
                    Point::new(-236.0, 352.0),
                ],
            },
        });

        assert_eq!(
            r#"{"name":"My level","timings":[0,0],"entities":[{"type":"text","params":{"angle":0,"x":200.0,"y":520.0,"copy":{"en":"This is the default level!\nEdit to your liking"},"anchor":{"x":0.5,"y":0.5},"align":"left","fillColor":16777215,"opacity":1.0}},{"type":"normal","params":{"isStatic":true,"vertices":[{"x":400.0,"y":820.0},{"x":400.0,"y":880.0},{"x":520.0,"y":880.0},{"x":520.0,"y":820.0}]}},{"type":"ice","params":{"isStatic":true,"vertices":[{"x":-260.0,"y":580.0},{"x":-260.0,"y":820.0},{"x":400.0,"y":820.0},{"x":400.0,"y":760.0},{"x":160.0,"y":760.0},{"x":-20.0,"y":640.0},{"x":-140.0,"y":640.0}]}},{"type":"endpoint","params":{"isStatic":true,"angle":0,"x":550.0,"y":630.0,"rightFacing":true}},{"type":"player","params":{"isStatic":false,"angle":0,"x":-60.0,"y":620.0,"magazine":["grenade","empty","bullet"]}},{"type":"normal","params":{"isStatic":false,"vertices":[{"x":-236.0,"y":292.0},{"x":-176.0,"y":292.0},{"x":-176.0,"y":352.0},{"x":-236.0,"y":352.0}]}}],"formatVersion":0}"#,
            serde_json::to_string(&level).unwrap()
        );
    }
}