用rust编写一个wasm贪吃蛇小游戏:day2

2,140 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情

在上一章中,我们搭建了js和wasm之间的基本交流框架。那么下面,正式开始贪吃蛇游戏的开发。

首先,编程的视角来看游戏的基本逻辑为:

  1. canvas定义好一个存在边界的游戏栅格地图。
  2. 随机产生一个蛇头(点)和一个蛇身(点),蛇头和蛇身不能重合。
  3. 蛇头可以随意运动,吞噬蛇身后长度增加一格,然后重新生成蛇身(不能与蛇重合)。
  4. 蛇身跟着蛇头运动,但运动方向不能与蛇身方向冲突(不能原地掉头)。
  5. 蛇头触碰地图边界或者蛇身,game over。

梳理清楚游戏逻辑之后,我们正式开始:

构建canvas地图:

首先在web目录的index.ts文件定义一个canvas地图,大小为20x20的栅格正方形,然后新建web/utils目录,里面新建index.js,加上一个随机生成蛇头的函数:

export function randomPointer(max) {
  return Math.floor(Math.random() * max);
}

接着在index.ts文件里,编写函数生成canvas

import init from "wasm_snake";
import { randomPointer } from "./utils/index";
init().then(wasm => {
  const worldWidth = 20;
  const worldHeiht = 20;
  const cell_size = 20;
  //生成随机点
  const spawnPoint = randomPointer(worldWidth * worldHeiht);
  //获取canvas元素
  const canvas = <HTMLCanvasElement>document.getElementById("snake-canvas");
  //获取这个元素的 context——图像稍后将在此被渲染
  const context = canvas.getContext("2d")!;
  canvas.width = worldWidth * cell_size;
  canvas.height = worldHeiht * cell_size;
  //清除栅格
  context.clearRect(0, 0, canvas.width, canvas.height);
  //新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径
  context.beginPath()
  for (let i = 0; i < worldWidth + 1; i++) {
    //从一个点到另一个点的移动过程,移动到指定的坐标 x 以及 y 上
    context.moveTo(cell_size * i, 0)
    //绘制一条从当前位置到指定 x 以及 y 位置的直线
    context.lineTo(cell_size * i, cell_size * worldHeight)
  }

  for (let x = 0; x < worldHeight + 1; x++) {
    context.moveTo(0, cell_size * x)
    context.lineTo(cell_size * worldWidth, cell_size * x)
  }
  //通过线条来绘制图形轮廓
  context.stroke();
});

cdweb目录打包一下,然后打开index.html看下效果:

1665405545496.png

可以看到canvas游戏地图已经画出来了。

按照正常逻辑,下一步就是初始化蛇头,然后一路狂奔是吧。。。。

虽然你很急,但你先别急,听我慢慢来分析:

  1. 有关地图的数据结构,这个游戏地图是一个二维地图。那么按照正常逻辑考虑,我们的地图数据应该是个二维数组,以某个边角为原点的直角坐标系开始计算每个栅格,里面放着对应的x、y坐标。按照这个逻辑,那么整个蛇的蛇身应该是这样的数据结构:[[1,1],[1,2],[1,3]....]。虽然很符合直觉上的逻辑,但是这样的二维数组,不管是蛇身的移动以及蛇身坐标的计算都会变得异常复杂。
  2. 我有幸看到过某个大佬的视频(其实整个游戏的思路也是从该大佬的视频中获取的灵感),我们换个角度来看待地图。把整个地图看作是一维数组,就像是堆积木一样,从左上角的1号,从左到右进行累加,一直到最后20x20的400号进行编号。那么,整条蛇的数据结构从二维数组变成了一个一维数组:[1,2,3,4,5.....]。这样,我们的整体计算逻辑和复杂度都会下降一个量级。

定义整体数据结构

那么接下来,就是我们的show time了。

因为蛇身变成了一维数组,里面存放着蛇在地图上的坐标位置。在项目src/lib目录下,定义相应的数据结构:

//蛇身积木
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct SnakeCell(i32);

//蛇
#[derive(Debug, PartialEq, Clone)]
pub struct Snake {
   //蛇身
   body: Vec<SnakeCell>,
   //运动方向
   direction: Direction,
}

蛇的运动方向其实就是上下左右,用一个枚举来标识即可:

#[wasm_bindgen]
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Direction {
   Up,
   Down,
   Left,
   Right,
}

注意#[wasm_bindgen]这个宏,这个是将函数、数据结构等导出到js进行使用的,也就是js和wasm进行通讯的办法。

同时,把整个canvas地图的数据记录下来:

//地图
#[wasm_bindgen]
pub struct World {
   //长度
   width: i32,
   //宽度
   height: i32,
   //蛇
   snake: Snake,
   //奖励点
   reward_cell: i32,
}

地图上记录了蛇的数据和奖励点(即刷新的蛇身点)。那么,这个width又有什么用处呢?有兴趣的同学可以思考一下(答案以后会揭晓)。

接着来初始化地图和蛇,在他们的struct上分别实现new方法:

impl Snake {
   pub fn new(spawn_index: i32) -> Snake {
       Self {
           body: vec![
               SnakeCell(spawn_index),
               SnakeCell(spawn_index - 1),
               SnakeCell(spawn_index - 2),
           ],
           direction: Direction::Down,
       }
   }
}

