手把手教你用 Canvas 开发声控跳跃游戏

8,904

这篇文章将从零开始,使用原生 Canvas 开发一款声控跳跃游戏,原生 Canvas 意味这个教程不会使用任何框架,全都是很好理解的基础代码,这样能更好地掌握整个小游戏的实现过程,没有其他学习成本。

Intro

如标题所言,这篇文章将手把手,从零开始介绍如何编码实现,所以即使你完全没有相关的开发经验也不用担心,跟着一步步来就可以。这是一篇入门级别的文章,内容相对基础也比较啰嗦。

从头开始看完这篇文章你将至少收获:

  1. Canvas 绘画的基本用法、设备密度 devicePixelRatio
  2. 复习向量 Vector 知识及其基本运算
  3. 在动画中应用位移、速度、加速度和力
  4. 粒子效果实现
  5. 无限循环障碍物的实现
  6. 方形盒子模型碰撞💥检测
  7. 基本的 Web 声音输入控制

如果你读完,觉得这篇文章对你有所帮助,不如点赞+关注+收藏一波❤️,这样我会更有动力写这些基础实现的文章。 当然,如果有任何优化改善的建议也欢迎在留言区评论。

废话不多说,先看最终实现效果。完整的Codesandbox 代码在这里纯享版链接Codepen 链接。这篇文章发布在我的掘金账号上,转载需授权。

Kapture 2022-02-19 at 21.01.54.gif

游戏玩法说明

在这个小游戏中,你可以通过按【空格】健、或者【向上】健来控制 Hero (黑色盒子)的跳跃,一旦 Hero 掉下平台(其他色块)或者撞到平台左侧游戏结束。前两次按键 Hero 会向上跳跃(二段跳),第三次按键时会向下俯冲以便在遇到紧急情况时快速落地。落地之后跳跃技能会得到恢复,又可以重新跳跃。

========== 多图预警!!! ==========

Canvas 基础入门

Canvas 已经是有些历史的技术了,往前翻也有几篇都是关于 Canvas 动画实现的文章。

区别于用 DOM,Canvas 可以实现一些不规则的图形绘制,对于开发者而言可编程能力更高,且由于没有复杂的 DOM 结构,整体会比较轻量。不过由于需要手动绘制每一个图形,需要自行做布局编排,多一些运算。

不规则图形

创建 context

使用 Canvas 绘制(这里讲的都是 2d)第一步是创建一个 context 对象,后续所有的绘制都将使用这个对象提供的方法。

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);
const ctx = canvas.getContext("2d");

除了 2d,还有 webgl 等其他类型,暂时用不到可以忽略。

CanvasRenderingContext2D 类提供了很多方法,包括绘制文本,线条,阴影,渐变,填充等等。API 非常丰富,不用背,用到的时候去查文档就行了。

绘制图形和清除绘制

这个例子中,我们只需要用到两个和绘制相关的 API,fillRectclearRect

ctx.fillRect(x, y, width, height);
ctx.clearRect(x, y, width, height);

顾名思义,fillRect 是从坐标点(x,y)(Canvas 的坐标原点是左上角)开始绘制一个宽高分别为 widthheight 的矩形,clearRect 则干的是相反的事情,清空绘制。

// 设置填充颜色
ctx.fillStyle = "#1D80FC";
ctx.fillRect(0, 0, 100, 100);
// 挖空中间绘制边框
ctx.clearRect(20, 20, 60, 60);

image.png

设备像素密度 devicePixelRatio

上面的图片是在我的 mac 上截的图,不难发现,内部边框部分出现了模糊。原因和一个叫屏幕像素密度的知识有关。在没有 Retina/High-DPI 屏幕的时候,一个 CSS 的像素单位和物理屏幕的像素单位是一致的。

为了追求更高清的画面,这些高清屏幕将原先一个物理像素拆分成多个,导致 CSS 中的 px 和物理像素不是一一对应的。在我的屏幕🖥(二倍屏),一个 CSS 的像素单位对应到了四个物理像素。

