Fork me on GitHub

横版游戏教程

在本教程中,我们将构建一个简单的横版游戏。本教程主要介绍如何使用Tiled作为关卡编辑器来创建游戏的基本元素。

介绍

在阅读本教程前您需要做如下准备:

  • 安装并运行Tiled Map Editor(0.9.0或更新版本)
  • melonJS模板,教程将使用它作为默认模板。
  • 教程数据文件,需解压后放入木板数据目录(或更高层级)中。数据文件包括:
    • 一个关卡tileset
    • 用于视察层的两张背景
    • 一些基础精灵表单
    • 一些音频效果与音乐
    • 一张主页面背景
  • melonJSlibrary,请复制进/lib下(请确保同时下载精简版与一般版,后者可能在调试阶段被调用)
  • 查看melonJS文档以了解更多

测试/调试:
如果您想使用文件系统的话,您会遇到“跨域请求”安全错误。在Chrome中,你需要在启动浏览器时使用"--disable-web-security”参数,或更有效的"--allow-file-access-from-files”参数。在测试任何本地内容时必须执行这一步,否则在尝试通过XHR加载资源时浏览器将会报错。但是我们并不建议使用这种方法,因为虽然你可以使用这一功能,但是却对环境的安全性造成了破坏。

第二个办法,也是更简单的办法是使用本地web服务器。在melonJS模板的README文档中详细介绍了如何使用grunt server工具,让您能够从浏览器中访问http://localhost:8000这一url测试您的游戏。

附加说明:

请按需修改。同时我们假定您已经熟悉如何使用Tiled;如果您需要更多有关这一工具的帮助,您可以查看Tiled官方页面以及wiki。

第1部分:使用Tiled创建关卡

首先打开Tiled并创建一个新地图:本教程中我们将使用640x480作为渲染分辨率,因为tile是32x32的,所以我们必须针对地图尺寸分别声明至少20与15个。在我们的实例中我将定义一个40x15的关卡,这样我们就可以随后演示滚动背景了。

Step 1 of creating a new map

同时由于melonJS仅支持未被压缩的tilemap,所以请确保您的设置是正确的。我们建议使用Base64编码,因为其生成的文件体积较小。您可自行决定。

接下来使用Map/New Tileset添加tileset。请确保在Tiled中将tileset的间距与偏移设置为0.

Adding a tileset

出于美观考虑,我们将创建两层图层-一层为背景图层,一层为前景图层。您也可自行设计。我将它们按照实际意义命名为background”与"foreground”,不过您可以随意命名。

下面就是当我完成这些步骤时关卡的样子:

Tiled level design

最后,我们可以定义关卡的背景颜色。可打开颜色选择工具(Map/Map Properties)并选择您喜爱的颜色。

Setting a background color in Tiled

完成后这一新地图将被命名为“area01”,并保存至"/data/map/”文件夹中(用于构建resources.js文件的Grunt任务只会在这一特定路径中检索地图),第一步就完成了!

第2部分:加载我们的关卡

首先,在解压教程资源至模板目录结构后,您应该能找到如下资源:

data/
  bgm/
    dst-inertexponent.mp3
    dst-inertexponent.ogg
  img/
    font/
     32x32_font.png
    gui/
     title_screen.png
    map/
     area01_level_tiles.png
     license.txt
    sprite/
     gripe_run_right.png
     spinning_coin_gold.png
     wheelie_right.png
    area01_bkg0.png
    area01_bkg1.png
   map/
   sfx/
    cling.mp3
    cling.ogg
    jump.mp3
    jump.ogg
    stomp.mp3
    stomp.ogg
js/
  game.js
  resources.js
  entities/
    HUD.js
    entities.JS
  screens/
    play.js
    title.js
index.html
index.css

模板同样提供一些默认代码,但首先让我们来看一下我们的js/game.js框架:

/* game namespace */
var game = {
  /**
   * an object where to store game global data
   */
  data : {
    score : 0
  },

  // Run on page load.
  onload : function () {
    // Initialize the video.
    if (!me.video.init(640, 480, {wrapper : "screen", scale : "auto", scaleMethod : "flex-width"})) {
      alert("Your browser does not support HTML5 canvas.");
      return;
    }

    // add "#debug" to the URL to enable the debug Panel
    if (me.game.HASH.debug === true) {
      window.onReady(function () {
        me.plugin.register.defer(this, me.debug.Panel, "debug", me.input.KEY.V);
      });
    }

    // Initialize the audio.
    me.audio.init("mp3,ogg");

    // Set a callback to run when loading is complete.
    me.loader.onload = this.loaded.bind(this);

    // Load the resources.
    me.loader.preload(game.resources);

    // Initialize melonJS and display a loading screen.
    me.state.change(me.state.LOADING);
  },

  // Run on game resources loaded.
  loaded : function () {
    me.state.set(me.state.MENU, new game.TitleScreen());
    me.state.set(me.state.PLAY, new game.PlayScreen());

    // add our player entity in the entity pool
    me.pool.register("mainPlayer", game.PlayerEntity);

    // Start the game.
    me.state.change(me.state.PLAY);
  }
};

