从Chrome小恐龙游戏学习2D游戏制做

在chrome浏览器的断网页面,按空格键或者向上键会出现一个小恐龙跑酷小游戏,这个2D小游戏在设计上精致小巧,在代码上也只有三千多行,思路清晰严谨,颇有学习价值前端

demo

在非断网状况下,能够经过chrome://dino 进行访问,源代码在source面板中没法显示,能够前往这里下载。在这篇文章中异名会梳理2D游戏的制做思路,主要包括游戏的mainloop主循环和实例的update更新、帧图的动态绘制和切换、帧率的控制、游戏对象的运动控制、碰撞检测的实现等web

游戏循环

循环是游戏的心跳,是一个定时回调,每隔一段时间去更新游戏的逻辑,好比处理用户的交互,更新游戏的状态,绘制动画等等chrome

mainloop() {
  this.clearCanvas()  // 清除画布

  //  处理逻辑....
  
  window.requestAnimationFrame(this.mainloop.bind(this));
}

rAF没出现以前,你们使用setTimeout和setInterval来触发视觉的变化,可是这两个api在时间的精准控制上有缺陷。由于「定时器属于异步任务,它必须等到同步任务执行完毕以后,以及异步队列里面的任务清空以后才轮到本身执行,它的实际执行时机通常都比设定的时间晚」,这就说明了它不能精准地按照必定的时间间隔去执行。还有一点就是「定时器的调用间隔和屏幕绘制频率不一致」,显示器的频率通常都默认是60Hz(1s绘制60次),每次绘制的时间差是16.7ms(1000/60≈16.7),由于定时器的调用间隔和屏幕频率不一致,因此下面这种状况就必定会出现canvas

settimeout

红色叉叉那里就丢帧了,下面经过一个更清晰的例子来讲明:api

这也是为何之前你们把setInterval的间隔设置为1000/60的缘由,可是这本质上是硬件的差别,只要换个硬件,定时器的执行步调和屏幕的刷新步调不一致就必定会产生丢帧。这也就是rAF的最大优点,它是「由系统来决定回调函数的执行时机,系统每次绘制以前会主动调用 rAF 中的回调函数」,它可以确保回调函数是按照系统的绘制频率来调用,不管是60Hz仍是50Hz,只要画面刷新就会调用回调函数,它就解决了步调统一以及回调频率可靠这两个问题。可是由于是系统主动调用,因此须要咱们本身去作时间管理,raf的回调第一个参数是一个时间戳,可是在实践上通常咱们本身计时浏览器

  mainloop() {
    const now = performance.now()
    const deltaTime = now - (this.time || now)
    this.time = now

    this.clearCanvas()  // 清除画布
    
    // 处理逻辑...
    
    window.requestAnimationFrame(this.mainloop.bind(this))
  }

在源码中,这里还作了一个严谨的设计,它在非游戏中的时候会暂停mainloop循环而且清除rAF,再次游戏的时候会再次触发mainloop,因此这里还作了一个加锁性能优化

