分类目录归档:Unity3D

Unity3D Mecanim动画AnimatorTransitionInfo和AnimatorStateInfo在角色移动和待机平滑切换中的应用

在之前的开发过程中一直仅仅使用到了AnimatorStateInfo这货,平时在做一些判断的时候还特意加入一个判断!Animator.IsInTransition(0)来确定当前这个Animator没有在进行动画过渡,可是这几天同事们总是反应游戏中主角移动起步很慢和停止移动的时候会出现滑步的情况,这个不能忍,听得我很是汗颜啊。

好吧,汗颜的事情就先不表了,我们来看看这个可能是神马问题吧。通常我们游戏当中,角色都会有一个待机的动作,跑步和行走都会有相应地动作,而不同的动作之间的切换,Unity3D会自行做动画融合,这样主角从待机动作切换到跑步的动作时,就不会出现一帧直接切换导致看起来非常机械和卡顿的问题,看起来整个过程会是非常平滑的,这个就是我们要谈到的重点了。

在之前的实现中,我在主角的移动控制脚本PlayerMovement中使用了一段这样的代码来判断当前女主角已经成功的从Idle状态切换到Locomotion状态了

AnimatorStateInfo animInfo = animator.GetCurrentAnimatorStateInfo (0);
if (animInfo.isName ("Locomotion")) {
    // 这里控制角色的位置移动
}

然后在控制角色位置移动的代码段中,我们根据需要进行计算获得女主角在不同坐标上的移动位置,然后将位移作用到角色上。

但是这样做会有什么问题呢?

  • 首先,当角色在从Idle切换到Locomotion动画的过渡中时,上面的这段代码会直接忽略动画过渡的这段时间,所以在女主角从静止起步到跑步的过程中,Animator实际上一直都会保持为Idle的状态,直到整个Transition完成了,Animator的State才会切换到Locomotion,这样的话女主角实际上是在原地播放了这个过渡的动画,而这段时间的动画中,女主角的脚步会从静止切换到小碎步,再到大步跑,而这个时候女主角的位置不会发生变化(没有在女主角跑动动画对应的方向上移动),最终的结果就是主角看起来反应非常慢,起步的时候会出现卡顿,要在原地跑一段时间之后才会移动,这显然是不能接受的。
  • 其次,当角色从Locomotion切换到Idle动画的过渡中时,依然会出现逻辑被忽略的情况,也就是角色实际上已经在从Locomotion切换到Idle了,角色的脚步动作越来越小了,但是因为Animator的设计是在切换过程中,State的名字不会改变,会保持为状态切换之前的状态名,也就是在动画完全切换到Idle之前,State的名字一直都是Locomotion,所以主角在这个时间里头,逻辑会让主角继续运动(因为它满足上面的逻辑,所以还会计算位移,并应用到角色对象上),最终的结果就是看起来主角的跑步动画已经停止但是身体还在动画方向上做位移,出现滑步了,尼玛啊。

既然已经找到问题了,那么我们就肯定有办法来解决它。既然我们知道了动画在切换的过程中可以通过AnimatorTransitionInfo来获取过渡的信息,那么就有办法了,首先我们能确定Idle到Locomotion和Locomotion到Idle的nameHash值,通过比对这两个值就能明确知道当前Animator是从哪个状态切换到哪个状态了,然后根据AnimatorTransitionInfo.normalizedTime可以获取到过渡的进度信息,这样一来我们就能准确的计算出来动画过渡的过程中,角色应有的运动速度了,例如从Idle切换到Locomotion是从速度0到速度4m/s,那么在Idle切换到Locomotion的过程中通过Mathf.Lerp (04normalizedTime)就可以获取实时速度了。最终代码如下:

int hashIdle2Locomotion = Animator.StringToHash ("Base Layer.Idle -> Base Layer.Locomotion");
int hashLocomotion2Idle = Animator.StringToHash ("Base Layer.Locomotion -> Base Layer.Idle");
float moveSpeed = 4f;
float speed = 0f;    
if (animator.IsInTransition (0)) {
    AnimatorTransitionInfo transitionInfo = animator.GetAnimatorTransitionInfo (0);
    float normalizedTime = transitionInfo.normalizedTime;
    if (transitionInfo.nameHash == hashIdle2Locomotion) {
        speed = Mathf.Lerp (0, moveSpeed, normalizedTime);
    } else if (transitionInfo.nameHash == hashLocomotion2Idle) {
        speed = Mathf.Lerp (moveSpeed, 0, normalizedTime);
    }
} else if (animInfo.IsName ("Locomotion")) {
    speed = moveSpeed;
} else if (animInfo.IsName ("Idle")) {
    speed = 0f;
}

