用rust编写一个wasm贪吃蛇小游戏:day2
在上一章中,我们搭建了js和wasm之间的基本交流框架。那么下面,正式开始贪吃蛇游戏的开发。
首先,编程的视角来看游戏的基本逻辑为:
- 用
canvas
定义好一个存在边界的游戏栅格地图。 - 随机产生一个蛇头(点)和一个蛇身(点),蛇头和蛇身不能重合。
- 蛇头可以随意运动,吞噬蛇身后长度增加一格,然后重新生成蛇身(不能与蛇重合)。
- 蛇身跟着蛇头运动,但运动方向不能与蛇身方向冲突(不能原地掉头)。
- 蛇头触碰地图边界或者蛇身,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();
});
先cd
到web
目录打包一下,然后打开index.html
看下效果:
可以看到canvas游戏地图已经画出来了。
按照正常逻辑,下一步就是初始化蛇头,然后一路狂奔是吧。。。。
虽然你很急,但你先别急,听我慢慢来分析:
- 有关地图的数据结构,这个游戏地图是一个二维地图。那么按照正常逻辑考虑,我们的地图数据应该是个二维数组,以某个边角为原点的直角坐标系开始计算每个栅格,里面放着对应的x、y坐标。按照这个逻辑,那么整个蛇的蛇身应该是这样的数据结构:
[[1,1],[1,2],[1,3]....]
。虽然很符合直觉上的逻辑,但是这样的二维数组,不管是蛇身的移动以及蛇身坐标的计算都会变得异常复杂。 - 我有幸看到过某个大佬的视频(其实整个游戏的思路也是从该大佬的视频中获取的灵感),我们换个角度来看待地图。把整个地图看作是一维数组,就像是堆积木一样,从左上角的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
的支持。目前我们的地图为width
xheiht
大小,所以奖励点的坐标需要生成在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
- 重新打包js文件:
yarn run build
4.千万不要忘记,把pkg
中的wasm_snake_bg.wasm
替换掉public
目录下的旧文件!
这时候再看index.html
,初始化蛇头的效果就出来了:
认真看文章的同学可能会发现了,这个过程只初始化了蛇头,没有渲染出奖励点啊喂! 那么,留个课后作业:怎么渲染出奖励点呢?(tips: 办法和渲染蛇头是一样的)
有兴趣的同学,可以自己思考和仿写一下渲染奖励点的方法。答案下期揭晓。
转载自:https://juejin.cn/post/7168860856334155806