非常简单。在页面加载完成后将调用onload()函数初始化显示与音频,同时会开始加载所有游戏资源。我们也定义了回调,以便在函数所有工序完成后进行调用。在回调过程中,我们定义了可用于游戏内物品的新状态,同时还提供可用于管理游戏事件(重置等)的PlayScreen对象。

在默认项目模板中,我们唯一需要做的是给`me.video.init()`函数赋视频分辨率,教程中使用640x480。同时我们会将scaleMethod改变为"flex-width",这样能够更好地适配横版游戏(可查看`me.video.init`以了解更多可用的缩放模式)。

模板会自动构建资源列表,并生成game.resources (build/js/resources.js) 供app在使用 grunt服务器任务时调用。

警告:如果您并没有使用模板的话,那您需要手动生成resources.js(这一过程十分耗时,同时也十分容易出错)。如果需要手动生成resources.js,您可参考git repo上的一个例子。

同时需要注意的是,虽然我们在这里直接使用了tmx文件,但在生产环境中我们建议使用json格式(可从Tiled直接导出),以获得更小的文件体积。同时也能够实现更快的关卡加载并防止任何有关.tmx扩展名的服务器问题。

最后让我们打开js/screens/play.js这一文件,并在onResetEvent()函数(这一函数会在状态改变时调用),通过添加对loadLevel函数的调用以及默认关卡名要求level director展示之前预加载的关卡:

game.PlayScreen = me.ScreenObject.extend({
  /**
   * action to perform on state change
   */
  onResetEvent : function () {
    // load a level
    me.levelDirector.loadLevel("area01");

    // reset the score
    game.data.score = 0;

    // add our HUD to the game world
    this.HUD = new game.HUD.Container();
    me.game.world.addChild(this.HUD);
  },

  /**
   * action to perform when leaving this screen (state change)
   */
  onDestroyEvent : function () {
    // remove the HUD from the game world
    me.game.world.removeChild(this.HUD);
  }
});

这就完成了!如果您正确地完成了上述步骤,就可以打开index.html (记住如果您并未使用web服务器的话,您需要允许浏览器访问本地文件。如有需要请参考本教程开头的“测试/调试”部分)。

尝试一下

(可在浏览器中点击图片观察其运行情况),您应该能够观察到这样的情况

Step 2 results

是的,没什么特别的,但这才刚开始!

同时如果你没有注意到的话,由于我们定义了应用展示分辨率为640x480,我们只能看到部分地图(确切的说应该是一半),这是正常现象。melonJS将自动创建视口。在下一步中我们将能够在地图中行走,同时添加“主要玩家”

第3部分:添加一个主要玩家

现在我们将通过继承默认me.Entity来创建新对象,并创建玩家。我们将使用自带的简单精灵表单(gripe_run_right.png)来绘制角色,并定义基础的移动与站立动画。当然您也可以为相同的实体定义更复杂的动画(跳跃、匍匐、受伤,等等),但是现在还是简单一些。

Gripe run right

接下来就是创建实体的时间了,打开`js/entities/entities.js`示例文件,按照以下步骤进行:

/**
 * a player entity
 */
game.PlayerEntity = me.Entity.extend({
  /**
   * constructor
   */
  init : function (x, y, settings) {
    // call the constructor
    this._super(me.Entity, 'init', [x, y, settings]);

    // set the default horizontal & vertical speed (accel vector)
    this.body.setVelocity(3, 15);

    // set the display to follow our position on both axis
    me.game.viewport.follow(this.pos, me.game.viewport.AXIS.BOTH);

    // ensure the player is updated even when outside of the viewport
    this.alwaysUpdate = true;

    // define a basic walking animation (using all frames)
    this.renderable.addAnimation("walk",  [0, 1, 2, 3, 4, 5, 6, 7]);

    // define a standing animation (using the first frame)
    this.renderable.addAnimation("stand",  [0]);

    // set the standing animation as default
    this.renderable.setCurrentAnimation("stand");
  },

  /*
   * update the player pos
   */
  update : function (dt) {
    if (me.input.isKeyPressed('left')) {
      // flip the sprite on horizontal axis
      this.renderable.flipX(true);

      // update the entity velocity
      this.body.vel.x -= this.body.accel.x * me.timer.tick;

      // change to the walking animation
      if (!this.renderable.isCurrentAnimation("walk")) {
        this.renderable.setCurrentAnimation("walk");
      }
    }
    else if (me.input.isKeyPressed('right')) {
      // unflip the sprite
      this.renderable.flipX(false);

      // update the entity velocity
      this.body.vel.x += this.body.accel.x * me.timer.tick;

      // change to the walking animation
      if (!this.renderable.isCurrentAnimation("walk")) {
        this.renderable.setCurrentAnimation("walk");
      }
    }
    else {
      this.body.vel.x = 0;

      // change to the standing animation
      this.renderable.setCurrentAnimation("stand");
    }

    if (me.input.isKeyPressed('jump')) {
      // make sure we are not already jumping or falling
      if (!this.body.jumping && !this.body.falling) {
        // set current vel to the maximum defined value
        // gravity will then do the rest
        this.body.vel.y = -this.body.maxVel.y * me.timer.tick;

        // set the jumping flag
        this.body.jumping = true;
      }
    }

    // apply physics to the body (this moves the entity)
    this.body.update(dt);

    // handle collisions against other shapes
    me.collision.check(this);

    // return true if we moved or if the renderable was updated
    return (this._super(me.Entity, 'update', [dt]) || this.body.vel.x !== 0 || this.body.vel.y !== 0);
  },

  /**
   * colision handler
   * (called when colliding with other objects)
   */
  onCollision : function (response, other) {
    // Make all other objects solid
    return true;
  }
});