Unity3D中使用位运算判断LayerMask是否匹配

在Unity3D开发的过程中,偶尔会碰到需要对物体的Layer进行匹配运算,例如针对不同Layer的GameObject我们可能有不同的处理逻辑。
这个时候能使用位运算来完成这个匹配的话,肯定比我们写一堆的==显得更有逼格啊。那么具体要怎么做呢?

是否直接通过GameObject.layer获取到Layer的值,然后直接跟LayerMask进行位运算呢?不是。因为我们通过GameObject.layer获取到的Layer的值,是我们通过Unity3D Editor中的Edit -> Project Setting -> Tags and Layers 进行Layer设置时候的Layer的数值,这是从0开始依次递增的连续的值,这个显然不适合我们用来做位运算。

实际上我们在通过GameObject.layer获取到Layer值之后,需要先进行移位操作,然后再进行位运算『与』,示例代码如下:

bool IsInLayerMask(GameObject obj, LayerMask layerMask)
{
    // 根据Layer数值进行移位获得用于运算的Mask值
    int objLayerMask = (1 << obj.layer);     
    return (layerMask.value & objLayerMask) > 0;
}

AnimationUtility.GetAnimationEvents获取的动画片段信息中time和SerializedObject对象中保存的time字段信息的区别

今天的工作中原本是需要做一个小工具来批量给某些动画加上一个事件,也就是AnimationEvent这货。查了一下官方文档,也Google了一番,最终在这里找到一个比较符合我需求的解决方案,方案是由Unity Technologies的童鞋提供的,看上去还是蛮靠谱的,传送地址在这里

我也就果断拿过来用了,当然前提还是我们自己需要做一番修改的,因为我的需求是只需要在原有的AnimationClip已有的AnimationEvent列表的最后面添加一个OnSkillEnd的AnimationEvent,所以我需要先获取到已有的AnimationEvent信息列表,这里我就想着直接用AnimationUtility.GetAnimationEvents方法来搞定了,而且我看到前面提到的Unity Technologies的童鞋也是这么做的,那么我也就想着这么搞应该就OK了。

不过事实证明这样还是有问题的,因为通过AnimationUtility.GetAnimationEvents获得的AnimationEvent信息列表中得所有AnimationEvent实例中的time字段确实是触发时间,也就是动画播放的具体时间,是绝对时间。而Unity实际保存到动画FBX文件对应的meta文件中的time字段是相对动画播放进度,可以认为是相对时间。

当我们使用文本方式打开动画FBX的meta文件时,我们会发现在这个YAML文件中有一个events节点,这个节点下面保存的就都是这个AnimationClip上绑定的所有AnimationEvent信息,我们找到time字段看一下就会发现,所有Event节点中的time字段都小于等于1.0,然后我们再通过AnimationUtility.GetAnimationEvents方法将AnimationClip中的AnimationEvent信息读取出来,我们会发现,这个AnimationEvent中time实际上就是等于meta文件中的time字段(动画播放进度百分比)乘以AnimationClip的完整时长。

我们可以看一段YAML文件:

events:
      - time: .0572792366
        functionName: OnSkillTrigger
        data: a_ke_zhan_attack_1#0
        objectReferenceParameter: {instanceID: 0}
        floatParameter: 0
        intParameter: 0
        messageOptions: 0
      - time: .143198088
        functionName: OnSkillTrigger
        data: a_ke_zhan_attack_1#1
        objectReferenceParameter: {instanceID: 0}
        floatParameter: 0
        intParameter: 0
        messageOptions: 0
      - time: .393794745
        functionName: OnSkillTrigger
        data: a_ke_zhan_attack_1#2
        objectReferenceParameter: {instanceID: 0}
        floatParameter: 0
        intParameter: 0
        messageOptions: 0
      - time: .744630039
        functionName: OnSkillTrigger
        data: a_ke_zhan_attack_1#3
        objectReferenceParameter: {instanceID: 0}
        floatParameter: 0
        intParameter: 0
        messageOptions: 0

