使用 Pixi.js 创建 HTML5 游戏
22

今天(2017.2.14)的 Google 涂鸦是一个小游戏,抛开单身狗不应该玩这个游戏来说,我对这个游戏的实现还是很好奇的,看了一下,是用 pixi.js 来实现的,于是花了两个小时学了一些简单的皮毛,大家可以试试,说不定哪天就用上了。这个地址有几个基础的教程,有兴趣可以自己查看。

了解 pixi.js

pixi.js - The HTML5 Creation Engine,Create beautiful digital content with the fastest, most flexible 2D WebGL renderer.

  • Fast PixiJS' strength is speed. When it comes to 2D rendering, PixiJS is the fastest there is.
  • Flexible Friendly, feature-rich API lets PixiJS take care of the fundamentals whilst you focus on producing incredible multiplatform experiences.
  • Free PixiJS is and always will be Open Source, with a large and supportive community pushing its growth and evolution.

英语太渣,不知道怎么翻译好,直接把官网的介绍 copy 过来.

创建简单的地牢狩猎游戏

基本上是按照这篇教程整理的,所有的图片资源在这里可以找到.

首先看看最终效果。

image_1b8tk6jlkced1c5i1r4s1igi6k9.png-29.9kB

左上角是地牢门,右上角是探险家的血条。地牢里面有一个探险家和 6 只怪物,在地牢的右边有一个宝箱。

游戏规则是:

  • 可以用上下左右控制探险家移动
  • 怪物一直在上下移动
  • 探险家碰到怪物,则血条就一直下降,当血条变为 0 时游戏 game over
  • 探险家取得宝箱并跑出地牢的门,则获胜

初始化项目

pixi.js 与我们常用的 jquery 等 js 库的使用没什么区别,可以使用 Bower、NPM 安装,也可以用 CDN 。

Bower 安装:

$> bower install pixi.js

NPM 安装:

$> npm install pixi.js

CDN(cdnjs):

<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/4.2.2/pixi.min.js"></script>

这里我使用 npm 来安装。

首先创建文件夹。

$> mkdir demo
$> cd demo

添加 package.json,安装 pixi.js

$> echo "{}" > package.json
$> npm install pixi.js

这个时候已经安装好了.

添加 html 页面

添加 index.html,这个作为游戏的页面。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>pixi game demo</title>
    <script src="node_modules/pixi.js/dist/pixi.min.js"></script>
</head>
<body>
<script src="demo.js"></script>
</body>
</html>

其中 demo.js 放置我们的游戏脚本代码。我们在 demo.js 里面添加一些测试代码:

var type = "WebGL";

if (!PIXI.utils.isWebGLSupported()) {
    type = "canvas";
}

PIXI.utils.sayHello(type);

现在访问页面,查看控制台,输出如下信息:

  Pixi.js 4.3.5 - ✰ WebGL ✰      http://www.pixijs.com/    ♥♥♥ 

到现在为止基本的设置就完成了,接下来就是添加代码了。

创建渲染器

在页面上创建动画的第一步就是需要添加一个渲染器(renderer),也就是创建一个可以播放动画的区域,相当于 html 页面上的 <canvas> 元素。PIXI 有一个 renderer 类来自动生成 html 元素以及处理图像的显示。代码如下:

var renderer = PIXI.autoDetectRenderer(512, 512);
document.body.appendChild(renderer.view);

访问页面,可以看到 html 中已经有一个 512*512 的 canvas 元素了。

除了 autoDetectRenderer 接口,还有 CanvasRendererWebGLRenderer 接口,autoDetectRenderer 可以根据客户端对 WebGL 的支持自动创建 WebGL 或 Canvas renderer。

创建舞台

渲染器(renderer)创建之后就是创建一个舞台(stage),舞台相当于一个容器(Container),里面可以添加不同的元素,甚至也可以添加容器,最后由渲染器(renderer)渲染舞台。相当于一个顶级的容器。

在 pixi.js 中有个 Container() 类,这个类就是一个容器。

var stage = new PIXI.Container();

添加舞台之后可以由渲染器(renderer)渲染。

renderer.render(stage);

现在还不需要这么做,因为我们的舞台还没搭建完成。

创建材质集

