使用bevy制作拼图游戏(jigsaw puzzle)

  • 将图片按照参数分割成小图片

切割图片

思路分析

网上搜索一番后,找到了一个网页版拼图生成器 和rust对应的SVG拼图生成器 阅读代码后,可以看到拼图每个块(Piece), 由上、右、下、左四条边(Edge)组成,每条边分为直边(StraightEdge)和锯齿边(IdentedEdge)两种, 锯齿边由三段二次贝塞尔曲线拼成, 突起方向和大小由TabSize和Jitter控制。

添加基础库

bezier-rs = "0.4.0"  // 贝塞尔曲线
image = "0.25.4"     // 图像处理
imageproc = "0.25.0" // 图像高级处理
log = "0.4.22"       // 日志库
glam = "=0.24.2"     // 数学计算

预处理图片

准备构造器

pub struct JigsawGenerator {  
    /// 原始图像 
    origin_image: DynamicImage,  
    /// 每列块数
    pieces_in_column: usize,  
    /// 每行块数
    pieces_in_row: usize,  
    /// 凸起系数
    tab_size: Option<f32>,  
    /// “抖动”系数。数字越大,拼图就越不对称  
    jitter: Option<f32>,  
    /// 生成拼图时随机性的可选种子值。
    seed: Option<usize>,  
}

从外部参数读取图片地址, 载入后,准备按照9 x 6的方式生成拼图

let image_path = env::args().nth(1).unwrap_or("images/raw.jpeg".to_string());  
info!("Start to load {}", image_path);  
let img = image::open(image_path).expect("Failed to open image");  
info!("load image successfully!");  
let template = JigsawGenerator::new(img, 9, 6).generate();

为了防止图片太大, 影响后续处理速度, 先按固定的尺寸缩放一下

/// 超过固定尺寸的进行缩放
fn scale_image(image: &DynamicImage) -> RgbaImage {  
    let (width, height) = image.dimensions();  
    let scale = if width > MAX_WIDTH || height > MAX_HEIGHT {  
        let scale_x = MAX_WIDTH as f32 / width as f32;  
        let scale_y = MAX_HEIGHT as f32 / height as f32;  
        scale_x.min(scale_y)  
    } else {  
        1.0  
    };  
    if scale < 1.0 {  
        image  
            .resize(  
                (width as f32 * scale) as u32,  
                (height as f32 * scale) as u32,  
                image::imageops::FilterType::Lanczos3,  
            )  
            .to_rgba8()  
    } else {  
        image.to_rgba8()  
    }  
}

计算轮廓

按照算法计算轮廓线. 注意坐标系原点是图片的左上角,x朝右, y朝下

let (starting_points_x, piece_width) = divide_axis(image_width, pieces_in_column);  
let (starting_points_y, piece_height) = divide_axis(image_height, pieces_in_row);  
let mut contour_gen = EdgeContourGenerator::new(  
    piece_width,  
    piece_height,  
    self.tab_size,  
    self.jitter,  
    self.seed,  
);
let mut vertical_edges = vec![];  
let mut horizontal_edges = vec![];
/// 将每条边的数据存下来
for index_y in 0..starting_points_y.len() {  
    let mut left_border = true;  
    for index_x in 0..starting_points_x.len() {  
        horizontal_edges.push(if top_border {  
            Edge::StraightEdge(StraightEdge {  
                starting_point: (starting_points_x[index_x], 0.0),  
                end_point: (end_point_pos(index_x, &starting_points_x, image_width), 0.0),  
            })  
        } else {  
            Edge::IndentedEdge(IndentedEdge::new(  
                (starting_points_x[index_x], starting_points_y[index_y]),  
                (  
                    end_point_pos(index_x, &starting_points_x, image_width),  
                    starting_points_y[index_y],  
                ),  
                &mut contour_gen,  
            ))  
        });

处理后,我们得到了水平边和垂直边的起始点、控制点、终点等坐标点。 我们按照上、右、下、左的顺序,将每条边先转为贝塞尔曲线,然后将4条边的曲线合起来组成了一个闭合路径(subpath)

let top_beziers = top_edge.to_beziers(false);  
let right_beziers = right_edge.to_beziers(false);  
let bottom_beziers = bottom_edge.to_beziers(true);  
let left_beziers = left_edge.to_beziers(true);  
let beziers: Vec<_> = vec![top_beziers, right_beziers, bottom_beziers, left_beziers]  
    .into_iter()  
    .flatten()  
    .collect();  
let sub_path: Subpath<PuzzleId> = Subpath::from_beziers(&beziers, true);

裁剪拼图

我们使用imageproc的绘画工具把线条画一下 感觉不错、接下来将每个小块裁出来,注意原本每条边的尺寸是按9x6的块数计算后获得的,但由于每个块可能有几条边会凸起,所以裁剪的位置要更大。幸好bezier-rs提供了bounding-box的计算

let [box_min, box_max] = piece  
    .sub_path  
    .bounding_box()  
    .expect("Failed to get bounding box");

有了包围盒的坐标,就可以计算出原图片上对应的起始坐标,进行图片裁剪

let mut piece_image = self  
    .origin_image  
    .view(  
        top_left_x as u32,  
        top_left_y as u32,  
        width as u32,  
        height as u32,  
    )  
    .to_image();

将不在封闭路径里的点改为透明, 这里使用了image的并行运算加速处理

piece_image  
    .par_enumerate_pixels_mut()  
    .for_each(|(x, y, pixel)| {  
        let point = DVec2::new(top_left_x + x as f64, top_left_y + y as f64);  
        if !piece.contains(point) {  
            *pixel = Rgba([0, 0, 0, 0])  
        }  
    });

再描一个白边, 完成。 |200 将代码移入workspace,准备开始用bevy搭建游戏

[workspace]  
resolver = "2"  
members = [  
    "jigsaw_puzzle_generator",  
]