所以为了避免出现使用实际时间来作为相对时间保存,我们可以选择自行换算或者直接采用同一个标准进行计算,例如我们读取的时候采用直接读取SerializedObject对象中的SerializedProperty来获取time字段,保存的时候也通过SerializedObject对象中SerializedProperty来设置新的time字段就可以了。

NGUI中Panel切换Camera之后丢失尺寸大小的问题

最近在项目中碰到一个比较诡异的问题,让自己着实头疼了一阵子。

具体问题是这样的:

游戏中使用PoolManager将使用过的所有界面Panel进行了缓存,在需要的时候从Pool中Spawn。

而为了避免每个Pool中所有的Prefab实例在切换场景时被自动Destory掉,我选择了将这部分通用的弹出式的UI所储存的Pool设置为dontDestroyOnLoad,这样一来这些界面就会一直存在内存里头,省去了来回内存释放又再次请求的过程,只需要每次从Pool里头Spawn就可以了。

当PanelA在Scene_1中显示过一次,那么PanelA就已经缓存到UIPool中了,当我们切换到Scene_2时,UIPool并不会自动Destory掉。那么这个时候如果我们需要显示PanelA,我们只需要调用SpawnPool的Spawn方法就可以直接把PanelA显示出来就好了。问题来了,这个时候显示的PanelA的尺寸完全不对,原本应该与屏幕宽度完全匹配的PanelA变成了宽度只有2个像素了。

我们这么辛苦地搞了一个自己认为可以节省内存重复申请和释放过程的机制,肯定不能因为碰到这个问题就放弃了对吧。那就开始找问题吧,那么为什么这个PanelA的宽度会最终变成了2呢?

首先,我们先来看看这个Panel的GameObject层级图:

UIPanel

|– UISprite(使用Anchors进行背景图尺寸和位置的设置)

|– UISprite

通过Unity3D Editor中,我们发现通过Anchors来进行尺寸和位置设置的背景UISprite的尺寸和位置相对于UIPanel这个根节点确实没有出错,Left Anchor对应UIPanel的Left相对距离为0,Right Anchor对应UIPanel的Right相对距离为0,Top Anchor对应UIPanel的Bottom的相对距离为100,Bottom Anchor对应UIPanel的Bottom相对距离为0。也就是说背景UISprite的高度还是正确的,但是由于整个UIPanel的尺寸出错了,所以导致了背景UISprite的尺寸和相对位置出现了错误。

那么怎么解决这个问题呢?首先我们可以确认UISprite是通过Anchors来设置尺寸和位置,我们可以在NGUI中找到对应的代码,这段代码在UIWidget.cs文件中的OnAnchor()方法中,这个方法中会获取Anchor设置中相对物体的边界信息,在我们这个案例中这个相对物体就是UIPanel了。UIPanel.cs中通过GetSides()方法来获取UIPanel的尺寸和位置信息,而这个最终的计算会依赖于UIPanel所处Layer的Camera。

这下问题就明朗了,因为我们将UIPanel在场景A中创建并显示出来,之后当UIPanel不再显示的时候将其缓存起来了,但是当我们再次从其他场景切换到场景A中时,我们又再次通过Spawn方法将UIPanel显示出来。此时UIPanel依然会调用其继承自UIRect的GetSlides方法来获取边界信息。

public virtual Vector3[] GetSides (Transform relativeTo)
{
if (anchorCamera != null) return mCam.GetSides(cameraRayDistance, relativeTo);

Vector3 pos = cachedTransform.position;
for (int i = 0; i &lt; 4; ++i)
mSides[i] = pos;

if (relativeTo != null)
{
for (int i = 0; i &lt; 4; ++i)
mSides[i] = relativeTo.InverseTransformPoint(mSides[i]);
}
return mSides;
}

而此时anchorCamera已经为空了,因为我们切换场景的时候,UIPanel首次Spawn出来时自动选择的UI层的Camera对象已经被Destroy了, 而现在我们重新将UIPanel显示出来的时候,UIPanel并不会自动重新选择当前场景中UI层的Camera对象,所以这里就需要我们手动来设置一下UIPanel的Camera。

