如何用Phaser實現一個全家福拼圖H5

嘻嘻哈哈學習發表於2019-03-16

一、Phaser介紹
二、整體框架搭建
三、資源載入
四、遊戲邏輯
五、完成
六、總結
參考文件

最近用Phaser做了一個全家福拼圖h5的專案,這篇文章將會從零開始講解如何用Phaser實現,最終效果如下:

如何用Phaser實現一個全家福拼圖H5


原始碼:https://github.com/ZENGzoe/phaser-puzzle.git demo:https://zengzoe.github.io/phaser-puzzle/dist/

如何用Phaser實現一個全家福拼圖H5

一、Phaser介紹

Phaser是一個開源的HTML5遊戲框架,支援桌面和移動HTML5遊戲,支援Canvas和WebGL渲染。官方文件齊全,上手也比較容易。

Phaser的功能主要還有預載入、物理引擎、圖片精靈、群組、動畫等。

如何用Phaser實現一個全家福拼圖H5

更多詳細內容可以檢視Phaser官網,我的學習過程是主要是邊看Phaser案例的實現,邊看API文件檢視用法。

二、整體框架搭建

1.目錄結構

目錄初始結構如下:

.
├── package.json            
├── postcss.config.js           //postcss配置
├── src                         //主要程式碼目錄
│   ├── css
│   ├── img
│   ├── index.html
│   ├── js  
│   │   └── index.js            //入口檔案
│   ├── json                    //json檔案目錄
│   ├── lib                     //其他庫
│   └── sprite                  //sprite雪碧圖合成目錄
├── webpack.config.build.js     //webpack生成distw檔案配置
└── webpack.config.dev.js       //webpack編譯配置
複製程式碼

專案的構建工具使用的是Webpack, Webpack的配置可以檢視原始碼webapck.config.dev.js,為避免文章篇幅過長,這裡將不會詳細介紹Webpack的配置過程,Webpck的配置介紹可以檢視Webpack的官方文件webpack.github.io/


2.建立遊戲

(1)庫引入

index.html引入Phaser官網下載的Phaser庫。

<script src="js/phaser.min.js"></script>
複製程式碼

(2)建立遊戲

Phaser中通過Phaser.Game來建立遊戲介面,也是遊戲的核心。可以通過建立的這個遊戲物件,新增更多生動的東西。

Phaser.Game(width, height, renderer, parent, state, transparent, antialias, physicsConfig)有八個引數:

width :遊戲介面寬度,預設值為800。
height :遊戲介面高度,預設值為600。
renderer :遊戲渲染器,預設值為Phaser.AUTO,隨機選擇其他值:Phaser.WEBGLPhaser.CANVASPhaser.HEADLESS(不進行渲染)。
parent :遊戲介面掛載的DOM節點,可以為DOM id,或者標籤。
state :遊戲state物件,預設值為null,遊戲的state物件一般包含方法(preload、create、update、render)。
transparent :是否設定遊戲背景為透明,預設值為false。
antialias :是否顯示圖片抗鋸齒。預設值為true。
physicsConfig :遊戲物理引擎配置。


//index.js

//以750寬度視覺搞為準
//選擇是canvas渲染方式
window.customGame = new Phaser.Game(750 , 750 / window.innerWidth * window.innerHeight , Phaser.CANVAS , 'container');

複製程式碼
//index.html
<div id="container"></div>
複製程式碼

這樣就可以在頁面上看到我們的Canvas介面。

3.功能劃分

在專案中,為了將專案模組化,將載入資源邏輯和遊戲邏輯分開,在src/js中新建load.js存放載入資源邏輯,新建play.js存放遊戲邏輯。在這裡的兩個模組以遊戲場景的形式存在。

場景(state)在Phaser中是可以更快地獲取公共函式,比如camera、cache、input等,表現形式為js自定義物件或者函式存在,只要存在preload、create、update這三個方法中地任意一個,就是一個Phaser場景。

在Phaser場景中,總共有五個方法:initpreloadcreateupdaterender。前三個的執行循序為:init => preload => create。

init :在場景中是最先執行的方法,可以在這裡新增場景的初始化。

preload :這個方法在init後觸發,如果沒有init,則第一個執行,一般在這裡進行資源的載入。

create :這個方法在preload後觸發,這裡可以使用預載入中的資源。

update :這是每一幀都會執行一次的更新方法。

render :這是在每次物件渲染之後都會執行渲染方法。

使用者自定義場景可以通過game.state.add方法新增到遊戲中,如在專案中,需要將預載入模組和遊戲邏輯模組加入到遊戲中:

//index.js