我认为上述代码是比较简单的。基本上来说,我们通过继承Entity,设定玩家默认速速,调整摄像头,测试一些按键并控制玩家的动作(通过设定玩家速度,并调用实体Body update函数)。同时,您可能会注意到我测试的是对象的最终速度(this.body.vel.x与this.body.vel.y),这可以告知我角色是否真的移动了,并且控制是否显示跑步动画。

虽然模板中声明了默认的game.PlayerEntity,我们仍然必须要修改"main”来真正意义上地声明对象池(用于引擎初始化对象时)中的新实体,并最终完成用于控制角色行动的键位映射。所以我们的loaded()函数会变成:

/**
 * callback when everything is loaded
 */
loaded : function () {
  // set the "Play/Ingame" Screen Object
  me.state.set(me.state.PLAY, new game.PlayScreen());

  // register our player entity in the object pool
  me.pool.register("mainPlayer", game.PlayerEntity);

  // enable the keyboard
  me.input.bindKey(me.input.KEY.LEFT,  "left");
  me.input.bindKey(me.input.KEY.RIGHT, "right");
  me.input.bindKey(me.input.KEY.X,     "jump", true);

  // start the game
  me.state.change(me.state.PLAY);
}

现在我们就能将实体添加至关卡中了!回到Tiled中添加新的Object Layer,新实体就添加完成了。如需添加新实体,可使用"Insert Rectangle”向Object Layer添加矩形,随后即可右键单击这一对象并添加属性。

将其命名为(大小写无所谓)mainPlayer(或使用在对象池中注册对象时相同的名称),并给对象添加两个属性:

  • image : 包含gripe_run_right值(资源名称)
  • framewidth : 值为 64,精灵表单中单张精灵图的尺寸
  • frameheight : 由于我们使用的是单线精灵表单,并且例子中引擎会将图片实际高度作为该值,顾我们无需特别定义。

这两个数据将创建对象时以参数(settings object 此处由构造器使用) 的形式传递。现在您可以通过Tiled或直接使用代码(同时处理多个对象时使用Tiled定义名称较为简单,其余的可造构造器中直接管理)明确这些字段。

备注:您可以添加任何数量的属性,这些属性在由对象传递给构造器的设定中是可用的。

Adding an entity

在关卡中完成实体的创建于定位之后,记住参考如下样例确保在Tiled依照实际精灵图尺寸调整对象矩形。

positioning an entity

定义碰撞图层

我们快要完成了!最后一步就是定义碰撞图层。完成这一步我们只需简单地创建一个新的名为“collision”的对象图层,并在其中添加一些基本形状。这样就OK了!

所以现在添加一个新的Object Group Layer。这一图层的名称中必须包含关键词"collision”,以便于引擎将其识别为为碰撞对向图层。

在完成图层的添加后,选择它并通过使用object toolbar添加任意形状来“绘制”关卡的碰撞地图

object tool bar

值得一提的是melonJS集成了使用分离轴定理的碰撞检测算法。应用于碰撞计算的所有多边形必须是凸多边形,其中的所有顶点的方向必须为顺时针。凸多边形的定义是其上任意两个顶点的连线均位于其内部且不穿过多边形的任意一边(相当于所有内角均小于180度),如下图所示:

多边形的顺时针“方向”是指其顶点全部朝向其右方定义(再次备注:上图中展示的是逆时针方向。)

如果您需要描述的环境较为复杂的话,建议使用单独的线段。您也可将线段使用在定义平台或墙壁元素上,这些地点您只需对象的一侧参与碰撞即可

尝试一下

保存一下,现在如果您重新打开index.html,您将看到如下界面:(点击图片以查看在浏览器中运行的效果)

Step 3 Results

您可能同时会注意到,现在的显示会跟随角色滚动环境。

最后一件事-在创建对象时会自动创建一个默认的碰撞形状来管理对象之间的碰撞。这一形状基于您在Tiled定义的对象大小。如需进行调试,您可通过在浏览器URL地址栏中添加#debug来打开debug面板。

如果您重新加载游戏并打开了“hitbox”,您会看到下图所示界面:

Enabling the debug panel

您可通过Tiled改变对象尺寸并匹配上述示例来调整碰撞框格。(碰撞形状亦可通过调整实体body的shapes属性进行手动调整)。