仔细看了一下UIPanel初始化的时候是如何设置Anchors的,然后发现了有ResetAnchors、UpdateAnchors和ResetAndUpdateAnchors和ResetAndUpdateAnchors就是我们想要找的货了。也就是说我们在当场景切换成功之后,应该手动将SpawnPool中缓存的Panels对象调用一遍ResetAndUpdateAnchors方法,这样一来就不会再出现因为丢失Camera对象,而导致UIPanel尺寸不正确的问题了。我们只需要在OnLevelWasLoaded的回调函数中这些处理一下就好了:

void OnLevelWasLoaded (int level)
{
Debug.Log ("OnLevelWasLoaded: " + level);
foreach (KeyValuePair&lt;string, PrefabPool&gt; pair in _uiSpawnPool.prefabPools) {
PrefabPool pool = pair.Value;
foreach (Transform xform in pool.spawned) {
UIPanel panel = xform.GetComponent ();
if (panel != null) {
panel.ResetAndUpdateAnchors ();
}
}
foreach (Transform xform in pool.despawned) {
UIPanel panel = xform.GetComponent ();
if (panel != null) {
panel.ResetAndUpdateAnchors ();
}
}
}
}

C# Socket如何判断连接已断开

相信很多Unity3D的程序员都会遇到网络编程的情况,虽然目前可能大部分的手游都会优先选择使用非长连接技术来实现,也就是我们常说的直接使用Http来完成跟服务器通信的部分,但是还是难免会有遇到需要使用实时网络连接的时候。那么这时候,我们通常还是需要回到网络编程的根源,那就是直接使用Socket进行编程,.NET Framework中的Socket已经封装得非常的完备了,真的很好使。

但是在使用的过程中难免还是会碰到一丢丢问题的,对吧。今天我要记录的就是在处理连接已断开的这种情况。

在手游的使用场景中,以下情况可能会出现客户端与服务器出现无法连接的情况:

  1. 客户端无法连接到网络,例如:客户端设备当前接入的Wi-Fi热点无法连接到互联网,客户端设备进入无线网络信号很差的地方(例如某些大厦的电梯,某些地铁线路等等),这个时候其实客户端和服务器都没有主动关闭过这个TCP连接,但是网络已经无法正常连接了;
  2. 服务器主动关闭了这条TCP连接,服务器为什么要主动关闭掉我的这条TCP连接呢?设想一下,客户端跟服务器建立TCP连接之后,客户端在10分钟内都没有向服务器发送过任何数据,那么服务器还有必要保留这条连接吗?如果当前在线的玩家较多,服务器可用的连接数已经达到一个容限值,那么是否需要及时将某些已经不活跃(例如10分钟以上没有发送任何消息)的连接主动关闭,回收这些连接资源,以备其他新登录游戏的玩家设备连接使用呢?所以,当服务器主动关闭了TCP连接的时候,10分钟我上完厕所回来了,我重新拿起我的手机,想继续玩我的游戏,那么就需要处理客户端如何重新连接到服务器的问题了;
  3. 服务器网络出现问题,例如服务器所在机房链路出现问题,通过某些网络就是无法访问服务器,例如服务器网线不小心被挖断了或者网线被网管拔了,服务器电源烧了,主板坏了,等等诸如此类的物理设备出现故障的情况下,客户端是无法连接到服务器的,服务器也不可能通过TCP协议给客户端发送什么FIN,ACK,SYN等等状态码啦,这个时候客户端也需要有正常的逻辑来处理这个情况。

其实说了这么多就是想表达一下,我们的客户端可能在任何情况下与服务器失去连接,而这个时候客户端需要合理地来处理这种连接已断开的情况。

我们可以查看一下.NET Framework的文档,看看Socket中的一些相关的属性和方法:

Socket.Connected Property

Gets a value that indicates whether a Socket is connected to a remote host as of the last Send or Receive operation.

The Connected property gets the connection state of the Socket as of the last I/O operation. When it returns false, the Socket was either never connected, or is no longer connected.

The value of the Connected property reflects the state of the connection as of the most recent operation. If you need to determine the current state of the connection, make a nonblocking, zero-byte Send call. If the call returns successfully or throws a WAEWOULDBLOCK error code (10035), then the socket is still connected; otherwise, the socket is no longer connected.

