实例学习: 宝物猎人

我要告诉你你现在已经拥有了全部的技能去开始制作一款游戏。什么?你不相信我?让我为你证明它!让我们来做一个简单的对象收集和躲避的敌人的游戏叫:宝藏猎人。(你能在examples文件夹中找到它。)

Treasure Hunter

宝藏猎手是一个简单的完整的游戏的例子,它能让你把目前所学的所有工具都用上。用键盘的方向键可以帮助探险者找到宝藏并带它出去。六只怪物在地牢的地板上上下移动,如果它们碰到了探险者,探险者变为半透明,而且他右上角的血条会减少。如果所有的血都用完了,”You Lost!”会出现在舞台上;如果探险者带着宝藏到达了出口,显示 “You Won!” 。尽管它是一个基础的原型,宝藏猎手包含了很多大型游戏里很大一部分元素:纹理贴图集,人机交互,碰撞以及多个游戏场景。让我们一起去看看游戏是如何被它们组合起来的,以便于你可以用它作你自己开发的游戏的起点。

代码结构

打开 treasureHunter.html 文件,你将会看到所有的代码都在一个大的文件里。下面是一个关于如何组织所有代码的概览:

  1. //Setup Pixi and load the texture atlas files - call the `setup`
  2. //function when they've loaded
  3. function setup() {
  4. //Initialize the game sprites, set the game `state` to `play`
  5. //and start the 'gameLoop'
  6. }
  7. function gameLoop(delta) {
  8. //Runs the current game `state` in a loop and renders the sprites
  9. }
  10. function play(delta) {
  11. //All the game logic goes here
  12. }
  13. function end() {
  14. //All the code that should run at the end of the game
  15. }
  16. //The game's helper functions:
  17. //`keyboard`, `hitTestRectangle`, `contain` and `randomInt`

把这个当作你游戏代码的蓝图,让我们看看每一部分是如何工作的。

用 setup 函数初始化游戏

一旦纹理图集图片被加载进来了,setup函数就会执行。它只会执行一次,可以让你为游戏执行一次安装任务。这是一个用来创建和初始化对象、精灵、游戏场景、填充数据数组或解析加载JSON游戏数据的好地方。

这是宝藏猎手setup函数的缩略图和它要执行的任务。

  1. function setup() {
  2. //Create the `gameScene` group
  3. //Create the `door` sprite
  4. //Create the `player` sprite
  5. //Create the `treasure` sprite
  6. //Make the enemies
  7. //Create the health bar
  8. //Add some text for the game over message
  9. //Create a `gameOverScene` group
  10. //Assign the player's keyboard controllers
  11. //set the game state to `play`
  12. state = play;
  13. //Start the game loop
  14. app.ticker.add(delta => gameLoop(delta));
  15. }

最后两行代码,state = play;gameLoop()可能是最重要的。运行 gameLoop 切换了游戏的引擎,而且引发了 play 一直被循环调用。但是在我们看它如何工作之前,让我们看看 setup 函数里的代码都做了什么。

创建游戏场景

setup 函数创建了两个被称为gameScenegameOverSceneContainer 分组。他们都被添加到了舞台上。

  1. gameScene = new Container();
  2. app.stage.addChild(gameScene);
  3. gameOverScene = new Container();
  4. app.stage.addChild(gameOverScene);

所有的的游戏主要部分的精灵都被添加到了gameScene分组。游戏结束的文字在游戏结束后显示,应当被添加到gameOverScene分组。

Displaying text

尽管它是在 setup 函数中添加的,但是 gameOverScene不应在游戏一开始的时候显示,所以它的 visible 属性被初始化为 false

  1. gameOverScene.visible = false;

你会在后面看到,为了在游戏结束之后显示文字,当游戏结束gameOverScenevisible 属性会被设置为 true

制作地牢,门,猎人和宝藏