备注:在使用调试面板时,精灵图边框将用绿色显示,已定义的碰撞形状将用红色显示,如果你使用的碰撞形状不只或不是矩形的话,您还应该看到橘色的包含所有已定义碰撞形状的最小矩形方框(也成实体body边界框)。

第4部分:添加滚动背景

这一部分十分简单。所有步骤都通过Tiled进行,我们甚至不需要添加哪怕一行代码。

首先,移除我们在第1部分中添加的背景色。(您需要对TMX文件进行文本标记并移除`backgroundcolor`属性)。由于背景会被滚动图层进行填充,所以我们不需要将其展示为特定的颜色(这样可以一定程度上提高帧率)。

然后我们会使用下列两个背景:

/data/img/area01_bkg0.png作为第一背景图层

Parallax background 1

/data/img/area01_bkg1.png作为第二背景图层

Parallax background 2

打开Tiled并添加两个新的 Image Layers,随意命名并确保图层的顺序正确(由下至上进行显示)

Layering parallax layers

现在右键单击图层并定义它们的属性,并设置下列属性:

  • 单击browse按钮并选择area01_bkg0图片作为第一图层(图中的Parallax_layer1)
  • 第二图层的操作同上(Parallax_layer2)
Configuring Image Layer properties

最后添加ratio 属性来明确每一图层的滚动速度:我们将第一图层设置为0.25(图中的Parallax_layer1),并将第二图层设置为0.35(记住ratio的数字越小,滚动的速度就越慢)。

注意Image Layer的默认动作是自动在x轴与y轴方向进行重复,这也是我们制作视差效果正好需要的动作。

尝试一下

"Et voila!"。现在打开您的index.html,您看到的将是:

Step 4 results

操作角色四处走动看看风景吧 :)

第5部分:添加一些基础物体与敌人

在这一部分中我们将添加一种可被收集的金币(可用于累计分数),使用spinning_coin_gold.png这一精灵表单:

Spinning gold coin

以及简单的敌人,使用wheelie_right.png这一精灵表单:

Wheelie right sprite

金币本身十分简单;我们直接继承me.CollectableEntity即可。事实上,我们可以直接在Tiled中使用它(并不需要创建CoinEntity),但因为我们后续会用这些金币来记分,同时还会添加一些收集金币的音效,所以我们采取现在的方式。

/**
 * a Coin entity
 */
game.CoinEntity = me.CollectableEntity.extend({
  // extending the init function is not mandatory
  // unless you need to add some extra initialization
  init: function (x, y, settings) {
    // call the parent constructor
    this._super(me.CollectableEntity, 'init', [x, y , settings]);

  },

  // this function is called by the engine, when
  // an object is touched by something (here collected)
  onCollision : function (response, other) {
    // do something when collected

    // make sure it cannot be collected "again"
    this.body.setCollisionMask(me.collision.types.NO_OBJECT);

    // remove it
    me.game.world.removeChild(this);

    return false
  }
});

另外,为了向您证明这两种方法均是可行的,我们将直接在Tiled中定义金币对象属性,这样我们就不需要再在构造器中添加其它东西了:

Spinning gold coin

敌人的话要稍微复杂一点:

/**
 * an enemy Entity
 */
game.EnemyEntity = me.Entity.extend({
  init: function (x, y, settings) {
    // define this here instead of tiled
    settings.image = "wheelie_right";

    // save the area size defined in Tiled
    var width = settings.width;
    var height = settings.height;

    // adjust the size setting information to match the sprite size
    // so that the entity object is created with the right size
    settings.framewidth = settings.width = 64;
    settings.frameheight = settings.height = 64;

    // redefine the default shape (used to define path) with a shape matching the renderable
    settings.shapes[0] = new me.Rect(0, 0, settings.framewidth, settings.frameheight);

    // call the parent constructor
    this._super(me.Entity, 'init', [x, y , settings]);

    // set start/end position based on the initial area size
    x = this.pos.x;
    this.startX = x;
    this.endX   = x + width - settings.framewidth
    this.pos.x  = x + width - settings.framewidth;

    // to remember which side we were walking
    this.walkLeft = false;

    // walking & jumping speed
    this.body.setVelocity(4, 6);

  },

  /**
   * update the enemy pos
   */
  update : function (dt) {

    if (this.alive) {
      if (this.walkLeft && this.pos.x <= this.startX) {
        this.walkLeft = false;
      }
      else if (!this.walkLeft && this.pos.x >= this.endX) {
        this.walkLeft = true;
      }

      // make it walk
      this.renderable.flipX(this.walkLeft);
      this.body.vel.x += (this.walkLeft) ? -this.body.accel.x * me.timer.tick : this.body.accel.x * me.timer.tick;
    }
    else {
      this.body.vel.x = 0;
    }

    // update the body movement
    this.body.update(dt);

    // handle collisions against other shapes
    me.collision.check(this);

    // return true if we moved or if the renderable was updated
    return (this._super(me.Entity, 'update', [dt]) || this.body.vel.x !== 0 || this.body.vel.y !== 0);
  },

  /**
   * colision handler
   * (called when colliding with other objects)
   */
  onCollision : function (response, other) {
    if (response.b.body.collisionType !== me.collision.types.WORLD_SHAPE) {
      // res.y >0 means touched by something on the bottom
      // which mean at top position for this one
      if (this.alive && (response.overlapV.y > 0) && response.a.body.falling) {
        this.renderable.flicker(750);
      }
      return false;
    }
    // Make all other objects solid
    return true;
  }
});

