用 MelonJS 开发一个游戏

做者:Fernando Dogliojavascript

翻译:疯狂的技术宅css

原文:blog.bitsrc.io/writing-a-t…html

未经容许严禁前端

img

游戏开发并不须要局限于使用 Unity 或 Unreal Engine4 的用户。 JavaScript 游戏开发已经有一段时间了。实际上,最流行的浏览器(例如Chrome,Firefox和Edge)的最新版本提供了对高级图形渲染(例如WebGL)的支持,从而带来了很是有趣的游戏开发机会。java

不过用 WebGL 进行游戏开发没有办法在一篇文章中涵盖其全部内容(有专门为此编写的完整书籍),而且出于我的喜爱,在深刻研究特定技术以前,我更倾向于依赖框架的帮助。node

这就是为何通过研究后,我决定用 MelonJS 编写此快速教程的缘由。react

什么是 MelonJS?

你可能已经猜到了,MelonJS 是一个 JavaScript 游戏引擎,与全部主流浏览器彻底兼容(从 Chrome 到 Opera,一直到移动版 Chrome 和 iOS Safari)。jquery

它具备一系列功能,在个人研究过程当中很是引人注目:git

  • 对于初学者来讲,它是彻底独立的,不须要外部依赖就可使它工做。
  • 可是,它能够与多个第三方工具集成在一块儿,使你的工做更加轻松,例如Tiled(可帮助你建立地图和游戏关卡),TexturePacker(帮助你建立所需的纹理图集并简化和优化精灵管理)。
  • 集成了 2D 物理引擎。这意味着你可使用开箱即用的逼真的 2D 运动和碰撞检测。这很关键,由于必须解决全部这些问题,这须要大量的工做(更不用说数学运算了,这并非个人菜)。
  • 支持声音 API,使你可以以出色的简便性添加声音效果和背景音乐。

该引擎还有其余使人赞叹的功能,你能够在其网站上进行查看,不过以上是本文中咱们最关注的功能。github

**提示:**使用 BitGithub)能够轻松共享和重用 JS 模块,项目中的 UI 组件,建议更新。

img

Bit 组件:可以轻松地在团队中跨项目共享

设计咱们的游戏

打字游戏的目的是经过打字(或敲击随机键)为玩家提供移动或执行某种动做的能力。

我记得小时候曾经学过如何打字(是的,好久之前)了,当时在“Mario Teaches Typing” 这个游戏中,必须键入单个字母才能前进,要么跳到乌龟上,要么从下面打一个方块。下图为你提供了游戏外观以及怎样与之进行互动的想法。

img

尽管这是一个有趣的小游戏,但它并非一个真正的平台游戏,Mario 所执行的动做始终对应一个按键,而且永远不会失效。

不过,对于本文,我想让事情变得更有趣,并非建立一个简单的打字游戏,例如上面的游戏:

游戏不会经过单个字母来决定下一步的行动,而是提供了五个选择,而且每一个选择都必须写一个完整的单词:

  1. 前进
  2. 向前跳
  3. 跳起来
  4. 向后跳
  5. 向后移动

换句话说,你能够经过输入的单词来移动角色,而不是经典的基于箭头进行控制。

除此以外,该游戏将是一个经典平台游戏,玩家能够经过走动收集金币。为了简洁起见,咱们会将敌人和其余类型的实体排除在本教程以外(尽管你应该可以推断出所使用的代码,并能基于该代码建立本身的实体)。

为了使本文保持合理的长度,我将只关注一个阶段,全方位的动做(换句话说,你将可以执行全部 5 个动做)、几个敌人、一种收藏品,还有数量可观的台阶供你跳来跳去。

你须要的工具

