其他分享
首页 > 其他分享> > 用 MelonJS 开发一个游戏[每日前端夜话0xD9]

用 MelonJS 开发一个游戏[每日前端夜话0xD9]

作者:互联网

用 MelonJS 开发一个游戏[每日前端夜话0xD9]

疯狂的技术宅 前端先锋

日前端夜话0xD9
每日前端夜话,陪你聊前端。
每天晚上18:00准时推送。
正文共:5935 字
预计阅读时间:15 分钟
作者:Fernando Doglio
翻译:疯狂的技术宅
来源:bitsrc

用 MelonJS 开发一个游戏[每日前端夜话0xD9]

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

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

这就是为什么经过研究后,我决定用 MelonJS【http://www.melonjs.org/】 编写此快速教程的原因。

什么是 MelonJS?

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

它具有一系列功能,在我的研究过程中非常引人注目:

提示:使用 Bit【https://github.com/teambit/bit】(Github【https://github.com/teambit/bit】)可以轻松共享和重用 JS 模块,项目中的 UI 组件,建议更新。
用 MelonJS 开发一个游戏[每日前端夜话0xD9]

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

设计我们的游戏

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

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

用 MelonJS 开发一个游戏[每日前端夜话0xD9]
尽管这是一个有趣的小游戏,但它并不是一个真正的平台游戏,Mario 所执行的动作始终对应一个按键,并且永远不会失效。

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

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

  1. 前进
  2. 向前跳
  3. 跳起来
  4. 向后跳
  5. 向后移动
    换句话说,你可以通过输入的单词来移动角色,而不是经典的基于箭头进行控制。

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

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

你需要的工具

尽管 melonJS 是完全独立的,但在此过程中有一些工具可以帮助大家,我建议你使用它们:

基本的平台游戏

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

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

了解现有代码


现在暂时将资源留在 data 文件夹中,我们需要了解该示例为我们提供了什么。

执行游戏

要执行游戏,你需要做一些事情:

  1. 一份 melonJS。如果已下载,请确保获得 dist 文件夹的内容。将其复制到任意文件夹中,并确保像其他 JS 文件一样,将其添加到 index.html 文件中。
  2. 安装(如果尚未安装)npm 中提供的 http-server【https://www.npmjs.com/package/http-server】 模块,该模块可以快速为相关文件夹提供 HTTP 服务。如果尚未安装,只需执行以下操作:

1$ npm install -g http-server

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


1$ http-server

这时你可以通过访问 http://localhost:8080 来测试游戏。

查看代码

在游戏中你会发现这是一个能够进行基本(非常尴尬)动作的平台游戏,几个不同的敌人和一个收藏品。基本上这与我们的目标差不多,但控制方案略有不同。

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

了解一切从何而来


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

这是关卡编辑器所起到的作用。如果你下载了Tiled,则可以在 data/map 文件夹中打开名为 map1.tmx 的文件,然后会看到类似下面的内容:
用 MelonJS 开发一个游戏[每日前端夜话0xD9]

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

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

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

更改运动方案

现在我们已经准备好进行编码了,让我们专注于本文的主要目的,我们将以示例的工作版本为例,尝试对其进行修改,使其可以用作打字游戏。

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

转到 entities/player.js 并检查 init 方法。你会注意到很多 bindKey 和 bindGamepad 调用。这些代码本质上是将特定按键与逻辑操作绑定在一起。简而言之,它可以确保无论你是按向右箭头键,D 键还是向右移动模拟摇杆,都会在代码中触发相同的“向右”动作。

所有这些都需要将其删除,这对我们没什么用。同时创建一个新文件,将其命名为 wordServices.js,并在此文件中创建一个对象,该对象将在每个回合中返回单词,这能够帮助我们了解玩家到底选择了哪个动作。


 1/**
 2 * Shuffles array in place.
 3 * @param {Array} a items An array containing the items.
 4 */
 5function shuffle(a) {
 6    var j, x, i;
 7    for (i = a.length - 1; i > 0; i--) {
 8        j = Math.floor(Math.random() * (i + 1));
 9        x = a[i];
10        a[i] = a[j];
11        a[j] = x;
12    }
13    return a;
14}
15
16
17ActionWordsService = {
18
19    init: function(totalActions) {
20        //load words...
21        this.words = [
22            "test", "hello", "auto", "bye", "mother", "son", "yellow", "perfect", "game"
23        ]
24        this.totalActions = totalActions
25        this.currentWordSet = []
26    },
27
28    reshuffle: function() {
29        this.words = shuffle(this.words)
30    },
31
32    getRegionPostfix: function(word) {
33        let ws = this.currentWordSet.find( ws => {
34            return ws.word == word
35        })
36        if(ws) return ws.regionPostfix
37        return false
38    },
39
40    getAction: function(word) {
41        let match = this.getWords().find( am => {
42            return am.word == word
43        })
44        if(match) return match.action
45        return false
46    },
47
48    getWords: function() {
49        let actions = [ { action: "right", coords: [1, 0], regionPostfix: "right"}, 
50                        { action: "left", coords: [-1, 0], regionPostfix: "left"}, 
51                        { action: "jump-ahead", coords: [1,-0.5], regionPostfix: "upper-right"}, 
52                        { action: "jump-back", coords:[-1, -0.5], regionPostfix: "upper-left"},
53                        { action: "up", coords: [0, -1], regionPostfix: "up"}
54                    ]
55
56       this.currentWordSet = this.words.slice(0, this.totalActions).map( w => {
57            let obj = actions.shift()
58            obj.word = w
59            return obj
60       })
61       return this.currentWordSet
62    }
63}