从微观视角上看,Canvas 绘制最终会生成点位图(bitmap),它们被绘制到二倍屏上,一个像素对应到四个物理像素。计算机为了使“拉伸”后的图像看起来平滑,会给这些多出来的像素及其周边计算出一个中间过度色。用 Canvas 绘制一条宽度为 1px,高度为 2px 的竖线的演示如下:

image.png

左边是预期效果,中间是一拆四物理像素示意,右边是实际渲染效果。

这和我们在电脑上放大查看非矢量图类似,由于图片提供的像素信息比实际要展示用到的物理像素少,显示器将其进行边缘模糊化,用相近颜色填充,于是我们看到了模糊。

image.png

了解模糊的原理,解决方案就是要提供足量的像素,让 canvas 绘制的像素和物理展示像素一致。浏览器提供了 devicePixelRatio 属性,它返回当前显示设备的物理像素分辨率与CSS像素分辨率之比,在我的 mac 屏幕上 devicePixelRatio 是 2,在外接普通屏幕上是 1。

利用这个比值,先将 canvas 尺寸放大,再通过 style 缩小(显示大小),并需要设置 ctx 的 scale,对绘制内容进行放大,这样做完之后,使用 ctx 绘制的像素就和物理像素对齐了。

image.png

左边是 Canvas 绘制尺寸,中间是实际渲染效果,右边是物理像素示意。

改动代码如下:

<canvas id="myCanvas" width="100" height="600"></canvas>

<script>
var c = document.getElementById("myCanvas");
// 放大再缩小
c.width = c.width * window.devicePixelRatio
c.height = c.height * window.devicePixelRatio
c.style.width = c.width / window.devicePixelRatio
c.style.height = c.height / window.devicePixelRatio

var ctx = c.getContext("2d");
ctx.fillStyle = "#1D80FC";
// ctx 缩放,这样就不需要每个数字都做缩放。
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
ctx.fillRect(0, 0, 100, 100);
ctx.clearRect(20, 20, 60, 60);
</script>

做了适配之后,图片边缘就不会出现模糊了。

image.png

左为改造前,右为改造后。

动画基础

掌握了基本的图形绘制,开始实现动画。之所以我们肉眼能看到连贯的动画,是因为光对视网膜所产生的视觉效果,在光停止之后,仍然能保留一段时间的现象。简称为视觉暂留,这是逐帧动画和胶片电影的原理。

image.png

停留的时间是由视神经的反应速度决定的,时间差不多是 1/16 秒左右(60 帧),在这个时间内完成绘制,肉眼看起来就会流畅连贯,而低于这个帧率则会有卡顿感。

回到编码上,我们可以使用 requestAnimationFrame 来达到每秒 60 次回调的效果。只需要每帧移动一像素,就能形成动画移动效果。

let x = 0
function tick () {
    requestAnimationFrame(tick)
    ctx.clearRect(0, 0, 900, 600)
    ctx.fillRect(x++, 100, 100, 100);
}
tick()

requestAnimationFrame 不是同步调用的,而是在浏览器每次 repaint 之前回调,所以这里不会调用栈溢出。

Kapture 2022-02-20 at 17.59.18.gif

向量

为了让物体运动更贴近真实,这里我们也引入物理世界的知识,先从向量开始。向量指一个同时具有大小方向的对象,只有大小没有反向的对象就不是向量,例如速率达到 50km/h,只描述了大小,没有指定哪个反向。而向 1 点钟反向前进 500m,就是位移向量。

在我们的 2d 动画平面内,任何向量也都是二维的,在代码中用 x, y 来表示。

export class Vector {
  x: number;
  y: number;

  constructor(x = 0, y = 0) {
    this.x = x;
    this.y = y;
  }
}

使用向量的知识可以方便地进行拆解、运算、转换,最重要的一点是不需要进行角度转换,因为坐标(x,y)就包含了方向的信息。

Slice.jpg

常见的方法有两个向量相加,除以一个标量,求向量的长度,获取单位向量等等。

// 加上另一个向量
add(vector) {
    this.x += vector.x;
    this.y += vector.y;
    return this;
}

// 除以一个标量,反向不变
div(scale) {
    this.x /= scale;
    this.y /= scale;
    return this;
}

// 计算向量的长度
mag() {
    return Math.sqrt(this.x * this.x + this.y * this.y);
}

