Game = Rust + WebAssembly + 浏览器

努力成为一个情绪价值的提供者

大家好,我是「柒八九」。一个「专注于前端开发技术/RustAI应用知识分享」Coder

前言

在上一篇Rust 编译为 WebAssembly 在前端项目中使用我们通过一个简单的Hello WorldDemo,讲述了如何将 Rust 编译为 WebAssembly,并在前端项目中使用。

虽然,是一个Demo;但是,我们由小见大,以点见面,分别描述了

  • Rust 如何编译为WebAssembly

  • WebAssembly如何内嵌到JS环境中

  • WebAssembly如何与JS进行交互

  • Rust如何能被 JS 调用的原理分析

  • web-sys充当wasm-bindgen的前端

在写完上篇文章中,总觉得如果只是一个Demo的话,有点意犹未尽。我们想要在实际开发中使用WebAssembly,总不能通过Rust唤起类似alert等前端唾手可得的功能。这就有点「脱裤子放屁」多此一举了。我们不能为了用而用。WebAssembly在前端项目中,更多扮演的是「功能加速」的角色。也就是我们用了它是为了降本增效的。(不是降本增笑哈)。

并且,在之前的文章中,还有很多开发上的不足,比方说

  • 缺少代码热更新

  • 本地 dev 服务器(上一篇中,我们特意用了Webpack搭建了一个本地服务器)

  • 操作浏览器的DOM元素

  • ...

所以,我们今天用一个功能更加丰富的例子(贪吃蛇游戏),来逐一解决上面的问题,并让大家真正的理解RustJS是如何更好的合作的。

我们的本文的内容,不是要写一个功能完备的贪吃蛇游戏,而是以这个例子来更加完善我们对Rust/WebAssembly/JS之间的数据交互的理解。「重在过程」,当然结果也很可爱,我们会写一个简单版本的贪吃蛇小游戏。

