Fork me on GitHub

太空侵入者教程

在本教程中,我们将复制一下太空侵入者。本教程主要关注如何通过代码构建更多游戏元素,并使用横版游戏教程未涉及的其它melonJS提供的API。

介绍

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

  • melonJS模板,教程将使用它作为默认模板。
  • 教程中的图片资源需要解压至模板数据目录中。在你解压后,您将看到:
  • data/img/player.png
    data/img/ships.png
    js/game.js
    etc
    
  • melonJS库。如果您下载了模板,您还将看到这一文件。您需要将其复制进/lib目录中。您可以复制开发版本,模板只提供精简版的任务。
  • 查看melonJS文档以了解更多

测试/调试:

最好的办法是使用本地web服务器。在melonJS模板的README文档中详细介绍了如何使用。通过使用`grunt connect`工具,您能够从浏览器中访问http://localhost:8000这一url来测试您的游戏。

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

设定飞船

模板中的目录结构应该与下述相似:

data/
  img/
    player.png
    ships.png
js/
  game.js
  resources.js
  entities/
    HUD.js
    entities.js
  screens/
    play.js
    title.js
index.html
index.css

模板提供了许多默认代码。在本教程中我们不会使用全部的文件。您可以删除screens/title.js这一文件,并整体移除掉js/entities这一文件夹。随后更新index.html以去除对这些文件的包含,并从game.js文件中移除对于TitleScreen的引用。

