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

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

这就是为什么经过研究后,我决定用 MelonJS 编写此快速教程的原因。

img

什么是 MelonJS?

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

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

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

该引擎还有其他令人赞叹的功能,你可以在其网站上进行查看,不过以上是本文中我们最关注的功能。

提示:使用 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 上查看该游戏的完整代码。