本文的一些基础内容,不再做出过多解释,也就是说大家需要有一定的Rust的基础和如何在浏览器中运行Rust有一定的了解。(可以Rust 编译为 WebAssembly 在前端项目中使用

好了,天不早了,干点正事哇。

af5dadaf5f3ed76c43a32b3d977d8643.gif

我们能所学到的知识点

  1. 前置知识点

  2. 项目初始化

  3. Rust 内部实现&原理

  4. 优化开发

  5. 效果展示


1. 前置知识点

「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」

同时,由于阅读我文章的群体有很多,所以有些知识点可能「我视之若珍宝,尔视只如草芥,弃之如敝履」。以下知识点,请「酌情使用」

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 类型通常与 WebAssemblyJavaScript 的互操作性有关。特别是在处理图像数据时,ClampedRust 代码中起到了重要的作用。

用途

  • 「Clamped Array(钳制数组)」:在 JavaScript 中,Clamped 通常与 Uint8ClampedArray 相关联,这是一个处理「图像数据时常用的数组类型」Uint8ClampedArray 用于「存储图像的像素数据」,其中每个像素的值被“钳制”在 0 到 255 的范围内,这正是标准 RGBA 颜色值的范围。

  • 「图像处理」:在使用 WebAssembly 处理 Canvas 或者其他图像数据时,Clamped 类型确保了像素值不会超出有效范围。这对于图像渲染非常重要,因为它保证了颜色数据的正确性和一致性。

工作原理

  • 当我们在 Rust 中处理图像数据并准备将其传递给 JavaScript 或者 Web API(例如 CanvasAPI)时,我们可能会使用一组像素数据。这些数据通常是一个字节数组,其中包含了图像的红色、绿色、蓝色和透明度(RGBA)信息。

  • 使用 Clamped 包装器(例如 Clamped(&some_array))时,我们告诉 wasm-bindgen 应该将这个数组视为一个 Uint8ClampedArray,并相应地处理它。这意味着当这个数组传递到 JavaScript 环境时,任何超出 0-255 范围的值都会被自动调整(钳制)到这个范围内。

  • 在我们提供的代码示例中,Clamped 被用来确保在创建 ImageData 对象时,传递给它的像素数组符合 Web 标准,并能被正确地渲染到 Canvas 上。

use wasm_bindgen::Clamped; 的用途是为了在 RustWebAssembly 中处理和传递图像数据时保证数据的正确性和一致性,特别是当这些数据需要与 JavaScriptUint8ClampedArray 类型互操作时。


2. 项目初始化

首先,我们来创建一个Rust WebAssembly项目

cargo new game --lib

这将创建一个包含基本项目结构的文件夹,其中包括一个 Cargo.toml 文件和一个 src 文件夹。

+-- Cargo.toml
+-- src
    +-- lib.rs

创建前端目录结构

在此项目的根目录下手动新增三个文件:

  1. index.html (项目的入口文件)

  2. index.js (逻辑的主入口)

  3. style.css

index.html

在里面其实没啥操作可言,就是将index.jsstyle.css按照资源类别引入。

然后,如果大家在用VSCode编辑器的话,可以使用Emmet命令,一键自动生成Html基础文档。

具体操作步骤简单如喝水,在*.html的空文件中,输入!然后就会出现Emmet的命令,然后选中对于的命令,按下Tab键,就可以自动生成Html文件了。

然后,在其内部引入我们定义的style.cssindex.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作为逻辑入口,它算是一个粘合剂,将WebAssemblyJS融合到一起,并且能够实现逻辑互通。

玩过贪吃蛇的同学都知道,小 🐍 会不断的按照既定的路线进行移动。在浏览器视角来看,它就是一幅动画,而想到在浏览器中执行动画,那第一选择就是利用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中引入了initwasm模块和从其中导出的Game示例。为什么,是从pkg文件引入,在之前的文章中有过详细的解释,这里就不再赘述了。

其次,就是init().then()中的代码,这就是标准的wasmJS初始化的代码,也没啥好唠的。

然后,就是利用requestAnimationFrame进行动画的渲染。主要的逻辑在onFrame的回调函数中。

last but not least,其实大家对onClick事件有点懵逼,我们在这节中先着重解释一下这里的逻辑。

  1. 获取 Canvas 元素的位置信息: event.target.getBoundingClientRect() 返回一个 DOMRect 对象,其中包含了目标元素(这里是 canvas)相对于「视口的位置信息」,包括左上角的坐标 lefttop

  2. 计算相对坐标: 通过使用鼠标事件对象 event 中的 clientXclientY 属性,获取了鼠标点击的在视口中的坐标。然后,通过减去 canvas 左上角的坐标,就得到了鼠标点击位置相对于 canvas 左上角的相对坐标。

  3. 统一坐标: 由于 canvas 的尺寸可能与屏幕上的「显示尺寸不同」,需要将相对坐标转换为 canvas 内部的坐标。通过除以 canvas 的宽度和高度,将相对坐标归一化到 [0, 1] 的范围内。乘以 game.width()game.height() 就得到了相对于游戏坐标系的点击位置。

  4. 传递给游戏对象: 最后,通过调用 game.click(x, y) 将计算得到的统一坐标传递给游戏对象的 click 方法处理。这个方法可能会用于处理玩家点击游戏中的某个位置时的逻辑,例如在该位置放置游戏元素或执行其他游戏操作。


3. Rust 内部实现&原理

引入第三方库(处理 Cargo.toml)

index.js中我们得知,几乎所有的操作都是在wasm导出的game对象上。也侧面说明了,

  • wasm中需要做Canvas的渲染和图像相关的逻辑,所以我们需要web-sys对于图像和canvas相关的特性。

  • 并且,在之前的Rust 编译为 WebAssembly 在前端项目中使用文章中提到过,要想实现wasmjs互通,我们还需要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
  1. src/lib.rs就不用多说了,它是业务逻辑的主入口,然后在其中引入对应的模块

  2. src/world.rs是承接游戏实体的模块

    pub struct World {
       pub screen: Screen, // 用于渲染游戏画布
       direction: Coord, // 当前蛇的前进方向
       snake: VecDeque<Coord>, // 代表蛇身体的一系列坐标
       alive: bool, // 游戏是否处于活动状态
     }
  3. src/world/screen.rs它表示一个屏幕或画布,可以在上面绘制像素

    pub struct Screen {
      pub pixel_buffer: Vec<u8>, // 存储屏幕上所有像素颜色值的缓冲区
      pub pixel_count: u32,      // 屏幕上的像素总数
      pub width: u32,            // 屏幕宽度
      pub height: u32,           // 屏幕高度
    }
  4. src/world/coord.rs作为world的子模块,就是维护x/y的信息

    pub struct Coord {
        pub x: i32,
        pub y: i32,
    }
  5. 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,同时提供了从 ColorRgb 类型和从 RgbColor 类型的转换。

Color 枚举用于明确地表示游戏中可能出现的几种颜色状态(如背景、蛇、食物和失败状态),而 Rgb 类型则用于表示实际的颜色值。

通过实现 From trait,允许我们在 Color 枚举Rgb 类型之间转换。例如,如果游戏逻辑决定某个素应该显示为 Color::Snake,那么渲染逻辑可以使用 .into() 方法将其转换为对应的 RGB[0, 255, 0] 以绘制屏幕。

相反的转换(从 RgbColor)可能用于图像处理或颜色识别的情况,其中基于 RGB 值来确定对应的游戏状态。

RgbColor 的转换实现了一个「模式匹配」,如果给定的 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_shRust 数组转换为 ImageData


4. 优化开发

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代码中但凡有一个风吹草动,就可以自动触发新的编译流程。怎是一个爽子了得。


5. 效果展示

d1677bc5ae63c1afb5fc2bdc8a09a32b.gif

TODO

在本文之前就说过,我们这个只是用一个简单的游戏来展示Rust转成Wasm并在JS中使用。其实作为一个游戏来说,我们还可以优化一下

  1. 用方向键来控制小蛇移动

  • 其实,我们如果想做的话,可以使用声音来控制移动方向,就是我们语音说出对应的指令,然后通过语音识别,然后让它做出对应的反应

新增声音效果

  • 吃到食物

  • 自己碰到自己

...


虽然,在上文中我们通过一些方式来使的项目更加完善,但是呢,感觉还是有点意犹未尽,所以,我们可以把这个项目移到我们真正的开发环境中,就是在Vite/Webpack项目中,然后参与打包和代码构建。让wasm真正的参与到前端开发中。(已经在筹备写了,马上就来,敬请期待)


后记

「分享是一种态度」

「全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。」

354ec169a87b9c10dd1f87631968311c.gif

Reference

[1]

devserver: https://crates.io/crates/devserver

[2]

cargo-watch: https://crates.io/crates/cargo-watch

42b3dc2230a5fa357dd534b1b0231488.png372ac80311fd368848fc11a6ffc53f00.pnga72131c3d8789d02ffec3a67c5bef307.png号外号外,免费送奇舞团定制周边啦!!!

346a908dda5f6b9101f46bffd13a997f.png51688f7507612adcda59b1def5b33d53.pngdd58bb4e283229a7a72a187c4d10d889.png小伙伴们,《奇舞精选》正在参加掘金年度人气创作者评选活动,活动期间每天投出您宝贵的两票即可参与本次抽奖(本次开奖时间截止本周四19:00)

0da41ca4d0a4813f8395fcf051262e6b.png3c87b751b4fbad249fdd14fb3f49aed2.pngbe7e2623d5d94a22aaa6dfad761f9e5a.png参与每天的投票即可获得抽奖机会,欢迎加入奇舞精选读者群获取每天的抽奖码~

c09d849a7e2aaa01a7d73e5ad4be8dc2.png

92189ebb9e23ac5259cc2069c82d8978.png

- END -

关于奇舞团

奇舞团是 360 集团最大的大前端团队,代表集团参与 W3C 和 ECMA 会员(TC39)工作。奇舞团非常重视人才培养,有工程师、讲师、翻译官、业务接口人、团队 Leader 等多种发展方向供员工选择,并辅以提供相应的技术力、专业力、通用力、领导力等培训课程。奇舞团以开放和求贤的心态欢迎各种优秀人才关注和加入奇舞团。

d2a952053b9defdb9ac9aa0e2700c305.png

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值