在上一篇教程中,我们了解了一套我自创的新手引导管理框架的使用原理,那么在本篇教程中,我们将考虑新手引导制作中可能遇到的一些棘手问题及探讨其解决方案。Are you ready my baby? Let`s go!
新手引导组件注册时间不对导致引导指示器指示位置出错
我在做一个游戏的新手引导的时候有时候会出现这样的一个问题,就是新手引导中指示玩家点击的位置是一个错误的位置,如下图所示:
可能作者的本意是让箭头指示到右上角那个叉叉代表的关闭按钮处,结果却因为种种原因让箭头指偏了位置,这是一个可以严重也可以不严重的问题。如果你使用的是强制性引导,就像我在上一篇教程中使用的那种使用全屏遮罩限制用户交互范围的方式的话,你一旦发生了位置偏移的问题,那么用户永远也无法点击到你期待他点击的东西了,这样就会造成引导进行不下去的严重后果。
在我的GuideManager中自带的showScreenMask方法可以产生全屏遮罩,它接受的showRect参数代表全屏遮罩中唯一显示出来的能接受交互的矩形区域
/**
* 显示全屏遮罩以限制交互范围
* @param showRect 唯一显示出来的能接受交互的矩形区域
* @param maskAlpha 遮罩透明度
* @param maskColor 遮罩颜色
* @param parent 遮罩添加到的父容器。若留空,则父容器就等于GuideManager.indicatorContainer
*/
public static function showScreenMask( showRect:Rectangle=null, maskAlpha:Number=0.5, maskColor:uint=0,
parent:DisplayObjectContainer=null, maskName:String="hotgirl" ):void{ ... }
showRect指示的区域是相对于parent参数指示的容器的,一般来说,只要我在这一点上没有弄错,显示出来的区域应该也不会出错。比如我将让全屏遮罩直接显示在stage对象上面,那么我就可以这么写:
var maskArea:Rectangle = _guideTarget.getBounds(stage);//getBounds方法的参数——参考系直接选stage对象
GuideManager.showScreenMask(maskArea, 0.5, 0, stage);//showScreenMask方法的parent参数也选择stage对象,与上面取矩形区域的参考系一致
但是有时候往往会事与愿违,我现在想创建一个三步的引导:点击右下角按钮弹出窗口 ——>点击窗口中按钮——>关闭窗口,那么guide.xml写成这样一定是没有问题的:
<step sequence="0" instanceName="ButtomButtonBar" subSeq="1"/>
<step sequence="1" instanceName="Window1" subSeq="1"/>
<step sequence="2" instanceName="Window1" subSeq="2"/>
下面是文档类主要代码:
private function initUI():void
{
.....
_buttonBar.onBtnClick = onButtonBarBtnClick;
.....
}
private function onButtonBarBtnClick(index:int):void
{
var win:DisplayObject;
switch(index)
{
case 0:
win = PopUpManager.createPopUp(Window1);
break;
}
PopUpManager.centerPopUp( Window1 );
}
这段代码给右下角按钮条添加了按钮点击侦听器,在侦听函数中我们判断,若索引位置为0的按钮被点击了,就弹出一个Window1的窗口,弹出窗口之后将其居中。
下面给出Window1的代码:
public class Window1 extends Window implements IGuideComponent
{
private var _btn:CustomButton;
public function Window1()
{
super(200, 200, 0x000000, 1, "面板一号", false);
showCloseButton = true;
_btn = new CustomButton("按我以完成引导!");
addChild( _btn );
_btn.x = (this.width - _btn.width) / 2;
_btn.y = (this.height - _btn.height) / 2;
onClose = function():void{ PopUpManager.removePopUp(Window1); };
GuideManager.register(this);
}
//-------------------------------interface implement----------------------------------//
private var _instanceName:String = "Window1";
private var _guideTarget:CustomButton;
public function guideProcess(data:Object=null):void
{
if( data.subSeq == 1 )
{
_guideTarget = _btn;
}
else if( data.subSeq == 2 )
{
_guideTarget = closeButton;
}
_guideTarget.addEventListener(MouseEvent.CLICK, onNextStep);
var maskArea:Rectangle = _guideTarget.getBounds(stage);
GuideManager.showScreenMask(maskArea);
}
public function guideClear():void
{
//没什么好做的这里
}
private function onNextStep( e:MouseEvent ):void
{
e.currentTarget.removeEventListener(MouseEvent.CLICK, onNextStep);
GuideManager.nextStep();
}
public function get instanceName():String
{
return _instanceName;
}
public function set instanceName(value:String):void
{
_instanceName = value;
}
}
这个的写法事实上是仿造的ButtonBar的写法:在构造函数里就执行了引导注册的工作。然后运行代码后发现在执行到引导第二步:引导用户去点击Window1中的按钮时,全屏遮罩中开放的交互区域位置发生了偏移
这是为什么呢?为什么呢?哪位同学可以告诉我原因?哪位同学知道请举手,哦,奥特曼就别举手了,我怕死!
好吧,没人回答我,那还是我来为各位同学讲一下谜底吧。首先,我们在上一章中介绍过GuideManager的工作流程:当执行nextStep方法跳转到下一步时,若下一步涉及组件未注册,则会暂停,直到下一步组件注册时才会重新开始播放引导。对于刚才案例中我们的窗口组件Window1来说,它的注册工作是在外部调用其构造函数时才去做的,即直到窗口打开时它才会被注册,那么在刚才的案例中我们的操作流程就可以用下图来表示:
我们看到,如果按照这个流程走,那么在Window1还未被弹出前全屏遮罩就会被添加到舞台上,此时,Window1由于还未被添加到舞台上,所以其stage属性为null,那么在Window1.guideProcess()方法中的_guideTarget.getBounds(stage)这条语句的执行结果肯定会出现问题,这就直接导致了显示出的全屏遮罩中给出的可交互区域位置发生问题。所以,总结一下,可交互区域位置错误的主要原因是因为Window1对象被注册的时间过早。
既然找到了原因,那么接下来就想办法拖延Window1注册到GuideManager中的时间就可以了,比如,我们可以在Window1实例被弹出并居中后再执行注册操作:
private function onButtonBarBtnClick(index:int):void
{
var win:DisplayObject;
switch(index)
{
case 0:
win = PopUpManager.createPopUp(Window1);
break;
}
PopUpManager.centerPopUp( Window1 );
if( win is IGuideComponent && GuideManager.isSetUp )
{
GuideManager.register(win as IGuideComponent);
}
}
这样一来,全屏遮罩显示出的可交互区域位置就正确了,当然,你不用担心同一个实例会被多次重复注册,在GuideManager的register方法中会自动忽略已注册过的组件。
以上案例的在线演示地址:
http://www.iamsevent.com/zb_users/UPLOAD/GuideManager/test2/GuideTest.html
源码下载:
http://www.iamsevent.com/zb_users/UPLOAD/GuideManager/Test2.zip
新手引导步骤的记录
在新手引导过程中万一玩家没有完成引导就退出了游戏或者关闭了页面或者掉线了怎么办?为了让用户下次登陆时能够“再续前缘”,我们需要在每完成一步时都记录用户当前进行到的引导步骤。
一般来说,当前进行到的引导步骤都会记录在后端的数据库里面,但是在本例中由于没有后端可以让我通讯,所以我暂时把数据保存在本地Flash缓存SharedObject中。下面给出的SOManager就是负责存取缓存记录的:
/**
* 本地存储管理器
* Created by S_eVent
* at 2013-5-30
*/
public class SOManager
{
private static var so:SharedObject = SharedObject.getLocal("GuideTest");
/** 保存当前引导步骤 */
public static function set step(value:int):void
{
so.data.step = value;
so.flush();
}
/** 获取本地存储的引导步骤 */
public static function get step():int
{
return so.data.step;
}
/** 清除本地存储记录 */
public static function clear():void
{
so.clear();
}
}
接下来,我们需要在游戏启动时取出上一次玩家下线时保存的引导步骤,根据它的值来设置新手引导是否需要播放或者从哪一步开始播放。
private function onAdded( e:Event ):void
{
stage.scaleMode = StageScaleMode.NO_SCALE;
stage.align = StageAlign.TOP_LEFT;
stage.addEventListener(Event.RESIZE, onResize);
onResize(null);
Message.stage = stage;
//新手引导最后一步的sequence是2,如果之前已完成步骤大于这个值,则表示玩家已经
//完成新手引导,否则表示玩家还未完成引导,需要加载引导数据并启动引导
if( SOManager.step <= 2 )
loadGuideXML()
}
.....
private function onGuideXMLLoadComp(e:Event):void
{
......
GuideManager.setUp( _guideData );
GuideManager.stage = stage;
GuideManager.onStepFinish = onStepFinish;
GuideManager.onGuideFinish = onGuideFinish;
//从上次离线时记录的步骤开始
GuideManager.start( getStepIndexBySequence(SOManager.step) );
}
//根据步骤号获取索引号
private function getStepIndexBySequence( s:int ):int
{
var len:int = _guideData.length;
for(var i:int; i<len; i++)
{
if( _guideData[i].sequence == s )
{
return i;
}
}
return 0;
}
//根据索引号获取步骤号
private function getStepSequenceByIndex( index:int ):int
{
var len:int = _guideData.length;
if( index >= len )return len;
if( _guideData[index] )
{
return _guideData[index].sequence;
}
return 0;
}
private function onStepFinish(data:Object):void
{
Message.show("您已完成第" + data.sequence + "步");
//当前步骤完成后需要将下一步步骤号存进本地缓存
SOManager.step = getStepSequenceByIndex( GuideManager.currentStep+1 );
}
在理解上述代码时,我需要再提一下一个步骤的索引号和步骤号之间的区别。索引号指的新手引导各步骤的执行顺序,它是从0开始的连贯数值;而步骤号则等于guide.xml中配置的各步骤标签中的sequence属性,它是不连贯的数值,我们仅依靠它来给全部步骤进行排序以获取各步骤的索引号。要保存在本地/后端数据库中的数据是步骤号,而能被GuideManager识别并使用的是索引号。所以,我们在存取数据时还需要时刻记得进行它们两者之间的转换工作。
运行一下上述代码,看起来一切工作正常,那是否我们就可以安枕无忧了呢?当然不是,考虑下面一种情况,我将进行的新手引导步骤如下:
点击按钮弹出窗口——>点击窗口中的功能按钮——>点击窗口的关闭按钮关闭窗口
如果我在进行到第二步的时候离线了,那么我本地/数据库中记录的步骤号为2,也就是说,下一次我登陆时会从步骤2开始。但是,从步骤2开始有一个坏处就是步骤2是由一个我未打开的窗口负责展示的,此时我上线后发现界面上没有任何箭头或者神马东西指示我去开启这个窗口,那此时作为一个新手玩家的我就会很困惑了,我不知道下一步该怎么做,不知道该点哪个按钮来打开步骤2所涉及的窗口。这一点难免会降低用户体验,为此,我们需要找一个方式来解决该问题,我们理想的情况是,当用户在未完成第三步之前离线,下次上线时依然从步骤1开始,因为步骤2和3是在刚上线时看不到的两个步骤,而步骤1则不然,要是我下次上线时给我从步骤1开始,我就能清楚地回想起我该点哪个按钮以继续上次未做完的新手引导。
如果我想根据我之前的设想来做,那么就不能每一步引导做完后都去同步一下(意思就是将步骤号保存到本地/数据库),为了识别当前做完的步骤是否需要同步,我们再guide.xml中为每个步骤标签增加一个属性:noSynchro(完成该步时是否跳过与同步的工作,若标签中存在该属性且该属性非0,这表示在完成该步骤后不会进行同步工作)
那么此时我们的guide.xml就可以写成这样:
<step sequence="1" instanceName="ButtomButtonBar" subSeq="1" noSynchro="1"/>
<step sequence="2" instanceName="Window1" subSeq="1" noSynchro="1"/>
<step sequence="3" instanceName="Window1" subSeq="2"/>
这样写的后果,就是当完成第1、2步时,不会做同步工作,即下次登陆时不会从第2/3步开始。改完了guide.xml后我们在文档类中再进行相应的修改:
private function onStepFinish(data:Object):void
{
Message.show("您已完成第" + data.sequence + "步");
//仅当不存在noSynchro属性或该属性值为0时才进行同步工作
if( !data.noSynchro )
{
//当前步骤完成后需要将下一步步骤号存进本地缓存
SOManager.step = getStepSequenceByIndex( GuideManager.currentStep+1 );
}
}
只需要在进行同步工作之前加一条判断语句就可以了。此时,我们就可以测试一下,看看结果是否正如我们期望的那样。
在线演示地址:(在进行到步骤2或3时刷新页面,看看第二次打开加载完成后新手引导步骤是从第几步开始的)
http://www.iamsevent.com/zb_users/UPLOAD/GuideManager/test3/GuideTest.html
源码下载:
http://www.iamsevent.com/zb_users/UPLOAD/GuideManager/Test3.zip
开放式引导
我们之前的所有例子都属于强制性引导,即通过一个全屏遮罩或者别的方式来限制用户的可交互区域,强制用户点击你希望他点击的区域。强制引导的好处有二:一,实现起来简单;二,不容易出BUG。然而它的坏处就在于限制了用户的操作自由,遮挡了大部分好看的区域,降低了用户体验。
为了增强用户体验,增加做新手引导时的自由度,有时我们需要实现一个开放式的引导。开放式引导虽然不会像强制性引导那样仅开放非常小的一块可交互范围给用户,但也不会完全开放用户的操作自由。开放式引导的主要难点在于,在某一时刻,哪些功能可用哪些功能不可用,那些不可用的功能又在什么时候会变成可用,这些事情实现起来是比较复杂的。
首先让我们考虑下面一种情况,有两个将要展示引导的窗口Window1和Window2,Window1先展示,Window2后展示:
点击按钮1打开Window1——>完成Window1中展示引导——>点击按钮2打开Window2——>完成Window2中展示引导
那么我期待用户是点击按钮1先打开Window1,在做完了Window1中展示的引导后再去点击按钮2打开Window2,因此,我不希望用户在完成第二步前去打开Window2,否则引导顺序将会乱套。但是,由于我在按钮2上添加了鼠标点击事件CLICK的事件侦听,并在事件处理函数中写了弹出Window2的相关逻辑。如果用户执意要点按钮2,那岂不一定会触发CLICK事件,弹出Window2?对此,我有一个解决方案,就是在未执行到第三步时给按钮2添加一个优先级较高的CLICK事件侦听器,一起来看如下代码:
//----------------------------ButtonBar.as-----------------------//
public function guideProcess(data:Object=null):void
{
_guideTarget = _buttons[data.subSeq-1];
var maskArea:Rectangle = _guideTarget.getBounds(stage);
GuideManager.showRectBorder(maskArea);
_guideTarget.addEventListener(MouseEvent.CLICK, onNextStep);
this.addEventListener(MouseEvent.CLICK, onClickWhenGuiding, false, 1);
}
private function onClickWhenGuiding( e:MouseEvent ):void
{
if( e.target != _guideTarget )
{
e.stopImmediatePropagation();
Message.show("别淘气!");
}
}
addEventListener方法的第四个参数priority代表该事件侦听器的优先级,默认情况下优先级都是0,因此,如果我们在注册事件侦听器的时候传入一个大于0的值给addEventListener方法的第四个参数,那么我们此时注册的侦听函数就会在事件触发时优先被执行到。在事件处理函数中,我们将判断点击目标是否是我们期望用户点击的,若不是,就使用event.stopImmediatePropagation方法来立即停止事件的冒泡,其结果是除了当前事件处理函数外的其他事件处理函数都不再会被调用。在上例中,onClickWhenGuiding事件处理函数在触发CLICK事件时会被优先调用,若在onClickWhenGuiding函数中调用了event.stopImmediatePropagation方法,那么同样侦听CLICK事件的onClick方法就不再会被执行。使用这种方法就可以有效地限制用户进行那些不希望他们做的动作了。(不要直接在onClick方法里面判断当前点击对象是否是_guideTarget,这样会增加耦合性,对于onClick方法来说,它并不需要关心当前有没有在进行新手引导)
使用这种方式来实现的开放式引导在线展示如下:
http://www.iamsevent.com/zb_users/UPLOAD/GuideManager/test4/GuideTest.html
源码下载:
http://www.iamsevent.com/zb_users/UPLOAD/GuideManager/Test4.zip
当然,上面只是说了一种限制用户交互的方式,可能还有更多的情况我没有考虑到,这还需要列位道友在实际开发过程中自己开动脑筋,想出一种耦合性不高又可靠的方案。
本期教程就到这里吧,希望大家喜欢,我提出的这种新手引导方案不一定是最好的,但也希望列位能仔细读一读,取其精华去其糟粕,如有任何意见也可以留言给我哦。出这篇教程的初衷在于让更多的人不用再为做新手引导而头疼,像我以前一个同事,新手引导步骤发生了一些改变,结果他一改就改了好几天,这样的结果是我们谁都不愿意看到和亲身体会的。最后,祝大家六一儿童节快乐啦,哈哈!