在动画中最重要的元素是图片(材质),这一类特殊的图片对象在 pixi.js 中称为精灵(sprite),通过控制 sprite 的大小,位置以及一些其他的属性,达到动画的效果。学会创建以及控制精灵(sprite),是学习 pixi.js 最重要的一个技能,也是你学会制作游戏或动画的第一步。

在 pixi 中有一个 sprite 类,可以根据外部的图片(材质)来创建一个可以在 pixi 中使用的 sprite 对象。有三种不同的方式创建:

  • 从某个单独的图片创建
  • 从整个材质图片创建,根据材质上不同的位置和大小截取某部分来创建 sprite
  • 从材质集创建

材质集是一个 json 文件,定义了某个材质图片里面图片的位置和大小等等,这样一方面不用每次创建 sprite 都要定义位置和大小,另外一方面修改了材质图片的时候不用修改代码。

在这个游戏中有五个材质,分别是 blob(怪物),door(大门),dungeon(地牢),explorer(探险家),treasure(宝箱),这几个图片都可以在这里下载。

可以使用 Texture Packer 来创建一个材质 atlas,创建后会生成一张包含所有图片的图片以及定义了图片位置的 json 文件。在 github 上面的 images 文件夹里面也可以下载这两个文件。我们来看看。

treasureHunter.png:
image_1b8tvegj71m36mor1gp2110p56rm.png-22.4kB

treasureHunter.json:

{"frames": {

"blob.png":
{
    "frame": {"x":35,"y":515,"w":32,"h":24},
    "rotated": false,
    "trimmed": false,
    "spriteSourceSize": {"x":0,"y":0,"w":32,"h":24},
    "sourceSize": {"w":32,"h":24},
    "pivot": {"x":0.5,"y":0.5}
},
"door.png":
{
    "frame": {"x":1,"y":515,"w":32,"h":32},
    "rotated": false,
    "trimmed": false,
    "spriteSourceSize": {"x":0,"y":0,"w":32,"h":32},
    "sourceSize": {"w":32,"h":32},
    "pivot": {"x":0.5,"y":0.5}
},
"dungeon.png":
{
    "frame": {"x":1,"y":1,"w":512,"h":512},
    "rotated": false,
    "trimmed": false,
    "spriteSourceSize": {"x":0,"y":0,"w":512,"h":512},
    "sourceSize": {"w":512,"h":512},
    "pivot": {"x":0.5,"y":0.5}
},
"explorer.png":
{
    "frame": {"x":69,"y":515,"w":21,"h":32},
    "rotated": true,
    "trimmed": false,
    "spriteSourceSize": {"x":0,"y":0,"w":21,"h":32},
    "sourceSize": {"w":21,"h":32},
    "pivot": {"x":0.5,"y":0.5}
},
"treasure.png":
{
    "frame": {"x":103,"y":515,"w":28,"h":24},
    "rotated": false,
    "trimmed": false,
    "spriteSourceSize": {"x":0,"y":0,"w":28,"h":24},
    "sourceSize": {"w":28,"h":24},
    "pivot": {"x":0.5,"y":0.5}
}},
"meta": {
    "app": "http://www.codeandweb.com/texturepacker",
    "version": "1.0",
    "image": "treasureHunter.png",
    "format": "RGBA8888",
    "size": {"w":514,"h":548},
    "scale": "1",
    "smartupdate": "$TexturePacker:SmartUpdate:3c5094e02c8477b61f5692e6cac9d0f1:3923663e59fb40b578d66a492a2cda2d:9995f8b4db1ac3cb75651b1542df8ee2$"
}
}

根据材质集加载图片

在 pixi 中有一个 loader 类来管理图片的加载,并且在加载完成后调用回调函数处理。

PIXI.loader
    .add("images/treasureHunter.json")
    .load(setup);

treasureHunter.json 是材质集的配置文件,setup 是在完成图片加载后调用的回调函数。PIXI.loader 在加载完成后可以通过 PIXI.loader.resources 来获取加载的图片。

回调函数

在完成图片加载后,PIXI.loader 会自动调用 setup 函数来进行下一步的处理。我们先定义一个测试方法,看看是否跟预期一样。

function setup() {
    console.log("加载完成.");
}

访问页面,查看控制器,显示如下:

Pixi.js 4.3.5 - ✰ WebGL ✰      http://www.pixijs.com/    ♥♥♥ 