// 求单位向量
normalize() {
    let m = this.mag();
    if (m !== 0) {
      return this.div(m);
    }
    return this;
}

把这些常见的向量计算方法都提前准备好封装好,就不需要每次开发都重新去写这些基础代码。更多的向量看这里 Vector.ts。接下来就在小游戏中用上位移、速度、加速度和力这些向量。

位移、速度、加速度和力

上面的动画例子中,我们是每一帧较上一帧移动一像素,也就是做匀速运动。在初中物理就学过,只有受力平衡才能匀速运动。在我们游戏中至少有重力的存在,怎么计算力对位移产生影响呢?复习一下几条初中的匀加速直线物理公式/定律。

牛顿第二定律,力和加速度成正比F=maF = ma

匀加速直线运动速度公式: V=v0+atV = v0 + at

匀加速直线运动位移公式S=v0t+1/2at2S = v0t + 1/2 * at^2

结合这几条公式,首先我们需要计算物体运动的加速度 a,通过加速度 a 和初始速度 v0 就可以计算出下一次渲染的初始速度和位移。

你可能忘了匀加速位移公式是怎么来的,在 V-T 的直角坐标系中画一下就很清晰,下图紫色区域面积就表示 t 时间内的位移。其面积等于正方形面积与三角形面积之和,所以 S=v0t+1/2at2S = v0t + 1/2 * at^2

image.png

但一个运动的物品可能同时会受重力,摩擦力,牵引力等等,来自多个方向受力,速度不一定是匀加速。同样绘制在速度-时间图中,速度变化不再是一条直线,而是一条曲线。前面说了 v-t 图中的面积就是位移,那这条曲线下面积如何计算呢?

image.png

看起来是无法通过公式直接运算的,但是我们可以通过无限贴近的方法,也就是积分的方式来计算这一块的面积。

S=abf(x)dxS = \int_a^b f(x)dx

转换成图表的方式来看,就是我们将 t 拆分成无限多个小块,每块的宽度是 dx,高度为 f(x),将这些小块的面积累加起来就和真实的曲线下面积相近。当 dx 趋于无穷小(但不等于0)时,面积和真实面积相等。

image.png

我们可以把每一帧的时间作为单位时间 dx,速度和加速度分别描述的是每单位时间内位移和速度的变化。举例有个瞬间速度为 V(1,2),用它乘以时间 dx 就得到这个时间内移动的位移也就是 x 轴移动 1 像素,y 轴移动 2 像素!!!

很多文章上来就是直接祭出代码,希望看完这部分内容,能让你明白为什么是这样。

扯了这么多,回到我们的代码中:

  // 重力加速度
  const G = new Vector(0, 1)

  update() {
    this.velocity.add(G);
    this.position.add(this.velocity);
  }

  draw = ({ ctx }: { ctx: CanvasRenderingContext2D }) => {
    ctx.fillStyle = this.color;
    ctx.fillRect(this.position.x, this.position.y, this.width, this.height);
  };

在每个时间片刻应用加速度修改速度,再应用速度修改位移(这些都是向量)就可以完成在力的作用下产生位移。

这里被省略掉的部分代码是,先 new 一个对象,在每一次 requestAnimationFrame 的回调中分别调用这个对象的 update 和 draw 方法。

在只受重力加速度影响下的掉落效果:

Kapture 2022-02-26 at 12.24.15.gif

再来模拟一下运动过程中受力,我们让物体在纵坐标大于 200 的时候受到一个向上的推动力。

  update() {
    // ...
    if (this.position.y > 200) {
      this.applyForce(new Vector(0, -2))
    }
  }

  applyForce = (force: Vector) => {
    this.velocity.add(force.clone().div(this.mass));
  };

物体就会在重力和推动力的作用下往返移动。

Kapture 2022-02-26 at 12.18.23.gif

庆幸在一顿复杂的分析(甚至扯上了积分,我的天)之后,使用向量,最终的代码写起来非常简单。

粒子效果