本质上,该服务包含一个单词列表,然后将其随机排列,并且每次请求该列表时(使用 getWords 方法),都会随机获取一组单词,并将它们分配给上面提到的一种操作。还有与每个操作相关的其他属性:

注意:继续前进之前,请记住,为了使新服务可用于其余代码,你必须将其包含在 index.html 文件中,就像其他 JS 库一样:


1<script type="text/javascript" src="js/wordServices.js"></script>

如何捕获用户输入


你可以潜在地使用键绑定的组合来模仿使用游戏元素的输入字段的行为,但是请考虑输入字段默认提供的所有可能的组合和行为(例如,粘贴文本、选择、移动而不删除字符等) ),必须对所有程序进行编程以使其可用。

相反,我们可以简单地在 HTML 主页面中添加一个文本字段,并使用 CSS 对其进行样式设置,使其位于 Canvas 元素之上,它将成为游戏的一部分。

你只需要在 <body> 内的这段代码即可:


1<input type="text" id="current-word" />

尽管这完全取决于你,但我还是建议你使用 jQuery 来简化将回调附加到 keypress 事件上所需的代码。当然可以使用原生 JS 完成此操作,但我更喜欢这个库提供的语法糖。

以下代码位于 game.js 文件的 load 方法中,负责捕获用户的输入:


 1me.$input = $("#current-word")
 2
 3let lastWord = ''
 4me.$input.keydown( (evnt) => {
 5
 6    if(evnt.which == 13) {
 7        console.log("Last word: ", lastWord)
 8        StateManager.set("lastWord", lastWord)
 9        lastWord = ''
10        me.$input.val("")
11    } else {
12        if(evnt.which > 20) {
13            let validChars = /[a-z0-9]+/gi
14            if(!String.fromCharCode(evnt.which).match(validChars)) return false
15          }
16
17        setTimeout(_ => {
18            lastWord = me.$input.val() //String.fromCharCode(evnt.which)
19            console.log("Partial: ", lastWord)
20        }, 1)
21    }
22    setTimeout(() => {
23        StateManager.set("partialWord", me.$input.val())
24    }, 1);
25})

本质上是我们捕获输入元素并将其存储在全局对象 me 中。这个全局变量包含游戏所需的一切。

这样,我们可以为按下的任何按键设置事件处理程序。如你所见,我正在检查键码 13(代表ENTER键)以识别玩家何时完成输入,否则我将确保他们输入的是有效字符(我只是避免使用特殊字符,这样可以防止 melonJS 提供的默认字体出现问题)。

最后我在 StateManager 对象上设置了两个不同的状态,lastWord 了解玩家输入的最后一个单词,partialWord 解现在正在输入的内容。这两种状态很重要。

组件之间共享数据

如何在组件之间共享数据是很多框架中的常见问题。我们将捕获的输入作为 game 组件的一部分,那么该如何与他人共享这个输入呢?

我的解决方案是创建一个充当事件发送器(event emitter)【https://nodejs.org/api/events.html】的全局组件


 1const StateManager = {
 2
 3    on: function(k, cb) {
 4        console.log("Adding observer for: ", k)
 5        if(!this.observers) {
 6            this.observers = {}
 7        }
 8
 9        if(!this.observers[k]) {
10            this.observers[k] = []
11        }
12        this.observers[k].push(cb)
13    },
14    clearObserver: function(k) {
15        console.log("Removing observers for: ", k)
16        this.observers[k] = []
17    },
18    trigger: function(k) {
19        this.observers[k].forEach( cb => {
20            cb(this.get(k))
21        })
22    },
23    set: function(k, v) {
24        this[k] = v
25        this.trigger(k)
26    },
27    get: function(k) {
28        return this[k]
29    }
30
31}

代码非常简单,你可以为特定状态设置多个“观察者”(它们是回调函数),并且一旦设置了该状态(即更改),便会用新值调用所有这些回调。

添加 UI