demo.js:19 加载完成.

删掉 setup 里面的内容,加载完图片后接下来要完善我们的舞台了。

创建场景

这个游戏要创建两个场景,一个场景(gameScene)用来显示正常游戏画面,一个场景(gameOverScene)显示游戏结果。在 gameScene 场景中要显示所有的图片,在 gameOverScene 中显示一些文字。

场景很简单,跟舞台一样是一个容器。我们先创建 gameScene。

var gameScene;

function setup() {
    gameScene = new PIXI.Container();
}

在容器中要添加所有的材质并创建对应的 sprite,如何添加?通过 PIXI.loader.resources 可以访问加载的素材。

id = PIXI.loader.resources["images/treasureHunter.json"].textures;
dungeon = new Sprite(id["dungeon.png"]);

创建了 dungeon 的 sprite 之后,需要把 sprite 添加到场景中。

gameScene.addChild(dungeon);

同样的,把 door(大门),explorer(探险家),treasure(宝箱) 等几个材质创建 sprite 并添加到场景中。可以通过 sprite 的 x, y 属性来定义他们在场景中的位置。添加完成后渲染一下,完整的代码以及效果。

function setup() {
    gameScene = new PIXI.Container();
    stage.addChild(gameScene);

    // 获取所有加载的素材
    id = PIXI.loader.resources["images/treasureHunter.json"].textures;
    // 获取地牢素材并创建对应的 sprite
    dungeon = new PIXI.Sprite(id["dungeon.png"]);
    // 添加到场景中
    gameScene.addChild(dungeon);
    // 添加门
    door = new PIXI.Sprite(id["door.png"]);
    door.position.set(32, 0);
    gameScene.addChild(door);
    // 添加探险家
    explorer = new PIXI.Sprite(id["explorer.png"]);
    explorer.x = 68;
    explorer.y = gameScene.height / 2 - explorer.height / 2;
    explorer.vx = 0;
    explorer.vy = 0;
    gameScene.addChild(explorer);
    // 添加宝箱
    treasure = new PIXI.Sprite(id["treasure.png"]);
    treasure.x = gameScene.width - treasure.width - 48;
    treasure.y = gameScene.height / 2 - treasure.height / 2;
    gameScene.addChild(treasure);
    // 渲染舞台,暂时为了查看效果,后面会把这个移到其他的地方
    renderer.render(stage);
}

image_1b8vishtp15l5cbe1g931oogr3f13.png-27kB

接下来添加怪物(blob),用 foreach 循环添加 6 个怪物。

var numberOfBlobs = 6,
        spacing = 48,   // 怪物之间的间隔
        xOffset = 150,  // x 坐标 offset
        speed = 2,      // 运动时的速度
        direction = 1;  // 运动方向

    blobs = [];

    for (var i = 0; i < numberOfBlobs; i++) {
        var blob = new PIXI.Sprite(id["blob.png"]);

        var x = spacing * i + xOffset;

        var y = randomInt(0, stage.height - blob.height);   // randomInt 随机生成指定范围的整数,自定义

        blob.x = x;
        blob.y = y;

        blob.vy = speed * direction;

        direction *= -1;

        blobs.push(blob);

        gameScene.addChild(blob);
    }

randomInt 方法:

return Math.floor(Math.random() * (max - min + 1)) + min;

最后,这个场景还差一个血条。血条的创建原理很简单,创建两个不同颜色的矩形然后添加到场景中就行。

// 添加血条,创建一个容器
healthBar = new PIXI.Container();
healthBar.position.set(stage.width - 170, 6);
gameScene.addChild(healthBar);
// 添加底层黑色矩形,在血条不断降低时显示这个
var innerBar = new PIXI.Graphics();
innerBar.beginFill(0x000000);
innerBar.drawRect(0, 0, 128, 8);
innerBar.endFill();
healthBar.addChild(innerBar);
// 添加外层红色血条
var outerBar = new PIXI.Graphics();
outerBar.beginFill(0xFF3300);
outerBar.drawRect(0, 0, 128, 8);
outerBar.endFill();
healthBar.addChild(outerBar);
// 设置为外层
healthBar.outer = outerBar;

到此 gameScene 场景的创建已经完成了。接下来创建 gameOverScene 场景。