If you call Connect on a User Datagram Protocol (UDP) socket, the Connected property always returns true; however, this action does not change the inherent connectionless nature of UDP.

这个是Connected属性的相关描述,大体的意思就是这个Connected属性会根据当前这个Socket最后一次的I/O操作来进行设定,也就是说这个属性为True还是为False会在Socket最后一次I/O操作完毕之后进行设定,应该也是根据错误返回码SocketError来进行设定的。如果我们使用的是阻塞的Socket的话,那么这个状态会在最后一次I/O操作出错后将这个属性设置为False,如果使用的是非阻塞的Socket就需要我们自己通过发送一个零字节的数据包,然后根据错误返回码来判断Socket连接是否断开。


 

Socket.Receive Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Receives data from a bound Socket into a receive buffer, using the specified SocketFlags.

在我们的项目中我们使用了这个方法来读取Socket中的数据 ,这个方法中的最后一个参数是一个out参数,也就是如果我们在读取数据的时候出现了一些异常,错误码会通过这个参数返回。


Socket.Send Method (Byte[], Int32, Int32, SocketFlags, SocketError)

Sends the specified number of bytes of data to a connected Socket, starting at the specified offset, and using the specified SocketFlags

我们使用这个Send方法来进行数据的发送,同样也是通过最后一个out参数SocketError来进行错误码的返回。

这个SocketError类型中呢有很多很多预定义的错误码,这个需要我们仔细查阅看看有哪些是我们需要使用到的,这个因项目类型和需求不同,这里不一一阐述了。

那么在我们实际的开发过程中呢,我们肯定可以通过判断SocketError错误码来进行一些异常情况的处理,不过有些时候我们其实并不想在出现异常了之后才处理,例如我有一个发送消息的队列,有一条消息发送出错了,我还得把这条消息再放回队列,可是如果直接入队到消息队列的队尾可能会有消息先后顺序的问题等等。所以有些时候我们就需要在异常出现之前就想好解决方法,也就是在我们读取或者发送数据之前就先检查一下连接是否正常消息是否能够成功发送出去,那么这又要肿么搞呢?

先看看Poll方法的描述吧:

Socket.Poll Method

Determines the status of the Socket.

Remarks

The Poll method will check the state of the Socket. Specify SelectMode.SelectRead for the selectMode parameter to determine if the Socket is readable. Specify SelectMode.SelectWrite to determine if the Socket is writable. Use SelectMode.SelectError to detect an error condition. Poll will block execution until the specified time period, measured in microseconds, elapses. Set the microSeconds parameter to a negative integer if you would like to wait indefinitely for a response. If you want to check the status of multiple sockets, you might prefer to use the Select method.

 看上去这个方法貌似就是我们想要的呢,对吧?如果你这么想那就太Naive了,在实际使用的过程中,我就犯了一个不仔细看文档,导致栽了一个大跟头的错误。我写了一段代码用来判断当前Socket是否已经跟服务器断开连接。

Socket socket;
// 省去初始化和连接服务器代码若干
if (!socket.Poll(1000, SelectMode.SelectRead)) {
// 做错误提示等相关的操作
}

事实证明这样是瞎搞,因为.NET Framework的文档关于SelectMode.SelectRead这样描述的。

true if Listen has been called and a connection is pending;
-or-
true if data is available for reading;
-or-
true if the connection has been closed, reset, or terminated;
otherwise, returns false.

你说这不是坑爹是啥?这个意思就是说,如果我们是一个服务器端的Socket,调用了Listen方法,并且有一个外部连接在等待我们处理的时候,这个Poll方法会返回true,如果当前Socket中有数据可以读取时,这个Poll方法也会返回true,或者当这个Socket连接被关闭、重置、强制终止了也会返回true,否则返回false。

实际测试的时候,发现这个值只在Socket缓冲区中有数据可以读取和连接被服务器关闭了之后才会返回true,好吧,我承认自己是猪吧。那么回到我们的初衷上来,我们还是希望能在读取数据之前就能知道这个连接是否已经被关闭了(是否与服务器断开连接了)。那么可以通过下面这段代码来进行判断。

