基于 pixi.js 开发H5游戏黄金矿工

3,043 阅读15分钟


话不多说,先放效果图

项目git地址

文章原地址

这次的H5游戏是做在支付宝小程序中的,支付宝小程序官方并没有建议使用什么样的方式亦或是什么样的物理引擎去开发游戏相关内容,当然它也提供了Canvas的能力,如果使用原生canvas的能力去做,你就不可避免的需要处理图层纹理精灵图Ticker元素碰撞动画时间轴资源加载等等涉及到游戏开发相关的内容,在时间不太充裕的情况下,重复造轮子固然灵活,但这对我来说显然不是一个最好的解决方案。

所以这次采用的是小程序内嵌的webview组件去承载我们H5游戏

有了之前使用Halo游戏物理引擎的经验,在H5游戏的开发上我也算是积累了一些经验,因此这次还是打算站在前人的肩膀上奥利给一下,不过这次用的是PIXIJS去开发这款炒冷饭的游戏。

目前,支付宝小程序的场景越来越多元,使用频次越来越大,蚂蚁官方也为小程序的推广和运营铺垫了很多渠道,对小程序游戏的支持我相信也在官方的日程表上了,不过目前并没有像微信小程序一样拥有一个专门供小游戏开发的引擎,因此在开发相对来说比较复杂的H5游戏时,利用其webview组件去做这样的一种伪Hybrid方式的开发也会为开发者节省更多的时间,因此当大家遇到相似的需求时,希望下面的内容能够给大家一些启发~

接下来我会从以下几个方便简单说下这个项目:

  • 为什么采用pixi.js开发
  • 黄金矿工开发中遇到的一些问题和解决方式
  • 如何和支付宝小程序webview组件进行对接

1. PIXI.JS

在使用H5 2D动画引擎的情况下,我们也许有一下几个选择,PixiJS、Fabric.js、Paper.js、EaselJS、Collie,至于为什么我会选择PIXIJS去做这次的开发,主要是基于以下几个方面:

  • GitHub 20K+ Star,广泛的用户基数意味着Google和Baidu会更容易找到答案,而且代码还在不断更新
  • 示例代码使用了ES6的语法,跟进时代
  • 文档友好,还有个中文版的gitbook,入门门槛低
  • 足够小足够灵活,并没有直接集成笨重的动画库,Audio库或者碰撞检测的库,这意味着我们遇到这些场景时,可以自由的组合市面上不同的SDK去支撑我们的游戏
  • 追求性能,据说它在2D渲染上只有第一没有第二,因为支付宝小程序的兼容性审核,这需要我们的应用能够适配哪怕是中低端的机型,虽然 PixiJS 非常适合制作游戏,但它并不是一个游戏引擎,它的核心本质是尽可能快速有效地在屏幕上移动物体
  • 支持Webgl和Canvas,在渲染方式上支持灵活切换和自动识别,无需专门学习 WebGL 就能感受到强大的硬件加速的力量

基于以上这些原因,我选择了PIXIJS去做这次的主角,下面也列举一些大神基于PIXI做的例子,大家也可以看看:

2. 开发「黄金矿工」

2.1 工程目录

这次只是为了开发一个H5游戏,这是一个重交互轻数据的项目,因此并没有采用像Vue或者是React这样的前端框架,基本的项目框架是:

ES6 + Jquery3 + Less + Rem + Webpack3 + Babel

游戏的框架采用的是:

Pixijs + Bump + TweenMax + TimelineMax + AlloyTouch

另外我也用Node开发了一个轻量的静态资源服务器去承载我的静态资源,接下来的介绍中,我也会一一提到这些内容,项目目录如下:

2.2 处理图层