var game = {
    // Run on page load.
    onload : function () {
        // Initialize the video.
        if (!me.video.init(640, 480, {wrapper : "screen", scale : 'auto'})) {
            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 () {
        // set the "Play/Ingame" Screen Object
        this.playScreen = new game.PlayScreen();
        me.state.set(me.state.PLAY, this.playScreen);

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

Game.js用于引导游戏。index.html用于在window ready事件加载完所有js文件后调用game.onload函数。me.video.init bit用于创建画布标签并获取视频设置。

第12-16行用于调试面板。

接下来我们初始化音频引擎并告知它我们游戏中支持的格式。

我们为me.loader设置一个调用已加载函数的回调,并通过数组告知它需要加载的资源。

这一步骤的最后一步是将游戏的状态设置为正在加载。

loaded : function () {
  this.playScreen = new game.PlayScreen();
  me.state.set(me.state.PLAY, this.playScreen);

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

随后已加载函数将设置游戏界面并告知游戏使用那一屏幕对象作为游戏状态。

接下来游戏状态就被设置为PLAY。

回到太空侵入者

第一件事就是向resources.js文件中添加图片。

game.resources = [
    { name: "player", type: "image", src: "data/img/player.png" },
    { name: "ships", type: "image", src: "data/img/ships.png" }
];

这一变量就是在game.js中被传递给me.loader.preload的那个

资源的结构为:

名称 您希望在游戏中使用的资源名称。是一段字符串key。
类型 资源类型。有效的类型为:audio, binary, image, json, tmx, tsx。Binary是一种加载纯文本或其它未列出格式的好方法TMX & TSX用于Tiled文件格式。无论是xml还是json格式。
src 资源的路径,为相对index.html而言的路径。对于音频文件您需要明确文件夹而不是直接路径。

打开js/screens/play.js 并清空下述两个方法中的代码:onResetEvent与onDestroyEvent。随后保存并在浏览器中打开游戏。

现在还看不出什么东西。让我们进一步修改。

首先创建角色实体。

在js文件夹中新建一个文件并将其命名为player.js。记住还需要在index.html中同步添加。

game.Player = me.Sprite.extend({
  init : function () {
      var image = me.loader.getImage("player");
      this._super(me.Sprite, "init", [
          me.game.viewport.width / 2 - image.width / 2,
          me.game.viewport.height - image.height - 20,
          { image : image }
      ]);
  }
});

这一做法是向继承于me.Sprite的window.game对象添加函数。设置了一个init方法来抓取角色图片。

在x轴坐标上,我们仅需获取最中间的坐标,并在此基础上减去飞船宽度的一半,这样就能将飞船放置在中点了。随后设置其y轴属性为距离底部20个像素点。最后将图片示例传递给它。

调用this._super是可用于引用父类中的方法。在本例中,我们将调用me.Sprite的构造器。

让我们在实体池中设置这一角色。打开game.js并将下述内容添加至加载后方法之上:

me.pool.register("player", game.Player);

现在打开js/screens/play.js并按照下述内容编辑onResetEvent方法:

game.PlayScreen = me.ScreenObject.extend({
  /**
   * action to perform on state change
   */
  onResetEvent : function () {
      me.game.world.addChild(me.pool.pull("player"));
  },

  /**
   * action to perform when leaving this screen (state change)
   */
  onDestroyEvent : function () {
  }
});

在当前状态加载完成后会调用onResetEvent。所以在触发game.js文件中的下述内容时


    me.state.change(me.state.PLAY);
    

将会调用onResetEvent。

现在飞船就在屏幕底部了!

但是现在我们还是能够看待进度条,这可不好看。导致这一现象的原因是melonJS并不会执行不需要执行的操作。有些时候您必须重新绘制背景图片,这样它就会覆盖原先的进度条。不过,我们并没有准备针对这一游戏的背景图片,所以我们将添加一个颜色图层。

    me.game.world.addChild(new me.ColorLayer("background", "#000000"), 0);
    

将其添加至游戏界面,就在角色上方。第一个参数是图层名称,这样在您有需要的时候就可以轻松地从游戏世界中获取到它了。

第二个参数是16进制的绘制颜色。

第传递给addChild函数的二个参数是z轴索引。我们想让其得到优先绘制,所以我们将这个值设定为0.

现在您应该看不见那个难看的进度条了。现在可以添加敌人了。在js文件夹中新建一个名为enemy.js的文件,并将其添加至index.html文件中。

由于敌人需要同其他物体(比如玩家的激光)产生碰撞,所以它需要继承自me.Entity而不是me.Sprite:

game.Enemy = me.Entity.extend({
  init: function (x, y) {
      this._super(me.Entity, "init", [x, y, {
          image : "ships",
          width : 32,
          height : 32
      }]);
  },

  update: function (dt) {
      this._super(me.Entity, "update", [dt]);
      return true;
  }
});

在生成敌人之后,我们需要将它们放置在不同的地方,所以需要在他们的构造器中添加x和y,并将其传递给me.Entity的构造器。数组中的第三个参数是设定的哈希值。设定中的图片是"ships",引用自game.resources数组。尺寸设置为为32x32。

我们还将创建一个自定义升级方法,来告诉游戏引擎重新绘图。当melon通过游戏循环时,他将对结果做一个或操作。如果在给定帧中没有任何改变,它就不会重新绘制。返回true的话会调用父类中的方法来保证敌人得到渲染。

在game.js中将敌人添加至实体池中:

me.pool.register("enemy", game.Enemy);
    

回到play.js,在游戏世界中添加一个敌人。您的play.js应该如下图所示:

game.PlayScreen = me.ScreenObject.extend({
  /**
   * action to perform on state change
   */
  onResetEvent : function () {
      me.game.world.addChild(new me.ColorLayer("background", "#000000"), 0);
      me.game.world.addChild(me.pool.pull("player"), 1);
      me.game.world.addChild(me.pool.pull("enemy", 50, 50), 2);
  },

  /**
   * action to perform when leaving this screen (state change)
   */
  onDestroyEvent : function () {
  }
});

您可以将敌人放置在任意的x和y坐标上进行尝试。保存并在浏览器中刷新这一页面。

您可能会注意到飞船会不断地改变外观。如果您打开位于data/img中的ships.png文件,您会发现这是一张包含有4中不同飞船形态的精灵表单。me.Entity是可渲染的,使用me.AnimationSheet类。由于我们没有对可渲染属性添加任何动画,所以它只会在每一帧中不断重复。让我们进一步修改。

给敌人添加一个新方法:

chooseShipImage: function () {
    var frame = ~~(Math.random() * 3);
    this.renderable.addAnimation("idle", [frame], 1);
    this.renderable.setCurrentAnimation("idle");
}

第一行只是将随机选择了我们需要的帧。飞船是32x32的,图片是64x64的,所以我们有4帧。~~是Math.floor中数字为0或正数时的简要写法。对于负数其效果等同于Math.ceil。

第二行是访问动画表单实例(this.renderable),并使用addAnimation添加一个空闲帧。所以我们只需简单地明确在random中生成的索引即可。

最后一行中我们将当前动画设置为空闲。

现在可在构造器最后调用这个函数了,如下:

game.Enemy = me.Entity.extend({
    init: function (x, y) {
        this._super(me.Entity, "init", [x, y, {
            image: "ships",
            width: 32,
            height: 32
        }]);
        this.chooseShipImage();
    },

    chooseShipImage: function () {
        var frame = ~~(Math.random() * 3);
        this.renderable.addAnimation("idle", [frame], 1);
        this.renderable.setCurrentAnimation("idle");
    },
});

现在刷新这一页面,我们的飞船就会以之前形式的其中之一出现。可尝试多次刷新已查看其变化。

应用动作

现在我们的屏幕上有飞船了,让我们来真正实现互动。

回到play.js中,让我们加入一些键位绑定。

game.PlayScreen = me.ScreenObject.extend({
  /**
   * action to perform on state change
   */
  onResetEvent : function () {
      me.game.world.addChild(new me.ColorLayer("background", "#000000"), 0);
      me.game.world.addChild(me.pool.pull("player"), 1);
      me.game.world.addChild(me.pool.pull("enemy", 50, 50), 2);

      me.input.bindKey(me.input.KEY.LEFT, "left");
      me.input.bindKey(me.input.KEY.RIGHT, "right");
      me.input.bindKey(me.input.KEY.A, "left");
      me.input.bindKey(me.input.KEY.D, "right");
  },

  /**
   * action to perform when leaving this screen (state change)
   */
  onDestroyEvent : function () {
      me.input.unbindKey(me.input.KEY.LEFT);
      me.input.unbindKey(me.input.KEY.RIGHT);
      me.input.unbindKey(me.input.KEY.A);
      me.input.unbindKey(me.input.KEY.D);
  }
});

在此处调用的方法十分简单明了。我们将按键绑定至动作名称上。可为一个动作名称分配多个按键。

提供多种键位绑定是一种优秀的游戏设计习惯。最好是做成可配置键位的形式。您同时还需注意惯用左手的玩家与实用不同布局的玩家。

您可能也注意到了我再调用addChild时添加了z索引。这是一个很好的技巧,因为这样的话您就能保证绘制顺序。

onDestroyEvent会在状态改变时移除事件。不过因为我们在正在加载状态后只有游戏状态,这一方法不是那么有用。但也是值得记住的一个技巧。

现在我们设置了键位绑定,接下来就是实现角色动作。将下述的update函数添加至角色类中:

然后在角色init方法中添加vlex属性,并将其在屏幕上能够到达的最远x轴位置(maxX0也添加进去:

init : function () {
    var image = me.loader.getImage("player");
    this._super(me.Sprite, "init", [
        me.game.viewport.width / 2 - image.width / 2,
        me.game.viewport.height - image.height - 20,
        image
    ]);
    this.velx = 450;
    this.maxX = me.game.viewport.width - this.width;
},

接下来依照按键事件修改update方法,并相应地移动角色。

update : function (time) {
    this._super(me.Sprite, "update", [time]);
    if (me.input.isKeyPressed("left")) {
        this.pos.x -= this.velx * time / 1000;
    }

    if (me.input.isKeyPressed("right")) {
        this.pos.x += this.velx * time / 1000;
    }

    this.pos.x = this.pos.x.clamp(0, this.maxX);

    return true;
}

我们游戏对象中的update函数会接收到delta时间(单位为毫秒)。这一点对于将其它传递给父类update是十分重要的。

this._super(me.Sprite, "update", [time]);

机械来就是确认left这个动作是否得到了正确传输。通过之前的速度值设置,我们能够轻松减去速度值并乘上转换为秒的delta。

if (me.input.isKeyPressed("left")) {
    this.pos.x -= this.velx * time / 1000;
}

如需要向右移动,我们可以查看right这个动作,并在x位置上加上速度值。

if (me.input.isKeyPressed("right")) {
    this.pos.x += this.velx * time / 1000;
}

随后我们使用clamp来保证x值不会超出屏幕范围。

this.pos.x = this.pos.x.clamp(0, this.maxX);

返回值将告知melon是否需要重新绘制。这一手段将指示在任何给定帧中是否有动画表单需要绘制。不过,我们这里使用的是单精灵图,所以我们可以直接让它重新绘制。

return true;

保存文件并在浏览器中刷新。试着使用A/D或左右方向键来移动。

敌人动作

太空侵入者最明显的特征就是,所有外星飞船都朝一个方向移动,然后向下移动一个并换一个方向移动。他们是同时移动的。我们可以借鉴应用于角色的速度逻辑,并将其应用于enemy类中。不过更好的是我们能够使用melonJS来实现。是时候使用我们的me.Container子类了

容器内的对象与其容器的位置是相对的。所以当我们移动容器时,其中的所有对象都会相应同时移动。转动或缩放时也同样适用。所以让我们构造一个容器吧。

新建一个文件:js/enemy_manager.js,并将其添加至index.html文件中。

game.EnemyManager = me.Container.extend({
  init : function () {
      this._super(me.Container, "init", [0, 32,
          this.COLS * 64 - 32,
          this.ROWS * 64 - 32
      ]);
      this.COLS = 9;
      this.ROWS = 4;
      this.vel = 16;
  }
});

这里最重要的是开始位置与基本宽度。首先将容器设定为32个像素,并放置在left(或x)为0处。

我们给每艘飞船的宽高分配64个像素。再减去32个像素,因为最后一行和最后一列并不需要流出间距。

我们需要其它方法来向容器中添加敌人:

createEnemies : function () {
  for (var i = 0; i < this.COLS; i++) {
      for (var j = 0; j < this.ROWS; j++) {
          this.addChild(me.pool.pull("enemy", i * 64, j * 64));
      }
  }
  this.updateChildBounds();
}

生成9列4行:36艘飞船。注意调用“updateChildBounds”函数以确保容器的尺寸能够包含所有添加的子元素。相应的在移除子元素时也要做同样的操作

现在从play.js中移除敌人的addChild,并在敌人管理器中设置属性。下面是触发createEnemies并将其添加至游戏世界中。

onResetEvent : function () {
    me.game.world.addChild(new me.ColorLayer("background", "#000000"), 0);
    me.game.world.addChild(me.pool.pull("player"), 1);

    this.enemyManager = new game.EnemyManager();
    this.enemyManager.createEnemies();
    me.game.world.addChild(this.enemyManager, 2);

    me.input.bindKey(me.input.KEY.LEFT, "left");
    me.input.bindKey(me.input.KEY.RIGHT, "right");
    me.input.bindKey(me.input.KEY.A, "left");
    me.input.bindKey(me.input.KEY.D, "right");
    me.input.bindKey(me.input.KEY.SPACE, "shoot", true);
}

在您保存并刷新之后,您应该能看待一些随机分布的飞船。

对于它们的移动的话,我们简单的每秒移动一下容器就好了。在这里我们可以使用melonJS定时器:

将这两个方法添加至enemy_manager.js中

onActivateEvent : function () {
    var _this = this;
    this.timer = me.timer.setInterval(function () {
        _this.pos.x += _this.vel;
    }, 1000);
},

onDeactivateEvent : function () {
    me.timer.clearInterval(this.timer);
}

随后在init方法中设置vel属性为16:

this.vel = 16;

在对象被添加至游戏世界中时会调用onActivateEvent(在已定义的情况下)。这是用于传递给容器addChild的任何对象。类似的,在将对象移出游戏世界时会调用onDeactivateEvent。

我们可以使用melonJS版本的setInterval(已内置在游戏循环中,不会使用window.setInterval)来递增x的位置。

保存并刷新浏览器。敌人的飞船就会同时一起移动了。

接下来添加类似removeChildNow的东西:

removeChildNow : function (child) {
    this._super(me.Container, "removeChildNow", [child]);
    this.updateChildBounds();
}

选择继承removeChildNow而不是removeChild的原因是removeChild是在当前帧结束时才会被调用。removeChildNow则能够实实在在地移除对象,同时我们想在对象被移除后重新缩放边界。

onActivateEvent : function () {
    var _this = this;
    this.timer = me.timer.setInterval(function () {
        var bounds = _this.childBounds;

        if ((_this.vel > 0 && (bounds.right + _this.vel) >= me.game.viewport.width) ||
            (_this.vel < 0 && (bounds.left + _this.vel) <= 0)) {
            _this.vel *= -1;
            _this.pos.y += 16;
            if (_this.vel > 0) {
              _this.vel += 5;
            }
            else {
              _this.vel -= 5;
            }
        }
        else {
            _this.pos.x += _this.vel;
        }
    }, 1000);
}

这部分代码不算太多,让我们一点一点分析。

通过子元素边界,我们可以将其left和right值转换为世界坐标。

var bounds = _this.childBounds;

if语句的第一部分是检查容器是否在向右移动,以及右边界+速度值是否已超出可视区域。

(_this.vel > 0 && (bounds.right + _this.vel) >= me.game.viewport.width)

第二部分检查容器是否在想左移动,以及左边界是否小于0。

(_this.vel < 0 && (bounds.left + _this.vel) <= 0)

在区块中我们将速度反向,向下移动16个像素后增加速度值。

_this.vel *= -1;
_this.pos.y += 16;
if (_this.vel > 0) {
    _this.vel += 5;
}
else {
    _this.vel -= 5;
}

最后,如果容器并没有向左或向右移动的话,我们会增加速度值

else {
    _this.pos.x += _this.vel;
}

保存并刷新后,飞船应该能在屏幕上左右移动,并逐渐靠近角色了。但还是有个问题!它在接触边界时并没有转向!这是怎么回事?

现在您应该添加#debug至URL中:http://localhost:8000/#debug,然后在调试面板中勾选“hitbox”。这将开启hitbox渲染,您就能看到对象的内部结构了。

hitbox显示出子边界(占据在敌人管理器原来位置上的紫色大矩形)并没有随着容器一同移动。这是因为子边界的更新并没有自动进行计算。您可以通过重载update方法来轻松重算子边界:

update : function (time) {
    this._super(me.Container, "update", [time]);
    this.updateChildBounds();
}

保存并刷新,现在一切就正常了!

添加激光,biubiubiu!

是时候搞一点真正刺激的东西了。

首先打开play.js文件,添加一个新的键位绑定和解绑:

me.input.bindKey(me.input.KEY.SPACE, "shoot", true);
me.input.unbindKey(me.input.KEY.SPACE);

调用bindKey时的boolean型变量是用于注册每次按键点击。所以如果想射击两次的话,玩家必须按下空格键,释放空格键,再按下去一次才会执行。

不过在我们让角色学会射击之前,我们得制作一个激光。创建laser.js文件,并将下述代码添加至其中。同时,依然需要将laser.js的脚本标签添加至index.html文件中。

game.Laser = me.Entity.extend({
    init : function (x, y) {
        this._super(me.Entity, "init", [x, y, { width: game.Laser.width, height: game.Laser.height }]);
        this.z = 5;
        this.body.setVelocity(0, 300);
        this.body.collisionType = me.collision.types.PROJECTILE_OBJECT;
        this.renderable = new (me.Renderable.extend({
            init : function () {
                this._super(me.Renderable, "init", [0, 0, game.Laser.width, game.Laser.height]);
            },
            destroy : function () {},
            draw : function (renderer) {
                var color = renderer.getColor();
                renderer.setColor('#5EFF7E');
                renderer.fillRect(0, 0, this.width, this.height);
                renderer.setColor(color);
            }
        }));
        this.alwaysUpdate = true;
    },

    update : function (time) {
        this.body.vel.y -= this.body.accel.y * time / 1000;
        if (this.pos.y + this.height <= 0) {
            me.game.world.removeChild(this);
        }

        this.body.update();
        me.collision.check(this);

        return true;
    }
});

game.Laser.width = 5;
game.Laser.height = 28;

现在就让我们开始吧。在最下方,我们设置了激光的宽度和高度属性,这样可以轻松重用。

game.Laser.width = 5;
game.Laser.height = 28;

这些就比较常见了。通过参数设置x与y的值,以及宽度与高度属性。与其它对象稍微有些不同的是,我们需要手动设置对象的z索引。这是一种在调用时将z索引传递至addChild中的另一种方式。

this._super(me.Entity, "init", [x, y, { width: game.Laser.width, height: game.Laser.height }]);
this.z = 5;

接下来的方法用于设置物理实体。我们会利用它来带激光穿越整个屏幕。

this.body.setVelocity(0, 300);
this.body.collisionType = me.collision.types.PROJECTILE_OBJECT;

me.Body在默认情况下不会为您设置形状。me.Entity则会根据位置、宽度以及高度来创建形状。

首先我们设定一个速度。速度是一个向量,并且我们需要激光是朝上走的。所以将速度设置为300.注意速度永远不应该是在指定方向上的负值。

this.body.setVelocity(0, 300);

然后我们设置碰撞类型。这在碰撞回调时将十分有用。

this.body.collisionType = me.collision.types.PROJECTILE_OBJECT;

这一部分代码内容还是比较丰富的。me.Renderable是melonJS中的基础绘图类。它将提供游戏世界中有效绘图对象需要的最低设置。

this.renderable = new (me.Renderable.extend({
    init : function () {
        this._super(me.Renderable, "init", [0, 0, game.Laser.width, game.Laser.height]);
    },
    destroy : function () {},
    draw : function (renderer) {
        var color = renderer.getColor();
        renderer.setColor('#5EFF7E');
        renderer.fillRect(0, 0, this.width, this.height);
        renderer.setColor(color);
    }
}));

这里的init就很常见了,我们将x和y设置为0(与激光实体是相对的)。对实体使用与之相等的宽度与高度。

init: function () {
    this._super(me.Renderable, "init", [0, 0, game.Laser.width, game.Laser.height]);
},

留空的destroy方法是用于在可渲染对象被移除时进行回调的。我们不需要定义回调逻辑,但是这个函数必须定义。

destroy : function () {},

接下来我们构建draw方法。目前已经使用的类已经能够满足我们的目的,现在我们将使用渲染器手工绘制一些东西。

draw : function (renderer) {
    var color = renderer.getColor();
    renderer.setColor('#5EFF7E');
    renderer.fillRect(0, 0, this.width, this.height);
    renderer.setColor(color);
}

渲染器既可以是me.CanvasRenderer也可以是me.WebGLRenderer,根据您在me.video.init中的设定选择即可。渲染器提供基本的绘图操作。

所以我们首先获取原始颜色的引用。getColor()将返回me.Color的实例。

var color = renderer.getColor();

将颜色设置为适合激光的绿色。

renderer.setColor('#5EFF7E');

然后使用填充rect数字。同样的,这里的0,0也是相对的。接下来使用宽度和高度指示我们需要填充的rect的尺寸

renderer.fillRect(0, 0, this.width, this.height);

然后将颜色设置回去。这一步非常重要。这样我们在调用draw时就不会受到颜色的影响了。

renderer.setColor(color);

一般来讲,您应该首先使用图片来构建游戏,而不是完全使用画布draw方法,但了解如何使用这一方法不会有什么坏处。

最后一步是激光的init方法:

init : function (x, y) {
    // ...
    this.alwaysUpdate = true;
}

尽量避开alwaysUpdate属性。它将会更新超出可视范围的对象。我们在这一游戏中使用它的原因是我们不希望在激光离开屏幕之前移除激光。如果alwaysUpdate设置为false,那等激光离开屏幕后就永远不会被移除了。

说道update方法。

update : function (time) {
    this.body.vel.y -= this.body.accel.y * time / 1000;
    if (this.pos.y + this.height <= 0) {
        me.game.world.removeChild(this);
    }

    this.body.update();
    me.collision.check(this);

    return true;
}

首先就是我们该如何移动激光。飞船的移动时通过直接操作位置进行的。由于激光有碰撞实体,我们将通过将y方向上的速度减去y方向上的加速度来进行操作。

this.body.vel.y -= this.body.accel.y * time / 1000;

如果激光的位置加上其高度(也就是激光底部)小于0.我们就可以把激光从游戏世界中移除了。再一次的,这一功能能够正常进行的前提是alwaysUpdate设置为true。

if (this.pos.y + this.height <= 0) {
    me.game.world.removeChild(this);
}

实体的update十分重要,这将会使用我们对这一帧设置的速度。

this.body.update();

然后我们让melonJS使用me.collision.check(this)来检查与这一对象有关的碰撞。

me.collision.check(this);

这一功能的下一步就是将激光加入实体池。在game.js中加入下列代码,就像处理角色和敌人对象时一样。

me.pool.register("laser", game.Laser);

然后回到player.js文件,在update方法中加入射击激光的代码:

if (me.input.isKeyPressed("shoot")) {
    me.game.world.addChild(me.pool.pull("laser", this.pos.x - game.Laser.width, this.pos.y - game.Laser.height))
}

重新加载游戏,试着射击一下。您应该能看到射出了激光。不过它们不会与任何东西产生碰撞。

碰撞

首先给敌人赋予物理实体。将下面的代码加入enemy.js的init方法中

this.body.setVelocity(0, 0);
this.body.collisionType = me.collision.types.ENEMY_OBJECT;

然后添加一个update方法,这样我们就能update这个实体了:

update : function (time) {
    this._super(me.Entity, "update", [time]);

    this.body.update();

    return true;
}

然后让我们在laser.js文件中加上碰撞处理器。

onCollision : function (res, other) {
    if (other.body.collisionType === me.collision.types.ENEMY_OBJECT) {
        me.game.world.removeChild(this);
        game.playScreen.enemyManager.removeChild(other);
        return false;
    }
}

我们没有用到的res参数只是碰撞的结果而已。其中包含了碰撞的重叠部分大小,以及碰撞发生的位置等等。

我们将敌人实体的碰撞类型设置为ENEMY_OBJECT,这样我们就在对象与激光碰撞时检查到那一类型。

if (other.body.collisionType === me.collision.types.ENEMY_OBJECT) {

然后我们将敌人从激光上移除,同时在enemyManager容器中也将敌人移除。

me.game.world.removeChild(this);
game.playScreen.enemyManager.removeChild(other);

这一情况中并不严格要求返回false,不过这值得一提。在melonJS中,如果碰撞处理器返回false,那么对象会被穿过。如果您返回true的话,那对象就会停住。

保存更改并在浏览器中刷新一下。您应该能干掉敌人的飞船了。

下一步是添加胜利与失败的条件。

胜利&失败条件

游戏的最后一步是添加胜利与失败的条件。条件本身并不复杂。当飞船进入到角色区域中时,玩家失败。当玩家摧毁所有敌方飞船时,玩家胜利。

所以在游戏结束时会发生什么呢?大多数情况下会出现一张展示有玩家是否取得胜利的界面。为了保证教程的简单我们选择直接重置游戏。接下来就让我们一起开始。

首先我们将处理失败的情形。

伪代码如下:

if enemy manager overlaps player
  then end game
else
  continue
end

PlayScreen就是当前的游戏状态。其中包含有对于角色的引用,同时能够重置状态。所以让我们在这里添加判断失败情形的逻辑。

checkIfLoss : function (y) {
    if (y >= this.player.pos.y) {
       this.reset();
    }
},

将代码添加至onResetEvent之前。它将会检查y值,来决定敌人是否已经超过了角色所在区域。然后调用它的reset方法。reset会清空游戏世界中的所有对象,并重新加载状态。所以它会重新触发onResetEvent,并重新填充敌人与角色。

只需在敌人管理器中的每个间隙时间添加调用情形检查的代码即可。

this.timer = me.timer.setInterval(function () {
    var bounds = _this.childBounds;

    if ((_this.vel > 0 && (bounds.right + _this.vel) >= me.game.viewport.width) ||
        (_this.vel < 0 && (bounds.left + _this.vel) <= 0)) {
        _this.vel *= -1;
        _this.pos.y += 16;
        if (_this.vel > 0) {
          _this.vel += 5;
        }
        else {
          _this.vel -= 5;
        }
        game.playScreen.checkIfLoss(bounds.bottom); // <<<
    }
    else {
       _this.pos.x += _this.vel;
    }
}, 1000);

我们是在检查checkIfLoss方法中传递而来的数字是否大于角色的y值,所以我们还需传递容器底部的坐标,也就是bounds.bottom。

此时的问题在于,如果您运行它的话,会报this.player未定义。这是因为我们还没有在playScreen中设置这一属性。您可以按照下述步骤设定这一属性:

替换onResetEvent中的这一行:

me.game.world.addChild(me.pool.pull("player"), 1);

使用:

this.player = me.pool.pull("player");
me.game.world.addChild(this.player, 1);

保存并刷新浏览器。this.player现在就已经被正确设置了,对于新方法的调用也能够使用了。让敌人们自己移动一阵,然后看看游戏会不会重置吧。

胜利情形

相似的,我们也会在玩家胜利时重置游戏。由于胜利条件是玩家击毁所有飞船,所以我们只需检查敌人管理器中子对象的长度即可。

首先在createEnemies底部添加下述boolean:

this.createdEnemies = true;

在敌人管理器中添加下述update方法:

update : function (time) {
    if (this.children.length === 0 && this.createdEnemies) {
        game.playScreen.reset();
    }
    this._super(me.Container, "update", [time]);
    this.updateChildBounds();
}

非常简单。子对象是一个数组,所以我们检查它的长度为0并确保它起初创建过敌人即可。如果没有boolean检查,游戏可能会不断重置,因为检查时还没有创建子对象。

保存并刷新浏览器。试着干掉所有飞船,看看游戏会不会重置。

挑战

我们在教程中故意漏掉了一部分来让你自行探索。这是程序与游戏开发不可或缺的一步。

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

挑战#1

添加一个合适的胜利与失败界面

  1. 这一界面可通过向游戏中添加额外的ScreenObjects并在game.js中注册,最后改变状态来完成。请在下述网址中查看适用于胜利和界面的状态:http://melonjs.github.io/melonJS/docs/me.state.html
  2. 胜利与失败界面可包含精灵图或文本,也可以两个同时包含。您可以依据自己的喜好而定。一定要查看me.Font与me.Sprite。如需展示me.Font对象,可使用一个包含me.Font实例的me.Renderable实例,并实现draw函数来触发me.Font#draw。
  3. 调整checkIfLoss方法来展示新的失败界面。
  4. 调整EnemyManager中update方法的if代码块来将状态改变至您的胜利screneobject。
  5. 进一步的,添加一个菜单界面来告诉玩家如何游玩。

挑战#2

添加UI

  1. 在屏幕左/右上角添加敌人计数器以及敌人速度。这些属性可从下列地方获得:game.playScreen.enemyManager.children.length game.playScreen.enemyManager.vel
  2. 再次查看me.Font,并实现用于绘制文字的renderable。试着只使用一个继承自renderable的类来实现这两个UI部件。
  3. 添加分数元素。在游戏界面记录分数。在每次干掉敌人时更新这个分数。记住敌人是通过激光的碰撞处理器移除的。

挑战#3

添加关卡的概念

  1. 在您击败一波敌人后,您可以创建速度更块的新一波敌人,而不是简单的刷新当前的一波。此处的主要逻辑是在game.js记录波数,并在每次胜利后增加这个值。然后在敌人管理器中使用这个值来设置速度。
  2. 让每一波的速度更快(比如将每次y轴的增量设置为8而不是5)。多尝试一下这个数字直到您感觉合适即可。