如何用Phaser实现一个全家福拼图H5

2019年11月08日 阅读数:11
这篇文章主要向大家介绍如何用Phaser实现一个全家福拼图H5,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

1、Phaser介绍
2、总体框架搭建
3、资源加载
4、游戏逻辑
5、完成
6、总结
参考文档
css

最近用Phaser作了一个全家福拼图h5的项目,这篇文章将会从零开始讲解如何用Phaser实现,最终效果以下:html


源码:https://github.com/ZENGzoe/phaser-puzzle.git demo:https://zengzoe.github.io/phaser-puzzle/dist/webpack

1、Phaser介绍

Phaser是一个开源的HTML5游戏框架,支持桌面和移动HTML5游戏,支持Canvas和WebGL渲染。官方文档齐全,上手也比较容易。git

Phaser的功能主要还有预加载、物理引擎、图片精灵、群组、动画等。github

更多详细内容能够查看Phaser官网,个人学习过程是主要是边看Phaser案例的实现,边看API文档查看用法。web

2、总体框架搭建

1.目录结构

目录初始结构以下:json

.
├── 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/canvas


2.建立游戏

(1)库引入

index.html引入Phaser官网下载的Phaser库。api

<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');
复制代码

3、资源加载

//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);
复制代码

第一个参数为加载完成事件,第二个参数为指定该事件的上下文。

以上就是预加载的主要实现。


4、游戏逻辑

游戏逻辑大体能够分为四个部分,分别为画面初始化、物件选择面板的建立、元素的编辑、生成长图。

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)从组中获取该对象。

这样就会在页面上建立一个这样的画面:


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类:

四个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.元素编辑

新添加的元素或点击画面区内的元素,会有这样的编辑框出现,使得该元素可进行删除缩放操做。

绘制编辑框

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();
},
复制代码

5、总结

以上是这个h5的主要实现过程,因为代码细节较多,部分代码未贴出,须要配合源码阅读~~

源码:https://github.com/ZENGzoe/phaser-puzzle.git demo:https://zengzoe.github.io/phaser-puzzle/dist/


参考文档

phaser.io/