玩家、出口、宝箱和地牢背景图都是从纹理图集制作而来的精灵。有一点很重要的是,他们都是被当做 gameScene 的孩子添加进来的。

  1. //Create an alias for the texture atlas frame ids
  2. id = resources["images/treasureHunter.json"].textures;
  3. //Dungeon
  4. dungeon = new Sprite(id["dungeon.png"]);
  5. gameScene.addChild(dungeon);
  6. //Door
  7. door = new Sprite(id["door.png"]);
  8. door.position.set(32, 0);
  9. gameScene.addChild(door);
  10. //Explorer
  11. explorer = new Sprite(id["explorer.png"]);
  12. explorer.x = 68;
  13. explorer.y = gameScene.height / 2 - explorer.height / 2;
  14. explorer.vx = 0;
  15. explorer.vy = 0;
  16. gameScene.addChild(explorer);
  17. //Treasure
  18. treasure = new Sprite(id["treasure.png"]);
  19. treasure.x = gameScene.width - treasure.width - 48;
  20. treasure.y = gameScene.height / 2 - treasure.height / 2;
  21. gameScene.addChild(treasure);

把它们都放在 gameScene 分组会使我们在游戏结束的时候去隐藏 gameScene 和显示 gameOverScene 操作起来更简单。

制造泡泡怪们

六个泡泡怪是被循环创建的。每一个泡泡怪都被赋予了一个随机的初始位置和速度。每个泡泡怪的垂直速度都被交替的乘以 1 或者 -1 ,这就是每个怪物和相邻的下一个怪物运动的方向都是相反的原因,每个被创建的怪物都被放进了一个名为 blobs 的数组。

  1. let numberOfBlobs = 6,
  2. spacing = 48,
  3. xOffset = 150,
  4. speed = 2,
  5. direction = 1;
  6. //An array to store all the blob monsters
  7. blobs = [];
  8. //Make as many blobs as there are `numberOfBlobs`
  9. for (let i = 0; i < numberOfBlobs; i++) {
  10. //Make a blob
  11. let blob = new Sprite(id["blob.png"]);
  12. //Space each blob horizontally according to the `spacing` value.
  13. //`xOffset` determines the point from the left of the screen
  14. //at which the first blob should be added
  15. let x = spacing * i + xOffset;
  16. //Give the blob a random `y` position
  17. let y = randomInt(0, stage.height - blob.height);
  18. //Set the blob's position
  19. blob.x = x;
  20. blob.y = y;
  21. //Set the blob's vertical velocity. `direction` will be either `1` or
  22. //`-1`. `1` means the enemy will move down and `-1` means the blob will
  23. //move up. Multiplying `direction` by `speed` determines the blob's
  24. //vertical direction
  25. blob.vy = speed * direction;
  26. //Reverse the direction for the next blob
  27. direction *= -1;
  28. //Push the blob into the `blobs` array
  29. blobs.push(blob);
  30. //Add the blob to the `gameScene`
  31. gameScene.addChild(blob);
  32. }

制作血条

当你玩儿宝藏猎人的时候,你会发现当猎人碰到其中一个敌人时,场景右上角的血条宽度会减少。这个血条是如何被制作的?他就是两个相同的位置的重叠的矩形:一个黑色的矩形在下面,红色的上面。他们被分组到了一个单独的 healthBar 分组。 healthBar 然后被添加到 gameScene 并在舞台上被定位。

  1. //Create the health bar
  2. healthBar = new PIXI.DisplayObjectContainer();
  3. healthBar.position.set(stage.width - 170, 4)
  4. gameScene.addChild(healthBar);
  5. //Create the black background rectangle
  6. let innerBar = new PIXI.Graphics();
  7. innerBar.beginFill(0x000000);
  8. innerBar.drawRect(0, 0, 128, 8);
  9. innerBar.endFill();
  10. healthBar.addChild(innerBar);
  11. //Create the front red rectangle
  12. let outerBar = new PIXI.Graphics();
  13. outerBar.beginFill(0xFF3300);
  14. outerBar.drawRect(0, 0, 128, 8);
  15. outerBar.endFill();
  16. healthBar.addChild(outerBar);
  17. healthBar.outer = outerBar;

你会看到 healthBar 添加了一个名为 outer 的属性。它仅仅是引用了 outerBar (红色的矩形)以便于过会儿能够被很方便的获取。

  1. healthBar.outer = outerBar;

你可以不这么做,但是为什么不呢?这意味如果你想控制红色 outerBar 的宽度,你可以像这样顺畅的写如下代码:

  1. healthBar.outer.width = 30;

这样的代码相当整齐而且可读性强,所以我们会一直保留它!