面对复杂的游戏,我们第一步要做的就是去分离我们的图层,因为不同的图层会包含不同的元素,不同的元素又可能存在不同的交互,适当的分离图层可以使我们在开发不同的样式和交互时更加专注,在这个游戏里,我分成了以下几个图层:

  • 天空背景层,这里包含了用户头像,金币数量,logo
  • 地表土壤层,这里包含了不同的道具,矿工,绳子,钩子,按钮以及按钮下方的文案
  • 弹窗蒙版层,这里包含了可能会出现在弹窗里的内容

2.3 加载相关的精灵元素

Pixi提供了强大的loader对象,它可以加载任何你需要种类的图像资源,我把所有的图像资源集中在了loader.js中,主要处理资源的加载和加载后需要完成的动作,这里传入loadAssets中的cb,就是在加载资源结束后需要做的回调:

资源的统一处理:

export const resources = {
    background1: {
        url: `${Params.oss_domain_fengdie}/static/Images/background-1.jpg?${new Date().getTime()}`,
        sprite: '',
    },

    ...
};

资源的加载:

/**
 * 加载资源
 */
export function loadAssets(cb) {
    PIXI.loader
        .add([
            ...Object.keys(resources).map(key => {
                return resources[key].url;
            })
        ])
        .load(setup);

    // loading 监听
    PIXI.loader.on('progress', function(target) {
        // if (progress == 100) {
        //     $('body').removeClass('loading').scrollTop(0);
        //     console.log('所有资源初始化完毕');
        // }
    });

    function setup() {
        console.log('资源加载完成');
        Object.keys(resources).forEach(key => {
            resources[key].sprite  = new PIXI.Sprite(PIXI.loader.resources[resources[key].url].texture);
        });

        cb();
    }
};

Loader对象提供了诸如:onProgress, onError, onComplete等监听事件,使我们能够在资源预加载过程中,灵活地去处理不同的业务逻辑,这里我们可以看一些示例:

loader.on('progress', function (target, resource) {
    console.log('监听-加载进度方式1:' + target.progress)
}); 

loader.onProgress.add(function (target, resource) { 
    console.log('监听-加载进度方式2:' + target.progress) 
}); 

loader.once('complete', function (target, resource) { 
    console.log('监听-加载完成方式1'); 
    var sprite1 = new PIXI.Sprite(resource.pic1.texture); 
    var sprite2 = new PIXI.Sprite(resource.pic2.texture); 
    app.stage.addChild(sprite1); 
    app.stage.addChild(sprite2); 
}); 

loader.onComplete.add(function (target, resource) { 
    console.log('监听-加载完成方式2');
});

2.4 设计道具的数据结构

在黄金矿工中,涉及到的道具主要包括:金币,炸弹以及福袋,这样的场景下很适合我们采用OO的方式去定义这些对象,他们具有一样的道具特征诸如XY坐标,ID等,但它们又具备不同的显示特征,诸如显示的精灵图,碰撞区域等,这些数据结构都在props.js中定义,首先我们看下这些道具对象的基类:

/**
 * 基础道具类
 */
class Props {
    constructor(id, x, y, scale, container) {
        this._id = id;
        this._x = x;
        this._y = y;
        this._scale = scale;
        this._container = container;
    }
}
  • _id,元素ID
  • _x,元素横坐标
  • _y,元素纵坐标
  • _scale,元素的缩放比例
  • _container,元素所处的父容器

再看一下炸弹对象:

/**
 * 炸弹对象
 */
export class Boom extends Props{
    constructor(id, x, y, scale, container) {
        super(id, x, y, scale, container);
        this.sprite = new PIXI.Sprite(PIXI.Texture.fromImage(resources.boom.url));
        this.propContainer = new PIXI.Container();
        this.hitRec = new PIXI.Graphics();
    }