尽管 melonJS 是彻底独立的,但在此过程当中有一些工具能够帮助你们,我建议你使用它们:

  • Texture Packer:有了这个,你将可以自动生成纹理图集,这是另外一种表达 JSON 文件的方式,其中打包了全部图像,以便引擎之后能够检索并根据须要使用它们。 若是你没有这个工具,那么手动维护地图集可能会消耗太多的时间。
  • Tiled:这将是咱们的关卡编辑器。尽管你能够免费下载它(你须要找到显示“No thanks, just take me to the downloads” 的连接),可是你能够向该神奇工具的做者捐献最低 1 美圆。若是你有可用的 PayPal 账户或借记卡,建议你这样作,这样的软件须要维护,而且须要付出时间和精力。

使用这些工具,你将能够继续学习并完成本教程,因此让咱们开始编码吧。

基本的平台游戏

为了开始这个项目,咱们可使用一些示例代码。下载引擎时,它将默认附带一组示例项目,你能够检出这些项目(它们位于 example 文件夹中)。

这些示例代码是咱们用来快速启动项目的代码。在其中,你会发现:

  • data 文件夹,包含与代码无关的全部内容。在这里你能够找到声音、音乐、图像、地图定义甚至字体。

  • js文件夹,你将在这里保存全部与游戏相关的代码。

  • index.html 和 index.css文件。这些是你的应用与外界互动所需的联系点。

了解现有代码

如今暂时将资源留在 data 文件夹中,咱们须要了解该示例为咱们提供了什么。

执行游戏

要执行游戏,你须要作一些事情:

  1. 一份 melonJS。若是已下载,请确保得到 dist 文件夹的内容。将其复制到任意文件夹中,并确保像其余 JS 文件同样,将其添加到 index.html 文件中。
  2. 安装(若是还没有安装)npm 中提供的 http-server 模块,该模块能够快速为相关文件夹提供 HTTP 服务。若是还没有安装,只需执行如下操做:
$ npm install -g http-server
复制代码

安装完成后,从项目文件夹中运行:

$ http-server
复制代码

这时你能够经过访问 http://localhost:8080 来测试游戏。

查看代码

在游戏中你会发现这是一个可以进行基本(很是尴尬)动做的平台游戏,几个不一样的敌人和一个收藏品。基本上这与咱们的目标差很少,但控制方案略有不一样。

这里要检查的关键文件是:

  • game.js:该文件包含全部初始化代码,有趣的是如何实例化游戏图形和主控件。
  • screens/play.js:包含设置关卡所需的全部代码。你会注意到它内容并很少。因为级别定义是使用其余工具(即 Tiled)完成的,因此此代码只是启用了该功能。
  • entities/player.js:显然这是你的主要目标。该文件包含你角色的移动代码,碰撞反应和控制键绑定。虽然规模并不大,倒是你想花费最多时间的地方。
  • entities/enemies.js:仅次于 player 代码,这很重要,由于你将看到如何基于预约义的坐标来设置自动行为。

其他文件也颇有用,但并非那么重要,咱们会在须要时使用它们。

了解一切从何而来

若是你提早作好了了功课,可能已经注意到了,没有一行实例化玩家或敌人的代码。他们的坐标无处可寻。那么,游戏该如何理解呢?

这是关卡编辑器所起到的做用。若是你下载了Tiled,则能够在 data/map 文件夹中打开名为 map1.tmx 的文件,而后会看到相似下面的内容:

img

屏幕的中心部分向你显示正在设计的关卡。若是仔细观察,你会看到图像和矩形形状,其中一些具备不一样的颜色和名称。这些对象表明游戏中的 东西,具体取决于它们的名称和所属的层。

在屏幕的右侧,你会在其中看到图层列表(在右上方)。有不一样类型的层:

  • 图像层:用于背景或前景图像
  • 对象层:用于碰撞对象、实体以及你想在地图中实例化的任何对象。
  • Tile 层:你将在其中放置 Tile 以建立实际关卡的位置。

右下角包含此地图的图块。 tileet 也能够由 Tiled 建立,而且能够在同一文件夹中以 tsx 扩展名找到该 tileet。

最后,在屏幕左侧,你会看到“属性”部分,在这里你将看到有关所选对象或单击的图层的详细信息。你将可以更改通用属性(例如图层的颜色,以便更好地了解其对象的位置)并添加自定义属性(稍后将其做为参数传递给游戏中实体的构造函数)。

