当前位置: 首页 > news >正文

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

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

demo

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

游戏循环

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

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

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

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

settimeout

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

这也是为什么以前大家把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) 分别是精灵图、裁剪区域的坐标,裁剪的区域大小,在画布上放置图像的位置坐标,在画布上放置图像的大小。简单拆分一下任务:

  • 下载图片资源

  • 创建画布

  • 从精灵图中裁剪地面部分并绘制

核心代码如下

// 下载资源
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(0, 0, CANVAS_WIDTH, CANVAS_WIDTH, CANVAS_HEIGHT)

// 绘制地面
this.ctx.drawImage(window.imageSprite,
  2, 54, 600, 12,
  this.xPos, this.yPos, 600, 12
)

同样利用context.drawImage可以把精灵图里面的其他对象也绘制画布上,组合出游戏里面的对象

绘制画面

动画和帧频控制

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

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

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

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

Trex.animFrames = {
  WAITING: {                    // 等待状态下的序列帧
    frames: [44, 0],            // 每一帧的起点位置
    msPerFrame: 1000 / 3        // 绘制的频率
  },
  RUNNING: {                    // 奔跑状态下的序列帧
    frames: [88, 132],          // 每一帧的地点位置
    msPerFrame: 1000 / 12       // 绘制的频率
  },
  CRASHED: {
    frames: [220],
    msPerFrame: 1000 / 60
  },
  JUMPING: {
    frames: [0],
    msPerFrame: 1000 / 60
  },
  DUCKING: {
    frames: [264, 323],
    msPerFrame: 1000 / 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游戏的制作思路也有了更清晰的理解。


技术交流,欢迎加我微信:ezglumes ,拉你入技术交流群。

推荐阅读:

音视频面试基础题

OpenGL ES 学习资源分享

开通专辑 | 细数那些年写过的技术文章专辑

NDK 学习进阶免费视频来了

推荐几个堪称教科书级别的 Android 音视频入门项目

觉得不错,点个在看呗~

相关文章:

  • UML科普文,一篇文章掌握14种UML图
  • 黑白键上的字节跳动:全球最大钢琴MIDI数据集背后的故事
  • 当当福利,音视频开发囤书活动!
  • 推荐我录制的免费 Android NDK 进阶视频
  • 炫酷的Android时钟UI控件,隔壁产品都馋哭了
  • 面试官:如何监测应用的 FPS ?
  • 一张图概括淘宝直播背后的前端技术 | 赠送多媒体前端手册
  • 活用 Shader,让你的页面更小,更炫,更快
  • 再见!onActivityResult!你好,Activity Results API!
  • 10 个你可能还不知道 VS Code 使用技巧
  • 细数 2020 年官方对 Android 的那些重大更新!
  • 64位系统究竟牛逼在哪里?
  • 如何区分IO密集型、CPU密集型任务?
  • 一起来玩玩WebGL--第一弹
  • 便利贴撕页效果,隔壁产品都馋哭了
  • JS中 map, filter, some, every, forEach, for in, for of 用法总结
  • 【JavaScript】通过闭包创建具有私有属性的实例对象
  • emacs初体验
  • exports和module.exports
  • GitUp, 你不可错过的秀外慧中的git工具
  • HTTP传输编码增加了传输量,只为解决这一个问题 | 实用 HTTP
  • iOS高仿微信项目、阴影圆角渐变色效果、卡片动画、波浪动画、路由框架等源码...
  • JavaScript对象详解
  • Linux链接文件
  • Mocha测试初探
  • MySQL-事务管理(基础)
  • nfs客户端进程变D,延伸linux的lock
  • python_bomb----数据类型总结
  • Python利用正则抓取网页内容保存到本地
  • SSH 免密登录
  • ubuntu 下nginx安装 并支持https协议
  • 从重复到重用
  • 关于 Cirru Editor 存储格式
  • 你不可错过的前端面试题(一)
  • 前端之Sass/Scss实战笔记
  • 强力优化Rancher k8s中国区的使用体验
  • 我的面试准备过程--容器(更新中)
  • #Spring-boot高级
  • $con= MySQL有关填空题_2015年计算机二级考试《MySQL》提高练习题(10)
  • (C#)if (this == null)?你在逗我,this 怎么可能为 null!用 IL 编译和反编译看穿一切
  • (第二周)效能测试
  • (非本人原创)史记·柴静列传(r4笔记第65天)
  • (附源码)springboot家庭财务分析系统 毕业设计641323
  • (论文阅读笔记)Network planning with deep reinforcement learning
  • (篇九)MySQL常用内置函数
  • (一)kafka实战——kafka源码编译启动
  • (转)Google的Objective-C编码规范
  • (转)Sublime Text3配置Lua运行环境
  • (最完美)小米手机6X的Usb调试模式在哪里打开的流程
  • .NET Core6.0 MVC+layui+SqlSugar 简单增删改查
  • .NET MVC、 WebAPI、 WebService【ws】、NVVM、WCF、Remoting
  • .net 调用php,php 调用.net com组件 --
  • .NET 设计模式—简单工厂(Simple Factory Pattern)
  • .NET/C# 阻止屏幕关闭,阻止系统进入睡眠状态
  • .net操作Excel出错解决