正如您在这里看到的,我直接在构造器中定义了 settings.image与settings.framewidth属性,也就是说在Tiled中我不需要再为对象添加这些属性了(当然,您仍然可以自行决定如何使用它)。

同时,我使用了由Tiled提供的width属性来定义敌人活动的路线。最后,我在onCollision方法中给敌人定义了如果有什么东西跳到了敌人上方,那敌人就会扁下去。

注意,一个Object Entity可绘制组件(或者是单一精灵图动画)都可以通过Entity的`renderable`属性进行访问,这也是下述操作的原因:`this.renderable.flicker(750);`

再一次的,我们向对象池中添加了这些新的对象

// register our object entities in the object pool
me.pool.register("mainPlayer", game.PlayerEntity);
me.pool.register("CoinEntity", game.CoinEntity);
me.pool.register("EnemyEntity", game.EnemyEntity);

现在就是在Tiled中完成关卡的时候了。创建新的对象图层,使用Insert Object工具来在合适的位置添加金币与敌人。右键单击每个对象以确保将他们的名字设置为CoinEntity或EnemyEntity。

Step 5

在测试之前,我们同时需要修改角色来检索与其他实体的碰撞。为实现这一功能,我们需要调用位于mainPlayer代码中的me.collision.check(this)函数,如下:

/**
 * update the player pos
 */
update : function (dt) {

  if (me.input.isKeyPressed('left')) {
    // flip the sprite on horizontal axis
    this.renderable.flipX(true);

    // update the entity velocity
    this.body.vel.x -= this.body.accel.x * me.timer.tick;

    // change to the walking animation
    if (!this.renderable.isCurrentAnimation("walk")) {
      this.renderable.setCurrentAnimation("walk");
    }
  }
  else if (me.input.isKeyPressed('right')) {
    // unflip the sprite
    this.renderable.flipX(false);

    // update the entity velocity
    this.body.vel.x += this.body.accel.x * me.timer.tick;

    // change to the walking animation
    if (!this.renderable.isCurrentAnimation("walk")) {
      this.renderable.setCurrentAnimation("walk");
    }
  }
  else {
    this.body.vel.x = 0;

    // change to the standing animation
    this.renderable.setCurrentAnimation("stand");
  }

  if (me.input.isKeyPressed('jump')) {
    if (!this.body.jumping && !this.body.falling) {
      // set current vel to the maximum defined value
      // gravity will then do the rest
      this.body.vel.y = -this.body.maxVel.y * me.timer.tick;

      // set the jumping flag
      this.body.jumping = true;
    }
  }

  // apply physics to the body (this moves the entity)
  this.body.update(dt);

  // handle collisions against other shapes
  me.collision.check(this);

  // return true if we moved or if the renderable was updated
  return (this._super(me.Entity, 'update', [dt]) || this.body.vel.x !== 0 || this.body.vel.y !== 0);
},

最后一步,由于我们在关卡中添加了一些平台,所以让我们来修改一下onCollision处理程序,给"WORLD_SHAPE”类型添加一些自订行为来模拟"platform”元素,如下所示。

请记住我们所需作为“platforms”的特殊碰撞形状可通过在Tiled中将其类型属性设置为“platform”来进行识别(您可以按照自己的喜好进行命名,只要保持两边的名称一致即可)。

/**
 * colision handler
 */
onCollision : function (response, other) {
  switch (response.b.body.collisionType) {
    case me.collision.types.WORLD_SHAPE:
      // Simulate a platform object
      if (other.type === "platform") {
        if (this.body.falling &&
          !me.input.isKeyPressed('down') &&

          // Shortest overlap would move the player upward
          (response.overlapV.y > 0) &&

          // The velocity is reasonably fast enough to have penetrated to the overlap depth
          (~~this.body.vel.y >= ~~response.overlapV.y)
        ) {
          // Disable collision on the x axis
          response.overlapV.x = 0;

          // Repond to the platform (it is solid)
          return true;
        }

        // Do not respond to the platform (pass through)
        return false;
      }
      break;

    case me.collision.types.ENEMY_OBJECT:
      if ((response.overlapV.y>0) && !this.body.jumping) {
        // bounce (force jump)
        this.body.falling = false;
        this.body.vel.y = -this.body.maxVel.y * me.timer.tick;

        // set the jumping flag
        this.body.jumping = true;
      }
      else {
        // let's flicker in case we touched an enemy
        this.renderable.flicker(750);
      }

      // Fall through

    default:
      // Do not respond to other objects (e.g. coins)
      return false;
  }

  // Make the object solid
  return true;
}

尝试一下