    /**
     * 渲染元素
     */
    render() {
        this.propContainer.position.set(
            super.x * super.scale, 
            super.y * super.scale,
        );

        this.sprite.scale.set(
            super.scale, super.scale);
        this.propContainer.addChild(this.sprite);
        AnimationOptions.playPropTada(this.sprite);

        // this.hitRec.beginFill(0xff0000);
        this.hitRec.drawRect(
            this.propContainer.width / 4, this.propContainer.height / 3, this.propContainer.width / 2, this.propContainer.height / 3);
        this.hitRec.endFill();

        this.propContainer.addChild(this.hitRec);

        super.container.addChild(this.propContainer);
    }
};
  • sprite,对象需要加载的精灵图,当然也可以不是个精灵图是其他的渲染元素,也许是个Graphic
  • propContainer,包裹这个对象里要显示的元素,比如炸弹对象中,这个属性中包含了炸弹的精灵图和碰撞区域
  • hitRec,这个对象的碰撞区域,似乎PIXI.Sprite的hitArea属性的修改不能触发Bump碰撞检测的变化,所以这里我hack了一个碰撞区域,这个碰撞区域处在这个显示元素的中间,当然如果有更好的办法,希望大家和我多多交流

2.5 创建舞台和画布

由于这次是一个H5游戏,需要兼容不同尺寸的移动端设备,因此我是这样声明我的Stage对象:

const width = $(window).width();
const height = $(window).height();

this._designWidth = 750;
this._designHeight = 1624;

initApp() {
    this._app = new PIXI.Application({
        width: width, 
        height: width * (this._designHeight / this._designWidth),
        forceCanvas: true,
        resolution: 2,
        antialias: true, //消除锯齿
        autoResize: true,
    });

    // 计算元素缩放比例
    this.scale = this.stageWidth / this._designWidth;

    document.getElementById('stage').appendChild(this._app.view);

    console.log('PIXI初始化完毕');
}
  • 这次设计稿的尺寸是:750 X 1624,因此我希望在不同的设备上能够按照设计稿的尺寸去缩放我的元素,从而得出scale缩放比
  • 为了适配不同的屏幕尺寸,需要设置autoResize
  • 为了保证在不同的设备像素下清晰的显示图片,需要将resolution设置为2或者更大的值,这里使用2就足矣
  • 消除图片锯齿,是图片显示的更圆滑
  • 为了使PIXI的渲染能够兼容更多的机型,这里设置了渲染方式是Canvas的方式,PixiJS 默认使用的 WebGL 渲染能够提供更好的性能,但是一些老旧设备并不支持。比如在一台 Android 4.4 测试机上,出现了画面持续闪烁的现象。这个问题在强制使用 Canvas 渲染模式后得到解决,并且动画性能也没有明显下降。

2.6 滑动舞台

所有的精灵都绘制出来了,但是此时屏幕还不可以拖动,这里需要用到一个滑动的库,Alloytouch和Scroller都可以,这里我使用的Alloytouch,这里用户可以滑动的最大距离就是1624 - 屏幕的高度

/**
 * 初始化滚动
 */
initScroll() {
    const target = document.querySelector("#stage");
    Transform(target,true);

    const { background1 } = resources;

    new AlloyTouch({
        touch: 'body', //反馈触摸的dom
        vertical: true,//不必需,默认是true代表监听竖直方向touch
        target: target,
        property: 'translateY',  //被滚动的属性
        sensitivity: 1,//不必需,触摸区域的灵敏度,默认值为1,可以为负数
        factor: 1,//不必需,默认值是1代表touch区域的1px的对应target.y的1
        min: -background1.sprite.height + this.stageHeight, //不必需,滚动属性的最小值
        max: 0, //不必需,滚动属性的最大值
        change: function (value) {
        },
    });

    console.log('舞台滚动初始化完毕');
}

2.7 定位元素

这里我们以弹窗蒙版中的光晕为例,简单地说下如何将这样一个元素显示在页面水平居中的位置上,首先我们可以看下效果:

这里我们看到有一个光晕的图片显示在页面的水平中间,垂直居中向上100个距离的位置上,那这里是如何定位呢,我们可以先看下代码:

const { propHalo, dialogClose } = resources;