gameOverScene场景相对比较简单,只需要显示一些文字就行,但是 gameOverScene 场景是在 game over 才显示的,visible 为 “false”.

// 创建 `gameOverScene` 组
    gameOverScene = new PIXI.Container();
    gameOverScene.visible = false;

    stage.addChild(gameOverScene);

    // 添加 game over 提示语
    message = new PIXI.Text(
        "The End!",
        {fontFamily: "64px Futura", fill: "white"}
    );

    message.x = 120;
    message.y = stage.height / 2 - 32;

    gameOverScene.addChild(message);

所有的场景已经完成。这些场景都是在 setup 函数中完成的,也就是加载完素材后创建场景。

让他们动起来

我们创建了不同的 sprite,现在是静态的,如何让他们动起来? 原理很简单,让 sprite 的 x,y 坐标不停的递增,这样看起来就像是在动。在 html5 中有一个方法:requestAnimationFrame,这个方法接受一个函数作为参数,然后以每秒60次的频率来调用。我们可以用递归的方式来调用 requestAnimationFrame 方法达到一个动态的效果。

创建一个 gameLoop 函数,并在 setup 函数结尾处调用这个函数。

function setup(){
    ...
    gameLoop();
}

function gameLoop() {
    requestAnimationFrame(gameLoop);
    renderer.render(stage);
}

这个时候可以正常的显示游戏画面,但是还没有动。游戏至少有两种不同的状态,一种是 play,一种是 stop,添加一个 state 变量代表游戏的不同状态,一个 play 函数和 stop 函数,代表不同状态的不同处理逻辑。在 setup 函数中把状态初始化为 play,然后在 gameLoop 函数中调用状态。

function setup(){
    ...
    state = play;
    gameLoop();
}

function gameLoop() {
    requestAnimationFrame(gameLoop);
    // 通过改变 state 的不同值,达到切换状态的目的
    state();
    renderer.render(stage);
}

function play() {
    // 游戏处理逻辑
}
function stop() {
    // 游戏结束处理逻辑
}

在 play 函数中添加一些测试代码,看看是否画面可以正常动起来。

function play() {
    explorer.x += 1;
}

查看页面,探险者已经可以往右边平移了,删除代码,接下来处理运动范围和碰撞。

限制探险家和怪物的运动范围

在上面的那个测试代码中可以看到,探险家一直在往右边跑,然后跑出了画面,而实际上探险家是有一个运动范围的,限定在地牢之中。

添加辅助函数 contain. contain 方法接受两个参数,第一个参数是我们要控制的 sprite,另外一个参数是限制的运动范围。

function contain(sprite, container) {

    var collision = undefined;

    // 如果 sprite 的 x 坐标小于控制范围的 x 坐标,这个时候判定 sprite 已经运动到最左边,x坐标等于控制范围的 x 坐标,并输出这个时候的冲突方向为 left
    if (sprite.x < container.x) {
        sprite.x = container.x;
        collision = "left";
    }

    //Top
    if (sprite.y < container.y) {
        sprite.y = container.y;
        collision = "top";
    }

    //Right
    if (sprite.x + sprite.width > container.width) {
        sprite.x = container.width - sprite.width;
        collision = "right";
    }

    //Bottom
    if (sprite.y + sprite.height > container.height) {
        sprite.y = container.height - sprite.height;
        collision = "bottom";
    }

    //Return the `collision` value
    return collision;
}

让怪物动起来

我们添加了运动限制范围函数之后,就可以先让怪物动起来了。这个游戏定义的是怪物一直沿着 y 轴做往返运动,碰到墙后方向就变为相反。在 play 函数中添加如下代码:

function play() {
    blobs.forEach(function(blob){
        // 怪物的 y 轴不停地累加,累加值就是速度
        blob.y += blob.vy;
        // 如果碰到墙后,就变换方向
        var blobHitsWall = contain(blob, {x: 28, y: 10, width: 488, height: 480});
        if (blobHitsWall === "top" || blobHitsWall === "bottom") {
            blob.vy *= -1;
        }
    });
}

查看页面,这个时候 6 个怪物已经在上下运动了。

用方向键控制探险家的运动

现在我们的探险家还不能动,这个时候添加一些按键事件监听器。