scheduleNextUpdate: function ({
  if (!this.updatePending) {
    this.updatePending = true
    this.raqId = requestAnimationFrame(this.update.bind(this))
  }
}

画面绘制

游戏基于canvas来绘制,游戏的图片资源只有一张base64格式的精灵图,以下微信

sprite

游戏的对象都在这张精灵图中,咱们先从精灵图中把地面绘制出来。这里面涉及到的知识点是canvas的建立、画面清除,以及drawImage的应用。经过drawImage咱们能够裁剪精灵图中某一部分的图像,并绘制到画布中,drawImage一共有9个参数context.drawImage(img,sx,sy,swidth,sheight,x,y,width,height) 分别是精灵图、裁剪区域的坐标,裁剪的区域大小,在画布上放置图像的位置坐标,在画布上放置图像的大小。简单拆分一下任务:app

  • 下载图片资源
  • 建立画布
  • 从精灵图中裁剪地面部分并绘制

核心代码以下框架

// 下载资源
loadImage() {
 return new Promise((resolve, reject) => {
  const img = new Image()
    img.src = "精灵图的base64"
    img.onload = () => {
      window.imageSprite = img
      resolve(img)
    }
    img.onerror = () => {
      reject()
    }
  })
}

// 绘制画布
initCanvas() {
  const canvas = document.createElement('canvas')
  canvas.width = CANVAS_WIDTH
  canvas.height = CANVAS_HEIGHT
  document.body.appendChild(canvas)

  this.canvas = canvas
  this.ctx = canvas.getContext('2d')
}

// 二次绘制的时候清除画布
this.ctx.clearRect(00, CANVAS_WIDTH, CANVAS_WIDTH, CANVAS_HEIGHT)

// 绘制地面
this.ctx.drawImage(window.imageSprite,
  25460012,
  this.xPos, this.yPos, 60012
)

一样利用context.drawImage能够把精灵图里面的其余对象也绘制画布上,组合出游戏里面的对象

绘制画面

动画和帧频控制

游戏中的每一个实例都有update的方法, update在每次主循环中都会执行,在这个小恐龙游戏中每一个实例的update都被直接地调用,若是须要更好地解耦和维护可使用订阅发布等模式

mainloop() {
  // ...
   ground.update()
   trex.update()
}

ground.update = function() {
 // ...
  context.drawImage() // 更新绘制
}

动画就涉及到更新频率,若是像上面那样每次循环的时候都去绘制,mainloop一秒会执行60次,可是绘制的内容更新并无这么频繁,因此咱们须要作时间管理。「游戏中的帧频能够分为两种,一个是序列帧的帧频,一个是游戏的全局帧频」。好比恐龙就是由指定的序列帧动画展现的,它一共有5种状态,其帧动画参数定义以下

Trex.animFrames = {
  WAITING: {                    // 等待状态下的序列帧
    frames: [440],            // 每一帧的起点位置
    msPerFrame: 1000 / 3        // 绘制的频率
  },
  RUNNING: {                    // 奔跑状态下的序列帧
    frames: [88132],          // 每一帧的地点位置
    msPerFrame: 1000 / 12       // 绘制的频率
  },
  CRASHED: {
    frames: [220],
    msPerFrame1000 / 60
  },
  JUMPING: {
    frames: [0],
    msPerFrame1000 / 60
  },
  DUCKING: {
    frames: [264323],
    msPerFrame1000 / 8
  }
};

拿奔跑状态来讲,它是由两张图片按12Hz的频率来更新的,每一帧的耗时是1000/12,咱们在update的时候作一个计时:

class Trex {
  constructor(ctx) {
    this.ctx = ctx
    this.currentAnimFrames = Trex.animFrames['RUNNING'].frames
    this.msPerFrame = Trex.animFrames['RUNNING'].msPerFrame
    this.currentFrame = 0
    this.timer = 0
  }
  
  update(dt) {
    this.timer += dt
    
    // 更新当前帧序号
    if (this.timer >= this.msPerFrame) {
      this.currentFrame = this.currentFrame == this.currentAnimFrames.length - 1 ? 0 : this.currentFrame + 1;
      this.timer = 0;
    }
    
    // 绘制当前帧图 
    const sx = this.currentAnimFrames[this.msPerFrame]
    this.ctx.drawImage(img,sx,sy,swidth,sheight,x,y,width,height)
  }
}

另一种动画就是非序列帧动画,好比地面的运动,由于没有指定的帧频因此它的运动频率就是全局的帧频

const FPS = 60    // 设定全局的帧频为60
ground.update(dt) {
  // 根据全局的帧频计算速度
  const increment = Math.floor(speed * (FPS / 1000) * dt);
  this.xPos -= increment
  
  // 绘制当前帧图 
  const x = this.xPos
  this.ctx.drawImage(img,sx,sy,swidth,sheight,x,y,width,height)
}

给小恐龙加上序列帧动画以及给跑道加上位移以后效果以下:

run

值得注意的是,在小恐龙游戏中没有对主循环作帧频控制,每一次循环的时候都会执行清除画布和画面重绘操做,若是遇到须要可控帧频的场景主循环就可能会产生过分绘制或者丢帧的状况了

用户交互和运动状态

小恐龙游戏中的用户交互主要是跳和下蹲,监听用户按键事件,根据键码去切换小恐龙的状态和处理位置信息。这里有两个小逻辑,在蹲的时候由于帧图的大小有变化须要作宽高的切换;在跳的时候由于游戏是变速运动,因此也根据游戏的当前速度作了一个关联咱们把仙人掌加上以后,游戏的核心交互流程就已经实现出来了:

碰撞检测

小恐龙里面使用的是矩形检测,每一个碰撞体都是一个矩形,游戏循环的时候判断每一个矩形是否重叠就知道是否碰撞了。

collision_boxs

由于物体是不规则的形状,因此像左上图那样只有两个矩形是作不到精准地描述物体的边界的。「在游戏中,为了简化每一帧中的计算计算量,只有当这两个外矩形相碰的时候,才会去遍历每一个对象下的细分矩形」,好比右上图小恐龙和仙人掌都分别用了四个矩形来描述它们的边界,当外矩形重叠的时候,内部矩形才开始遍历判断重叠,下面这个过程图很好地把这个过程演示了出来:

collision

碰撞盒子以及恐龙的碰撞盒子定义:矩形重合判断在mainloop中进行碰撞检测:

结尾

上面就已经把小恐龙的核心功能过了一遍,剩下的一些小功能堆叠和细节的完善,就再也不展开。异名以往都是经过游戏引擎或者互动框架来开发游戏,这仍是第一次生撸,引擎封装带来的开发体验和本身从零开发是不同的,这也是前段时间异名的小困惑,高度封装就表明底层的隐藏,开发一段时间以后很快就会遇到概念上的困惑,甚至你的理解和真实的状况彻底相反,虽然他们的表现一致,此次跟着代码敲完一次以后,异名对2D游戏的制做思路也有了更清晰的理解。




 

融球效果(shader)    水波扩散效果(shader)

设计稿生成游戏界面   游戏性能优化

金币落袋效果  镜面光泽效果   追光效果

shader 溶解效果    放大镜效果

子弹跟踪效果    移动残影效果    刮刮卡实现

微信小游戏首包超出4M以后   前端生僻字显示

使用cocos进行2D和3D混合开发

Cocos游戏开发入门最佳实践 



本文分享自微信公众号 - 异名(async-code)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索