// 初始化光晕
propHalo.sprite.scale.set(this.scale, this.scale);
propHalo.sprite.position.set(
    this._dialogContainer.width / 2, 
    this._dialogContainer.height / 2 - 100 * this.scale);
propHalo.sprite.anchor.set(0.5);
this._dialogContainer.addChild(propHalo.sprite);

这里主要做了这样几件事:

  • 为了保证显示的精灵图和设计稿的缩放比例一致,需要设置它的scale是设计稿根据当前屏幕计算出来的缩放比
  • 定位的时候我希望元素的位置是正中央,但是默认定位的时,只根据元素的左上角去定位的,当然这样也并不影响你做居中的设置,无非就是再减去一个元素宽高一般的距离即可,这个translate(-50%, -50%)的道理是一样的,这里我将定位的点设置在元素的中央,就如同transform-origin(50%, 50%)一致,不过在这里设置的是anchor
  • 设置元素显示位置的时候亦是如此,不过由于,整个场景都是存在缩放比例的,所以指定的距离也需要做这样的设置,也就是乘以已开始计算好的scale值,至于这个垂直距离显示了-100,是因为我希望这个元素能够靠上一点罢了

2.8 构建矿工核心对象

在这个游戏里,最重要的三个元素是:转动的钩子,可以伸长缩短的绳子以及可以被碰撞的道具,刚才提到了道具的数据结构,这里我们来说下矿工的数据结构:

// 矿工相关属性
this.goldenHunter = {
    left: true,
    right: false,
    // 爪子是否转动的开关
    hookStop: false,
    // 绳子伸缩动画的开关
    ropeStop: true,
    // 绳子在伸长还是在缩短
    roteExtend: true,
    // 绳子伸长的初始速度
    ropeInitSpeed: 2,
    // 绳子伸长的当前速度
    ropeCurrentSpeed: 0,
    // 绳子伸长的加速度
    ropeAcceleratedSpeed: 0.2,
    // 绳子最长长度
    ropeMaxLength: 888,
    // 钩子初始长度
    ropeInitHeight: 100,
};
  • 需要判断当前钩子是转动到左边还是右边
  • 需要能够控制钩子是否能够转动
  • 需要能够控制设置是否「能够」伸长或者缩短,是否「在」伸长还是缩短
  • 需要能够控制绳子的初始长度,初始速度和伸长缩短的加速度以及最大的伸长长度

2.9 绳子伸长和钩子转动

首先说下绳子,由于绳子是能够伸长和缩短的,因此使用Graphic来自由处理可变化长度的绳子,其实就是一个可长可短的矩形

// 画一个矩形
const rope = new PIXI.Graphics();
rope.beginFill(0x64371f);
rope.drawRect(42 * this.scale, 0, 4 * this.scale, this.goldenHunter.ropeInitHeight * this.scale);
rope.endFill();
this._ropeContainer.addChild(rope);

Pixi提供了基本的Ticker,也就是游戏循环,任何在游戏循环里的代码都会1秒更新60次,这里使用Ticker为了使钩子转动起来,绳子能够伸长,这里我们以转动钩子为例

// 爪子转动动画
this._app.ticker.add(delta => {
    if (this.chances - this.playCount > 0) {
        if (this.goldenHunter.left && !this.goldenHunter.hookStop) {
            this._ropeContainer.rotation += 0.01 * delta;
            if (this._ropeContainer.rotation > 0.8) {
                this.goldenHunter.left = false;
                this.goldenHunter.right = true;
            }
        }

        if (this.goldenHunter.right && !this.goldenHunter.hookStop) {
            this._ropeContainer.rotation -= 0.01 * delta;
            if (this._ropeContainer.rotation < -0.8) {
                this.goldenHunter.left = true;
                this.goldenHunter.right = false;
            }
        }
    }
});

这里我们可以看到,当用户没有游戏机会时,钩子就不再转动了。

这里的转动是匀速转动,当钩子转动到两端时,将钩子的转动方向变更。

2.10 布置道具以及检测碰撞