...
const load = require('./load');
const play = require('./play');

customGame.state.add('Load' , load);
customGame.state.add('Play' , play);
複製程式碼

game.state.add第一個引數為場景命名,第二個引數為場景。

此時我的遊戲場景就有Load和Play。遊戲中首先要執行的是Load場景,可以通過game.state.start方法來開始執行Load場景。

//index.js

customGame.state.start('Load');
複製程式碼

三、資源載入

//load.js

const load = {
}
module.exports = load;
複製程式碼

1.畫面初始化

進入頁面前,需要進行一些遊戲畫面的初始化。在這裡進行初始化的原因在於在場景裡才能使用一些設定的方法。

(1)新增畫布背景色

//load.js
customGame.stage.backgroundColor = '#4f382b';

複製程式碼

(2)設定螢幕適配模式

由於不同裝置螢幕尺寸不同,需要根據需求設定適合的適配模式。可通過game.scale.scaleMode設定適配模式,適配模式Phaser.ScaleManager有五種:

NO_SCALE :不進行任何縮放

EXACT_FIT :對畫面進行拉伸撐滿螢幕,比例發生變化,會有縮放變形的情況

SHOW_ALL :在比例不變、縮放不變形的基礎上顯示所有的內容,通常使用這種模式

RESIZE :適配畫面的寬度不算高度,不進行縮放,不變形

USER_SCALE : 根據使用者的設定變形

在這裡的適配模式選擇的是SHOW_ALL

//load.js
customGame.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;
複製程式碼

2.資源預載入

Phaser中通過game.load進行載入資源的預載入,預載入的資源可以為圖片、音訊、視訊、雪碧圖等等,這個遊戲的資源只有普通圖片和雪碧圖,其他型別的載入方式可檢視官網文件Phaser. Loader

(1)預載入

普通圖片

customGame.load.image('popup' , '../img/sprite.popup.png');
複製程式碼

普通圖片使用的是game.load.image(圖片key名,圖片地址);

雪碧圖

customGame.load.atlasJSONHash('tvshow' , '../img/tvshow.png' , '' , this.tvshowJson);
複製程式碼

雪碧圖的合成工具我使用的是texturepacker,選擇的是輸出檔案模式是Phaser(JSONHash),因此使用的是atlasJSONHash方法。第一個引數為圖片key名,第二個引數為資源地址,第三個引數為圖片資料檔案地址,第四個引數為圖片資料json或xml物件。

(2)圖片跨域

如果圖片資源和畫布不是同源的,需要設定圖片可跨域。

customGame.load.crossOrigin = 'anonymous';
複製程式碼

(3)監聽載入事件

單個資源載入完成事件

通過onFileComplete方法來監聽每個資源載入完的事件,可以用來獲取載入進度。

customGame.load.onFileComplete.add(this.loadProgress , this);

function loadProgress(progress){
    //progress為獲取的資源進度百分比
    $('.J_loading .progress').text(`${progress}%`)
}
複製程式碼

onFileComplete第一個引數為每個資源載入完的事件,第二個引數為指定該事件的上下文。

全部資源載入完成事件

通過onLoadComplete方法來監聽全部資源載入完成事件。

customGame.load.onLoadComplete.addOnce(this.loadComplete , this);
複製程式碼

第一個引數為載入完成事件,第二個引數為指定該事件的上下文。

以上就是預載入的主要實現。


四、遊戲邏輯

遊戲邏輯大致可以分為四個部分,分別為畫面初始化、物件選擇皮膚的建立、元素的編輯、生成長圖。

1.畫面初始化

初始化的頁面主要有牆面、桌子和電視機,主要是建立這三個物件。在此之前,先介紹下用到的兩個概念。

sprite :可用於展示絕大部分的視覺化的物件。

//建立新影象
//spriteName為預載入資源的唯一key,frame為雪碧圖內的frame名,可通過雪碧圖的json獲得
const newObject = game.add.sprite(0,0,spriteName , frame);

複製程式碼

group :用於包含一系列物件的容器,方便批量操作物件,比如移動、旋轉、放大等。

//建立組
const group1 = game.add.group();
//向組內新增新物件newObject
group1.add(newObject);
複製程式碼

接下來是例項,建立牆面、桌子和電視機:

