这篇文章将从零开始,使用原生 Canvas 开发一款声控跳跃游戏,原生 Canvas 意味这个教程不会使用任何框架,全都是很好理解的基础代码,这样能更好地掌握整个小游戏的实现过程,没有其他学习成本。
Intro
如标题所言,这篇文章将手把手,从零开始介绍如何编码实现,所以即使你完全没有相关的开发经验也不用担心,跟着一步步来就可以。这是一篇入门级别的文章,内容相对基础也比较啰嗦。
从头开始看完这篇文章你将至少收获:
- Canvas 绘画的基本用法、设备密度 devicePixelRatio
- 复习向量 Vector 知识及其基本运算
- 在动画中应用位移、速度、加速度和力
- 粒子效果实现
- 无限循环障碍物的实现
- 方形盒子模型碰撞💥检测
- 基本的 Web 声音输入控制
如果你读完,觉得这篇文章对你有所帮助,不如点赞+关注+收藏一波❤️,这样我会更有动力写这些基础实现的文章。 当然,如果有任何优化改善的建议也欢迎在留言区评论。
废话不多说,先看最终实现效果。完整的Codesandbox 代码在这里,纯享版链接,Codepen 链接。这篇文章发布在我的掘金账号上,转载需授权。
游戏玩法说明
在这个小游戏中,你可以通过按【空格】健、或者【向上】健来控制 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,fillRect
和 clearRect
。
ctx.fillRect(x, y, width, height);
ctx.clearRect(x, y, width, height);
顾名思义,fillRect
是从坐标点(x,y)
(Canvas 的坐标原点是左上角)开始绘制一个宽高分别为 width
和 height
的矩形,clearRect
则干的是相反的事情,清空绘制。
// 设置填充颜色
ctx.fillStyle = "#1D80FC";
ctx.fillRect(0, 0, 100, 100);
// 挖空中间绘制边框
ctx.clearRect(20, 20, 60, 60);
设备像素密度 devicePixelRatio
上面的图片是在我的 mac 上截的图,不难发现,内部边框部分出现了模糊。原因和一个叫屏幕像素密度的知识有关。在没有 Retina/High-DPI 屏幕的时候,一个 CSS 的像素单位和物理屏幕的像素单位是一致的。
为了追求更高清的画面,这些高清屏幕将原先一个物理像素拆分成多个,导致 CSS 中的 px 和物理像素不是一一对应的。在我的屏幕🖥(二倍屏),一个 CSS 的像素单位对应到了四个物理像素。
从微观视角上看,Canvas 绘制最终会生成点位图(bitmap),它们被绘制到二倍屏上,一个像素对应到四个物理像素。计算机为了使“拉伸”后的图像看起来平滑,会给这些多出来的像素及其周边计算出一个中间过度色。用 Canvas 绘制一条宽度为 1px,高度为 2px 的竖线的演示如下:
左边是预期效果,中间是一拆四物理像素示意,右边是实际渲染效果。
这和我们在电脑上放大查看非矢量图类似,由于图片提供的像素信息比实际要展示用到的物理像素少,显示器将其进行边缘模糊化,用相近颜色填充,于是我们看到了模糊。
了解模糊的原理,解决方案就是要提供足量的像素,让 canvas 绘制的像素和物理展示像素一致。浏览器提供了 devicePixelRatio
属性,它返回当前显示设备的物理像素分辨率与CSS像素分辨率之比,在我的 mac 屏幕上 devicePixelRatio
是 2,在外接普通屏幕上是 1。
利用这个比值,先将 canvas 尺寸放大,再通过 style 缩小(显示大小),并需要设置 ctx 的 scale,对绘制内容进行放大,这样做完之后,使用 ctx 绘制的像素就和物理像素对齐了。
左边是 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>
做了适配之后,图片边缘就不会出现模糊了。
左为改造前,右为改造后。
动画基础
掌握了基本的图形绘制,开始实现动画。之所以我们肉眼能看到连贯的动画,是因为光对视网膜所产生的视觉效果,在光停止之后,仍然能保留一段时间的现象。简称为视觉暂留,这是逐帧动画和胶片电影的原理。
停留的时间是由视神经的反应速度决定的,时间差不多是 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 之前回调,所以这里不会调用栈溢出。
向量
为了让物体运动更贴近真实,这里我们也引入物理世界的知识,先从向量开始。向量指一个同时具有大小和方向的对象,只有大小没有反向的对象就不是向量,例如速率达到 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)就包含了方向的信息。
常见的方法有两个向量相加,除以一个标量,求向量的长度,获取单位向量等等。
// 加上另一个向量
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。接下来就在小游戏中用上位移、速度、加速度和力这些向量。
位移、速度、加速度和力
上面的动画例子中,我们是每一帧较上一帧移动一像素,也就是做匀速运动。在初中物理就学过,只有受力平衡才能匀速运动。在我们游戏中至少有重力的存在,怎么计算力对位移产生影响呢?复习一下几条初中的匀加速直线物理公式/定律。
牛顿第二定律,力和加速度成正比:
匀加速直线运动速度公式:
匀加速直线运动位移公式:
结合这几条公式,首先我们需要计算物体运动的加速度 a
,通过加速度 a
和初始速度 v0
就可以计算出下一次渲染的初始速度和位移。
你可能忘了匀加速位移公式是怎么来的,在 V-T
的直角坐标系中画一下就很清晰,下图紫色区域面积就表示 t 时间内的位移。其面积等于正方形面积与三角形面积之和,所以
。
但一个运动的物品可能同时会受重力,摩擦力,牵引力等等,来自多个方向受力,速度不一定是匀加速。同样绘制在速度-时间图中,速度变化不再是一条直线,而是一条曲线。前面说了 v-t
图中的面积就是位移,那这条曲线下面积如何计算呢?
看起来是无法通过公式直接运算的,但是我们可以通过无限贴近的方法,也就是积分的方式来计算这一块的面积。
转换成图表的方式来看,就是我们将 t 拆分成无限多个小块,每块的宽度是 dx
,高度为 f(x)
,将这些小块的面积累加起来就和真实的曲线下面积相近。当 dx
趋于无穷小(但不等于0)时,面积和真实面积相等。
我们可以把每一帧的时间作为单位时间 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 方法。
在只受重力加速度影响下的掉落效果:
再来模拟一下运动过程中受力,我们让物体在纵坐标大于 200 的时候受到一个向上的推动力。
update() {
// ...
if (this.position.y > 200) {
this.applyForce(new Vector(0, -2))
}
}
applyForce = (force: Vector) => {
this.velocity.add(force.clone().div(this.mass));
};
物体就会在重力和推动力的作用下往返移动。
庆幸在一顿复杂的分析(甚至扯上了积分,我的天)之后,使用向量,最终的代码写起来非常简单。
粒子效果
所谓粒子效果就是由多个细小的颗粒聚集移动产生的视觉效果。在这个游戏中当 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()
方法恢复绘画上下文。
需要注意📢,如果粒子不断产生但不销毁,运行时间长了之后会出现内存泄漏问题。我们可以对已经销毁的粒子进行回收♻(享元模式),比如创建一个对象池,每次创建粒子先从池子中借一个容器,不可见的时候再归还。也可以是维护一个粒子数组,每次移动指针,指针下标对最大粒子数进行取模。
// 每一帧调用一次
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 }));
};
运行效果:
碰撞💥 检测
接下来就差 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;
这里需要注意的是,由于在计算机程序中,位移不是连续的,可能上一帧距离尚远,下一帧可能就超过相交甚至内嵌了。
为了能够正确判断相交时是在平台的上方还是在左侧,这里需要记录发生碰撞前一帧的位置和速度。
具体的代码如下,模拟计算从上一帧的 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;
}
更精准地,这里纵向应该是使用加速度位移公式 ,解一个一元二次方程求出 ty,这个例子中的加速度和速度都不会特别大,所以简化成直接用匀速运动计算。
参考下图,tx
为 0.7,ty
为 0.74,tx < ty
说明发生碰撞时 Hero 会从上方切入。而如果反过来,则认为 Hero 会撞上平台左侧,游戏结束。
基本的声控实现
本来整个 Demo 游戏开发到这里就结束了,直到我突发奇想:诶,加上声控会怎么样。于是就有了下面这张动图,用打响指来控制游戏(需要找一个安静的空间,实际游戏体验不佳 哈哈哈)。
获取声控的代码如下,使用 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 到文章发布花了周末我两天半的时间,如果你觉得这篇教程对你有帮助,欢迎点赞❤️+评论❤️+关注❤️。时间仓促,水平有限,也欢迎👏🏻各种纠正和讨论。