基於 pixi.js 開發H5遊戲黃金礦工

話不多說,先放效果圖

項目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. 下面是一些小編在開發過程中用到的資料,也分享給大家:

 

最後還是要說下,在這個遊戲開發裏,其實還有好多細節需要處理的,在這篇文章裏未必能夠一一列舉出來,也容我日後再逐漸完善,當然也希望大家對上述內容有好的建議能夠與我及時溝通,小編的郵箱還是那個:[email protected],歡迎大家多多交流!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章