// 省去初始化和连接服务器代码若干
if (socket.Available == 0 && socket.Poll(1000, SelectMode.SelectRead)) {
// 因为根据文档的描述,如果连接正常的话,这两个条件不可能同时成立
// 只有在连接已断开的时候,才可能会出现这两个条件同时成立的情况
}

写到这里的时候,突然之间很想看看Available这个属性又是怎么一回事,好吧,我们再来看看.NET Framework的文档吧。

Socket.Available Property

Gets the amount of data that has been received from the network and is available to be read.

Remarks

If you are using a non-blocking Socket, Available is a good way to determine whether data is queued for reading, before calling Receive. The available data is the total amount of data queued in the network buffer for reading. If no data is queued in the network buffer, Available returns 0.

If the remote host shuts down or closes the connection, Available can throw a SocketException. If you receive a SocketException, use the SocketException.ErrorCode property to obtain the specific error code. After you have obtained this code, refer to the Windows Sockets version 2 API error code documentation in the MSDN library for a detailed description of the error.

虽然文档中提到如果我们使用的是非阻塞的Socket的话,那么Available属性将会是一个用于检测Socket缓冲区中是否有数据可以Receive的好选择,这样我们如果使用阻塞的Socket难道就不能用了吗?当然也不是,肯定还是可以用的,只是说非阻塞的Socket如果一直循环调用Receive很有可能会做无用功,因为如果当前Socket缓冲区中没有数据可以读取的话,Receive方法会很快速的结束调用并且抛出一个SocketException,而如果每次Receive之前检查一下变量的成本相对于调用一次Receive方法可能开销会更小一些吧。而对于阻塞的Socket来说,每次Receive调用都是阻塞直到有数据返回,或者超时后抛出一个超时异常。所以我们阻塞的Socket也是可以用Available这个属性来进行提前判断的。但是实际测试中,Unity3D中使用的Mono中的Socket实现并非和.NET Framework的文档描述一致。关于如果Remote Host关闭连接之后,再调用这个Available属性会抛出异常的行为就没有。

 关于SelectMode中另外两个SelectWrite和SelectError类型,显然我们也不能放过啊。一一测试之后发现,在服务器主动断开连接之后,通过Socket.Poll(1000, SelectMode.SelectWrite)返回一直都是true。即便我们通过Send方法发出去的字节数为0,返回的SocketError中的错误码为Shutdown,依然不为所动啊,所以对于这个SelectMode.SelectWrite我已经凌乱了,希望哪天可以找到合理的解释。

但是Socket.Poll(1000, SelectMode.SelectError)在服务器断开连接之后会在第一时间内从false变成true,貌似这个还是蛮靠谱的,但是看了一下文档之后我又有点凌乱了。

true if processing a Connect that does not block, and the connection has failed;
-or-
true if OutOfBandInline is not set and out-of-band data is available;
otherwise, returns false.

这个意思是说,如果正在建立一个非阻塞的连接,然后这个连接失败了会返回true,或者没有设置OutOfBandInline但是又收到了out-of-band数据的时候会返回true,否则就返回false。

综合上面说的那一大堆东西,最终得出的结论就是Connected字段可以用,但是有滞后性,如果想通过这个字段来检测连接是否已经断开的话,就要明白这个字段是在我们通过Receive或者Send方法成功进行一次I/O操作之后,系统根据最后一次I/O操作的错误返回码来设定Connected字段的值的。

而如果想在进行I/O操作之前就检测到连接出现断开的情况的话,是可以通过Poll来达成目的的,当然这个会比较纠结。首先Poll配合SelectMode.SelectRead和SelectMode.SelectWrite都不能非常直接的反馈连接是否被断开 。但是貌似这个SelectMode.SelectError还是蛮靠谱的,所以呢,从目前测试得到的结果中,可以使用这个来提前判断连接是否已经断开。

不过在实际的应用中,我个人倒是更倾向于在使用SelectMode.SelectError做判断的同时,一定还要对在Receive和Send方法中返回的SocketError进行检查,这个返回码是直接从Native层返回的Socket错误码,非常可靠也很能说明问题。例如”HostDown”、”ShutDown“、”NetworkDown“等等状态码还是很能直观地反应当前Socket的状态的,所以还是需要好好地看看.NET Framework中关于SocketError的描述文档。