制作消息文字

当游戏结束的时候, “You won!” 或者 “You lost!” 的文字会显示出来。这使用文字纹理制作的,并添加到了 gameOverScene。因为 gameOverScenevisible 属性设为了 false ,当游戏开始的时候,你看不到这些文字。这段代码来自 setup 函数,它创建了消息文字,而且被添加到了 gameOverScene

  1. let style = new TextStyle({
  2. fontFamily: "Futura",
  3. fontSize: 64,
  4. fill: "white"
  5. });
  6. message = new Text("The End!", style);
  7. message.x = 120;
  8. message.y = app.stage.height / 2 - 32;
  9. gameOverScene.addChild(message);

开始游戏

所有的让精灵移动的游戏逻辑代码都在 play 函数里,这是一个被循环执行的函数。这里是 play 函数都做了什么的总体概览:

  1. function play(delta) {
  2. //Move the explorer and contain it inside the dungeon
  3. //Move the blob monsters
  4. //Check for a collision between the blobs and the explorer
  5. //Check for a collision between the explorer and the treasure
  6. //Check for a collision between the treasure and the door
  7. //Decide whether the game has been won or lost
  8. //Change the game `state` to `end` when the game is finsihed
  9. }

让我们弄清楚这些特性都是怎么工作的吧。

移动探险者

探险者是被键盘控制的,实现它的代码跟你在之前学习的键盘控制代码很相似。在 play 函数里, keyboard 对象修改探险者的速度,这个速度和探险者的位置相加。

  1. explorer.x += explorer.vx;
  2. explorer.y += explorer.vy;

控制运动的范围

一个新的地方的是,探险者的运动是被包裹在地牢的墙体之内的。绿色的轮廓表明了探险者运动的边界。

Displaying text

通过一个名为 contain 的自定义函数可以帮助实现。

  1. contain(explorer, {x: 28, y: 10, width: 488, height: 480});

contain 接收两个参数。第一个是你想控制的精灵。第二个是包含了 x, y, widthheight属性的任何一个对象。在这个例子中,控制对象定义了一个区域,它稍微比舞台小了一点,和地牢的尺寸一样。

这里是实现了上述功能的 contain 函数。函数检查了精灵是否跨越了控制对象的边界。如果超出,代码会把精灵继续放在那个边界上。 contain 函数也返回了一个值可能为”top”, “right”, “bottom” 或者 “left” 的 collision 变量,取决于精灵碰到了哪一个边界。(如果精灵没有碰到任何边界,collision 将返回 undefined 。)

  1. function contain(sprite, container) {
  2. let collision = undefined;
  3. //Left
  4. if (sprite.x < container.x) {
  5. sprite.x = container.x;
  6. collision = "left";
  7. }
  8. //Top
  9. if (sprite.y < container.y) {
  10. sprite.y = container.y;
  11. collision = "top";
  12. }
  13. //Right
  14. if (sprite.x + sprite.width > container.width) {
  15. sprite.x = container.width - sprite.width;
  16. collision = "right";
  17. }
  18. //Bottom
  19. if (sprite.y + sprite.height > container.height) {
  20. sprite.y = container.height - sprite.height;
  21. collision = "bottom";
  22. }
  23. //Return the `collision` value
  24. return collision;
  25. }

你会在接下来看到 collision 的返回值在代码里是如何让怪物在地牢的顶部和底部之间来回反弹的。

移动怪物

play 函数也能够移动怪物,保持它们在地牢的墙体之内,并检测每个怪物是否和玩家发生了碰撞。如果一只怪物撞到了地牢的顶部或者底部的墙,它就会被设置为反向运动。完成所有这些功能都是通过一个 forEach循环,它每一帧都会遍历在 blobs 数组里的每一个怪物。

  1. blobs.forEach(function(blob) {
  2. //Move the blob
  3. blob.y += blob.vy;
  4. //Check the blob's screen boundaries
  5. let blobHitsWall = contain(blob, {x: 28, y: 10, width: 488, height: 480});
  6. //If the blob hits the top or bottom of the stage, reverse
  7. //its direction
  8. if (blobHitsWall === "top" || blobHitsWall === "bottom") {
  9. blob.vy *= -1;
  10. }
  11. //Test for a collision. If any of the enemies are touching
  12. //the explorer, set `explorerHit` to `true`
  13. if(hitTestRectangle(explorer, blob)) {
  14. explorerHit = true;
  15. }
  16. });