然后这就是您应该看到的结果(记住我已经完成了关卡的一部分了,添加了平台之类的):

Step 5 results

试着收集金币,避开敌人或踩掉它!

第6部分:添加一些基础HUD信息

在我们收集到这些金币时应该展示分数。

我们会使用位图字体来展示分数!为了方便,我们会提供所需的位图与数据信息,但是您也可以简单地生成所需的文件,请查看这里的指南。

在`data\fnt`文件夹下您将找到两个文件:一个PNG文件(实际材质)与一个FNT文件(字体定义文件),我们所提供的字体示例名为"PressStart2P",我们只需要将其加入现有的预先加载资源列表中即可:

// game font
{ name: "PressStart2P", type:"image", src: "data/fnt/PressStart2P.png" },
{ name: "PressStart2P", type:"binary", src: "data/fnt/PressStart2P.fnt"},

这里需要注意,FNT文件类型需要设置为binary。

我们先前使用的模板已经包含了HUD框架,可以直接用于游戏中。这一框架十分简单,包含:

  • 一个名为 game.HUD.Container的对象,继承自me.Container
  • 一个名为game.HUD.ScoreItem的基础分数对象,继承自me.Renderable

HUD容器仅是一个对象容器而已,被定义为persistent(这样就能在关卡变化时通用),展示在其它所有对象之上(z属性设置为Infinity),同时我们将其设置为不可碰撞,这样在碰撞检查时就会忽略它。

分数对象被定义为floating(这样在将其添加至HUD容器中时我们可以使用屏幕坐标)并且对分数值进行暂时缓存(在game.data中定义)。

/**
 * a HUD container and child items
 */
game.HUD = game.HUD || {};

game.HUD.Container = me.Container.extend({
  init: function () {
    // call the constructor
    this._super(me.Container, 'init');

    // persistent across level change
    this.isPersistent = true;

    // make sure we use screen coordinates
    this.floating = true;

    // give a name
    this.name = "HUD";

    // add our child score object
    this.addChild(new game.HUD.ScoreItem(-10, -10));
  }
});

/**
 * a basic HUD item to display score
 */
game.HUD.ScoreItem = me.Renderable.extend({
  /**
   * constructor
   */
  init : function (x, y) {
      // call the parent constructor
      // (size does not matter here)
      this._super(me.Renderable, 'init', [x, y, 10, 10]);

      // local copy of the global score
      this.score = -1;
  },

  /**
   * update function
   */
  update : function (dt) {
    // we don't do anything fancy here, so just
    // return true if the score has been updated
    if (this.score !== game.data.score) {
      this.score = game.data.score;
      return true;
    }
    return false;
  },

  /**
   * draw the score
   */
  draw : function (renderer) {
    // draw it baby !
  }
});

现在就可以展示当前分数了!我们可以通过完成给定的ScoreItem对象,通过正确创建本地字体(使用前面提到过的位图字体),并使用位图字体绘制分数。

/**
 * a basic HUD item to display score
 */
game.HUD.ScoreItem = me.Renderable.extend( {
  /**
   * constructor
   */
  init : function (x, y) {
    // call the parent constructor
    // (size does not matter here)
    this._super(me.Renderable, 'init', [x, y, 10, 10]);

    // create the font object
    this.font = new me.BitmapFont(me.loader.getBinary('PressStart2P'), me.loader.getImage('PressStart2P'));

    // font alignment to right, bottom
    this.font.textAlign = "right";
    this.font.textBaseline = "bottom";

    // local copy of the global score
    this.score = -1;
  },

  /**
   * update function
   */
  update : function (dt) {
    // we don't draw anything fancy here, so just
    // return true if the score has been updated
    if (this.score !== game.data.score) {
      this.score = game.data.score;
      return true;
    }
    return false;
  },

  /**
   * draw the score
   */
  draw : function (renderer) {
        // this.pos.x, this.pos.y are the relative position from the screen right bottom
		this.font.draw (renderer, game.data.score, me.game.viewport.width + this.pos.x, me.game.viewport.height + this.pos.y);
  }
});

在游戏开始时,HUD已经被添加并移除,所以可不处理。注意我们会在关卡加载结束后将HUD添加至游戏世界,me.Container对象默认会自动设置z值(通过autoDepth特性进行),这将确保HUD能够正确展示在其它部分之上。

game.PlayScreen = me.ScreenObject.extend({
  /**
   * action to perform on state change
   */
  onResetEvent : function () {
    // load a level
    me.levelDirector.loadLevel("area01");

    // reset the score
    game.data.score = 0;

    // add our HUD to the game world
    this.HUD = new game.HUD.Container();
    me.game.world.addChild(this.HUD);
  },

  /**
   * action to perform when leaving this screen (state change)
   */
  onDestroyEvent : function () {
    // remove the HUD from the game world
    me.game.world.removeChild(this.HUD);
  }
});

最后一步当然是在收集到金币时改变分数了!现在让我们来修改金币对象:

onCollision : function () {
  // do something when collected

  // give some score
  game.data.score += 250;

  // make sure it cannot be collected "again"
  this.body.setCollisionMask(me.collision.types.NO_OBJECT);

  // remove it
  me.game.world.removeChild(this);
}