所谓粒子效果就是由多个细小的颗粒聚集移动产生的视觉效果。在这个游戏中当 Hero 和色块平台接触时会产生粒子喷发,模拟灰尘。每个灰尘颗粒也受重力和平台水平移动速度影响,利用我们前面学到的知识,给每个粒子设置随机的初始速度放入物理世界就可以了。

const particle =  new Particle({
  // 随机生成速度
  velocity: new Vector(random(-4, -2), random(-6, -1)),
  position: new Vector(
    mouseX,
    mouseY
  ),
  color: '#feca57',
  // ...
});

// 给一个向上的初始动力
particle.applyForce(
  new Vector(0, -2)
);

Particle 类的计算和绘制代码。

  applyForce = (force: Vector) => {
    this.velocity.add(force.clone().div(this.mass));
  };

  update = () => {
      // 每次修改一点透明度,产生逐渐消失的视觉效果
      this.opacity -= 0.05;
      this.velocity.add(verticalAcceleration);
      this.position.add(Vector.add(this.velocity, horizontalVelocity));
      this.opacity = Math.max(this.opacity, 0);
  };
  
  draw = ({ ctx }: { ctx: CanvasRenderingContext2D }) => {
    ctx.save();
    ctx.globalAlpha = this.opacity;
    ctx.fillStyle = this.color;
    ctx.fillRect(this.position.x, this.position.y, this.width, this.height);
    ctx.restore();
  };

ctx.save() 会暂存当前绘画上下文,并进入新的作用域,之后执行的代码只对当前作用域中内绘制生效,直到 restore() 方法恢复绘画上下文。

Kapture 2022-02-26 at 14.53.57.gif

需要注意📢,如果粒子不断产生但不销毁,运行时间长了之后会出现内存泄漏问题。我们可以对已经销毁的粒子进行回收♻(享元模式),比如创建一个对象池,每次创建粒子先从池子中借一个容器,不可见的时候再归还。也可以是维护一个粒子数组,每次移动指针,指针下标对最大粒子数进行取模。

// 每一帧调用一次
particles[particleIndex++ % maxParticleLength] = new Particle({
    ...
})

任然是不断创建新的粒子,但是被替换掉的那些会在下一次 GC 时回收。

无限循环的障碍物

前面的内容中粒子类和绘制 Hero 类的代码很类似,包括接下来平台类的代码,可以抽象出一个矩形的父类 Rect.ts,集成这个类就可以有位置,尺寸等属性和绘制方法。

封装完之后,平台类(Platform)只需要维护自己和其他矩形不一样的点(也就是 update 方法),平台就是地面,它不受重力加速度影响。

export class Platform extends Rect {
  update() {
    this.position.add(horizontalVelocity);
  }
}

接下来还需要维护一个平台的管理类(PlatformManager),这个类会不断的创建新的随机平台。出于性能考虑,平台管理类需要在每个平台超过屏幕左侧的时候对它进行回收♻️。和其他需要参与绘制的类一样,平台管理类也有 update 和 draw 方法。

主要逻辑在 update 方法中,在这个方法里面会先判断当前所有的平台能否铺满画布,如果不能就继续增加平台,一直加到铺满画布,将它标记📌为队尾。在每一次调用里面判断每个平台是否已经超出画布左侧,如果是,需要将它的位置调整到队尾,并重新修改队尾的指针。

  update = () => {
    // 如果所有平台不能铺满画布
    while (
      !this.platforms.length ||
      this.lastPlatform.position.x < canvas.width
    ) {
      // ... 创建新的平台,标记📌最后一个平台
      const newPlatform = new Platform();
      this.lastPlatform = newPlatform;
      this.platforms.push(newPlatform);
    }

    for (let i = 0; i < this.platforms.length; i++) {
      const platform = this.platforms[i];
      // 调用每个平台自身的 update方法
      platform.update();

      // 如果平台的右侧已经不可见
      if (platform.position.x + platform.width < 0) {
        // ...重新随机生成平台, 标记📌最后一个平台
        this.lastPlatform = platform;
      }
    }
  };

draw 方法就是调用每个平台自身的绘制方法。

  draw = ({ ctx }) => {
    this.platforms.forEach((p) => p.draw({ ctx }));
  };

运行效果:

Kapture 2022-02-26 at 15.36.38.gif