//play.js
const play = {
    create : function(){
        this.createEditPage();  //建立編輯頁
    },
    createEditPage : function(){
        this.mobilityGroup = customGame.add.group();    //建立mobilityGroup組,用於存放遊戲中的物件
        this.createWall();      //建立牆
        this.createTableSofa('sofatable1.png');     //建立沙發
        this.createTelevision('television1.png');   //建立電視機
    },
    createWall : function(){
        const wall = customGame.add.sprite(0,this.gameHeightHf + 80,'wall1.png');

        wall.anchor.set(0 , 0.5);  
        wall.name = 'wall';

        this.mobilityGroup.add(wall);
    },
    createTableSofa : function(spriteName){
        const tableSofa = customGame.add.sprite(this.gameWidthHf , this.gameHeightHf + 20, 'tableSofa' , spriteName );

        tableSofa.anchor.set(0.5,0.5);
        tableSofa.name = 'tableSofa';
        tableSofa.keyNum = this.keyNum++;   //設定唯一key值

        this.mobilityGroup.add(tableSofa);
    },
}
module.exports = play;
複製程式碼

createTelevision建立同createTableSofa,可通過原始碼檢視。 object.anchor.set(0,0) 設定物件偏移位置的基準點,預設是左上角的位置(0,0),如果是右下角則是(1,1),物件的中間點是(0.5,0.5); object.name = 'name'設定物件的名稱,可通過group.getByName(name)從組中獲取該物件。

這樣就會在頁面上建立一個這樣的畫面:

如何用Phaser實現一個全家福拼圖H5


2.物件選擇皮膚的建立

物件選擇皮膚的主要邏輯可以分為幾部分:建立左側tab和批量建立元素、tab切換、元素滑動和新增元素。

(1)建立左側tab和批量建立元素

物件選擇皮膚可以分為新年快樂框、tab標題、tab內容、完成按鈕四個部分。

...
createEditPage : function(){
    ...
    this.createEditWrap();          //建立編輯皮膚
},
createEditWrap : function(){
    this.editGroup = customGame.add.group();    //editGroup用於存放皮膚的所有元素
    this.createNewyear();           //建立新年快樂框
    this.createEditContent();       //建立tab內容
    this.createEditTab();           //建立tab標題
    this.createFinishBtn();         //建立完成按鈕
}
...
複製程式碼

新年快樂框、tab標題、完成按鈕的實現可以檢視原始碼,這裡主要著重介紹tab內容的實現。

物件選擇皮膚主要有四個tab類:

如何用Phaser實現一個全家福拼圖H5

四個tab類建立方式相同,因此取較為複雜的人物tab類為例介紹實現方法。

這裡插播一些新的API:

graphics: 可以用來繪畫,比如矩形、圓形、多邊形等圖形,還可以用來繪畫直線、圓弧、曲線等各種基本物體。

//新建圖形,第一個引數為x軸位置,第二個引數為y軸位置
const graphicObject = game.add.graphics(0,100); 
//畫一個黑色的矩形
graphicObject.beginFill(0x000000);  //設定矩形的顏色
graphicObject.drawRect(0,0,100 , 100);   //設定矩形的x,y,width,height
複製程式碼

編輯框的實現:

//index.js
createEditContent : function(){
    const maskHeight = this.isIPhoneXX ? (this.gameHeight - 467) : (this.gameHeight - 430);
    const editContent = customGame.add.graphics(0 , this.gameHeight); 
    //遮罩
    const mask = customGame.add.graphics(0, maskHeight);    
    mask.beginFill(0x000000);
    mask.drawRect(0,0,this.gameWidth , 467); 
    //tab內容背景
    editContent.beginFill(0xffffff);
    editContent.drawRect(0,0,this.gameWidth , 350);
    editContent.mask = mask;

    this.editGroup.add(editContent);
    this.editContent = editContent;
    
    //建立人物
    this.createPostContent();
},
複製程式碼

editContent新增了遮罩是為了在子元素滑動的時候,可以遮住滑出的內容。

人物選擇內容框分為左側tab和右側內容。左側tab主要是文字,通過Phaser的text api實現,右側通過封裝的createEditListDetail方法批量生成。