你可以在上面这段代码中看到, contain 函数的返回值是如何被用来让怪物在墙体之间来回反弹的。一个名为 blobHitsWall 的变量被用来捕获返回值:

  1. let blobHitsWall = contain(blob, {x: 28, y: 10, width: 488, height: 480});

blobHitsWall 通常应该是 undefined。但是如果怪物碰到了顶部的墙,blobHitsWall 将会变成 “top”。如果碰到了底部的墙,blobHitsWall 会变为 “bottom”。如果它们其中任何一种情况为 true,你就可以通过给怪物的速度取反来让它反向运动。这是实现它的代码:

  1. if (blobHitsWall === "top" || blobHitsWall === "bottom") {
  2. blob.vy *= -1;
  3. }

把怪物的 vy (垂直速度)乘以 -1 就会反转它的运动方向。

检测碰撞

在上面的循环代码里用了 hitTestRectangle 来指明是否有敌人碰到了猎人。

  1. if(hitTestRectangle(explorer, blob)) {
  2. explorerHit = true;
  3. }

如果 hitTestRectangle 返回 true,意味着发生了一次碰撞,名为 explorerHit 的变量被设置为了 true。如果 explorerHittrue, play 函数让猎人变为半透明,然后把 health 条减少1像素的宽度。

  1. if(explorerHit) {
  2. //Make the explorer semi-transparent
  3. explorer.alpha = 0.5;
  4. //Reduce the width of the health bar's inner rectangle by 1 pixel
  5. healthBar.outer.width -= 1;
  6. } else {
  7. //Make the explorer fully opaque (non-transparent) if it hasn't been hit
  8. explorer.alpha = 1;
  9. }

如果 explorerHitfalse,猎人的 alpha 属性将保持1,完全不透明。

play 函数也要检测宝箱和探险者之间的碰撞。如果发生了一次撞击, treasure 会被设置为探险者的位置,在做一点偏移。看起来像是猎人携带着宝藏一样。

Displaying text

这段代码实现了上述效果:

  1. if (hitTestRectangle(explorer, treasure)) {
  2. treasure.x = explorer.x + 8;
  3. treasure.y = explorer.y + 8;
  4. }

处理到达出口和结束游戏

游戏结束有两种方式:如果你携带宝藏到达出口你将赢得游戏,或者你的血用完你就死了。

想要获胜,宝箱只需碰到出口就行了。如果碰到了出口,游戏的 state 会被设置为 endmessage 文字会显示 “You won!”。

  1. if (hitTestRectangle(treasure, door)) {
  2. state = end;
  3. message.text = "You won!";
  4. }

如果你的血用完,你将输掉游戏。游戏的 state 也会被设置为 endmessage 文字会显示 “You Lost!”。

  1. if (healthBar.outer.width < 0) {
  2. state = end;
  3. message.text = "You lost!";
  4. }

但是这是什么意思呢?

  1. state = end;

你会在早些的例子看到 gameLoop 在持续的每秒60次的更新 state 函数。 gameLoop 的代码如下:

  1. function gameLoop(delta){
  2. //Update the current game state:
  3. state(delta);
  4. }

你也会记住我们给 state 设定的初始值为 play,这也就是为什么 play 函数会循环执行。通过设置 stateend 我们告诉代码我们想循环执行另一个名为 end 的函数。在大一点的游戏你可能会为每一个游戏等级设置 tileScene 状态和状态集,像leveOne, levelTwolevelThree

end 函数是什么?就是它!

  1. function end() {
  2. gameScene.visible = false;
  3. gameOverScene.visible = true;
  4. }

它仅仅是反转了游戏场景的显示。这就是当游戏结束的时候隐藏 gameScene 和显示 gameOverScene

这是一个如何更换游戏状态的一个很简单的例子,但是你可以想在你的游戏里添加多少状态就添加多少状态,然后给它们添加你需要的代码。然后改变 state 为任何你想循环的函数。

这就是完成宝藏猎人所需要的一切了。然后再通过更多一点的工作就能把这个简单的原型变成一个完整的游戏 - 快去试试吧!