正如你所见,在onCollision函数中我们只需修改game.data.score属性(对其进行加减值)即可,然后我们会确保对象无法被重复收集,并移除这些金币

尝试一下

现在我可以来查看一下结果,同时分数应该展示在屏幕的右下角:

Step 6 results

第7部分:添加一些音频

在这一部分中我们将向游戏中添加一些音频:

  • 收集金币时的音效
  • 跳跃时的音效
  • 踩到敌人时的音效
  • 背景(或游戏内)音效

如果我们回顾一下最初我们是如何初始化音频的,您就能发现我们是通过将"mp3,ogg”参数传递给初始化函数,表示我们将提供两种格式的音频文件,一种是mp3,另一种是ogg。melonJS会根据浏览器的兼容性选择合适的格式。

// initialize the "audio"
me.audio.init("mp3,ogg");

现在让我们来修改游戏:

收集金币

在CoinEntity代码中,我们处理了获得的分数,我们只需添加一个新的对me.audio.play()的调用并使用 "cling”音频资源即可!

onCollision : function () {
  // do something when collected

  // play a "coin collected" sound
  me.audio.play("cling");

  // give some score
  game.data.score += 250;

  // make sure it cannot be collected "again"
  this.body.setCollisionMask(me.collision.types.NO_OBJECT);

  // remove it
  me.game.world.removeChild(this);
}

跳跃

在mainPlayer的update() 函数中,我们亦添加了对于me.audio.play() 的调用并使用"jump”音频资源。同时您应该注意的是,我给doJump()添加了返回值以进行测试。doJump将在不允许进行跳跃的情况(比如已经跳起时等等)时返回false,在这种情况下就不需要再播放音频了。

if (me.input.isKeyPressed('jump')) {
  if (!this.body.jumping && !this.body.falling) {
    // set current vel to the maximum defined value
    // gravity will then do the rest
    this.body.vel.y = -this.body.maxVel.y * me.timer.tick;

    // set the jumping flag
    this.body.jumping = true;

    // play some audio
    me.audio.play("jump");
  }
}

踩踏

流程是一样的,但使用“stomp”资源即可,并且这次修改mainPlayer的碰撞处理器函数:

/**
 * colision handler
 */
onCollision : function (response, other) {

      // ...

      case me.collision.types.ENEMY_OBJECT:
        if ((response.overlapV.y>0) && !this.body.jumping) {
          // bounce (force jump)
          this.body.falling = false;
          this.body.vel.y = -this.body.maxVel.y * me.timer.tick;

          // set the jumping flag
          this.body.jumping = true;

          // play some audio
          me.audio.play("stomp");
        }
        else {
          // let's flicker in case we touched an enemy
          this.renderable.flicker(750);
        }

        // Fall through

      default:
        // Do not respond to other objects (e.g. coins)
        return false;
    }

  // Make the object solid
  return true;
}

游戏内音乐

我们在onResetEvent()函数中添加对于me.audio.playTrack()函数的调用,明确需要使用的音轨:

onResetEvent : function () {
  // play the audio track
  me.audio.playTrack("dst-inertexponent");

  // ...
},

同时我们需要修改onDestroyEvent()函数以便在退出游戏时停止当前音轨的播放:

onDestroyEvent : function () {

  // ...

  // stop the current audio track
  me.audio.stopTrack();
}

这就介绍完了!点击这里查看最终结果。

第8部分:添加第二关

现在您应该了解如何创建关卡了。不过我还是会给你展示一下如何创建另外的关卡。

melonJS有一个专门的名为me.LevelEntity的对象,我们会在Tiled中将其添加至Entities层,以声明主要角色碰到它时的动作:

Creating an object to go to next level

假设我们的新关卡名为“area02”,我们只需添加一个值为"area02"的"to”属性即可。这样当我们的角色触碰到对象时,引擎会自动加载"area02”关卡。

我们也可以要求引擎在变更关卡时加入渐入/渐出特效,这是可选的。您只需添加"fade"颜色与"duration" (单位为ms)属性即可(如图所示)

点击这里查看最终结果。

第9部分:添加主界面

在完成游戏设计之前让我们添加一个标题界面,可使用位于"/data/img/gui/”文件夹中的title_screen.png文件(当然需要先将其加入资源列表中,就像之前我们处理其他图片一样):

Title screen

在其之上我们将添加一些信息,并等待用户输入以开始游戏!

首先让我们声明一个继承自me.ScreenObject的对象:

/**
 * A title screen
 */
game.TitleScreen = me.ScreenObject.extend({
  // reset function
  onResetEvent : function () {
    // ...
  },

  // destroy function
  onDestroyEvent : function () {
    // ...
  }
});