createPostContent : function(){
    const postContent = customGame.add.group(this.editContent);
    
    //左側背景
    const leftTab = customGame.add.graphics(0,0);
    const leftTabGroup = customGame.add.group(leftTab)
    leftTab.beginFill(0xfff7e0);
    leftTab.drawRect(0,0,155 , 350);

    //左側選中背景
    const selected = customGame.add.graphics(0,0);
    selected.beginFill(0xffffff);
    selected.drawRect(0,0,155,70);
    selected.name = 'selected';
    
    //左側文字
    const text = customGame.add.text(155/2 , 23 , "站姿\n坐姿\n癱姿\n不可描述" , {font : "24px" , fill : "#a55344" , align : "center"});
    text.lineSpacing = 35;
    text.anchor.set(0.5 , 0);

    //左側文字區域
    this.createLeftBarSpan(4 ,leftTabGroup );

    //右側sprite合集
    const standSpriteSheet = {
        number : 12,
        info : [
            { name : 'stand' , spriteSheetName : 'stand' , number : 8 , startNum : 0} , 
            { name : 'stand2' , spriteSheetName : 'stand' , number : 4 , startNum : 8}
        ]
    };
    const sitSpriteSheet = { name : 'sit', spriteSheetName : 'sit' , number : 12};
    const stallSpriteSheet = { name : 'stall' , spriteSheetName : 'stall' , number : 13};
    const indescribeSpriteSheet = { name : 'indescribe' , spriteSheetName : 'indescribe' , number : 12};

    // 右側合集
    const standGroup = customGame.add.group();
    const sitGroup = customGame.add.group();
    const stallGroup = customGame.add.group();
    const indescribeGroup = customGame.add.group();

    //右側生成
    const stallSpecialSize = {
        'stall0.png' : 0.35,
        'stall9.png' : 0.35,
        'stall12.png' : 0.8
    };
    const standSpecialSize = {
        'stand8.png' : 0.6,
        'stand9.png' : 0.6,
        'stand10.png' : 0.6,
        'stand11.png' : 0.6,
    }  
    this.createEditListDetail(standSpriteSheet , 0.37 , standGroup , 105 , 220 , 25 , 20 , 40 , 17 , 160 , 590 , standSpecialSize , 4);
    this.createEditListDetail(sitSpriteSheet , 0.42 , sitGroup , 105 , 220, 25 , 20, 40 , 17, 160 , 590 , null , 4);
    this.createEditListDetail(stallSpriteSheet , 0.4 , stallGroup , 170 , 194, 25 , 15, 33 , 30, 160, 590 , stallSpecialSize , 3);
    this.createEditListDetail(indescribeSpriteSheet , 0.4 , indescribeGroup , 105 , 220, 25 , 20, 40 , 17, 160 , 590 , null , 4);

    leftTabGroup.addMultiple([selected,text]);
    postContent.addMultiple([leftTab,sitGroup,standGroup,stallGroup,indescribeGroup])

    this.postContent = postContent;
    this.postLeftTab = leftTabGroup;
    this.sitGroup = sitGroup;
    this.standGroup = standGroup;
    this.stallGroup = stallGroup;
    this.indescribeGroup = indescribeGroup;
},
複製程式碼

右側的內容需要考慮的是不同內容的位置、尺寸和顯示數量不一定的問題,因此需要抽取出不同的設定作為引數傳入:

/**
    * 
    * @param {*} spriteSheet  spriteSheet雪碧圖資訊
    * @param {*} scaleRate    影象顯示的縮放
    * @param {*} group        新建影象存放的組
    * @param {*} spriteWidth  影象顯示區域尺寸的寬度
    * @param {*} spriteHeight 影象顯示區域尺寸的高度
    * @param {*} verticalW     影象顯示區域的橫向間距
    * @param {*} horizentalH   影象顯示區域的縱向間距
    * @param {*} startX        整塊影象區域的x偏移量
    * @param {*} startY        整塊影象區域的y偏移量
    * @param {*} groupleft     左側tab的寬度
    * @param {*} groupWidth    整塊區域的寬度
    * @param {*} specialSize   特殊元素的縮放尺寸,由於元素的尺寸縮放標準不一,因此需要設定特殊元素的縮放尺寸
    * @param {*} verticalNum   列項數量
    */
createEditListDetail : function(spriteSheet , scaleRate , group , spriteWidth , spriteHeight , verticalW , horizentalH , startX , startY , groupleft ,groupWidth , specialSize , verticalNum){
    let { name , spriteSheetName , number } = spriteSheet; 
    const hv = number % verticalNum == 0 ? number : number + (verticalNum-number%verticalNum);
    const box = customGame.add.graphics(groupleft,0,group);
    box.beginFill(0xffffff);
    box.drawRect(0,0,groupWidth,startY + (spriteHeight + horizentalH) * parseInt(hv/verticalNum) + horizentalH);        
    box.name = 'box';

    //由於元素的體積過大,部分元素集不能都合併成一張雪碧圖,因此需要區分合併成一張和多張都情況
    if(spriteSheet.info){
        let i = 0;
        spriteSheet.info.map((item , index) => {
            let { name , spriteSheetName , number} = item;
            for(let j = 0 ; j < number ; j++){
                createOne(i, name , spriteSheetName);
                i++;
            }
        })
    }else{
        for(let i = 0 ;  i < number ; i++ ){
            createOne(i, name , spriteSheetName)
        }
    }
    
    function createOne(i , name , spriteSheetName){
        const x = startX + (spriteWidth+verticalW) * (i%verticalNum) + spriteWidth/2,
                y = startY + (spriteHeight + horizentalH) * parseInt(i/verticalNum) + spriteHeight/2;  
        const item = customGame.add.sprite(x , y , name , `${spriteSheetName}${i}.png`);

        let realScaleRate = scaleRate;

        if(spriteWidth/item.width >= 1.19){
            realScaleRate = 1;
        }
        if(specialSize && specialSize[`${spriteSheetName}${i}.png`]){
            realScaleRate = specialSize[`${spriteSheetName}${i}.png`];
        }
        item.anchor.set(0.5);
        item.scale.set(realScaleRate);
        item.inputEnabled = true;
        box.addChild(item);
    }
},
複製程式碼