创建关卡之前的最后一步是显示一些基本的 UI。因为我们需要显示玩家可以移动的方向以及需要输入的单词。

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

第一个是 ActionControl 组件,如下所示:


 1game.HUD.ActionControl = me.GUI_Object.extend({
 2    init: function(x, y, settings) {
 3        game.HUD.actionControlCoords.x = x //me.game.viewport.width - (me.game.viewport.width / 2)
 4        game.HUD.actionControlCoords.y = me.game.viewport.height - (me.game.viewport.height / 2) + y
 5
 6        settings.image = game.texture;
 7
 8        this._super(me.GUI_Object, "init", [
 9            game.HUD.actionControlCoords.x, 
10            game.HUD.actionControlCoords.y, 
11            settings
12        ])
13
14        //update the selected word as we type
15        StateManager.on('partialWord', w => {
16            let postfix = ActionWordsService.getRegionPostfix(w)
17            if(postfix) {
18                this.setRegion(game.texture.getRegion("action-wheel-" + postfix))
19            } else {
20                this.setRegion(game.texture.getRegion("action-wheel")
21            }
22            this.anchorPoint.set(0.5,1)
23        })
24
25        //react to the final word
26        StateManager.on('lastWord', w => {
27            let act = ActionWordsService.getAction(w)
28            if(!act) {
29
30                me.audio.play("error", false);
31                me.game.viewport.shake(100, 200, me.game.viewport.AXIS.X)
32                me.game.viewport.fadeOut("#f00", 150, function(){})
33           } else {
34               game.data.score += Constants.SCORES.CORRECT_WORD
35           }
36        })
37    }
38})

看起来很多,但是它只是做了一点事情:

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

 1game.HUD.ActionWords = me.Renderable.extend({
 2    init: function(x, y) {
 3        this.relative = new me.Vector2d(x, y);
 4
 5        this._super(me.Renderable, "init", [
 6            me.game.viewport.width + x,
 7            me.game.viewport.height + y,
 8            10, //x & y coordinates
 9            10
10        ]);
11
12         // Use screen coordinates
13        this.floating = true;
14
15        // make sure our object is always draw first
16        this.z = Infinity;
17        // create a font
18        this.font = new me.BitmapText(0, 0, {
19            font : "PressStart2P",
20            size: 0.5,
21            textAlign : "right",
22            textBaseline : "bottom"
23        });
24
25        // recalculate the object position if the canvas is resize
26        me.event.subscribe(me.event.CANVAS_ONRESIZE, (function(w, h){
27            this.pos.set(w, h, 0).add(this.relative);
28        }).bind(this));
29
30        this.actionMapping = ActionWordsService.getWords()
31    },
32
33    update: function() {
34        this.actionMapping = ActionWordsService.getWords()
35        return true
36    },
37    draw: function(renderer) {
38        this.actionMapping.forEach( am => {
39            if(am.coords[0] == 0 && am.coords[1] == 1) return 
40            let x = game.HUD.actionControlCoords.x + (am.coords[0]*80) + 30
41            let y = game.HUD.actionControlCoords.y + (am.coords[1]*80) - 30
42            this.font.draw(renderer, am.word, x, y)
43        })
44    }
45})

该组件的繁重工作是通过 draw 方法完成的。init 方法只是初始化变量。在调用 draw 的过程中,我们将迭代选定的单词,并使用与之相关的坐标以及一组固定数字,将单词定位在 ActionControl 组件的坐标周围。

这是建议的动作控制设计的样子(以及坐标如何与之关联):
用 MelonJS 开发一个游戏[每日前端夜话0xD9]

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

只需确保将这些图像保存在 /data/img/assets/UI 文件夹中,这样当你打开 TexturePacker 时,它将识别出新图像并将其添加到纹理中地图集。
用 MelonJS 开发一个游戏[每日前端夜话0xD9]

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

将所有内容与Tiled放在一起

好了,现在我们已经介绍了基础知识,让我们一起玩游戏。首先要注意的是:地图。

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

用 MelonJS 开发一个游戏[每日前端夜话0xD9]
这些是我正在使用的图层:


1// register our objects entity in the object pool
2me.pool.register("mainPlayer", game.PlayerEntity);
3me.pool.register("CoinEntity", game.CoinEntity);
4me.pool.register("HUD.ActionControl", game.HUD.ActionControl);

这些代码用来注册你的实体(你要使用 Tiled 直接放置在地图上的实体)。第一个参数提供的名称是你需要用 Tiled 进行匹配的名称。

此外,在此文件中,onLoad 方法应如下所示:


 1  onl oad: function() {
 2
 3        // init the video
 4        if (!me.video.init(965, 512, {wrapper : "screen", scale : "auto", scaleMethod : "fit", renderer : me.video.AUTO, subPixel : false })) {
 5            alert("Your browser does not support HTML5 canvas.");
 6            return;
 7        }
 8
 9        // initialize the "sound engine"
10        me.audio.init("mp3,ogg");
11
12        // set all ressources to be loaded
13        me.loader.preload(game.resources, this.loaded.bind(this));
14        ActionWordsService.init(5)
15    },

我们的基本要求是 965x512 的分辨率(我发现,当屏幕的高度与地图的高度相同时效果很好。在我们的例子中为 16*32 = 512)之后,将使用5个单词(这些是你可以继续前进的5个方向)初始化 ActionWordsService 。

onLoad 方法中另一条有趣的代码是:


1me.loader.preload(game.resources, this.loaded.bind(this));

资源文件


游戏需要的所有类型的资源(即图像、声音、背景音乐、JSON 配置文件等)都需要添加到 resources.js 文件中。

这是你资源文件的内容:


 1game.resources = [
 2
 3    { name: "tileset",     type:"image", src: "data/img/tileset.png" },
 4    { name: "background",  type:"image", src: "data/img/background.png" },
 5    { name: "clouds",      type:"image", src: "data/img/clouds.png" },
 6
 7
 8    { name: "screen01", type: "tmx", src: "data/map/screen01.tmx" },
 9
10    { name: "tileset",  type: "tsx", src: "data/map/tileset.json" },
11
12    { name: "action-wheel", type:"image", src: "data/img/assets/UI/action-wheel.png" },
13    { name: "action-wheel-right", type:"image", src: "data/img/assets/UI/action-wheel-right.png" },
14    { name: "action-wheel-upper-right",type:"image", src: "data/img/assets/UI/action-wheel-upper-right.png" },
15    { name: "action-wheel-up", type:"image", src: "data/img/assets/UI/action-wheel-up.png" },
16    { name: "action-wheel-upper-left", type:"image", src: "data/img/assets/UI/action-wheel-upper-left.png" },
17    { name: "action-wheel-left", type:"image", src: "data/img/assets/UI/action-wheel-left.png" },
18
19    { name: "dst-gameforest", type: "audio", src: "data/bgm/" },
20
21    { name: "cling",     type: "audio", src: "data/sfx/" },
22    { name: "die",       type: "audio", src: "data/sfx/" },
23    { name: "enemykill", type: "audio", src: "data/sfx/" },
24    { name: "jump",      type: "audio", src: "data/sfx/" },
25
26    { name: "texture",   type: "json",  src: "data/img/texture.json" },
27    { name: "texture",   type: "image", src: "data/img/texture.png" },
28
29    { name: "PressStart2P", type:"image", src: "data/fnt/PressStart2P.png" },
30    { name: "PressStart2P", type:"binary", src: "data/fnt/PressStart2P.fnt"}
31];

其中你可以使用诸如图块集、屏幕映射之类的东西(请注意,名称始终是不带扩展名的文件名,这是强制性的要求,否则将找不到资源)。

硬币


游戏中的硬币非常简单,但是当你与它们碰撞时,需要发生一些事情,它们的代码如下所示:


 1game.CoinEntity = me.CollectableEntity.extend({
 2
 3    /**
 4     * constructor
 5     */
 6    init: function (x, y, settings) {
 7        // call the super constructor
 8        this._super(me.CollectableEntity, "init", [
 9            x, y ,
10            Object.assign({
11                image: game.texture,
12                region : "coin.png"
13            }, settings)
14        ]);
15
16    },
17
18    /**
19     * collision handling
20     */
21    onCollision : function (/*response*/) {
22
23        // do something when collide
24        me.audio.play("cling", false);
25        // give some score
26        game.data.score += Constants.SCORES.COIN
27
28        //avoid further collision and delete it
29        this.body.setCollisionMask(me.collision.types.NO_OBJECT);
30
31        me.game.world.removeChild(this);
32
33        return false;
34    }
35});

请注意,硬币实体实际上是扩展了 CollectibleEntity (这给它提供了一个特殊的冲撞类型给实体,因此melonJS知道在玩家移过它时会调用碰撞处理程序),你要做的就是调用其父级的构造函数,然后当你拾起它时,在 onCollision 方法上会播放声音,在全局得分中加 1,最后从世界中删除对象。

成品

将所有内容放在一起,就有了一个可以正常工作的游戏,该游戏可以让你根据输入的单词在 5 个不同的方向上移动。

它看起来应该像这样:
用 MelonJS 开发一个游戏[每日前端夜话0xD9]

并且由于本教程已经太长了,你可以在 Github【https://github.com/deleteman/plaformer-sample-1】 上查看该游戏的完整代码。

原文:https://blog.bitsrc.io/writing-a-typing-game-with-melonjs-ef0dd42f37bf

标签:me,MelonJS,游戏,0xD9,夜话,game,type,action,data
来源: https://blog.51cto.com/15077562/2612810