#[wasm_bindgen]
impl World {
   pub fn new(width: i32, height: i32, index: i32) -> World {
       let snake = Snake::new(index);
       let reward_cell = World::new_reward_cell(width, height, &snake.body);
       World {
           width,
           height,
           reward_cell,
           snake,
       }
    }
   
    fn new_reward_cell(snake_body: &Vec<SnakeCell>) -> i32 {
       let mut reward_cell;
       loop {
           reward_cell = utils::random();
           if !snake_body.contains(&SnakeCell(reward_cell)) {
               break;
           }
       }
       reward_cell
   }
   
    pub fn get_reward_cell(&self) -> i32 {
       self.reward_cell
   }
}

这里,我把初始化的蛇设定长度为3格,运动方向向下。初始化地图的时候新建蛇头,而随机生成的奖励点不能与蛇身重合,所以需要loop循环生成,并且排除重合的坐标。其中,需要引入rand依赖来生成随机数。

Cargo.toml新增rand依赖:

[dependencies]
.....
rand = "0.8.5"
getrandom = { version = "0.2", features = ["js"] }

然后在utils文件新增一个工具函数:

use rand::Rng;

pub fn random(max: i32) -> i32 {
   let mut rng = rand::thread_rng();
   let random = rng.gen_range(0..max);
   random
}

因为rand并不支持wasm环境,还需要getrandom依赖添加对wasm的支持。目前我们的地图为widthxheiht大小,所以奖励点的坐标需要生成在0-width*heiht之间的随机数。

初始化蛇头

好了,现在万事俱备,只欠东风。 下面开始蛇头的初始化和渲染:

import init, { World } from 'wasm_snake'
import { randomPointer } from './utils/index'

init().then(wasm => {
 ....
 //生成随机点
 const spawnPoint = randomPointer(worldWidth * worldHeight)
 let world = World.new(worldWidth, spawnPoint)
 ...
 //清除栅格
 context.clearRect(0, 0, canvas.width, canvas.height)
 initCanvas(context, worldWidth, worldHeight,  cell_size)
 drawSnakeCells(wasm, world, context, worldWidth, cell_size)
})

//初始化canvas
const initCanvas = (context, worldWidth, worldHeight,  cell_size) => {
 //新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径
 context.beginPath()
 for (let i = 0; i < worldWidth + 1; i++) {
   //从一个点到另一个点的移动过程,移动到指定的坐标 x 以及 y 上
   context.moveTo(cell_size * i, 0)
   //绘制一条从当前位置到指定 x 以及 y 位置的直线
   context.lineTo(cell_size * i, cell_size * worldHeight)
 }

 for (let x = 0; x < worldHeight + 1; x++) {
   context.moveTo(0, cell_size * x)
   context.lineTo(cell_size * worldWidth, cell_size * x)
 }
 //通过线条来绘制图形轮廓
 context.stroke()
}

//蛇身
const drawSnakeCells = (wasm, world, context, worldWidth, cell_size) => {
 const snakeCells = new Uint32Array(wasm.memory.buffer, world.snake_cells(), world.snake_length())
 
 context.beginPath()
 snakeCells.forEach((item, index) => {
   const row = Math.floor(item / worldWidth)
   const col = item - row * worldWidth
   context.fillStyle = index === 0 ? 'green' : '#000000'
   context.fillRect(col * cell_size, row * cell_size, cell_size, cell_size)
 })
 context.stroke()
}

把渲染canvas的函数提取一下,同时从wasm引入地图的数据,新增蛇身渲染的函数。

蛇身的渲染其实跟canvas渲染差不多,只是拿到每段蛇身的坐标位置,然后蛇头和蛇身分别渲染不同的颜色进行区分,蛇头颜色我们渲染为绿色,蛇身为黑色。前面,我们之所以把蛇身的数据结构放进一维数组,这样做的好处就是计算坐标会变得非常简单:只需要蛇身的编号除地图长度以及获取相应的余数,即可将蛇身的编号转化为地图上的x\y坐标进行渲染。

这里用到了Uint32Array这个方法,需要从wasm中获取蛇身的数据。 所以在World结构体增加两个实现方法:

#[wasm_bindgen]
impl World {
   ....
   pub fn snake_cells(&self) -> *const SnakeCell {
       self.snake.body.as_ptr()
   }

   pub fn snake_length(&self) -> i32 {
       self.snake.body.len() as i32
   }
}

coding is done!

打包构建

下面就是打包过程:

1、打包wasm

wasm-pack build --target web

2.cd到web目录,删除node_modules目录,重新下载js依赖:

yarn install
  1. 重新打包js文件:
yarn run build

4.千万不要忘记,把pkg中的wasm_snake_bg.wasm替换掉public目录下的旧文件!

这时候再看index.html,初始化蛇头的效果就出来了:

1665412653355.png

认真看文章的同学可能会发现了,这个过程只初始化了蛇头,没有渲染出奖励点啊喂! 那么,留个课后作业:怎么渲染出奖励点呢?(tips: 办法和渲染蛇头是一样的)

有兴趣的同学,可以自己思考和仿写一下渲染奖励点的方法。答案下期揭晓。