首先我们可以看下,所有道具布置的区域,每个道具的容器大小以及每个道具的碰撞区域

这里我在土壤层去部署我的所有道具,他们集中在一个720 X 680的区域内,在这个区域内分成了3 X 4个格子,图中紫色的区域就是每个格子,数据结构如下:

this.goldenArea = {
    row: 3,
    column: 4,
    // 道具布局区域长宽
    initWidth: 720,
    initHeight: 680,
    list: [],
    // 触碰到的元素序号
    hitIndex: -1,
};

有了每个道具父容器,接下来我需要在每个格子里去显示我的道具,当然这里的「随机」也是有条件的,用户也许没有用尽所有的游戏机会,就退出了游戏,我需要用户再次进来的时候,之前的在哪里显示的道具,他再次看到的道具依旧在那里。

这里的后端并没有存放每个道具的X,Y坐标,这也确实没有意义,因此后端只提供了一个用来生成随机位置的种子,这个种子是可以回溯这些道具的位置的,那如何去做随机算法的呢,显然Math.random是不可能的,这个随机我是没办法回溯的,这里采用的方式如下:

/**
 * 种子随机数
 * @param {*} seed 
 */
export function seedRandom(seed, min, max) {
    seed = (seed * 9301 + 49297) % 233280;
    const rnd = seed / 233280.0;
    let result = min + rnd * (max - min);
    return result; 
};

传入的seed就是后端提供的随机种子,我们可以看到,它不是一个随机数生成器,而是一个基于提供的种子的伪随机数。至于为什么这样做,大家可以参考这篇文章:为什么“(seed * 9301 49297)%233280 / 233280.0”生成一个随机数?这里我就不再赘述了!

道具布置好了,接下来我们需要检测碰撞了,Pixi 没有内置的碰撞检测系统, 所以这里我使用一个名为 Bump 的库,Bump 是一个易于使用的2D碰撞方法的轻量级库,可与 Pixi 渲染引擎一起使用。它提供了制作大多数2D动作游戏所需的所有碰撞工具。

这里我使用的是Bump中的hit方法,hit 方法是一种通用碰撞检测功能。它会自动检测碰撞中使用的精灵种类,并选择适当的碰撞方法。这意味着你不必记住要使用 Bump 库中的许多碰撞方法的哪一个,你只需要记住一个 hit 。

由于在矿工的游戏中,采用的是矩形碰撞检测的算法~,钩子的PNG和道具的PNG都存在不同程度的透明区域,也就一定程度放大了矩形碰撞检测的区域,圆形碰撞亦是如此,而且我也没找到一个比较好的方式去定义一个精灵元素的可碰撞的区域,因此我在每个元素的中间叠加了一个Graphic,Hack的方式作为这个元素的碰撞区域,有些愚笨,也希望大家有更好的方式和我多多交流!

碰撞的检测,是在钩子伸长的过程中的,由于是12个格子,这里使用遍历的方式去判断钩子是否触碰到了某一个元素:

// 碰撞检测
for (let i = 0;i < this.goldenArea.list.length;i++) {
    let prop = this.goldenArea.list[i];
    if (!(prop instanceof Ghost)) {
        if (prop.propContainer.visible) {
            if (this.bump.hit(
                goldenHook.sprite, prop.hitRec, false, false, true)) {
                this.goldenHunter.roteExtend = false;
                prop.propContainer.visible = false;
                this.distinguish(prop, i, rope.height);
                hasHit = true;
                break;
            }
        }
    }
}

当然,如果这个格子的精灵已经被碰撞过了,就将它的visible属性置为false就好

这里大家会疑问,这个Ghost对象是什么,这里我将它定义为幽灵元素,它具备道具基类的所有属性,只不过他没有长宽,不具备任何精灵图,由于12个格子里分别显示什么元素是由后端计算的,所以有些格子也许是没有显示任何东西的,幽灵元素就是用来填充这样的格子的,我们可以看下它的数据结构:

