Game = Rust + WebAssembly + 浏览器
前言
在上一篇Rust 编译为 WebAssembly 在前端项目中使用我们通过一个简单的Hello World
的 Demo
,讲述了如何将 Rust
编译为 WebAssembly
,并在前端项目中使用。
虽然,是一个Demo
;但是,我们由小见大,以点见面,分别描述了
Rust
如何编译为WebAssembly
WebAssembly
如何内嵌到JS
环境中WebAssembly
如何与JS
进行交互Rust
如何能被 JS 调用的原理分析web-sys
充当wasm-bindgen
的前端
在写完上篇文章中,总觉得如果只是一个Demo
的话,有点意犹未尽。我们想要在实际开发中使用WebAssembly
,总不能通过Rust
唤起类似alert
等前端唾手可得的功能。这就有点**「脱裤子放屁」多此一举了。我们不能为了用而用。WebAssembly
在前端项目中,更多扮演的是「功能加速」**的角色。也就是我们用了它是为了降本增效的。(不是降本增效哈)。
并且,在之前的文章中,还有很多开发上的不足,比方说
- 缺少代码热更新
- 本地 dev 服务器(上一篇中,我们特意用了
Webpack
搭建了一个本地服务器) - 操作浏览器的
DOM
元素 - ...
所以,我们今天用一个功能更加丰富的例子(贪吃蛇游戏),来逐一解决上面的问题,并让大家真正的理解Rust
和JS
是如何更好的合作的。
我们的本文的内容,不是要写一个功能完备的贪吃蛇游戏,而是以这个例子来更加完善我们对Rust
/WebAssembly
/JS
之间的数据交互的理解。「重在过程」,当然结果也很可爱,我们会写一个简单版本的贪吃蛇小游戏。
本文的一些基础内容,不再做出过多解释,也就是说大家需要有一定的Rust
的基础和如何在浏览器中运行Rust
有一定的了解。(可以Rust 编译为 WebAssembly 在前端项目中使用)
好了,天不早了,干点正事哇。
我们能所学到的知识点
❝
- 前置知识点
- 项目初始化
- Rust 内部实现&原理
- 优化开发
- 效果展示
❞
- 前置知识点
❝
「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」 同时,由于阅读我文章的群体有很多,所以有些知识点可能**「我视之若珍宝,尔视只如草芥,弃之如敝履」。以下知识点,请「酌情使用」**。
❞
WebAssembly 是什么
这个问题,在之前也有过解释,参看浏览器第四种语言-WebAssembly
如何在 JS 中调用 WebAssembly
上文中多次提过,参看Rust 编译为 WebAssembly 在前端项目中使用
requestAnimationFrame
requestAnimationFrame
是为了**「优化动画性能」**而设计的。它会在浏览器下一次重绘之前调用注册的回调函数,以确保动画更新发生在浏览器执行下一帧之前。这样可以避免在浏览器不活跃或隐藏的标签页中执行不必要的动画更新,节省系统资源。从而利用浏览器的优化机制,提供更流畅的动画效果。
requestAnimationFrame
的回调函数会传递一个时间戳参数
,表示动画开始执行的时间。我们可以利用这个参数来计算动画的进度,从而实现更复杂的动画效果。
使用方法
function animate(timestamp) {
// 在这里执行动画更新
// 通过递归调用 requestAnimationFrame 来实现持续的动画
requestAnimationFrame(animate);
}
// 启动动画
requestAnimationFrame(animate);
示例
function animate(timestamp) {
// 在这里执行动画更新
// 例如:移动一个元素
const element = document.getElementById("animatedElement");
const speed = 0.1; // 移动速度
const timePassed = timestamp - startTime;
element.style.left = timePassed * speed + "px";
// 通过递归调用 requestAnimationFrame 来实现持续的动画
requestAnimationFrame(animate);
}
// 获取动画开始的时间戳
const startTime = performance.now();
// 启动动画
requestAnimationFrame(animate);
❝
尽管
requestAnimationFrame
会在下一帧前执行回调函数,但仍然可能以较高的频率调用。如果需要控制动画的帧率,可以使用其他手段来进行节流。❞
Clamped 类型处理图像相关
在 Rust
中,使用 wasm-bindgen
包的 Clamped
类型通常与 WebAssembly
和 JavaScript
的互操作性有关。特别是在处理图像数据时,Clamped
在 Rust
代码中起到了重要的作用。
用途
- 「Clamped Array(钳制数组)」:在
JavaScript
中,Clamped
通常与Uint8ClampedArray
相关联,这是一个处理**「图像数据时常用的数组类型」。Uint8ClampedArray
用于「存储图像的像素数据」**,其中每个像素的值被“钳制”在0 到 255
的范围内,这正是标准RGBA
颜色值的范围。 - 「图像处理」:在使用
WebAssembly
处理Canvas
或者其他图像数据时,Clamped
类型确保了像素值不会超出有效范围。这对于图像渲染非常重要,因为它保证了颜色数据的正确性和一致性。
工作原理
- 当我们在
Rust
中处理图像数据并准备将其传递给JavaScript
或者Web API
(例如Canvas
的API
)时,我们可能会使用一组像素数据。这些数据通常是一个字节数组,其中包含了图像的红色、绿色、蓝色和透明度(RGBA
)信息。 - 使用
Clamped
包装器(例如Clamped(&some_array)
)时,我们告诉wasm-bindgen
应该将这个数组视为一个Uint8ClampedArray
,并相应地处理它。这意味着当这个数组传递到JavaScript
环境时,任何超出0-255
范围的值都会被自动调整(钳制)到这个范围内。 - 在我们提供的代码示例中,
Clamped
被用来确保在创建ImageData
对象时,传递给它的像素数组符合 Web 标准,并能被正确地渲染到 Canvas 上。
❝
use wasm_bindgen::Clamped;
的用途是为了在Rust
和WebAssembly
中处理和传递图像数据时保证数据的正确性和一致性,特别是当这些数据需要与JavaScript
的Uint8ClampedArray
类型互操作时。❞
- 项目初始化
首先,我们来创建一个Rust WebAssembly
项目
cargo new game --lib
这将创建一个包含基本项目结构的文件夹,其中包括一个 Cargo.toml
文件和一个 src
文件夹。
+-- Cargo.toml
+-- src
+-- lib.rs
创建前端目录结构
在此项目的根目录下手动新增三个文件:
index.html
(项目的入口文件)index.js
(逻辑的主入口)style.css
index.html
在里面其实没啥操作可言,就是将index.js
和style.css
按照资源类别引入。
然后,如果大家在用VSCode
编辑器的话,可以使用Emmet
命令,一键自动生成Html
基础文档。
具体操作步骤简单如喝水,在*.html
的空文件中,输入!
然后就会出现Emmet
的命令,然后选中对于的命令,按下Tab
键,就可以自动生成Html
文件了。
然后,在其内部引入我们定义的style.css
和index.js
。(下面的内容只展示了核心代码)
其中有一点,简单的解释一下,在处理index.js
时,我们将其放置到body
靠后的位置,这样做的目的是能够在页面充分渲染好时,才会进行对于事件的注册和触发。
<!DOCTYPE html>
<html lang="en-US">
<head>
<title>Game</title>
<!-- 引入样式信息 -->
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- 注意这块,会将rust渲染的内容放置到canvas元素下 -->
<canvas id="canvas"></canvas>
<!-- 承接业务逻辑-->
<script type="module" src="index.js"></script>
</body>
</html>
style.css
在这个文件中,定义一下元素的样式信息。这就很简单了。我们就不过多解释了哈。
body {
text-align: center;
font-size: 18px;
margin: 0;
}
#canvas {
width: 320px;
height: 320px;
image-rendering: pixelated;
}
@media (min-width: 600px) {
#canvas {
width: 500px;
height: 500px;
}
}
有一点简单说一下:在#canvas
下有一个image-rendering: pixelated
pixelated
是一种特殊的图像渲染技术,它将图像呈现为**「像素化」**的样式,使图像看起来像是由像素点组成的。这种效果通常用于创造复古或像素风格的视觉效果。
index.js
index.js
作为逻辑入口
,它算是一个粘合剂,将WebAssembly
和JS
融合到一起,并且能够实现逻辑互通。
玩过贪吃蛇的同学都知道,小 🐍 会不断的按照既定的路线进行移动。在浏览器视角来看,它就是一幅动画,而想到在浏览器中执行动画,那第一选择就是利用requestAnimationFrame
来处理动画的渲染。
为了让游戏尽可能的简单,我们监听click
事件而不是通过键盘上的方向键
来控制小 🐍 移动方向。
我们先把对应的代码贴到下面,然后我们会逐一解释。
// 引入 wasm 模块,并导出 Game 类
import init, { Game } from "./pkg/game.js";
// 获取 canvas 元素
const canvas = document.getElementById("canvas");
// 记录上一帧的时间戳
let lastFrame = Date.now();
// 初始化 wasm 模块,并在初始化完成后执行回调函数
init().then(() => {
// 创建 Game 实例
const game = Game.new();
// 设置 canvas 尺寸为游戏尺寸
canvas.width = game.width();
canvas.height = game.height();
// 为 canvas 添加点击事件监听器
canvas.addEventListener("click", (event) => onClick(game, event));
// 使用 requestAnimationFrame 启动游戏循环
requestAnimationFrame(() => onFrame(game));
});
// 游戏循环的回调函数
function onFrame(game) {
// 计算帧间隔时间
const delta = Date.now() - lastFrame;
lastFrame = Date.now();
// 更新游戏状态
game.tick(delta);
// 渲染游戏画面
game.render(canvas.getContext("2d"));
// 使用递归调用 requestAnimationFrame,实现持续的游戏循环
requestAnimationFrame(() => onFrame(game));
}
// 处理点击事件的回调函数
function onClick(game, event) {
// 获取点击位置相对于 canvas 的坐标
const rect = event.target.getBoundingClientRect();
const x = ((event.clientX - rect.left) / rect.width) * game.width();
const y = ((event.clientY - rect.top) / rect.height) * game.height();
// 将点击事件传递给游戏对象处理
game.click(x, y);
}
首先,在首行中,我们从pkg/game.js
中引入了init
的wasm
模块和从其中导出的Game
示例。为什么,是从pkg
文件引入,在之前的文章中有过详细的解释,这里就不再赘述了。
其次,就是init().then()
中的代码,这就是标准的wasm
在JS
初始化的代码,也没啥好唠的。
然后,就是利用requestAnimationFrame
进行动画的渲染。主要的逻辑在onFrame
的回调函数中。
last but not least,其实大家对onClick
事件有点懵逼,我们在这节中先着重解释一下这里的逻辑。
获取
Canvas
元素的位置信息:event.target.getBoundingClientRect()
返回一个DOMRect
对象,其中包含了目标元素(这里是canvas
)相对于**「视口的位置信息」**,包括左上角的坐标left
和top
。计算相对坐标: 通过使用鼠标事件对象
event
中的clientX
和clientY
属性,获取了鼠标点击的在视口中的坐标。然后,通过减去canvas
左上角的坐标,就得到了鼠标点击位置相对于canvas
左上角的相对坐标。统一坐标: 由于
canvas
的尺寸可能与屏幕上的**「显示尺寸不同」**,需要将相对坐标转换为canvas
内部的坐标。通过除以canvas
的宽度和高度,将相对坐标归一化到[0, 1]
的范围内。乘以game.width()
和game.height()
就得到了相对于游戏坐标系的点击位置。传递给游戏对象: 最后,通过调用
game.click(x, y)
将计算得到的统一坐标传递给游戏对象的click
方法处理。这个方法可能会用于处理玩家点击游戏中的某个位置时的逻辑,例如在该位置放置游戏元素或执行其他游戏操作。Rust 内部实现&原理
引入第三方库(处理 Cargo.toml)
在index.js
中我们得知,几乎所有的操作都是在wasm
导出的game
对象上。也侧面说明了,
wasm
中需要做Canvas
的渲染和图像相关的逻辑,所以我们需要web-sys
对于图像和canvas
相关的特性。- 并且,在之前的
Rust 编译为 WebAssembly 在前端项目中使用
文章中提到过,要想实现wasm
和js
互通,我们还需要wasm_bindgen
。 - 同时,我们是将
Rust
输出为wasm
,那么还需要对lib
区块做对应的处理。
省去不相关的默认配置
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2.84"
[dependencies.web-sys]
version = "0.3.4"
features = [
'CanvasRenderingContext2d',
'ImageData',
]
Rust 层级
现在我们从上帝视角来看Rust
的设计结构
+-- src
+-- world
+-- color.rs
+-- coord.rs
+-- screen.rs
+-- lib.rs
+-- world.rs
src/lib.rs
就不用多说了,它是业务逻辑的主入口,然后在其中引入对应的模块src/world.rs
是承接游戏实体的模块
pub struct World {
pub screen: Screen, // 用于渲染游戏画布
direction: Coord, // 当前蛇的前进方向
snake: VecDeque<Coord>, // 代表蛇身体的一系列坐标
alive: bool, // 游戏是否处于活动状态
}
src/world/screen.rs
它表示一个屏幕或画布,可以在上面绘制像素
pub struct Screen {
pub pixel_buffer: Vec<u8, // 存储屏幕上所有像素颜色值的缓冲区
pub pixel_count: u32, // 屏幕上的像素总数
pub width: u32, // 屏幕宽度
pub height: u32, // 屏幕高度
}
src/world/coord.rs
作为world
的子模块,就是维护x/y
的信息
pub struct Coord {
pub x: i32,
pub y: i32,
}
src/world/color.rs
用于表示和转换不同的颜色状态
color.rs - 处理颜色
// 定义一个别名 Rgb,它是一个包含三个 u8(无符号8位整数)元素的数组,代表 RGB 颜色值
pub type Rgb = [u8; 3];
// 定义一个枚举 Color,它有四个变体,代表不同的颜色
#[derive(Debug, Eq, PartialEq, Copy, Clone)]
pub(crate) enum Color {
Background,
Snake,
Food,
Fail,
}
// 实现从 Color 引用到 Rgb 类型的转换
impl From<&Color> for Rgb {
fn from(color: &Color) -> Self {
match color {
Color::Background => [0; 3], // 背景色为黑色
Color::Snake => [0, 255, 0], // 蛇的颜色为绿色
Color::Food => [0, 0, 255], // 食物的颜色为蓝色
Color::Fail => [255, 0, 0], // 失败的颜色为红色
}
}
}
// 实现从 Rgb 引用到 Color 类型的转换
impl From<&Rgb> for Color {
fn from(rgb: &Rgb) -> Self {
match rgb {
[0, 0, 0] => Color::Background, // 黑色对应背景
[0, 255, 0] => Color::Snake, // 绿色对应蛇
[0, 0, 255] => Color::Food, // 蓝色对应食物
[255, 0, 0] => Color::Fail, // 红色对应失败
_ => panic!("颜色不匹配"), // 如果颜色不匹配,则引发错误
}
}
}
这段 Rust
代码定义了一个 RGB
颜色类型以及一个颜色枚举 Color
,同时提供了从 Color
到 Rgb
类型和从 Rgb
到 Color
类型的转换。
Color 枚举
用于明确地表示游戏中可能出现的几种颜色状态(如背景、蛇、食物和失败状态),而 Rgb 类型
则用于表示实际的颜色值。
通过实现 From trait
,允许我们在 Color 枚举
和 Rgb 类型
之间转换。例如,如果游戏逻辑决定某个素应该显示为 Color::Snake
,那么渲染逻辑可以使用 .into()
方法将其转换为对应的 RGB
值 [0, 255, 0]
以绘制屏幕。
相反的转换(从 Rgb
到 Color
)可能用于图像处理或颜色识别的情况,其中基于 RGB
值来确定对应的游戏状态。
从 Rgb
到 Color
的转换实现了一个**「模式匹配」**,如果给定的 RGB
值不匹配任何预定义的颜色,它将会触发 panic
,这可能会导致程序崩溃。
coord.rs - 操作坐标
// 引入 Add trait,用于重载 '+' 运算符
use std::ops::Add;
// 为 Coord 结构体派生 Debug(用于格式化输出),PartialEq(用于比较),Clone 和 Copy 特性
#[derive(Debug, PartialEq, Clone, Copy)]
pub struct Coord {
pub x: i32, // x 坐标
pub y: i32, // y 坐标
}
// 实现 Coord 结构体
impl Coord {
// 构造函数,创建一个新的 Coord 实例
pub fn new(x: i32, y: i32) -> Coord {
Coord { x, y }
}
}
// 为 Coord 实现 Add trait,使两个 Coord 实例可以相加
impl Add<Coord> for Coord {
type Output = Coord; // 定义加法操作的返回类型为 Coord
// 实现加法操作,将两个坐标点相加得到一个新的 Coord 实例
fn add(self, rhs: Coord) -> Self::Output {
Coord {
x: self.x + rhs.x, // 新的 x 坐标是两个 x 坐标之和
y: self.y + rhs.y, // 新的 y 坐标是两个 y 坐标之和
}
}
}
// 实现从元组 (i32, i32) 到 Coord 的转换
impl From<(i32, i32)> for Coord {
// 实现转换方法
fn from((x, y): (i32, i32)) -> Self {
Coord::new(x, y) // 使用 new 方法创建 Coord 实例
}
}
这段代码的用途是定义了一个用于二维空间计算的 Coord
类型,并允许我们执行以下操作:
- 创建新的
Coord
实例。 - 将两个
Coord
实例相加,得到它们对应坐标之和的新Coord
实例。 - 将一个
(i32, i32)
类型的元组转换成Coord
类型。 - 这样的类型定义和方法实现使得
Coord
类型可以方便地用于二维空间的数学计算。例如,我们可以使用运算符
来移动一个点或合并两个空间中的位移。通过实现From trait
,你可以从一个元组
轻松创建一个Coord 实例
。
screen.rs - 绘制屏幕
// 引入 color 模块中的 Color 和 Rgb 结构
use super::color::{Color, Rgb};
// 引入 coord 模块中的 Coord 结构
use super::coord::Coord;
// 定义每个像素占用的字节数
const BYTES_PER_PIXEL: u32 = 4;
// Screen 结构体表示画布,包含像素数据、像素总数以及屏幕的宽度和高度
pub struct Screen {
pub pixel_buffer: Vec<u8, // 存储屏幕上所有像素颜色值的缓冲区
pub pixel_count: u32, // 屏幕上的像素总数
pub width: u32, // 屏幕宽度
pub height: u32, // 屏幕高度
}
impl Screen {
// 创建一个新的 Screen 实例
pub fn new(width: u32, height: u32) -> Self {
let pixel_count = width * height; // 计算像素总数
let screen_size_in_bytes = pixel_count * BYTES_PER_PIXEL; // 计算缓冲区的大小
Self {
pixel_count,
width,
height,
pixel_buffer: vec![255u8; screen_size_in_bytes as usize], // 初始化所有像素为白色
}
}
// 清除屏幕,将所有像素设置为背景颜色
pub fn clear(&mut self) {
self.iter_coords().for_each(|coord| {
self.set_color_at(&coord, Color::Background);
});
}
// 在指定坐标上设置颜色
pub fn set_color_at(&mut self, coord: &Coord, color: Color) {
// 计算坐标对应的缓冲区索引
let i = self.get_buffer_index_for(coord);
// 设置颜色
self.pixel_buffer[i..i + 3].copy_from_slice(Rgb::from(&color).as_slice());
}
// 在屏幕的边缘设置颜色
pub fn set_color_at_edges(&mut self, color: Color) {
let screen_width = self.width as i32;
let screen_height = self.height as i32;
// 过滤出边缘的坐标并设置颜色
self.iter_coords()
.filter(|Coord { x, y }| {
*x == 0 || *y == 0 || *x == screen_width - 1 || *y == screen_height - 1
})
.for_each(move |coord| self.set_color_at(&coord, color));
}
// 获取指定坐标上的颜色
pub fn get_color_at(&self, coord: &Coord) -> Color {
// 计算坐标对应的缓冲区索引
let i = self.get_buffer_index_for(coord);
(&[
self.pixel_buffer[i],
self.pixel_buffer[i + 1],
self.pixel_buffer[i + 2],
])
.into()
}
// 根据坐标计算缓冲区的索引
pub fn get_buffer_index_for(&self, Coord { x, y }: &Coord) -> usize {
(*y as usize * self.width as usize*x as usize) * BYTES_PER_PIXEL as usize
}
// 生成一个迭代器,用于迭代屏幕上的所有坐标
pub fn iter_coords(&self) -> impl Iterator<Item = Coord> {
let width = self.width;
let height = self.height;
// 创建一个迭代器,生成屏幕上所有点的坐标
(0..height as i32).flat_map(move |y| (0..width as i32).map(move |x| (x, y).into()))
}
// 创建一个迭代器,生成屏幕上所有点的坐标
pub fn iter_pixels(&self) -> impl Iterator<Item = (Color, Coord)> + '_ {
self.iter_coords()
.map(|coord: Coord| (self.get_color_at(&coord), coord))
}
}
这个Screen
可以表示一个**「可视化界面」**,
set_color_at
等方法可以用来绘制图形clear
可以用来清空界面iter_pixels
允许遍历所有像素进行读取和修改。
这个 Screen 是该项目的一个**「基础模块」**。
world.rs - 我的世界,需要包罗万象
// 导入相关模块
mod coord;
mod color;
mod screen;
// 使用语句,引入模块中的类型
use self::coord::Coord;
use self::color::Color;
use self::screen::Screen;
use rand::Rng; // 使用 rand crate 中的 Rng trait
use std::collections::VecDeque; // 使用标准库中的 VecDeque 类型
// 定义起始蛇的长度
const START_LEN: i32 = 7;
// 定义不变量,表示蛇的长度始终大于0
const INVARIANT: &str = "蛇的长度始终大于0";
// 定义游戏世界的结构体
pub struct World {
pub screen: Screen, // 游戏屏幕,负责绘图
direction: Coord, // 当前蛇的前进方向
snake: VecDeque<Coord>, // 代表蛇身体的一系列坐标
alive: bool, // 游戏是否处于活动状态
}
// World 结构体的实现
impl World {
// 构造函数,初始化游戏世界
pub fn new(width: u32, height: u32) -> World {
let mut world = World {
screen: Screen::new(width, height), // 初始化屏幕
direction: (1, 0).into(), // 初始方向向右
snake: VecDeque::new(), // 初始化蛇的身体
alive: true, // 开始时蛇是活的
};
// 清屏,创建初始的蛇和食物
world.screen.clear();
world.create_initial_snake();
world.create_initial_food();
world
}
// 游戏逻辑的“tick”方法,每次调用都更新游戏状态
pub fn tick(&mut self) {
if self.alive {
// 如果蛇活着,计算新的头部位置
let new_head = self.get_new_head();
// 获取新头部位置的颜色,以确定蛇下一步的动作
let new_head_pixel = self.screen.get_color_at(&new_head);
// 把蛇的头部移动到新位置
self.extend_head_to(&new_head);
// 根据新头部位置的像素颜色决定蛇的动作
match new_head_pixel {
Color::Food => self.create_food(), // 吃到食物,创建新的食物
Color::Snake => self.die(), // 吃到自己,游戏结束
_ => self.shorten_tail(), // 没有吃到食物,移动蛇身
}
}
}
// 处理点击事件,改变蛇的移动方向
pub fn click(&mut self, x: i32, y: i32) {
if self.alive {
let head = self.snake.back().expect(INVARIANT);
// 根据点击位置和蛇头的位置确定新的移动方向
self.direction = match self.direction.x {
// 如果当前水平方向没有移动,则改变水平方向
0 => (if x < head.x { -1 } else { 1 }, 0),
// 如果当前垂直方向没有移动,则改变垂直方向
_ => (0, if y < head.y { -1 } else { 1 }),
}
.into();
} else {
// 如果蛇死了,点击屏幕任何位置都会重置游戏
self.reset_game()
}
}
// 清空屏幕并重新开始游戏
fn reset_game(&mut self) {
self.direction = (1, 0).into();
self.snake = VecDeque::new();
self.alive = true;
self.screen.clear();
self.create_initial_snake();
self.create_initial_food();
}
// 创建初始蛇
fn create_initial_snake(&mut self) {
let start_y = self.screen.height as i32 / 2;
for x in 0..START_LEN {
self.screen.set_color_at(&(x, start_y).into(), Color::Snake);
self.snake.push_back((x, start_y).into());
}
}
// 创建初始食物
fn create_initial_food(&mut self) {
let initial_food_y = self.screen.height as i32 / 2 - 2;
self.screen
.set_color_at(&(START_LEN, initial_food_y).into(), Color::Food);
}
// 计算新的蛇头位置
fn get_new_head(&self) -> Coord {
let screen_width = self.screen.width;
let screen_height = self.screen.height;
let moved_head = *self.snake.back().expect(INVARIANT) + self.direction;
let x = (moved_head.x + screen_width as i32) % screen_width as i32;
let y = (moved_head.y + screen_height as i32) % screen_height as i32;
(x, y).into()
}
// 将蛇头移动到新位置
fn extend_head_to(&mut self, new_head: &Coord) {
self.screen.set_color_at(new_head, Color::Snake);
self.snake.push_back(*new_head);
}
// 缩短蛇尾
fn shorten_tail(&mut self) {
let tail = self.snake.pop_front().expect(INVARIANT);
self.screen.set_color_at(&tail, Color::Background);
}
// 创建新的食物
fn create_food(&mut self) {
let pixel_count = self.screen.pixel_count as usize;
let random_skip = rand::thread_rng().gen_range(0..pixel_count) as usize;
let coord = self
.screen
.iter_pixels()
.filter(|(color, _)| *color == Color::Background)
.map(|(_, coord)| coord)
.collect::<Vec<_>>()
.into_iter()
.cycle()
.skip(random_skip)
.next()
.expect("至少有一个像素应该是空闲的");
self.screen.set_color_at(&coord, Color::Food);
}
// 游戏结束处理
fn die(&mut self) {
self.alive = false;
self.screen.set_color_at_edges(Color::Fail);
}
}
World
包含
- 一个屏幕(
Screen
实例)用于显示游戏状态, - 一个代表蛇当前移动方向的
Coord
实例, - 一个
VecDeque<Coord>
队列代表蛇的身体, - 以及一个表示蛇是否存活的
alive
变量
游戏的 World
结构体包含以下关键功能:
- 初始化:在
new
函数中创建游戏世界,并设置初始的蛇和食物。 - 游戏循环:
tick
函数作为游戏的主循环,处理蛇的移动、食物的生成和游戏结束的条件。 - 用户交互:
click
函数允许玩家通过点击来改变蛇的方向或者在游戏结束后重置游戏。 - 蛇的管理:函数
create_initial_snake
,extend_head_to
,shorten_tail
负责管理蛇的身体,包括初始化、移动蛇头、缩短蛇尾。 - 食物的管理:
create_food
函数在游戏世界中随机生成食物。 - 游戏状态管理:
die
函数处理蛇死亡后的逻辑,reset_game
函数重置游戏到初始状态。
还有一点需要说明,在world.rs
中使用了rand
第三方包,所以我们需要在toml
中加入相关包信息。
[dependencies]
// ...
rand = "0.8"
getrandom = { version = "0.2", features = ["js"] }
这里看到,在引入了rand crate
后,我们额外引入了getrandom crate
,具体原因如下:
rand crate
本身不提供随机数生成的源,它**「只提供了生成随机数的算法」**。而实际的随机数需要一个entropy
(熵)源作为种子来产生。
getrandom crate
正是提供了获取 entropy
的功能。它会利用操作系统提供的随机设备或其他源来获取高质量的随机性。
之所以需要在 toml
中明确声明引入 getrandom
,是因为 rand
在没有设置特定 feature
的情况下默认会使用内建的模拟随机数生成器,这在实际项目中是不够安全和随机的。
通过在 getrandom
中启用 "js" feature
,rand
就会自动使用 getrandom
提供的随机源来做种子,这样可以产生更高质量的随机数,适合在实际项目中使用。
rand
提供算法,getrandom
提供熵源,两者结合可以生成安全的随机数。
lib.rs - 业务主入口
mod world;
// 通过 wasm-bindgen 导入必要的依赖项和类型
use wasm_bindgen::prelude::*;
use wasm_bindgen::Clamped;
use web_sys::{CanvasRenderingContext2d, ImageData};
use world::World; // 假定在 'world' 模块中定义了游戏逻辑
// 设置游戏逻辑更新的时间间隔为75毫秒
const TICK_MILLISECONDS: u32 = 75;
// 使用 wasm-bindgen 定义 Game 结构体,以便在 JS 中使用
#[wasm_bindgen]
pub struct Game {
world: World, // 包含游戏世界状态的 World 结构体
elapsed_milliseconds: u32, // 记录自上次更新以来经过的时间
}
#[wasm_bindgen]
impl Game {
// 创建一个新的 Game 实例
pub fn new() -> Game {
Game {
world: World::new(30, 30), // 初始化游戏世界,假设为30x30的网格
elapsed_milliseconds: 0,
}
}
// 游戏的“tick”方法,基于经过的时间来更新游戏状态
pub fn tick(&mut self, elapsed_milliseconds: u32) {
self.elapsed_milliseconds += elapsed_milliseconds;
// 当累积的时间超过设定的间隔时,更新游戏世界状态并重置计时器
if self.elapsed_milliseconds >= TICK_MILLISECONDS {
self.elapsed_milliseconds = 0;
self.world.tick(); // 调用 World 结构体的 tick 方法更新游戏状态
}
}
// 渲染游戏画面到 Canvas
pub fn render(&mut self, ctx: &CanvasRenderingContext2d) {
// 创建 ImageData 对象,用于将 Rust 管理的像素数据传递到 JavaScript
let data = ImageData::new_with_u8_clamped_array_and_sh(
Clamped(&self.world.screen.pixel_buffer), // 使用 Clamped 确保数据在正确的范围内
self.world.screen.width,
self.world.screen.height,
)
.expect("应该从数组创建 ImageData");
// 将 ImageData 对象绘制到 Canvas 上下文中
ctx.put_image_data(&data, 0.0, 0.0)
.expect("应该将数组写入上下文");
}
// 获取游戏画面的宽度
pub fn width(&self) -> u32 {
self.world.screen.width
}
// 获取游戏画面的高度
pub fn height(&self) -> u32 {
self.world.screen.height
}
// 处理鼠标点击事件,将点击坐标传递给 World 实例处理
pub fn click(&mut self, x: i32, y: i32) {
self.world.click(x, y); // 调用 World 结构体的 click 方法处理点击
}
}
这段代码的用途是在浏览器中运行贪吃蛇游戏。它定义了游戏的主要逻辑框架,处理游戏的更新(tick
)、渲染(render
)和用户输入(click
)。通过 WebAssembly
,这允许 Rust
代码在网页环境中与 JavaScript
交互,并在 HTML canvas
元素上绘制游戏画面。
在这段代码中,最需要注意的就是render
中使用ImageData::new_with_u8_clamped_array_and_sh
将 Rust
数组转换为 ImageData
。
- 优化开发
devserver - 本地开发服务器
前面也提到过,在之前我们为了在本地运行WebAssembly
项目,我们特意用Webpack
弄了一个前端DevServer
用于查看对应的效果。
具体操作流程可以参考Rust 编译为 WebAssembly 在前端项目中使用-构建 Web 服务器
上面有对应文章 🔗,这里就不贴了。
虽然,用Webpack
配合各种库,搭建了一个前端服务器,效果也挺好。但是,总有一种**「为了那口醋,特意包了顿饺子」**的既视感。
其实哇,我们是可以用其他方式来代替的。
在crates.io
,我们就可以找到一款可以解决我们上述问题的库devserver([1])。
它用于在本地开发。通过cargo
进行安装。
cargo install devserver
并且,在cli
中仅需要一行代码就可以托管我们的本地目录
devserver
然后,我们就可以在localhost:8080
的地址来查验我们刚才所写的项目了。
代码热更新
既然,开发服务器有了,我们又想在开发阶段,在代码发生变化的时候,能够实时看到效果。那我们就需要一个**「代码热更新」**的工具。
在crates.io
中按watch
关键字搜索,出现了很多牛鬼蛇神
。
写代码第一性原则,「跟着大部队走」。然后,我们就相中了cargo-watch([2])
cargo install cargo-watch
这样我们愉快的使用新的编译命令来查验我们的代码了。
cargo watch -- wasm-pack build --target web
并且,Rust
代码中但凡有一个风吹草动,就可以自动触发新的编译流程。怎是一个爽子了得。
- 效果展示
TODO
在本文之前就说过,我们这个只是用一个简单的游戏来展示Rust
转成Wasm
并在JS
中使用。其实作为一个游戏来说,我们还可以优化一下
- 用方向键来控制小蛇移动
- 其实,我们如果想做的话,可以使用声音来控制移动方向,就是我们语音说出对应的指令,然后通过语音识别,然后让它做出对应的反应
- 新增声音效果
- 吃到食物
- 自己碰到自己
- ...
虽然,在上文中我们通过一些方式来使的项目更加完善,但是呢,感觉还是有点意犹未尽,所以,我们可以把这个项目移到我们真正的开发环境中,就是在Vite/Webpack
项目中,然后参与打包和代码构建。让wasm
真正的参与到前端开发中。(已经在筹备写了,马上就来,敬请期待)
[1]**devserver:**https://crates.io/crates/devserver
[2]**cargo-watch:**https://crates.io/crates/cargo-watch