到這裡就搭好了遊戲的全部畫面,接下來是tab的切換。

(2)tab切換

tab的切換邏輯是顯示指定的內容,隱藏其他內容。通過組的visible屬性設定元素的顯示和隱藏。

//顯示
newObject.visible = true;
//隱藏
newObject.visible = false;
複製程式碼

除此之外,tab的切換還涉及到元素的點選事件,繫結事件前需要啟用元素的inputEnabled屬性,在元素的events屬性上新增點選事件:

newObject.inputEnabled = true;
newObject.events.onInputDown.add(clickHandler , this);  //第一個引數為事件的回撥函式,第二個引數為繫結的上下文
複製程式碼

以人物選擇內容框的左側tab切換為例

給左側tab新增點選事件:

createPostContent : function(){
    ...
    //組內批量新增點選事件,用setAll設定屬性,用callAll新增事件
    leftTabGroup.setAll('inputEnabled' , true);
    leftTabGroup.callAll('events.onInputDown.add' , 'events.onInputDown' , this.switchPost , this);
},
switchPost : function(e){
    const item = e.name || '';
    if(!item) return;

    let selectedTop = 0;

    switch(item){
        case 'text0' :
            selectedTop = 0;
            this.standGroup.visible = true;
            this.sitGroup.visible = false;
            this.stallGroup.visible = false;
            this.indescribeGroup.visible = false;
            break;
        case 'text1' :
            selectedTop = 70;
            this.standGroup.visible = false;
            this.sitGroup.visible = true;
            this.stallGroup.visible = false;
            this.indescribeGroup.visible = false;
            break;
        case 'text2' :
            selectedTop = 140;
            this.standGroup.visible = false;
            this.sitGroup.visible = false;
            this.stallGroup.visible = true;
            this.indescribeGroup.visible = false;
            break;
        case 'text3' :
            selectedTop = 210;
            this.standGroup.visible = false;
            this.sitGroup.visible = false;
            this.stallGroup.visible = false;
            this.indescribeGroup.visible = true;
    }
    //設定選中框的位置
    this.postLeftTab.getByName('selected').y = selectedTop;
},
複製程式碼

(3)元素滑動和新增元素

這裡把元素滑動和新增元素放在一起是考慮到組內元素的滑動操作和點選操作的衝突,元素的滑動是通過拖拽實現,如果組內元素新增了點選事件,點選事件優先於父元素的拖拽事件,當手指觸控到子元素時,無法觸發拖拽事件。如果忽略子元素的點選事件,則無法捕獲子元素的點選事件。

因此給元素新增滑動的邏輯如下:

1.觸發滑動的父元素的拖拽功能,並且禁止橫向拖拽,允許縱享拖拽。

2.給元素新增物理引擎(因為要給元素一個慣性的速度)。

3.結合onDragStart、onDragStop和onInputUp三個事件的觸發判斷使用者的操作是點選還是滑動,如果是滑動,則三個事件都會觸發,並且onInputUp的事件優先於onDragStop,如果是點選,則只會觸發InputUp。

4.在onDragUpdate設定邊界點,如果使用者滑動超過一定邊界點則只能滑動到邊界點。

5.在onDragStop判斷使用者滑動的距離和時間計算出手勢停止時,給定元素的速度。

6.在onDragStart判斷是否有因慣性正在移動的元素,如果有則讓該元素停止運動,讓移動速度為0。

7.在update裡讓移動元素的速度減少直至為0停下來模擬慣性。

