卷出新高度,几百行的Canvas游戏秘籍

前言

本文将简单实现一个canvas游戏,总共逻辑一共才几百行,但是能学到很多通用2d的游戏基础。

对于普通的Canvas游戏来说重要的是面向对象思想、碰撞检测、精灵图的应用、游戏背景的处理、键盘鼠标等交互。熟练掌握就可轻松驾驭。

游戏简介

这个游戏比较简单,就是玩家不断通过鼠标点击乌鸦,每次点击会消灭,如果一旦超过屏幕的左侧,则为失败

1684671155234.gif

开始游戏之旅!

分层

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看看会发生什么

1684676709328.gif

然后我们可以看到下面的通过缓存的数组,里面保存了各个类,通过解构赋值能清晰的执行更新绘制逻辑

首先我们需要拆分类,拆分规则按照我们的需要复用的物体来拆,一共三个:拖尾的类、乌鸦类、爆炸类。当然也可以抽离成通用的类,被其他继承,比如你有很多不同的敌人的时候,比如敌人都有通用的血条等。注意我们同样可以用函数式的闭包来处理,但是一旦复杂起来,是很难维护的。

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,那么对应为

image.png

是不是很方便?我们可以控制动作了,那么我们怎么控制动作的速率呢,就是拍打翅膀的速度。我们从代码可以看到flapInterval,flapInterval其实就是控制我们帧的累加是否超过限制。

精灵图的应用

image.png

image.png

精灵图可以让我们把多个动作的图,只要一张图就可以加载,除了性能考虑也方便我们控制帧。

毕竟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;
}

这里我们把透明度改改,看看就明白了

image.png

QQ录屏20230521221943.gif

神奇的拖尾效果

其实我们简单分析就是大量的圆的有规律的绘制。

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上下文中, 画面会一卡一卡,而一旦我们保存之前的状态,那么会在原来的基础上叠加我们的拖尾效果,画面就流畅很多了。

image.png

es6的语法结构巧妙处理图层绘制

我们都知道canvas的绘制是根据darw的先后顺序的,那么我们回顾下我们之前代码

 [...ravens, ...trailings, ...explosions].forEach((object) =>
    object.update(deltatime)
  );
  [...ravens, ...trailings, ...explosions].forEach((object) => object.draw());

绘制效果:

image.png

那么我们修改下顺序

  [...trailings, ...ravens, ...explosions].forEach((object) =>
    object.update(deltatime)
  );
  [...trailings, ...ravens, ...explosions].forEach((object) => object.draw());

修改后的效果

image.png

是不是感觉结构语法的先后顺序其实就是方便我们调整图层的绘制

再来看看效果吧!

1684671155234.gif

总结

几百行搞定后其实还有很多可以优化的地方,比如乌鸦的移动轨迹其实是比较简单的,我们同样可以用三角函数让其轨迹变得曲线圆滑,另外我们的背景是不是有点简陋了,我们同样可以网上找个首尾相接的背景图,让乌鸦有更好的前进效果。如果我们的游戏在移动端,是不是也要考虑横屏和竖屏的适配呢?

猜你喜欢

转载自juejin.im/post/7235534634776248381