前言
本文将简单实现一个canvas游戏,总共逻辑一共才几百行,但是能学到很多通用2d的游戏基础。
对于普通的Canvas游戏来说重要的是面向对象思想、碰撞检测、精灵图的应用、游戏背景的处理、键盘鼠标等交互。熟练掌握就可轻松驾驭。
游戏简介
这个游戏比较简单,就是玩家不断通过鼠标点击乌鸦,每次点击会消灭,如果一旦超过屏幕的左侧,则为失败
开始游戏之旅!
分层
html和css就不说了,首先这里我们分了两层的canvas,最底层是基本的绘制,最顶层是用于交互层
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// 用于后面根据颜色块体删除被点击的敌人,注意是使用canvas的获取像素的api
const collisionCanvas = document.getElementById("collisionCanvas");
const collisionCtx = collisionCanvas.getContext("2d");
collisionCanvas.width = window.innerWidth;
collisionCanvas.height = window.innerHeight;
绘制逻辑
//通过帧的控制
function animate(timestamp) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// ...
// 简单的canvas绘制分数
drawScore();
// 在这里做一系列绘制的工作,如乌鸦类、爆炸类、拖尾类
// ...
//游戏结束就取消
if (!gameOver) requestAnimationFrame(animate);
else drawGameOver();
}
animate(0);
思考完分层后,我们要开始着眼于绘制的入口,requestAnimationFrame可以根据电脑性能按需渲染帧, 同时电脑空闲并不会执行。我们要很清楚整个绘制的流程,从清理画布 -> 绘制类 -> 绘制其他 -> 游戏结束条件
巧妙的帧率控制
let ravenInterval = 500
//通过帧的控制
function animate(timestamp) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
collisionCtx.clearRect(0, 0, canvas.width, canvas.height);
// 绘制乌鸦的帧
let deltatime = timestamp - lastTime;
lastTime = timestamp;
// 控制渲染下一个乌鸦的帧是否达到目标值
timeToNextRaven += deltatime;
//获取每次绘制乌鸦的需要的帧,超过500ms,开始绘制下一个乌鸦
if (timeToNextRaven > ravenInterval) {
ravens.push(new Raven());
timeToNextRaven = 0;
ravens.sort(function (x, y) {
return x.width - y.width;
});
}
// 绘制分数
drawScore();
//这里的扩展运算符可以更直观序列化的展示所有要绘制的内容
[...trailings, ...ravens, ...explosions].forEach((object) =>
object.update(deltatime)
);
[...trailings, ...ravens, ...explosions].forEach((object) => object.draw());
// 把被消灭的从数组中移除
ravens = ravens.filter((object) => !object.markedForDeletion);
explosions = explosions.filter((object) => !object.markedForDeletion);
trailings = trailings.filter((object) => !object.markedForDeletion);
//游戏结束就取消
if (!gameOver) requestAnimationFrame(animate);
else drawGameOver();
}
animate(0);
接下来我们增加基础的绘制逻辑,我们这里通过requestAnimationFrame计算帧率,一旦超过500ms来加入敌人,某种意义这就是控制敌人的数量的变量,同时能有更好的性能。这里我们把ravenInterval改成10看看会发生什么
然后我们可以看到下面的通过缓存的数组,里面保存了各个类,通过解构赋值能清晰的执行更新绘制逻辑
类
首先我们需要拆分类,拆分规则按照我们的需要复用的物体来拆,一共三个:拖尾的类、乌鸦类、爆炸类。当然也可以抽离成通用的类,被其他继承,比如你有很多不同的敌人的时候,比如敌人都有通用的血条等。注意我们同样可以用函数式的闭包来处理,但是一旦复杂起来,是很难维护的。
Raven 乌鸦类
class Raven {
constructor() {
//定义乌鸦的尺寸以及位置
this.spriteWidth = 271;
this.spriteHeight = 194;
this.sizeModifier = Math.random() * 0.6 + 0.4;
this.width = this.spriteWidth * this.sizeModifier;
this.height = this.spriteHeight * this.sizeModifier;
this.x = canvas.width;
// 控制竖向位置
this.y = Math.random() * (canvas.height - this.height);
// 控制方向
this.directionX = Math.random() * 5 + 3; // [3, 8)
this.directionY = Math.random() * 5 - 2.5;
// 加载图片
this.image = new Image();
this.image.src = "../static/collision/raven.png";
// 默认未被消灭
this.markedForDeletion = false;
// 控制精灵图的帧率
this.frame = 0;
this.maxFrame = 4;
this.timeSinceFlap = 0;
this.flapInterval = Math.random() * 50 + 50;
this.randomColors = [
Math.floor(Math.random() * 255),
Math.floor(Math.random() * 255),
Math.floor(Math.random() * 255),
];
this.color =
"rgb(" +
this.randomColors[0] +
"," +
this.randomColors[1] +
"," +
this.randomColors[2] +
")";
this.hasTrail = Math.random() > 0.2;
}
update(deltatime) {
// 控制在画面内
if (this.y < 0 || this.y > canvas.height - this.height) {
this.directionY = this.directionY * -1;
}
this.x -= this.directionX;
this.y += this.directionY;
if (this.x < 0 - this.width) this.markedForDeletion = true;
this.timeSinceFlap += deltatime;
if (this.timeSinceFlap > this.flapInterval) {
if (this.frame > this.maxFrame) this.frame = 0;
else this.frame++;
this.timeSinceFlap = 0;
//放入拖尾效果
if (this.hasTrail) {
for (let i = 0; i < 3; i++) {
trailings.push(new Trailing(this.x, this.y, this.width, this.color));
}
}
}
if (this.x < 0 - this.width) gameOver = true;
}
这里最重要的就是帧的控制,deltatime代表绘制一个乌鸦的帧的个数,frame和maxFrame就是控制我们要渲染的精灵图的个数,如果maxFrame为3,那么对应为
是不是很方便?我们可以控制动作了,那么我们怎么控制动作的速率呢,就是拍打翅膀的速度。我们从代码可以看到flapInterval,flapInterval其实就是控制我们帧的累加是否超过限制。
精灵图的应用
精灵图可以让我们把多个动作的图,只要一张图就可以加载,除了性能考虑也方便我们控制帧。
毕竟gif大体积的控制帧是很麻烦的,ae json动画虽然性能好控制帧稍微复杂点也是问题。
在这里我们只要用drawImage来裁剪图像就可以了,根据每帧的渲染,我们只要控制x的值不断循环变化。并且可以通过一个系数来处理速率。
绘制
draw() {
collisionCtx.fillStyle = this.color;
collisionCtx.fillRect(this.x, this.y, this.width, this.height);
ctx.drawImage(
this.image, //图片
// 控制在图片的位置
this.frame * this.spriteWidth,
0,
// 图的尺寸
this.spriteWidth,
this.spriteHeight,
// 控制在canvas画布的位置
this.x,
this.y,
// 截取的尺寸
this.width,
this.height
);
}
就此逻辑都差不多了,爆炸类,只要我们在点击屏幕位置的时候,检查坐标是否在乌鸦内就push到爆炸的队列中。
巧妙的处理爆炸交互
window.addEventListener("click", function (e) {
const pixelColor = collisionCtx.getImageData(e.x, e.y, 1, 1);
let pc = pixelColor.data;
ravens.forEach((object) => {
if (
object.randomColors[0] === pc[0] &&
object.randomColors[1] === pc[1] &&
object.randomColors[2] === pc[2]
) {
object.markedForDeletion = true;
score++;
explosions.push(new Explosion(object.x, object.y, object.width));
}
});
});
其实这里我并没有计算坐标,而是巧妙的通过getImageData得到像素的颜色,来判断颜色值是否相同,颜色值相同证明被点击,如果你说重复的概率也是有的,微乎其微,但确实方便。所以之前collisionCtx的上下文派上用场了。
#collisionCanvas {
opacity: 0;
}
这里我们把透明度改改,看看就明白了
神奇的拖尾效果
其实我们简单分析就是大量的圆的有规律的绘制。
class Trailing {
constructor(x, y, size, color) {
this.color = color;
this.size = size;
this.x = x + this.size / 2 + Math.random() * 10 - 5;
this.y = y + this.size / 3 + Math.random() * 10 - 5;
this.radius = (Math.random() * this.size) / 10 + 2;
this.maxRadius = Math.random() * 20 + 35;
this.markedForDeletion = false;
this.speedX = Math.random() * 1 + 0.5;
this.color = color;
}
//执行动作
update() {
// 控制不断变大并移动
this.x += this.speedX;
this.radius += 0.8;
// 控制最大变化
if (this.radius > this.maxRadius - 5) this.markedForDeletion = true;
}
draw() {
ctx.save();
ctx.globalAlpha = 1 - this.radius / this.maxRadius;
ctx.beginPath();
ctx.fillStyle = this.color;
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
这里有个很细节的地方,比如save和restore,我们都知道这是一个canvas状态的栈的结构。如果这里我们不加save和restore很明显会抢占乌鸦的帧率,因为是在同一个canvas上下文中, 画面会一卡一卡,而一旦我们保存之前的状态,那么会在原来的基础上叠加我们的拖尾效果,画面就流畅很多了。
es6的语法结构巧妙处理图层绘制
我们都知道canvas的绘制是根据darw的先后顺序的,那么我们回顾下我们之前代码
[...ravens, ...trailings, ...explosions].forEach((object) =>
object.update(deltatime)
);
[...ravens, ...trailings, ...explosions].forEach((object) => object.draw());
绘制效果:
那么我们修改下顺序
[...trailings, ...ravens, ...explosions].forEach((object) =>
object.update(deltatime)
);
[...trailings, ...ravens, ...explosions].forEach((object) => object.draw());
修改后的效果
是不是感觉结构语法的先后顺序其实就是方便我们调整图层的绘制
再来看看效果吧!
总结
几百行搞定后其实还有很多可以优化的地方,比如乌鸦的移动轨迹其实是比较简单的,我们同样可以用三角函数让其轨迹变得曲线圆滑,另外我们的背景是不是有点简陋了,我们同样可以网上找个首尾相接的背景图,让乌鸦有更好的前进效果。如果我们的游戏在移动端,是不是也要考虑横屏和竖屏的适配呢?