预备知识
译者按, 本文是一篇使用 HTML5 开发游戏的入门教程, 要完全看懂教程, 需要懂得一点简单的 HTML 和 JavaScript 技术. 如果你还对此一无所知, 请先学习相关的基础知识. 本教程面向的读者主要是对游戏开发的基本流程还一无所知但却怀有兴趣的人, 经验更丰富的爱好者可以尝试参考其它更高级的教程.
如何基于 HTML5 画布制作一款简单的游戏
听说, 你们想要找一篇用HTML5开发简单游戏的快速入门教程? 那我们这就来一行一行来实际地做一个小游戏出来吧!(本文原作者为 Matt Hackett, 已经开发过一款上架 Steam 的作品 A Wizard's Lizard, 因此无需怀疑他的资历问题).
点开右边的超链接可以浏览game.js的代码. 你也可以通过本句内的超链接试玩这款游戏.
1. 创建画布
// 创建画布 var canvas = document.createElement("canvas"); var ctx = canvas.getContext("2d"); canvas.width = 512; canvas.height = 480; document.body.appendChild(canvas);
首先我们需要创建一个画布元素. 我并没有直接在HTML中写入画布元素的标签, 而是选择用几行简短的JavaScript代码来创建它, 步骤非常容易. 创建好元素后, 我们将它的上下文保存到变量ctx中, 稍后可以用它来调用负责渲染的指令. 我们设定画布为二维画布(这个例子显然只是个简单的平面小游戏), 接着将它加入文档模型, 这样我们就能在页面上看到画布出现了.
2. 加载图片
// 背景图 var bgReady = false; var bgImage = new Image(); bgImage.onload = function () { bgReady = true; }; bgImage.src = "images/background.png";
游戏需要视觉呈现, 因此让我们来加载一些图片! 为了尽可能让这个例子保持简单, 这里我将素材只作为一张图片来载入, 并没有使用一个封装精美的类或者其他的结构. 布尔量bgReady可以告诉我们现在能否安全地绘制该图片, 如果尝试在加载完毕前绘制图片, 将会抛出一个DOM报错.
本例中, 我们共计需要为三种图片素材编写加载代码, 包括: 背景图, 英雄还有怪物.
3. 游戏对象
// 游戏对象 var hero = { speed: 256, // movement in pixels per second x: 0, y: 0 }; var monster = { x: 0, y: 0 }; var monstersCaught = 0;
现在, 我们来定义一些稍后会用到的变量. 英雄hero的属性speed用来设置代表其角色的图片在画布上每秒移动多少像素. 本游戏设定怪物无法移动, 因此其字面量定义中只包含代表位置的坐标属性. 最后, 我们定义变量monstersCaught来存储分数, 这个例子中, 它代表玩家抓住了多少怪物.
4. 玩家输入
// 键盘控制操作码(key control handle) // 用 KeyDown 缓存按键序列 var keysDown = {}; addEventListener("keydown", function (e) { keysDown[e.keyCode] = true; }, false); addEventListener("keyup", function (e) { delete keysDown[e.keyCode]; }, false);
接着我们来编写控制键盘输入的代码. (那些仅有网页背景的开发者可能会在这里遇见第一个小小的障碍.) 在进行网页开发时, 最恰当的处理是在用户一开始输入的时候就开始播放动画或者发送响应数据. 但在游戏中, 我们会要求游戏逻辑只作出唯一的响应, 以保持对事件发生的时机和时间能够做到紧凑的控制. 因此我们将用户的输入缓存起来, 而不是直接执行它们.
我们只需使用对象keysDown来存储输入事件对应的键盘代码就可以了. 当该对象中包含某个键盘代码时就说明玩家按下了对应的按键. 简单极了!
5. 新游戏
// 当玩家抓住怪物时重置游戏中的某些状态 var reset = function () { hero.x = canvas.width / 2; hero.y = canvas.height / 2; // 将怪物随机放在屏幕上的某个位置 monster.x = 32 + (Math.random() * (canvas.width - 64)); monster.y = 32 + (Math.random() * (canvas.height - 64)); };
调用重置函数可以开始一局新游戏或新关卡, 你也可以在任何需要的时候调用它. 它用来将游戏中的一些变量重置为默认值. 本游戏中, 它会把玩家控制的英雄放置在屏幕中央, 把怪物放置在屏幕的某个随机位置.
6. 更新对象状态
//更新对象状态 var update = function (modifier) { // 键盘 if (38 in keysDown) { // Player holding up hero.y -= hero.speed * modifier; } if (40 in keysDown) { // Player holding down hero.y += hero.speed * modifier; } if (37 in keysDown) { // Player holding left hero.x -= hero.speed * modifier; } if (39 in keysDown) { // Player holding right hero.x += hero.speed * modifier; } // 怪物和英雄是否相遇? // 根据怪物图片和英雄图片位置的距离来判断. if ( hero.x <= (monster.x + 32) && monster.x <= (hero.x + 32) && hero.y <= (monster.y + 32) && monster.y <= (hero.y + 32) ) { ++monstersCaught; reset(); } };
状态更新函数每隔一小段时间就被调用一次. 首先它会检查玩家是否按下了上下左右四个方向键. 如果有按下, 则将英雄朝对应方向移动一小段距离.
该函数的参数modifier乍看之下令人摸不到头绪, 但你稍后会看到主函数是如何调用这个函数的, 我这里就先解释一下. modifier是一个单位为1的表示更新函数调用时间间隔的数. 如果恰好有一秒过去, 该参数的值为1, 英雄移动速度乘以1倍, 这意味着在一秒内英雄将移动256像素. 如果更新函数调用间隔仅有半秒, 则该参数值为0.5, 英雄的移动速度乘以0.5倍, 这意味着在半秒内英雄将移动128像素. 诸如此类. 由于该函数调用十分频繁快速, 因此该修正参数的值总是非常的小, 但使用这种方法我们可以确保无论游戏脚本的执行速度多快多慢, 玩家所控制的英雄却总能保持相同的移动速度.
我们已经能够检查玩家的输入并据此来移动英雄的位置, 现在我们接着来处理我们控制的英雄能做些什么, 在本游戏中, 我们要处理的是代表英雄和玩家的图片相遇时(简单的碰撞检测实现), 会发生些什么. 这样就勉强是个可玩的游戏了. 这里我们将分数+1(即对monstersCaught变量+1), 然后重置游戏.
7. 渲染对象
// 绘制所有东西 var render = function () { if (bgReady) { ctx.drawImage(bgImage, 0, 0); } if (heroReady) { ctx.drawImage(heroImage, hero.x, hero.y); } if (monsterReady) { ctx.drawImage(monsterImage, monster.x, monster.y); } // Score ctx.fillStyle = "rgb(250, 250, 250)"; ctx.font = "24px Helvetica"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.fillText("Monsterrs caught: " + monstersCaught, 32, 32); };
视觉的交互能够为游戏带来更多的乐趣, 因此让我们把一切都"画"到屏幕上来! 首先我们将背景图绘制在屏幕上, 接着绘制英雄和怪物. 注意, 绘制的顺序非常重要, 后绘制的素材会遮挡之前画布上的内容.
接着, 我们通过画布的上下文变量的属性来设置字体和颜色, 之后我们调用fillText方法来显示玩家得分. 鉴于我们并没有加入更复杂的动画和移动方式, 因此绘制工作就此完成.
8. 游戏主循环
// 游戏主循环 var main = function () { var now = Date.now(); var delta = now - then; update(delta / 1000); render(); then = now; // 再次调用主循环 requestAnimationFrame(main); };
游戏的主循环用来控制整个游戏的执行流程. 首先我们获得当前的时间戳, 然后算出时间间隔量delta(继上次执行主循环后经过了多少时间). 我们将该间隔量除以1000(因为1s=1000ms)来得到参数modifier以调用状态更新函数. 然后我们调用绘制函数, 再次保存时间戳.
这篇教程里谈到了更多有关游戏主循环的知识:Onslaught! Arena Case Study.
9. 游戏主循环的注解
// 对 requestAnimationFrame 的跨浏览器支持 var w = window; requestAnimationFrame = w.requestAnimationFrame || w.webkitRequestAnimationFrame || w.msRequestAnimationFrame || w.mozRequestAnimationFrame;
无需担心, 这部分无法完全理解也无关紧要, 但我觉得稍微解释一点循环部分的代码对初学者会大有裨益.
为了持续地执行主循环函数, 本教程的老版本曾经使用setInterval方法. 近来又出来了更好的新办法: 即通过requestAnimationFrame方法. 然而, 正如多数的 web 技术一样, 我们需要考虑代码的跨浏览器兼容问题. 我这里使用的兼容代码主要基于Paul Irish的一篇文章.
10. 开始游戏
// 开始游戏! var then = Date.now(); reset(); main();
几近完成, 这就是最后的代码片段了! 我们需要先设定一次时间戳(保存到变量then中). 接着我们将游戏的状态置为默认值(你应该还记得这会将英雄放置在屏幕中央, 而怪物则出现在屏幕的随机位置), 然后开始游戏主循环.
恭喜你! (我希望)你现在应该已经理解了编写 JavaScript 通过画布元素进行游戏开发的基本知识了. 自己来尝试一下吧! 你可以试玩游戏, 或者在github上创建代码的分支来尝试改造一个属于自己的版本.
社区的改造版本
这篇教程只涵盖了基本的制作流程, 但作为一款完整的游戏这个例子还是有点单薄, 缺乏很多必要内容: 音效呢? 能否让英雄不跑到屏幕之外? 如果你已经感觉到这篇教程做出的游戏过于简单, 缺乏玩点, 那还不快点动手发挥自己的创意去改造它! 在原教程的回复区内一些读者已经分享了他们的创意.
- Codepen复刻版 基本和教程中做出的游戏没有区别. 代码存放在了网站codepen上,你可以很方便地在线编辑修改和运行代码.
- 小宝宝吃甜筒 游戏关于一只误入超级玛丽世界喜欢吃甜筒的小宝宝. 在本教程的基础上实现了边缘碰撞检测等2d游戏常用技术.
- 小智大战鲤鱼王 尽管温室效应逐年加剧, 人与自然的对峙日益加重, 小智还是毅然决然地投身珍稀保护动物抓捕之旅. 这一次他前往大海深处抓捕一只贪吃的鲤鱼王. 专门用于打击单身狗的那种本地双人小游戏. 用方向键控制小智或者用wasd控制鲤鱼王逃离小智的魔掌.
- 方块弹幕 增加了音效, 游戏菜单还有撞到障碍物就会结束游戏等许多新要素. 颇具可玩性. 试着来挑战一下能得到多少分数吧.
寻找进一步的帮助
有点迷茫? 我们的社区充满了助人为乐的游戏开发爱好者. 到论坛来发帖问问吧! GOTO 原文作者的论坛 | indienova论坛
作品推荐
本教程原文作者Matt Hackett所开发的A Wizard's Lizard是另一款模仿以撒的结合的roguelike游戏. 要素丰富, 画风可爱. 根据玩家的反馈信息来看, 存在操作手感偏差的缺憾. 有兴趣的读者尝试一下. 购买链接:
文章原址
文章原址作者背景: Matt Hackett, 独立游戏开发者, 主要作品A Wizard's Lizard。
Construct2,基于HTML5的游戏引擎
还是挺喜欢 canvas 的,简单明了,唯一不足的是不支持什么特效,我是指 sprite 变个颜色什么的,更不用说模糊发光之类的了。