addScrollHandler : function(target){
    let isDrag = false; //判斷是否滑動的標識
    let startY , endY , startTime , endTime;
    const box = target.getByName('box');
    box.inputEnabled = true;
    box.input.enableDrag();
    box.input.allowHorizontalDrag = false;  //禁止橫向拖拽
    box.input.allowVerticalDrag = true;     //允許縱向拖拽
    box.ignoreChildInput = true;            //忽略子元素事件
    box.input.dragDistanceThreshold = 10;       //滑動閾值
    //允許滑動到底部的最高值
    const maxBoxY = -(box.height - 350);       
    //給父元素新增物理引擎
    customGame.physics.arcade.enable(box);

    box.events.onDragUpdate.add(function(){
        //滑到頂部,禁止繼續往下滑
        if(box.y > 100){
            box.y = 100;
        }else if(box.y < maxBoxY - 100){
            //滑到底部,禁止繼續往上滑
            box.y = maxBoxY - 100;
        }
        endY = arguments[3];
        endTime = +new Date();
    } , this);
    box.events.onDragStart.add(function(){
        isDrag = true;
        startY = arguments[3];
        startTime = +new Date();
        if(this.currentScrollBox){
            //如果當前有其他正在滑動的元素,取消滑動
            this.currentScrollBox.body.velocity.y = 0;
            this.currentScrollBox = null;
        }
        
    } , this);
    box.events.onDragStop.add(function(){
        isDrag = false;
        //指定可以點選滑動的區域
        box.hitArea = new Phaser.Rectangle(0,-box.y,box.width,box.height + box.y);
        //向下滑動到極限,給極限到最值位置動畫
        if(box.y > 0){
            box.hitArea = new Phaser.Rectangle(0, 0 , box.width , box.height);
            customGame.add.tween(box).to({ y : 0} , 100 , Phaser.Easing.Linear.None, true , 0 , 0);
            return;
        }
        //向上滑動到極限,給極限到最值位置動畫
        if(box.y < maxBoxY){
            box.hitArea = new Phaser.Rectangle(0, -maxBoxY , box.width , box.height - maxBoxY);
            customGame.add.tween(box).to({ y : maxBoxY} , 100 , Phaser.Easing.Linear.None , true , 0, 0);
            return;
        }
        //模擬滑動停止父元素仍滑動到停止的慣性
        //根據使用者的滑動距離和滑動事件計算元素的慣性滑動速度
        const velocity = (Math.abs(Math.abs(endY) - Math.abs(startY)) / (endTime - startTime)) * 40;
        //scrollFlag標識父元素是向上滑動還是向下滑動
        if(endY > startY){// 向下
            box.body.velocity.y = velocity;
            box.scrollFlag = 'down';
        }else if(endY < startY){ //向上
            box.body.velocity.y = -velocity;
            box.scrollFlag = 'up';
        }   
        this.currentScrollBox = box;         
    } , this);
    box.events.onInputUp.add(function(e , p ){
        if(isDrag) return;

        const curX = p.position.x - e.previousPosition.x;
        const curY = p.position.y - e.previousPosition.y;
        //根據點選區域,判斷使用者點選的是哪個元素
        const idx = e.wrapData.findIndex((val , index , arr) => {
            return curX >= val.minX && curX <= val.maxX && curY >= val.minY && curY <= val.maxY;
        })
        if(idx == -1) return;
        const children = e.children[idx];
        //新增新元素到畫面
        this.addNewMobilityObject(children.key , children._frame.name);
    } , this);
},
dealScrollObject : function(){
    if(this.currentScrollBox && this.currentScrollBox.body.velocity.y !== 0){
        const currentScrollBox = this.currentScrollBox,
                height = currentScrollBox.height,
                width = currentScrollBox.width;

        const maxBoxY = -(height - 350);
        if(currentScrollBox.y > 0){
            currentScrollBox.hitArea = new Phaser.Rectangle(0, 0 , width , height);
            customGame.add.tween(currentScrollBox).to({ y : 0} , 100 , Phaser.Easing.Linear.None, true , 0 , 0);
            currentScrollBox.body.velocity.y = 0;
            return;
        }
        if(currentScrollBox.y < maxBoxY){
            currentScrollBox.hitArea = new Phaser.Rectangle(0, -maxBoxY , width , height - maxBoxY);
            customGame.add.tween(currentScrollBox).to({ y : maxBoxY} , 100 , Phaser.Easing.Linear.None , true , 0, 0);
            currentScrollBox.body.velocity.y = 0;
            return;
        }
        currentScrollBox.hitArea = new Phaser.Rectangle(0,-currentScrollBox.y,width,height + currentScrollBox.y);
        if(currentScrollBox.scrollFlag == 'up'){
            currentScrollBox.body.velocity.y += 1.5;
            if(currentScrollBox.body.velocity.y >= 0){
                currentScrollBox.body.velocity.y = 0;
            }
        }else if(currentScrollBox.scrollFlag == 'down'){
            currentScrollBox.body.velocity.y -= 1.5;
            if(currentScrollBox.body.velocity.y <= 0){
                currentScrollBox.body.velocity.y = 0;
            }
        }
    }
},
update : function(){
    this.dealScrollObject();
}
複製程式碼