/**
 * 幽灵元素,即空元素,什么都不做
 */
export class Ghost extends Props{
    constructor(id, x, y, scale, container) {
        super(id, x, y, scale, container);
        this.sprite = null;
    }

    /**
     * 渲染元素
     */
    render() {}
}

当然你也可以不用这样做,这里之所以我用一个幽灵元素来填充这样一个格子,一是为了保证所有格子道具对象化定义,另外也是为了区分一个被勾走的元素所在的格子和一个本来就没有任何元素显示的格子!

2.11 设计动画

这里我采用了TweenMax去构建动画的每一帧,TimelineMax去控制动画的时间轴,这些都是GSAP中比较核心的库,也能够很好的和PIXI协作,这里我们以抽中金币后,小金币的上升渐显渐隐的效果为例:

/**
 * 播放金币上浮动画
 * @param {*} container 
 * @param {*} scale 
 */
export function playFloatGolden(container, scale) {
    const { goldenFloat } = resources;
    const sp = new PIXI.Sprite(PIXI.Texture.fromImage(goldenFloat.url));
    sp.scale.set(scale, scale);
    sp.position.set(120 * scale, 120 * scale);
    sp.alpha = 0;
    container.addChild(sp);

    var tl = new TimelineMax();

    tl.add(TweenMax.to(sp, 1, {
        alpha: 1,
        y: 90 * scale,
    }));

    tl.add(TweenMax.to(sp, 0.5, {
        alpha: 0,
        y: 60 * scale,
    }));

    tl.play();
};

我们可以看到,小金币第一秒会上升90个距离同时透明度恢复100%,接下来的0.5秒会再次上升60个距离,同时透明度为0。

3. 如何对接支付宝小程的webview组件

支付宝小程序提供了webview的开放组件,大家可以参考支付宝官方文档

这里大家要注意,若要小程序和内嵌的H5发生通信并且H5能够捕获到来自小程序的消息,需要H5先发起请求,这里我们以游戏一打开初始化来自后端的数据为例:

H5向小程序发送一个要求初始化数据的消息:

// 支付宝宿主环境监测
alipayH5Utils.isMiniEnv((flag) => {
    if (flag) {
        // H5向小程序拉取系统信息
        alipayH5Utils.postMessage({
            type: alipayH5Utils.INIT_GAME,
        });

    } else {
        alert(alipayH5Utils.NO_MINI_ENV);
    }
});

这里的postMessage定义如下:

postMessage: function(o) {
    if (isOpenAlipayH5Utils) {
        my.postMessage(o);
    } else {
        return true;
    }
},

my实际上就是小程序向webview注入的对象,这是一个和window平级的对象

接下来需要在支付宝小程序侧定义下onMessage这个监听函数,用来处理来自H5的消息:

async onMessage(e) {
    try {
        my.showLoading({
            content: '请稍后...',
        });

        const { detail } = e;
        console.log(detail);

        switch (detail.type) {
            case alipayH5Utils.INIT_GAME:
                this.webViewContext.postMessage({
                    type: alipayH5Utils.INIT_GAME,
                    data: this.data.status,
                });
                break;
        }
    } catch (e) {
        console.log(e);
        this.webViewContext.postMessage({
            type: alipayH5Utils.PROP_CATCH,
            data: {
                status: alipayH5Utils.STATUS_FAIL,
            },
        });

        my.alert({
            title: '异常',
            content: e.data ? e.data.message : '异常',
        });
    }

    my.hideLoading();
}

这样的话,一个基本的双向通信就建立好了!


P.S. 下面是一些小编在开发过程中用到的资料,也分享给大家:

最后还是要说下,在这个游戏开发里,其实还有好多细节需要处理的,在这篇文章里未必能够一一列举出来,也容我日后再逐渐完善,当然也希望大家对上述内容有好的建议能够与我及时沟通,小编的邮箱还是那个:kameleon@126.com,欢迎大家多多交流!