源码物语 2019-07-01
文章首发于我的个人博客
当 Chrome 处于离线情况下,会显示以下页面:
当按下空格键
或者 ↑ 键
,小恐龙游戏彩蛋就触发啦 (๑•̀ㅂ•́)و✧
游戏虽然简单,但源码却有三千多行,代码严谨且富有逻辑,值得拿来学习研究。这个教程将会从零开始,一步步解读源码并最终实现这个游戏。
要获取游戏的源码,可以通过下面几种方式:
chrome://dino
,进入小恐龙页面,用开发者工具获取源码游戏用到的雪碧图,音频文件可以在官方提供的源码网址里获取到。
为了方便食用,我将雪碧图中各个小图片的坐标信息标了出来(W: Width, H: Height, L: Left, T: Top
):
关于上面雪碧图的坐标信息,我是用一个在线工具获取的:http://www.spritecow.com/,个别坐标信息通过这个网站获取的不太准,这里我已经通过参考源码里的数据进行了修正。
戳这里获取上面这张图片的 JPG 原图和 PSD 原图。
游戏源码主要包括九个类:
背景类 Horizon
这个教程并不会完全按照源码来,而是抽取主要的内容来一步步实现这个游戏。这样做并不意味着改变源码的思路,而是去除了一些目前可以先不考虑的代码,比如:去除了适配 HDPI 和 LDPI、适配移动端等。
这个游戏源码的探究已经有前辈 @逐影 写了系列教程。在这里,我写这个教程的目的,一是当做学习笔记,二是提供与前辈不一样的源码解读思路。
游戏文件结构目录:
chrome-dino - index.html - index.css - index.js // JS 入口文件 - offline.js // 游戏逻辑实现 - imgs - sounds
想要获取整个教程的源代码,戳这里:GitHub
HTML、CSS 就不过多解释,直接贴代码:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" /> <title>Chrome Dino</title> <link rel="stylesheet" href="./index.css" /> <script src="./offline.js"></script> </head> <body> <!-- 游戏的 “根” DOM节点,用来容纳游戏的主体部分 --> <div id="chrome-dino"></div> <!-- 游戏用到的雪碧图,音频资源 --> <div id="offline-resources"> <img id="offline-resources-1x" src="./imgs/100-offline-sprite.png" alt="sprite" /> </div> <script src="./index.js"></script> </body> </html>
* { margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; } #chrome-dino { width: 100%; max-width: 600px; margin: 0 auto; } #offline-resources { display: none; } .offline .runner-container { position: absolute; top: 35px; width: 100%; max-width: 600px; height: 150px; overflow: hidden; } .offline .runner-canvas { z-index: 10; position: absolute; top: 0; height: 150px; max-width: 600px; overflow: hidden; opacity: 1; }
下面来分析 JS 代码:
首先看一下游戏的主体类 Runner
,这个类用于控制游戏的主要逻辑:
/** * 游戏主体类,控制游戏的整体逻辑 * @param {String} containerSelector 画布外层容器的选择器 * @param {Object} opt_config 配置选项 */ function Runner(containerSelector, opt_config) { // 获取游戏的 “根” DOM 节点,整个游戏都会输出到这个节点里 this.outerContainerEl = document.querySelector(containerSelector); // canvas 的外层容器 this.containerEl = null; this.config = opt_config || Runner.config; this.dimensions = Runner.defaultDimensions; this.time = 0; // 时钟计时器 this.currentSpeed = this.config.SPEED; // 当前的速度 this.activated = false; // 游戏彩蛋是否被激活(没有被激活时,游戏不会显示出来) this.playing = false; // 游戏是否进行中 this.crashed = false; // 小恐龙是否碰到了障碍物 this.paused = false // 游戏是否暂停 // 加载雪碧图,并初始化游戏 this.loadImages(); } window['Runner'] = Runner; // 将 Runner 类挂载到 window 对象上
相关的数据和配置参数:
var DEFAULT_WIDTH = 600; // 游戏画布默认宽度 var FPS = 60; // 游戏默认帧率 // 游戏配置参数 Runner.config = { SPEED: 6, // 移动速度 }; // 游戏画布的默认尺寸 Runner.defaultDimensions = { WIDTH: DEFAULT_WIDTH, HEIGHT: 150, }; // 游戏用到的 className Runner.classes = { CONTAINER: 'runner-container', CANVAS: 'runner-canvas', PLAYER: '', // 预留出的 className,用来控制 canvas 的样式 }; // 雪碧图中图片的坐标信息 Runner.spriteDefinition = { LDPI: { HORIZON: { x: 2, y: 54 }, // 地面 }, }; // 游戏中用到的键盘码 Runner.keyCodes = { JUMP: { '38': 1, '32': 1 }, // Up, Space DUCK: { '40': 1 }, // Down RESTART: { '13': 1 }, // Enter }; // 游戏中用到的事件 Runner.events = { LOAD: 'load', };
在 Runner
原型链上添加的方法:
Runner.prototype = { // 初始化游戏 init: function () { // 生成 canvas 容器元素 this.containerEl = document.createElement('div'); this.containerEl.className = Runner.classes.CONTAINER; // 生成 canvas this.canvas = createCanvas(this.containerEl, this.dimensions.WIDTH, this.dimensions.HEIGHT, Runner.classes.PLAYER); this.ctx = this.canvas.getContext('2d'); this.ctx.fillStyle = '#f7f7f7'; this.ctx.fill(); // 加载背景类 Horizon this.horizon = new Horizon(this.canvas, this.spriteDef); // 将游戏添加到页面中 this.outerContainerEl.appendChild(this.containerEl); }, // 加载雪碧图资源 loadImages() { // 图片在雪碧图中的坐标 this.spriteDef = Runner.spriteDefinition.LDPI; // 获取雪碧图 Runner.imageSprite = document.getElementById('offline-resources-1x'); // 当图片加载完成(complete 是 DOM 中 Image 对象自带的一个属性) if (Runner.imageSprite.complete) { this.init(); } else { // 图片没有加载完成,监听其 load 事件 Runner.imageSprite.addEventListener(Runner.events.LOAD, this.init.bind(this)); } }, };
其中 createCanvas
方法定义如下:
/** * 生成 canvas 元素 * @param {HTMLElement} container canva 的容器 * @param {Number} width canvas 的宽度 * @param {Number} height canvas 的高度 * @param {String} opt_className 给 canvas 添加的类名(可选) * @return {HTMLCanvasElement} */ function createCanvas(container, width, height, opt_className) { var canvas = document.createElement('canvas'); canvas.className = opt_className ? opt_className + ' ' + Runner.classes.CANVAS : Runner.classes.CANVAS; canvas.width = width; canvas.height = height; container.appendChild(canvas); return canvas; }
定义好 Runner
类之后,为了方便探究,接下来从简单的背景开始说起。首先是绘制静态的地面。
定义地面类 HorizonLine
:
/** * 地面类 * @param {HTMLCanvasElement} canvas 画布 * @param {Object} spritePos 雪碧图中的位置 */ function HorizonLine(canvas, spritePos) { this.canvas = canvas; this.ctx = this.canvas.getContext('2d'); this.dimensions = {}; // 地面的尺寸 this.spritePos = spritePos; // 雪碧图中地面的位置 this.sourceXPos = []; // 雪碧图中地面的两种地形的 x 坐标 this.xPos = []; // canvas 中地面的 x 坐标 this.yPos = 0; // canvas 中地面的 y 坐标 this.bumpThreshold = 0.5; // 随机地形系数,控制两种地形的出现频率 this.init(); this.draw(); } HorizonLine.dimensions = { WIDTH: 600, HEIGHT: 12, YPOS: 127, // 绘制到 canvas 中的 y 坐标 };
在 HorizonLine
原型链上添加方法:
HorizonLine.prototype = { // 初始化地面 init: function () { for (const d in HorizonLine.dimensions) { if (HorizonLine.dimensions.hasOwnProperty(d)) { const elem = HorizonLine.dimensions[d]; this.dimensions[d] = elem; } } this.sourceXPos = [this.spritePos.x, this.spritePos.x + this.dimensions.WIDTH]; this.xPos = [0, HorizonLine.dimensions.WIDTH]; this.yPos = HorizonLine.dimensions.YPOS; }, // 绘制地面 draw: function () { // 使用 canvas 中 9 个参数的 drawImage 方法 this.ctx.drawImage( Runner.imageSprite, // 原图片 this.sourceXPos[0], this.spritePos.y, // 原图中裁剪区域的起点坐标 this.dimensions.WIDTH, this.dimensions.HEIGHT, this.xPos[0], this.yPos, // canvas 中绘制区域的起点坐标 this.dimensions.WIDTH, this.dimensions.HEIGHT, ); this.ctx.drawImage( Runner.imageSprite, this.sourceXPos[1], this.spritePos.y, this.dimensions.WIDTH, this.dimensions.HEIGHT, this.xPos[1], this.yPos, this.dimensions.WIDTH, this.dimensions.HEIGHT, ); }, };
背景类 Horizon
负责管理 HorizonLine
、Cloud
、Obstacle
、NightMode
这几个类。
所以接下来需要通过 Horizon
类来调用 HorizonLine
类。
定义背景类 Horizon
:
/** * 背景类 * @param {HTMLCanvasElement} canvas 画布 * @param {Object} spritePos 雪碧图中的位置 */ function Horizon(canvas, spritePos) { this.canvas = canvas; this.ctx = this.canvas.getContext('2d'); this.spritePos = spritePos; // 地面 this.horizonLine = null; this.init(); }
在 Horizon
原型链上添加方法:
Horizon.prototype = { // 初始化背景 init: function () { this.horizonLine = new HorizonLine(this.canvas, this.spritePos.HORIZON); }, };
最后,通过调用 Runner
类来运行游戏:
index.js:
window.onload = function () { var chromeDino = document.getElementById('chrome-dino'); chromeDino.classList.add('offline'); new Runner('#chrome-dino'); };
到这里,不出意外的话,就可以绘制出静态的地面,如图:
查看完整的代码:戳这里
这里各个方法和类之间的调用逻辑是(箭头代指调用):
new Runner() -> loadImage() // Runner -> init() // Runner -> new Horizon() -> init() // Horizon -> new HorizonLine() -> init() // HorizonLine -> draw() // HorizonLine
简单来说就是:游戏主体类 Runner
控制背景类 Horizon
,再由背景类 Horizon
控制地面类 HorizonLine
。
遵循的思想就是把游戏层层抽象,由抽象程度高的类一层一层向下调用抽象程度低的类。这样做的好处是,思路清晰并且易于扩展。