更改运动方案

如今咱们已经准备好进行编码了,让咱们专一于本文的主要目的,咱们将以示例的工做版本为例,尝试对其进行修改,使其能够用做打字游戏。

这意味着,须要更改的第一件事是运动方案,或者换句话说:更改控制。

转到 entities/player.js 并检查 init 方法。你会注意到不少 bindKeybindGamepad 调用。这些代码本质上是将特定按键与逻辑操做绑定在一块儿。简而言之,它能够确保不管你是按向右箭头键,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 方法),都会随机获取一组单词,并将它们分配给上面提到的一种操做。还有与每一个操做相关的其余属性:

  • 基于动做 HUD,coords 属性用于将文本放置在正确的坐标中(稍后会详细介绍)
  • regionPostfix 属性用于为 HUD 操做选择正确的框架。

如今,让咱们看看如何在游戏过程当中请求用户输入。

注意:继续前进以前,请记住,为了使新服务可用于其他代码,你必须将其包含在 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。由于咱们须要显示玩家能够移动的方向以及须要输入的单词。

为此将使用两个不一样的UI元素:

  • 一个用于图形,它将具备几个不一样的帧,本质上一个用于正常图像,而后一个将每一个方向显示为“selected”(与 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
           }
        })
    }
})
复制代码

看起来不少,可是它只是作了一点事情:

  1. 它从 settings 属性中提取其坐标,在 Tiled 上设置地图后,咱们将对其进行检查。
  2. 添加对输入了一部分的单词做出反应的代码。咱们将 postfix 属性用于当前编写的单词。
  3. 并添加了对完整的词作出反应的代码。若是某个动做与该字词相关联(便是正确的词),那么它将为玩家加分。不然将晃动屏幕并播放错误声音。

第二个图形部分,即要输入的单词,以下所示:

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 组件的坐标周围。

这是建议的动做控制设计的样子(以及坐标如何与之关联):

img

固然,它应该有透明的背景。

只需确保将这些图像保存在 /data/img/assets/UI 文件夹中,这样当你打开 TexturePacker 时,它将识别出新图像并将其添加到纹理中地图集。

img

上图显示了如何添加 action wheel 的新图像。而后,你能够单击“Publish sprite sheet”并接受全部默认选项。它将覆盖现有的地图集,所以对于你的代码无需执行任何操做。这一步骤相当重要,由于纹理地图集将做为资源加载(一分钟内会详细介绍),而且多个实体会将其用于动画之类的东西。请记住,在游戏上添加或更新图形时,都务必这样作。

将全部内容与Tiled放在一块儿

好了,如今咱们已经介绍了基础知识,让咱们一块儿玩游戏。首先要注意的是:地图。

经过使用 tiled 和 melonJS 中包含的默认 tileet,我建立了这个地图( 25x16 tiles 地图,其中 tile 为 32 x 32px):

img

这些是我正在使用的图层:

  • HUD:它仅包含一个名为 HUD.ActionControl 的元素(重要的是要保持名称相同,一下子你会明白为何)。下图显示了此元素的属性(请注意自定义属性)

img

  • collision:默认状况下,melonJS 会把以 collision 开头的全部层都假定为碰撞层,这意味着其中的任何形状都是不可遍历的。在这里你将定义地板和平台的全部形状。
  • player:该层仅包含 mainPlayer 元素(一种形状,该形状将使 melonJS 知道在游戏开始时须要放置玩家的位置)。
  • entities:在这一层中,我再次添加了硬币,它们的名称很重要,请保持一致,由于它们须要与你在代码中注册的名称相匹配。
  • 最后三层就能够在其中添加地图和背景的图像。

准备好以后,咱们能够转到 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 个不一样的方向上移动。

它看起来应该像这样:

img

而且因为本教程已经太长了,你能够在 Github 上查看该游戏的完整代码。

欢迎关注前端公众号:前端先锋,领取前端工程化实用工具包。

相关文章
相关标签/搜索