每次元素移動都要設定hitArea屬性,用來設定元素的點選和滑動區域。這是因為元素的mask不可見區域還是可點選和滑動的,需要手動設定。

新增元素:

addNewMobilityObject : function(key , name){
    //預設新元素的位置在螢幕居中位置取隨機值
    const randomPos = 30 * Math.random();
    const posX = Math.random() > 0.5 ? this.gameWidthHf + randomPos : this.gameWidthHf - randomPos;
    const posY = Math.random() > 0.5 ? this.gameHeightHf + randomPos : this.gameHeightHf - randomPos;
    const newOne = customGame.add.sprite(posX , posY , key , name);

    newOne.anchor.set(0.5);
    newOne.keyNum = this.keyNum++;

    this.mobilityGroup.add(newOne);
},
複製程式碼

3.元素編輯

新新增的元素或點選畫面區內的元素,會有這樣的編輯框出現,使得該元素可進行刪除縮放操作。

如何用Phaser實現一個全家福拼圖H5

繪製編輯框

addNewMobilityObject : function(){
    ...
    //繫結選中元素
    this.bindObjectSelected(newOne);
    //讓新建元素成為當前選中元素
    this.objectSelected(newOne);
},
bindObjectSelected : function(target){
    target.inputEnabled = true;
    target.input.enableDrag(false , true);
    //繪製編輯框
    target.events.onDragStart.add(this.objectSelected , this ); 
},
objectSelected : function(e, p){
    if(e.name == 'wall' || e.name == this.selectedObject) return;
    //如果點選的元素是當前選中元素,則不進行任何操作
    if(this.selectWrap && e.keyNum == this.selectWrap.keyNum) return;
    //去掉當前選中元素狀態
    this.deleteCurrentWrap();

    const offsetNum = 10 , 
            width = e.width,
            height = e.height, 
            offsetX = -width/2 ,
            offsetY = -height / 2,
            boxWidth = width + 2*offsetNum , 
            boxHeight = height + 2*offsetNum; 
    
    const dashLine = customGame.add.bitmapData(width + 2*offsetNum , height + 2*offsetNum);
    const wrap = customGame.add.image(e.x + offsetX - offsetNum, e.y + offsetY - offsetNum, dashLine)
    wrap.name = 'wrap';
    wrap.keyNum = e.keyNum;

    //繪製虛線
    dashLine.ctx.shadowColor = '#a93e26';
    dashLine.ctx.shadowBlur = 20;
    dashLine.ctx.beginPath();
    dashLine.ctx.lineWidth = 6;
    dashLine.ctx.strokeStyle = 'white';
    dashLine.ctx.setLineDash([12 , 12]);
    dashLine.ctx.moveTo(0,0);
    dashLine.ctx.lineTo(boxWidth , 0);
    dashLine.ctx.lineTo(boxWidth , boxHeight);
    dashLine.ctx.lineTo(0 , boxHeight);
    dashLine.ctx.lineTo(0,0);
    dashLine.ctx.stroke();
    dashLine.ctx.closePath();
    wrap.bitmapDatas = dashLine;

    //刪除按鈕
    const close = customGame.add.sprite(- 27, -23,'objects','close.png');
    close.inputEnabled = true;
    close.events.onInputDown.add(this.deleteObject , this , null , e , e._frame.name);
    wrap.addChild(close);
    //放大按鈕
    const scale = customGame.add.sprite(boxWidth - 27 , -23 , 'objects' , 'scale.png');
    scale.inputEnabled = true;
    scale.events.onInputDown.add(function(ev , pt){
        //判斷使用者是否要縮放元素
        this.isOnTarget = true;
        this.onScaleTarget = e;
        this.onScaleTargetValue = e.scale.x;
    } , this);
    
    wrap.addChild(scale);
    this.selectWrap = wrap;
},
複製程式碼

繪製虛線框使用了BitmapDataapi實現,BitmapData物件可以有canvas context的操作,可以作為圖片或雪碧圖的texture。