首先添加辅助函数 keyboard,把对应的按键 listener 绑定到事件监听器中。

function keyboard(keyCode) {
    var key = {};
    key.code = keyCode;
    key.isDown = false;
    key.isUp = true;
    key.press = undefined;
    key.release = undefined;
    //The `downHandler`,把 keydown 事件绑定到 press 方法中
    key.downHandler = function (event) {
        if (event.keyCode === key.code) {
            if (key.isUp && key.press) key.press();
            key.isDown = true;
            key.isUp = false;
        }
        event.preventDefault();
    };

    //The `upHandler`,把 keyup 事件绑定到 release 方法中
    key.upHandler = function (event) {
        if (event.keyCode === key.code) {
            if (key.isDown && key.release) key.release();
            key.isDown = false;
            key.isUp = true;
        }
        event.preventDefault();
    };

    //Attach event listeners
    window.addEventListener(
        "keydown", key.downHandler.bind(key), false
    );
    window.addEventListener(
        "keyup", key.upHandler.bind(key), false
    );
    return key;
}

在 setup 函数中添加按键监听.

// 添加按键监听
var left = keyboard(37),
    up = keyboard(38),
    right = keyboard(39),
    down = keyboard(40);

// 当按键按下时,设置速度为 -5 px
left.press = function () {
    explorer.vx = -5;
    explorer.vy = 0;
};
// 当按键释放时,如果其他按键没有按下,设置速度为 0 
left.release = function () {
    if (!right.isDown && explorer.vy === 0) {
        explorer.vx = 0;
    }
};

up.press = function () {
    explorer.vy = -5;
    explorer.vx = 0;
};
up.release = function () {
    if (!down.isDown && explorer.vx === 0) {
        explorer.vy = 0;
    }
};

right.press = function () {
    explorer.vx = 5;
    explorer.vy = 0;
};
right.release = function () {
    if (!left.isDown && explorer.vy === 0) {
        explorer.vx = 0;
    }
};

down.press = function () {
    explorer.vy = 5;
    explorer.vx = 0;
};
down.release = function () {
    if (!up.isDown && explorer.vx === 0) {
        explorer.vy = 0;
    }
};

这个时候按方向键还是没有运动,因为我们没有直接修改 sprite 的x,y 坐标,而是修改x,y 方向的速度 vx,vy 。

在 play 函数中添加如下代码:

// 通过修改 sprite 的vx vy来控制 sprite 是否运动,而 vx,vy 则由按键控制
explorer.x += explorer.vx;
explorer.y += explorer.vy;
// 判断探险家的运动范围
contain(explorer, {x: 28, y: 10, width: 488, height: 480});

查看页面,这个时候我们已经可以用上下左右按键控制探险家的运动了。

判断碰撞

在这个游戏中有两个碰撞要判断:一个是探险家碰撞到怪物的时候,探险家的血条会下降。另外一个碰撞时探险家和宝箱的碰撞以及和门的碰撞。

判断两个 sprite 是否碰撞,首先获取两个 sprite 的中心点的距离,然后获取两个 sprite 的宽度之和的一半,如果小于或等于两个sprite 的中心点距离,则说明发生碰撞了。

添加辅助函数 hitTestRectangle

function hitTestRectangle(r1, r2) {

    var hit, combinedHalfWidths, combinedHalfHeights, vx, vy;
    // 默认没有碰撞
    hit = false;

    // 获取两个 sprite 的中心点在x,y轴上的值
    r1.centerX = r1.x + r1.width / 2;
    r1.centerY = r1.y + r1.height / 2;
    r2.centerX = r2.x + r2.width / 2;
    r2.centerY = r2.y + r2.height / 2;

    //获取 sprite 的半宽或半高
    r1.halfWidth = r1.width / 2;
    r1.halfHeight = r1.height / 2;
    r2.halfWidth = r2.width / 2;
    r2.halfHeight = r2.height / 2;

    // 计算两个 sprite 的 x y 轴的距离
    vx = r1.centerX - r2.centerX;
    vy = r1.centerY - r2.centerY;

    // 计算两个 sprite 的半宽之和及半高之和
    combinedHalfWidths = r1.halfWidth + r2.halfWidth;
    combinedHalfHeights = r1.halfHeight + r2.halfHeight;

    // 首先判断 x 轴方面,如果 x 轴中心点距离小于两个 sprit 的半宽和,则判断 y 轴方面
    if (Math.abs(vx) < combinedHalfWidths) {

        // 如果 y 轴方面半宽和也小于 y 轴中心点的距离,则判定为碰撞
        hit = Math.abs(vy) < combinedHalfHeights;
    } else {

        // 否则没有发生碰撞
        hit = false;
    }

    return hit;
}