碰撞💥 检测

接下来就差 Hero,Particle,和 Platform 都整合起来。当 Hero 掉落到平台上方时,需要让它停留在平台下面不继续下坠,而当它和平台的左侧发生碰撞时需要结束游戏重新开始。

判断两个长方形是否相交可以列举四种不相交的情况然后取反:

function isIntersect(r1: Rect, r2: Rect): boolean {
  return !(
    r2.position.x > r1.position.x + r1.width ||
    r2.position.x + r2.width < r1.position.x ||
    r2.position.y > r1.position.y + r1.height ||
    r2.position.y + r2.height < r1.position.y
  );
}

当相交时,将 Hero 的速度设置为 0,纵坐标也设置刚好在平台上方。

hero.velocity = new Vector(0, 0);
hero.position.y = platform.position.y - hero.height;

这里需要注意的是,由于在计算机程序中,位移不是连续的,可能上一帧距离尚远,下一帧可能就超过相交甚至内嵌了。

image.png

为了能够正确判断相交时是在平台的上方还是在左侧,这里需要记录发生碰撞前一帧的位置和速度。

image.png

具体的代码如下,模拟计算从上一帧的 Hero 右下角位置移动到相交平台左上角的位置,如果纵向移动时间 ty 比 横向移动时间 tx 小,说明在碰撞时是撞到了平台左侧。

function isIntersectLeft(hero: Hero, platform: Platform) {
  // 省略上一帧已经是在上方或左侧的判断代码
  // ...
  const { x, y } = platform.prevPosition;
  const prevRightBottomX = hero.width + hero.prevPosition.x;
  const prevRightBottomY = hero.height + hero.prevPosition.y;
  const tx = (x - prevRightBottomX) / -stage.horizontalVelocity.x;
  const ty = (y - prevRightBottomY) / hero.prevVelocity.y;
  return ty < tx;
}

更精准地,这里纵向应该是使用加速度位移公式 S=v0t+1/2at2S = v0t + 1/2 * at^2,解一个一元二次方程求出 ty,这个例子中的加速度和速度都不会特别大,所以简化成直接用匀速运动计算。

参考下图,tx 为 0.7,ty 为 0.74,tx < ty 说明发生碰撞时 Hero 会从上方切入。而如果反过来,则认为 Hero 会撞上平台左侧,游戏结束。

image.png

基本的声控实现

本来整个 Demo 游戏开发到这里就结束了,直到我突发奇想:诶,加上声控会怎么样。于是就有了下面这张动图,用打响指来控制游戏(需要找一个安静的空间,实际游戏体验不佳 哈哈哈)。

Kapture 2022-02-19 at 20.50.20.gif

获取声控的代码如下,使用 AudioContext 解析麦克风输入的流,截取一段 buffer 中的音量🔊最大值。

if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
  let audioContext = new (window.AudioContext || window.webkitAudioContext)();

  navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
    const mediaStreamSource = audioContext.createMediaStreamSource(stream);
    const scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
    mediaStreamSource.connect(scriptProcessor);
    scriptProcessor.connect(audioContext.destination);

    scriptProcessor.onaudioprocess = function (e) {
      let buffer = e.inputBuffer.getChannelData(0);
      let maxVal = Math.max(...buffer);
      console.log(maxVal);
    };
  });
}

由于声音会在空间中持续传播一段时间,在应用到游戏上时还需要加一段 debounce 的代码。

最后,再贴一下Codepen 链接Codesandbox完整的代码纯享版链接。这篇文章发布在我的掘金账号上,转载需授权。

总结

特别说明,这个示例参考了 CodePen 上 Daily Pen #010: A generic Infinite Runner game 的游戏模式,我在试玩之后觉得很有趣,于是便尝试自己从头开始实现,并在原有模式上增加了二段跳和声控等功能。

开发完之后觉得挺有意思的,于是便有了这篇文章,从开始编写 Demo 到文章发布花了周末我两天半的时间,如果你觉得这篇教程对你有帮助,欢迎点赞❤️+评论❤️+关注❤️。时间仓促,水平有限,也欢迎👏🏻各种纠正和讨论。

扩展阅读