create : function(){
    ...
    this.bindScaleEvent();
},
bindScaleEvent : function(){
    this.isOnTarget = false;    //判斷是否按了當前選中元素的縮放按鈕
    this.onScaleTarget = null;      //選中元素
    this.objectscaleRate = null;        //通過滑動位置計算出得縮放倍數
    this.onScaleTargetValue = null;     //選中元素當前的縮放倍數

    customGame.input.addMoveCallback(function(e){
        if(!this.isOnTarget) return;

        const currentMoveX = arguments[1] == 0 ? 1 : arguments[1];
        const currentMoveY = arguments[2] == 0 ? 1 : arguments[2];

        if(!this.objectscaleRate){
            this.objectscaleRate = currentMoveX / currentMoveY;
            return;
        }
        const currentRate = currentMoveX / currentMoveY;
        //元素的縮放要以上一次縮放後的倍數被基礎進行縮放
        let scaleRate = currentRate / this.objectscaleRate - 1 + this.onScaleTargetValue;
        scaleRate = scaleRate <= 0.25 ? 0.25 : scaleRate >=2 ? 2 : scaleRate;
        this.onScaleTarget.scale.set(scaleRate);

        const dashLine = this.selectWrap.bitmapDatas;
        const onScaleTarget = this.onScaleTarget;
        const scaleBtn = this.selectWrap.getChildAt(1);

        const offsetNum = 10 , 
                width = onScaleTarget.width,
                height = onScaleTarget.height, 
                offsetX = -width/2 ,
                offsetY = -height / 2,
                boxWidth = width + 2*offsetNum , 
                boxHeight = height + 2*offsetNum; 
        //元素需要縮放,編輯框只縮放尺寸,不縮放按鈕和虛線實際大小,因此每次縮放都要重新繪製虛線框
        dashLine.clear(0,0,this.selectWrap.width , this.selectWrap.height);
        dashLine.resize(width + 2*offsetNum , height + 2*offsetNum)
        this.selectWrap.x = onScaleTarget.x + offsetX - offsetNum, 
        this.selectWrap.y = onScaleTarget.y + offsetY - offsetNum;
        scaleBtn.x = this.selectWrap.width - 30;

        dashLine.ctx.shadowColor = '#a93e26';
        dashLine.ctx.shadowBlur = 20;
        dashLine.ctx.shadowOffsetX = 0;
        dashLine.ctx.shadowOffsetY = 0;
        dashLine.ctx.beginPath();
        dashLine.ctx.lineWidth = 6;
        dashLine.ctx.strokeStyle = 'white';
        dashLine.ctx.setLineDash([12 , 12]);
        dashLine.ctx.moveTo(0,0);
        dashLine.ctx.lineTo(boxWidth , 0);
        dashLine.ctx.lineTo(boxWidth , boxHeight);
        dashLine.ctx.lineTo(0 , boxHeight);
        dashLine.ctx.lineTo(0,0);
        dashLine.ctx.stroke();
        dashLine.ctx.closePath();
    } , this);
    customGame.input.onUp.add(function(){
        this.isOnTarget = false;
        this.onScaleTarget = null;
        this.objectscaleRate = null;
        this.onScaleTargetValue = null;
    } , this);
},
複製程式碼

由於元素的縮放都會改變尺寸,編輯框的只縮放虛線框尺寸,不改變按鈕的尺寸大小,因此每次縮放都要清楚編輯框,重新繪製編輯框。

4.生成長圖

生成長圖較為簡單,只需要通過game.canvas.toDataURL生成。

createFinishBtn : function(){
    ...
    finishBtn.events.onInputUp.add(this.finishPuzzle , this);
},
finishPuzzle : function(){
    //顯示結果頁
    $('.J_finish').show();
    //刪除編輯框
    this.deleteCurrentWrap();
    //隱藏選擇元素皮膚
    this.editGroup.visible = false;
    //建立底部結果二維碼等
    this.createResultBottom();
    //隱藏選擇元素皮膚和建立底部結果二維碼需要時間,需要間隔一段時候後再生成長圖
    setTimeout(() => {
        this.uploadImage();
    } , 100);
},
uploadImage : function(){
    const dataUrl = customGame.canvas.toDataURL('image/jpeg' , 0.7);
    //todo 可以在此將圖片上傳到伺服器再更新到結果頁
    this.showResult(dataUrl);
},
showResult : function(src){
    $('.J_finish .result').attr('src' , src).css({ opacity : 1});
    $('.J_finish .btm').css({opacity : 1});
    $('.J_finish .load').hide();
},
複製程式碼

五、總結

以上是這個h5的主要實現過程,由於程式碼細節較多,部分程式碼未貼出,需要配合原始碼閱讀~~

原始碼:https://github.com/ZENGzoe/phaser-puzzle.git demo:https://zengzoe.github.io/phaser-puzzle/dist/

如何用Phaser實現一個全家福拼圖H5

參考文件

phaser.io/

相關文章