现在我们将要:

  • 展示上述背景图片
  • 在屏幕中间添加一些文本("Press enter to play”)
  • 等待用户输入(按下enter)

同时,我还想要添加一段简短的有关本教程的滚动文本。

game.TitleScreen = me.ScreenObject.extend({
  /**
   * action to perform on state change
   */
  onResetEvent : function () {
    // title screen
    var backgroundImage = new me.Sprite(0, 0, {
            image: me.loader.getImage('title_screen'),
        }
    );

    // position and scale to fit with the viewport size
    backgroundImage.anchorPoint.set(0, 0);
    backgroundImage.scale(me.game.viewport.width / backgroundImage.width, me.game.viewport.height / backgroundImage.height);

    // add to the world container
    me.game.world.addChild(backgroundImage, 1);

    // add a new renderable component with the scrolling text
    me.game.world.addChild(new (me.Renderable.extend ({
      // constructor
      init : function () {
        this._super(me.Renderable, 'init', [0, 0, me.game.viewport.width, me.game.viewport.height]);

        // font for the scrolling text
        this.font = new me.BitmapFont(me.loader.getBinary('PressStart2P'), me.loader.getImage('PressStart2P'));

        // a tween to animate the arrow
        this.scrollertween = new me.Tween(this).to({scrollerpos: -2200 }, 10000).onComplete(this.scrollover.bind(this)).start();

        this.scroller = "A SMALL STEP BY STEP TUTORIAL FOR GAME CREATION WITH MELONJS       ";
        this.scrollerpos = 600;
      },

      // some callback for the tween objects
      scrollover : function () {
        // reset to default value
        this.scrollerpos = 640;
        this.scrollertween.to({scrollerpos: -2200 }, 10000).onComplete(this.scrollover.bind(this)).start();
      },

      update : function (dt) {
        return true;
      },

      draw : function (renderer) {
        this.font.draw(renderer, "PRESS ENTER TO PLAY", 20, 240);
        this.font.draw(renderer, this.scroller, this.scrollerpos, 440);
      },
      onDestroyEvent : function () {
        //just in case
        this.scrollertween.stop();
      }
    })), 2);

    // change to play state on press Enter or click/tap
    me.input.bindKey(me.input.KEY.ENTER, "enter", true);
    me.input.bindPointer(me.input.pointer.LEFT, me.input.KEY.ENTER);
    this.handler = me.event.subscribe(me.event.KEYDOWN, function (action, keyCode, edge) {
      if (action === "enter") {
        // play something on tap / enter
        // this will unlock audio on mobile devices
        me.audio.play("cling");
        me.state.change(me.state.PLAY);
      }
    });
  },

  /**
   * action to perform when leaving this screen (state change)
   */
  onDestroyEvent : function () {
    me.input.unbindKey(me.input.KEY.ENTER);
    me.input.unbindPointer(me.input.pointer.LEFT);
    me.event.unsubscribe(this.handler);
  }
});

上述内容中有什么东西呢?

  1. 1) onResetEvent函数,我们创建两个可读组件并将它们添加至我们的游戏世界、第一个是基本的精灵图对象,用于展示我们的标题北京图片。第二个将用于处理“press ENTER”消息,以及一个基于漂浮对象的滚动器。备注:如果您仔细查看过相关资源(32x32 font.png)的话,您会注意到其中只包含大写字母,所以请确保您的文本中只出现大写字母。
  2. 2) 我们还会注册按键事件,或者鼠标/触摸事件来在按下按键后自动切换至PLAY状态。
  3. 3) 在销毁时,我们会解除所有按键与指针事件的绑定。

当然最后一件事就是告诉引擎我们创建了一个新对象,同时将它同对应的状态联系起来(在本例中是 MENU)。通过使用me.state转换函数,我们就能告知引擎在状态转换时加入渐变特效。

最后,我选择立即切换至MENU状态,而不是在函数加载结束时切换至PLAY状态:

/*
 * callback when everything is loaded
 */
loaded : function () {
  // set the "Play/Ingame" Screen Object
  me.state.set(me.state.MENU, new game.TitleScreen());

  // set the "Play/Ingame" Screen Object
  me.state.set(me.state.PLAY, new game.PlayScreen());

  // set a global fading transition for the screen
  me.state.transition("fade", "#FFFFFF", 250);

  // register our player entity in the object pool
  me.pool.register("mainPlayer", game.PlayerEntity);
  me.pool.register("CoinEntity", game.CoinEntity);
  me.pool.register("EnemyEntity", game.EnemyEntity);

  // enable the keyboard
  me.input.bindKey(me.input.KEY.LEFT, "left");
  me.input.bindKey(me.input.KEY.RIGHT, "right");
  me.input.bindKey(me.input.KEY.X, "jump", true);

  // display the menu title
  me.state.change(me.state.MENU);
}

尝试一下

恭喜!本教程已结束,您应该能够看到下面图片中的场景:

Your completed game

第10部分:总结

好了,我们希望您能够轻松理解这一melonJS教程,现在您可以进一步自由探索了。这是程序与游戏开发不可或缺的一步。

如果您在使用过程中遇到了任何困难,或对于教程有任何不明白的地方,请检索这些问题或通过在论坛中@html5gamedevs来联系我们

不要忘记这一切只是为了更好玩,所以好好享受吧!