完善游戏逻辑

在探险家运动的时候,如果探险家和怪物发生碰撞,则血条下降,下降到小于 0 时修改游戏状态为 stop;如果探险家和宝箱发生碰撞并且和门发生碰撞后,游戏状态修改为 stop;

在 play 函数中添加如下代码:

function play() {
    explorer.x += explorer.vx;
    explorer.y += explorer.vy;
    contain(explorer, {x: 28, y: 10, width: 488, height: 480});

    blobs.forEach(function (blob) {
        blob.y += blob.vy;
        var blobHitsWall = contain(blob, {x: 28, y: 10, width: 488, height: 480});
        if (blobHitsWall === "top" || blobHitsWall === "bottom") {
            blob.vy *= -1;
        }
        // 如果探险家和怪物发生碰撞后,explorerHit 为 true
        if (hitTestRectangle(explorer, blob)) {
            explorerHit = true;
        }
    });

    if (explorerHit) {
        // 探险家透明度变为一半
        explorer.alpha = 0.5;
        // 血条不断下降
        healthBar.outer.width -= 1;
    } else {
        explorer.alpha = 1;
    }
    // 如果探险家碰撞到宝箱,则把探险家和宝箱绑定到一起
    if (hitTestRectangle(explorer, treasure)) {
        treasure.x = explorer.x + 8;
        treasure.y = explorer.y + 8;
    }
    // 如果宝箱碰到门后,则停止游戏,显示胜利
    if (hitTestRectangle(treasure, door)) {
        state = stop;
        message.text = "You won!";
    }
    // 如果血条下降为0后,停止游戏,显示失败.
    if (healthBar.outer.width < 0) {
        state = stop;
        message.text = "You lost!";
    }

看看游戏效果吧. 基本上可以正常运行了,还差最后一个步骤,显示停止画面。

停止游戏

当游戏停止时,state 的状态变为 stop,所以在 loopGame 函数中会不停的调用 stop 函数,这个时候在 stop 函数中添加处理逻辑,只需要简单的控制场景的可见性就行。

function stop() {
    gameScene.visible = false;
    gameOverScene.visible = true;
}

the end

花了 2 个小时学习了一下,结果花了 4 个小时把这篇文章写完。我常常喜欢去尝试一些稀奇古怪的东西,对技术的好奇是驱动我的动力。自己对 pixi 的理解可能也不是特别正确,欢迎指正。

代码

完整代码如下,可以去原文查看更多的技术细节。

html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>pixi game demo</title>
    <script src="node_modules/pixi.js/dist/pixi.js"></script>
</head>
<body>
<script src="demo.js"></script>
</body>
</html>

demo.js:

var type = "WebGL";

if (!PIXI.utils.isWebGLSupported()) {
    type = "canvas";
}

PIXI.utils.sayHello(type);

var renderer = PIXI.autoDetectRenderer(512, 512);
document.body.appendChild(renderer.view);

var stage = new PIXI.Container();

PIXI.loader
    .add("images/treasureHunter.json")
    .load(setup);

var gameScene, id, dungeon, door, explorer, treasure, blobs, healthBar, gameOverScene, state, explorerHit;

function setup() {
    gameScene = new PIXI.Container();
    stage.addChild(gameScene);

    id = PIXI.loader.resources["images/treasureHunter.json"].textures;
    dungeon = new PIXI.Sprite(id["dungeon.png"]);
    gameScene.addChild(dungeon);

    door = new PIXI.Sprite(id["door.png"]);
    door.position.set(32, 0);
    gameScene.addChild(door);

    explorer = new PIXI.Sprite(id["explorer.png"]);
    explorer.x = 68;
    explorer.y = gameScene.height / 2 - explorer.height / 2;
    explorer.vx = 0;
    explorer.vy = 0;
    gameScene.addChild(explorer);

    treasure = new PIXI.Sprite(id["treasure.png"]);
    treasure.x = gameScene.width - treasure.width - 48;
    treasure.y = gameScene.height / 2 - treasure.height / 2;
    gameScene.addChild(treasure);

    var numberOfBlobs = 6,
        spacing = 48,
        xOffset = 150,
        speed = 2,
        direction = 1;

    blobs = [];

    for (var i = 0; i < numberOfBlobs; i++) {
        var blob = new PIXI.Sprite(id["blob.png"]);

        var x = spacing * i + xOffset;

        var y = randomInt(0, stage.height - blob.height);

        blob.x = x;
        blob.y = y;

        blob.vy = speed * direction;

        direction *= -1;

        blobs.push(blob);

        gameScene.addChild(blob);
    }

    // 添加血条,创建一个容器
    healthBar = new PIXI.Container();
    healthBar.position.set(stage.width - 170, 6);
    gameScene.addChild(healthBar);
    // 添加底层黑色矩形,在血条不断降低时显示这个
    var innerBar = new PIXI.Graphics();
    innerBar.beginFill(0x000000);
    innerBar.drawRect(0, 0, 128, 8);
    innerBar.endFill();
    healthBar.addChild(innerBar);
    // 添加外层红色血条
    var outerBar = new PIXI.Graphics();
    outerBar.beginFill(0xFF3300);
    outerBar.drawRect(0, 0, 128, 8);
    outerBar.endFill();
    healthBar.addChild(outerBar);
    // 设置为外层
    healthBar.outer = outerBar;

    // 创建 `gameOverScene` 组
    gameOverScene = new PIXI.Container();
    gameOverScene.visible = false;

    stage.addChild(gameOverScene);

    // 添加 game over 提示语
    message = new PIXI.Text(
        "The End!",
        {fontFamily: "64px Futura", fill: "white"}
    );

    message.x = 120;
    message.y = stage.height / 2 - 32;

    gameOverScene.addChild(message);

    // 添加按键监听
    var left = keyboard(37),
        up = keyboard(38),
        right = keyboard(39),
        down = keyboard(40);

    // 当按键按下时,设置速度为 -5 px
    left.press = function () {
        explorer.vx = -5;
        explorer.vy = 0;
    };
    // 当按键释放时,如果其他按键没有按下,设置速度为 0
    left.release = function () {
        if (!right.isDown && explorer.vy === 0) {
            explorer.vx = 0;
        }
    };

    up.press = function () {
        explorer.vy = -5;
        explorer.vx = 0;
    };
    up.release = function () {
        if (!down.isDown && explorer.vx === 0) {
            explorer.vy = 0;
        }
    };

    right.press = function () {
        explorer.vx = 5;
        explorer.vy = 0;
    };
    right.release = function () {
        if (!left.isDown && explorer.vy === 0) {
            explorer.vx = 0;
        }
    };

    down.press = function () {
        explorer.vy = 5;
        explorer.vx = 0;
    };
    down.release = function () {
        if (!up.isDown && explorer.vx === 0) {
            explorer.vy = 0;
        }
    };

    state = play;
    gameLoop();
}

function play() {
    explorer.x += explorer.vx;
    explorer.y += explorer.vy;
    contain(explorer, {x: 28, y: 10, width: 488, height: 480});

    blobs.forEach(function (blob) {
        blob.y += blob.vy;
        var blobHitsWall = contain(blob, {x: 28, y: 10, width: 488, height: 480});
        if (blobHitsWall === "top" || blobHitsWall === "bottom") {
            blob.vy *= -1;
        }
        // 如果探险家和怪物发生碰撞后,explorerHit 为 true
        if (hitTestRectangle(explorer, blob)) {
            explorerHit = true;
        }
    });

    if (explorerHit) {
        // 探险家透明度变为一半
        explorer.alpha = 0.5;
        // 血条不断下降
        healthBar.outer.width -= 1;
    } else {
        explorer.alpha = 1;
    }
    // 如果探险家碰撞到宝箱,则把探险家和宝箱绑定到一起
    if (hitTestRectangle(explorer, treasure)) {
        treasure.x = explorer.x + 8;
        treasure.y = explorer.y + 8;
    }
    // 如果宝箱碰到门后,则停止游戏,显示胜利
    if (hitTestRectangle(treasure, door)) {
        state = stop;
        message.text = "You won!";
    }
    // 如果血条下降为0后,停止游戏,显示失败.
    if (healthBar.outer.width < 0) {
        state = stop;
        message.text = "You lost!";
    }
}
function stop() {
    gameScene.visible = false;
    gameOverScene.visible = true;
}

function gameLoop() {
    requestAnimationFrame(gameLoop);
    state();
    renderer.render(stage);
}

function randomInt(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
}

function contain(sprite, container) {

    var collision = undefined;

    //Left
    if (sprite.x < container.x) {
        sprite.x = container.x;
        collision = "left";
    }

    //Top
    if (sprite.y < container.y) {
        sprite.y = container.y;
        collision = "top";
    }

    //Right
    if (sprite.x + sprite.width > container.width) {
        sprite.x = container.width - sprite.width;
        collision = "right";
    }

    //Bottom
    if (sprite.y + sprite.height > container.height) {
        sprite.y = container.height - sprite.height;
        collision = "bottom";
    }

    //Return the `collision` value
    return collision;
}

function keyboard(keyCode) {
    var key = {};
    key.code = keyCode;
    key.isDown = false;
    key.isUp = true;
    key.press = undefined;
    key.release = undefined;
    //The `downHandler`
    key.downHandler = function (event) {
        if (event.keyCode === key.code) {
            if (key.isUp && key.press) key.press();
            key.isDown = true;
            key.isUp = false;
        }
        event.preventDefault();
    };

    //The `upHandler`
    key.upHandler = function (event) {
        if (event.keyCode === key.code) {
            if (key.isDown && key.release) key.release();
            key.isDown = false;
            key.isUp = true;
        }
        event.preventDefault();
    };

    //Attach event listeners
    window.addEventListener(
        "keydown", key.downHandler.bind(key), false
    );
    window.addEventListener(
        "keyup", key.upHandler.bind(key), false
    );
    return key;
}

function hitTestRectangle(r1, r2) {

    //Define the variables we'll need to calculate
    var hit, combinedHalfWidths, combinedHalfHeights, vx, vy;

    //hit will determine whether there's a collision
    hit = false;

    //Find the center points of each sprite
    r1.centerX = r1.x + r1.width / 2;
    r1.centerY = r1.y + r1.height / 2;
    r2.centerX = r2.x + r2.width / 2;
    r2.centerY = r2.y + r2.height / 2;

    //Find the half-widths and half-heights of each sprite
    r1.halfWidth = r1.width / 2;
    r1.halfHeight = r1.height / 2;
    r2.halfWidth = r2.width / 2;
    r2.halfHeight = r2.height / 2;

    //Calculate the distance vector between the sprites
    vx = r1.centerX - r2.centerX;
    vy = r1.centerY - r2.centerY;

    //Figure out the combined half-widths and half-heights
    combinedHalfWidths = r1.halfWidth + r2.halfWidth;
    combinedHalfHeights = r1.halfHeight + r2.halfHeight;

    //Check for a collision on the x axis
    if (Math.abs(vx) < combinedHalfWidths) {

        //A collision might be occuring. Check for a collision on the y axis
        hit = Math.abs(vy) < combinedHalfHeights;
    } else {

        //There's no collision on the x axis
        hit = false;
    }

    //`hit` will be either `true` or `false`
    return hit;
}
本帖由系统于 1年前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 7
Summer

这难道是 LC 最长的文章 :smile:

1年前

@Summer 这段时间比较闲,所以就啰嗦了一下 :laughing:

1年前
MrJing

好厉害,用这个做网页小游戏

1年前
乔布斯隆

我也要找个周末的时间去玩一玩

1年前

服气:+1:

1年前

深得我心

1年前

厉害了

1年前

  • 请注意单词拼写,以及中英文排版,参考此页
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`, 更多语法请见这里 Markdown 语法
  • 支持表情,使用方法请见 Emoji 自动补全来咯,可用的 Emoji 请见 :metal: :point_right: Emoji 列表 :star: :sparkles:
  • 上传图片, 支持拖拽和剪切板黏贴上传, 格式限制 - jpg, png, gif
  • 发布框支持本地存储功能,会在内容变更时保存,「提交」按钮点击时清空
  请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!