做者:Fernando Doglio翻译:疯狂的技术宅javascript
原文:https://blog.bitsrc.io/writin...css
未经容许严禁html
游戏开发并不须要局限于使用 Unity 或 Unreal Engine4 的用户。 JavaScript 游戏开发已经有一段时间了。实际上,最流行的浏览器(例如Chrome,Firefox和Edge)的最新版本提供了对高级图形渲染(例如WebGL)的支持,从而带来了很是有趣的游戏开发机会。前端
不过用 WebGL 进行游戏开发没有办法在一篇文章中涵盖其全部内容(有专门为此编写的完整书籍),而且出于我的喜爱,在深刻研究特定技术以前,我更倾向于依赖框架的帮助。java
这就是为何通过研究后,我决定用 MelonJS 编写此快速教程的缘由。node
你可能已经猜到了,MelonJS 是一个 JavaScript 游戏引擎,与全部主流浏览器彻底兼容(从 Chrome 到 Opera,一直到移动版 Chrome 和 iOS Safari)。react
它具备一系列功能,在个人研究过程当中很是引人注目:jquery
该引擎还有其余使人赞叹的功能,你能够在其网站上进行查看,不过以上是本文中咱们最关注的功能。git
提示:使用 Bit(Github)能够轻松共享和重用 JS 模块,项目中的 UI 组件,建议更新。程序员
Bit 组件:可以轻松地在团队中跨项目共享
打字游戏的目的是经过打字(或敲击随机键)为玩家提供移动或执行某种动做的能力。
我记得小时候曾经学过如何打字(是的,好久之前)了,当时在“Mario Teaches Typing” 这个游戏中,必须键入单个字母才能前进,要么跳到乌龟上,要么从下面打一个方块。下图为你提供了游戏外观以及怎样与之进行互动的想法。
尽管这是一个有趣的小游戏,但它并非一个真正的平台游戏,Mario 所执行的动做始终对应一个按键,而且永远不会失效。
不过,对于本文,我想让事情变得更有趣,并非建立一个简单的打字游戏,例如上面的游戏:
游戏不会经过单个字母来决定下一步的行动,而是提供了五个选择,而且每一个选择都必须写一个完整的单词:
换句话说,你能够经过输入的单词来移动角色,而不是经典的基于箭头进行控制。
除此以外,该游戏将是一个经典平台游戏,玩家能够经过走动收集金币。为了简洁起见,咱们会将敌人和其余类型的实体排除在本教程以外(尽管你应该可以推断出所使用的代码,并能基于该代码建立本身的实体)。
为了使本文保持合理的长度,我将只关注一个阶段,全方位的动做(换句话说,你将可以执行全部 5 个动做)、几个敌人、一种收藏品,还有数量可观的台阶供你跳来跳去。
尽管 melonJS 是彻底独立的,但在此过程当中有一些工具能够帮助你们,我建议你使用它们:
使用这些工具,你将能够继续学习并完成本教程,因此让咱们开始编码吧。
为了开始这个项目,咱们可使用一些示例代码。下载引擎时,它将默认附带一组示例项目,你能够检出这些项目(它们位于 example 文件夹中)。
这些示例代码是咱们用来快速启动项目的代码。在其中,你会发现:
如今暂时将资源留在 data 文件夹中,咱们须要了解该示例为咱们提供了什么。
要执行游戏,你须要作一些事情:
dist
文件夹的内容。将其复制到任意文件夹中,并确保像其余 JS 文件同样,将其添加到 index.html
文件中。$ npm install -g http-server
安装完成后,从项目文件夹中运行:
$ http-server
这时你能够经过访问 http://localhost:8080
来测试游戏。
在游戏中你会发现这是一个可以进行基本(很是尴尬)动做的平台游戏,几个不一样的敌人和一个收藏品。基本上这与咱们的目标差很少,但控制方案略有不一样。
这里要检查的关键文件是:
其他文件也颇有用,但并非那么重要,咱们会在须要时使用它们。
若是你提早作好了了功课,可能已经注意到了,没有一行实例化玩家或敌人的代码。他们的坐标无处可寻。那么,游戏该如何理解呢?
这是关卡编辑器所起到的做用。若是你下载了Tiled,则能够在 data/map
文件夹中打开名为 map1.tmx
的文件,而后会看到相似下面的内容:
屏幕的中心部分向你显示正在设计的关卡。若是仔细观察,你会看到图像和矩形形状,其中一些具备不一样的颜色和名称。这些对象表明游戏中的 东西,具体取决于它们的名称和所属的层。
在屏幕的右侧,你会在其中看到图层列表(在右上方)。有不一样类型的层:
右下角包含此地图的图块。 tileet 也能够由 Tiled 建立,而且能够在同一文件夹中以 tsx 扩展名找到该 tileet。
最后,在屏幕左侧,你会看到“属性”部分,在这里你将看到有关所选对象或单击的图层的详细信息。你将可以更改通用属性(例如图层的颜色,以便更好地了解其对象的位置)并添加自定义属性(稍后将其做为参数传递给游戏中实体的构造函数)。
如今咱们已经准备好进行编码了,让咱们专一于本文的主要目的,咱们将以示例的工做版本为例,尝试对其进行修改,使其能够用做打字游戏。
这意味着,须要更改的第一件事是运动方案,或者换句话说:更改控制。
转到 entities/player.js
并检查 init
方法。你会注意到不少 bindKey
和 bindGamepad
调用。这些代码本质上是将特定按键与逻辑操做绑定在一块儿。简而言之,它能够确保不管你是按向右箭头键,D 键仍是向右移动模拟摇杆,都会在代码中触发相同的“向右”动做。
全部这些都须要将其删除,这对咱们没什么用。同时建立一个新文件,将其命名为 wordServices.js
,并在此文件中建立一个对象,该对象将在每一个回合中返回单词,这可以帮助咱们了解玩家到底选择了哪一个动做。
/** * Shuffles array in place. * @param {Array} a items An array containing the items. */ function shuffle(a) { var j, x, i; for (i = a.length - 1; i > 0; i--) { j = Math.floor(Math.random() * (i + 1)); x = a[i]; a[i] = a[j]; a[j] = x; } return a; } ActionWordsService = { init: function(totalActions) { //load words... this.words = [ "test", "hello", "auto", "bye", "mother", "son", "yellow", "perfect", "game" ] this.totalActions = totalActions this.currentWordSet = [] }, reshuffle: function() { this.words = shuffle(this.words) }, getRegionPostfix: function(word) { let ws = this.currentWordSet.find( ws => { return ws.word == word }) if(ws) return ws.regionPostfix return false }, getAction: function(word) { let match = this.getWords().find( am => { return am.word == word }) if(match) return match.action return false }, getWords: function() { let actions = [ { action: "right", coords: [1, 0], regionPostfix: "right"}, { action: "left", coords: [-1, 0], regionPostfix: "left"}, { action: "jump-ahead", coords: [1,-0.5], regionPostfix: "upper-right"}, { action: "jump-back", coords:[-1, -0.5], regionPostfix: "upper-left"}, { action: "up", coords: [0, -1], regionPostfix: "up"} ] this.currentWordSet = this.words.slice(0, this.totalActions).map( w => { let obj = actions.shift() obj.word = w return obj }) return this.currentWordSet } }
本质上,该服务包含一个单词列表,而后将其随机排列,而且每次请求该列表时(使用 getWords 方法),都会随机获取一组单词,并将它们分配给上面提到的一种操做。还有与每一个操做相关的其余属性:
如今,让咱们看看如何在游戏过程当中请求用户输入。
注意:继续前进以前,请记住,为了使新服务可用于其他代码,你必须将其包含在 index.html
文件中,就像其余 JS 库同样:
<script type="text/javascript" src="js/wordServices.js"></script>
你能够潜在地使用键绑定的组合来模仿使用游戏元素的输入字段的行为,可是请考虑输入字段默认提供的全部可能的组合和行为(例如,粘贴文本、选择、移动而不删除字符等) ),必须对全部程序进行编程以使其可用。
相反,咱们能够简单地在 HTML 主页面中添加一个文本字段,并使用 CSS 对其进行样式设置,使其位于 Canvas 元素之上,它将成为游戏的一部分。
你只须要在 <body>
内的这段代码便可:
<input type="text" id="current-word" />
尽管这彻底取决于你,但我仍是建议你使用 jQuery 来简化将回调附加到 keypress
事件上所需的代码。固然可使用原生 JS 完成此操做,但我更喜欢这个库提供的语法糖。
如下代码位于 game.js
文件的 load
方法中,负责捕获用户的输入:
me.$input = $("#current-word") let lastWord = '' me.$input.keydown( (evnt) => { if(evnt.which == 13) { console.log("Last word: ", lastWord) StateManager.set("lastWord", lastWord) lastWord = '' me.$input.val("") } else { if(evnt.which > 20) { let validChars = /[a-z0-9]+/gi if(!String.fromCharCode(evnt.which).match(validChars)) return false } setTimeout(_ => { lastWord = me.$input.val() //String.fromCharCode(evnt.which) console.log("Partial: ", lastWord) }, 1) } setTimeout(() => { StateManager.set("partialWord", me.$input.val()) }, 1); })
本质上是咱们捕获输入元素并将其存储在全局对象 me
中。这个全局变量包含游戏所需的一切。
这样,咱们能够为按下的任何按键设置事件处理程序。如你所见,我正在检查键码 13(表明ENTER键)以识别玩家什么时候完成输入,不然我将确保他们输入的是有效字符(我只是避免使用特殊字符,这样能够防止 melonJS 提供的默认字体出现问题)。
最后我在 StateManager
对象上设置了两个不一样的状态,lastWord 了解玩家输入的最后一个单词,partialWord 解如今正在输入的内容。这两种状态很重要。
如何在组件之间共享数据是不少框架中的常见问题。咱们将捕获的输入做为 game
组件的一部分,那么该如何与他人共享这个输入呢?
个人解决方案是建立一个充当事件发送器(event emitter)的全局组件:
const StateManager = { on: function(k, cb) { console.log("Adding observer for: ", k) if(!this.observers) { this.observers = {} } if(!this.observers[k]) { this.observers[k] = [] } this.observers[k].push(cb) }, clearObserver: function(k) { console.log("Removing observers for: ", k) this.observers[k] = [] }, trigger: function(k) { this.observers[k].forEach( cb => { cb(this.get(k)) }) }, set: function(k, v) { this[k] = v this.trigger(k) }, get: function(k) { return this[k] } }
代码很是简单,你能够为特定状态设置多个“观察者”(它们是回调函数),而且一旦设置了该状态(即更改),便会用新值调用全部这些回调。
建立关卡以前的最后一步是显示一些基本的 UI。由于咱们须要显示玩家能够移动的方向以及须要输入的单词。
为此将使用两个不一样的UI元素:
ActionWordsService
上的 regionPostfix
属性相关联)ActionWordsService
上的 coords
属性相关联。咱们能够在 js 文件夹内搭上现有的 HUD.js 文件。在其中添加两个新组件。
第一个是 ActionControl
组件,以下所示:
game.HUD.ActionControl = me.GUI_Object.extend({ init: function(x, y, settings) { game.HUD.actionControlCoords.x = x //me.game.viewport.width - (me.game.viewport.width / 2) game.HUD.actionControlCoords.y = me.game.viewport.height - (me.game.viewport.height / 2) + y settings.image = game.texture; this._super(me.GUI_Object, "init", [ game.HUD.actionControlCoords.x, game.HUD.actionControlCoords.y, settings ]) //update the selected word as we type StateManager.on('partialWord', w => { let postfix = ActionWordsService.getRegionPostfix(w) if(postfix) { this.setRegion(game.texture.getRegion("action-wheel-" + postfix)) } else { this.setRegion(game.texture.getRegion("action-wheel") } this.anchorPoint.set(0.5,1) }) //react to the final word StateManager.on('lastWord', w => { let act = ActionWordsService.getAction(w) if(!act) { me.audio.play("error", false); me.game.viewport.shake(100, 200, me.game.viewport.AXIS.X) me.game.viewport.fadeOut("#f00", 150, function(){}) } else { game.data.score += Constants.SCORES.CORRECT_WORD } }) } })
看起来不少,可是它只是作了一点事情:
settings
属性中提取其坐标,在 Tiled 上设置地图后,咱们将对其进行检查。postfix
属性用于当前编写的单词。第二个图形部分,即要输入的单词,以下所示:
game.HUD.ActionWords = me.Renderable.extend({ init: function(x, y) { this.relative = new me.Vector2d(x, y); this._super(me.Renderable, "init", [ me.game.viewport.width + x, me.game.viewport.height + y, 10, //x & y coordinates 10 ]); // Use screen coordinates this.floating = true; // make sure our object is always draw first this.z = Infinity; // create a font this.font = new me.BitmapText(0, 0, { font : "PressStart2P", size: 0.5, textAlign : "right", textBaseline : "bottom" }); // recalculate the object position if the canvas is resize me.event.subscribe(me.event.CANVAS_ONRESIZE, (function(w, h){ this.pos.set(w, h, 0).add(this.relative); }).bind(this)); this.actionMapping = ActionWordsService.getWords() }, update: function() { this.actionMapping = ActionWordsService.getWords() return true }, draw: function(renderer) { this.actionMapping.forEach( am => { if(am.coords[0] == 0 && am.coords[1] == 1) return let x = game.HUD.actionControlCoords.x + (am.coords[0]*80) + 30 let y = game.HUD.actionControlCoords.y + (am.coords[1]*80) - 30 this.font.draw(renderer, am.word, x, y) }) } })
该组件的繁重工做是经过 draw
方法完成的。 init
方法只是初始化变量。在调用 draw
的过程当中,咱们将迭代选定的单词,并使用与之相关的坐标以及一组固定数字,将单词定位在 ActionControl
组件的坐标周围。
这是建议的动做控制设计的样子(以及坐标如何与之关联):
固然,它应该有透明的背景。
只需确保将这些图像保存在 /data/img/assets/UI
文件夹中,这样当你打开 TexturePacker 时,它将识别出新图像并将其添加到纹理中地图集。
上图显示了如何添加 action wheel 的新图像。而后,你能够单击“Publish sprite sheet”并接受全部默认选项。它将覆盖现有的地图集,所以对于你的代码无需执行任何操做。这一步骤相当重要,由于纹理地图集将做为资源加载(一分钟内会详细介绍),而且多个实体会将其用于动画之类的东西。请记住,在游戏上添加或更新图形时,都务必这样作。
好了,如今咱们已经介绍了基础知识,让咱们一块儿玩游戏。首先要注意的是:地图。
经过使用 tiled 和 melonJS 中包含的默认 tileet,我建立了这个地图( 25x16 tiles 地图,其中 tile 为 32 x 32px):
这些是我正在使用的图层:
collision
开头的全部层都假定为碰撞层,这意味着其中的任何形状都是不可遍历的。在这里你将定义地板和平台的全部形状。准备好以后,咱们能够转到 game.js
文件,并在 loaded
方法内添加如下几行:
// register our objects entity in the object pool me.pool.register("mainPlayer", game.PlayerEntity); me.pool.register("CoinEntity", game.CoinEntity); me.pool.register("HUD.ActionControl", game.HUD.ActionControl);
这些代码用来注册你的实体(你要使用 Tiled 直接放置在地图上的实体)。第一个参数提供的名称是你须要用 Tiled 进行匹配的名称。
此外,在此文件中,onLoad
方法应以下所示:
onload: function() { // init the video if (!me.video.init(965, 512, {wrapper : "screen", scale : "auto", scaleMethod : "fit", renderer : me.video.AUTO, subPixel : false })) { alert("Your browser does not support HTML5 canvas."); return; } // initialize the "sound engine" me.audio.init("mp3,ogg"); // set all ressources to be loaded me.loader.preload(game.resources, this.loaded.bind(this)); ActionWordsService.init(5) },
咱们的基本要求是 965x512
的分辨率(我发现,当屏幕的高度与地图的高度相同时效果很好。在咱们的例子中为 16*32 = 512
)以后,将使用5个单词(这些是你能够继续前进的5个方向)初始化 ActionWordsService
。
onLoad
方法中另外一条有趣的代码是:
me.loader.preload(game.resources, this.loaded.bind(this));
游戏须要的全部类型的资源(即图像、声音、背景音乐、JSON 配置文件等)都须要添加到 resources.js
文件中。
这是你资源文件的内容:
game.resources = [ { name: "tileset", type:"image", src: "data/img/tileset.png" }, { name: "background", type:"image", src: "data/img/background.png" }, { name: "clouds", type:"image", src: "data/img/clouds.png" }, { name: "screen01", type: "tmx", src: "data/map/screen01.tmx" }, { name: "tileset", type: "tsx", src: "data/map/tileset.json" }, { name: "action-wheel", type:"image", src: "data/img/assets/UI/action-wheel.png" }, { name: "action-wheel-right", type:"image", src: "data/img/assets/UI/action-wheel-right.png" }, { name: "action-wheel-upper-right",type:"image", src: "data/img/assets/UI/action-wheel-upper-right.png" }, { name: "action-wheel-up", type:"image", src: "data/img/assets/UI/action-wheel-up.png" }, { name: "action-wheel-upper-left", type:"image", src: "data/img/assets/UI/action-wheel-upper-left.png" }, { name: "action-wheel-left", type:"image", src: "data/img/assets/UI/action-wheel-left.png" }, { name: "dst-gameforest", type: "audio", src: "data/bgm/" }, { name: "cling", type: "audio", src: "data/sfx/" }, { name: "die", type: "audio", src: "data/sfx/" }, { name: "enemykill", type: "audio", src: "data/sfx/" }, { name: "jump", type: "audio", src: "data/sfx/" }, { name: "texture", type: "json", src: "data/img/texture.json" }, { name: "texture", type: "image", src: "data/img/texture.png" }, { name: "PressStart2P", type:"image", src: "data/fnt/PressStart2P.png" }, { name: "PressStart2P", type:"binary", src: "data/fnt/PressStart2P.fnt"} ];
其中你可使用诸如图块集、屏幕映射之类的东西(请注意,名称始终是不带扩展名的文件名,这是强制性的要求,不然将找不到资源)。
游戏中的硬币很是简单,可是当你与它们碰撞时,须要发生一些事情,它们的代码以下所示:
game.CoinEntity = me.CollectableEntity.extend({ /** * constructor */ init: function (x, y, settings) { // call the super constructor this._super(me.CollectableEntity, "init", [ x, y , Object.assign({ image: game.texture, region : "coin.png" }, settings) ]); }, /** * collision handling */ onCollision : function (/*response*/) { // do something when collide me.audio.play("cling", false); // give some score game.data.score += Constants.SCORES.COIN //avoid further collision and delete it this.body.setCollisionMask(me.collision.types.NO_OBJECT); me.game.world.removeChild(this); return false; } });
请注意,硬币实体其实是扩展了 CollectibleEntity
(这给它提供了一个特殊的冲撞类型给实体,所以melonJS知道在玩家移过它时会调用碰撞处理程序),你要作的就是调用其父级的构造函数,而后当你拾起它时,在 onCollision
方法上会播放声音,在全局得分中加 1,最后从世界中删除对象。
将全部内容放在一块儿,就有了一个能够正常工做的游戏,该游戏可让你根据输入的单词在 5 个不一样的方向上移动。
它看起来应该像这样:
而且因为本教程已经太长了,你能够在 Github 上查看该游戏的完整代码。