49. 2023-02-26周总结

  1. 重新设计摄像机聚焦和震屏功能
  2. 重写一套UI框架
  3. Unity新增Layer无法热更新
  4. 特效Mesh Read/Write Enabled问题
  5. 特效无法通过将scale设置成-1的方式翻转
  6. 统一的账号服务器

重新设计了摄像机聚焦和震屏功能

之前的聚焦和震屏想的比较简单,导致在整个效果及和美术配合上有点问题,所以和美术一起把这一块的需求重新整理了一遍。

聚焦

提供给美术的主要参数:

  • 聚焦类型(单个目标,还是所有目标)
  • 聚焦移动的时长
  • 速度曲线(使用DOTween的模板曲线)

聚焦还是比较简单的,主要确定聚焦的位置,移动的时长,曲线之后直接调用DOTween的DOMove函数即可

震屏

提供给美术的主要参数:

  • 是否需要随机震动的初始方向
  • 初始方向(如果勾选随机初始方向,这个无效)
  • 来回震动的角度偏移量(初始方向确定之后,会以这个方向进行来回震动,这个偏移量控制来回震动的可偏差的角度,比如如果配置5,则往另外一个方向震动的时候可以在175~185度之间随机一个角度)
  • 震动总时长
  • 震动总次数(保证震动时长结束的时候,正好震动完该次数)
  • 震动频率曲线(用来控制震动每次的分布,比如震动越来越慢,还是越来越快,使用DOTween模板曲线)
  • 振幅曲线(直接配置Unity Curve)

震动比较麻烦,需要考虑频率曲线控制震动次数的分布。简单讲解下我的思路,我们这里假定每个震动来回是一次震动,现在如果t时间内,设置需要震动x次,那根据频率曲线,我们对0~x用曲线进行时间上的插值。DOTween有个函数DOVirtual.EasedValue可以进行插值。因为我们无法确定什么时间点真好是1或者2或者x,所以用了循环去做,每次检测下是否到达1或者2,相应的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const float step = 0.03f;

// 每个来回震动花费的时间
var tempDurationList = new List<float>();
float lastTime = 0;
float totalTime = SkillTimeline.FrameToTime(_shockConfig.Duration);
int needValue = 1;
for (float t = 0; t <= totalTime; t += step)
{
float v = DOVirtual.EasedValue(0, _shockConfig.ShockCount, t / totalTime, _shockConfig.FPSEaseType);

if (v >= needValue)
{
tempDurationList.Add(t - lastTime);
lastTime = t;
++needValue;
}
}

if (tempDurationList.Count < _shockConfig.ShockCount)
{
// 还差最后一个
tempDurationList.Add(totalTime - lastTime);
}

这里就根据频率曲线,计算出每次震动需要花费的时间,接下去只需要计算每次位移的时间以及位移的位置,然后通过DOTween的DOMove进行播放即可。

完整的震动代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
protected override void OnEnter()
{
List<float> durationList = CalculateDurationList();
List<Vector3> shockPosList = CalculateShockPosList(durationList);

// 插入所有动画
Sequence sequence = DOTween.Sequence();
for (int i = 0; i < durationList.Count; i++)
{
int index = i;
sequence.Append(_entity.transform.DOMove(shockPosList[index], durationList[index]).SetEase(_shockConfig.AmplitudeEasyType));
}
}

protected override void OnExit()
{
}

protected override void OnUpdate()
{
}

/// <summary>
/// 计算每次移动要花费的时间
/// </summary>
/// <returns></returns>
List<float> CalculateDurationList()
{
const float step = 0.03f;

// 每个来回震动花费的时间
var tempDurationList = new List<float>();
float lastTime = 0;
float totalTime = SkillTimeline.FrameToTime(_shockConfig.Duration);
int needValue = 1;
for (float t = 0; t <= totalTime; t += step)
{
float v = DOVirtual.EasedValue(0, _shockConfig.ShockCount, t / totalTime, _shockConfig.FPSEaseType);

if (v >= needValue)
{
tempDurationList.Add(t - lastTime);
lastTime = t;
++needValue;
}
}

if (tempDurationList.Count < _shockConfig.ShockCount)
{
// 还差最后一个
tempDurationList.Add(totalTime - lastTime);
}

var ret = new List<float>();

// 计算每次移位置花费的时间
float lastLeftTime = 0;
for (int i = 0; i < tempDurationList.Count; i++)
{
ret.Add(tempDurationList[i] / 4 + lastLeftTime);
ret.Add(tempDurationList[i] / 2);
lastLeftTime = tempDurationList[i] / 4;

if (i == tempDurationList.Count - 1)
{
ret.Add(lastLeftTime);
}
}

Assert.AreEqual(ret.Count, 2 * _shockConfig.ShockCount + 1);

return ret;
}

/// <summary>
/// 计算每次震动的位置
/// </summary>
/// <param name="durationList">每次移动位置花费的时间</param>
/// <returns></returns>
List<Vector3> CalculateShockPosList(List<float> durationList)
{
Vector3 originDir = CalculateFirstShockDir();
Vector3 dir = originDir;

var originPos = _entity.transform.position;

float t = 0;
var ret = new List<Vector3>();
float totalTime = SkillTimeline.FrameToTime(_shockConfig.Duration);
for (int i = 0; i < durationList.Count; i++)
{
if (i == durationList.Count - 1)
{
// 回到原始位置
ret.Add(originPos);
break;
}

var duration = durationList[i];
t += duration;
var offset = _shockConfig.AmplitudeCurve.Evaluate(t / totalTime);
ret.Add(originPos + dir * offset);
dir = CalculateReverseDir(originDir, _shockConfig.ShockAngleRandomOffset);
originDir *= -1;
}

return ret;
}

/// <summary>
/// 计算第一次震动方向
/// </summary>
/// <returns></returns>
Vector3 CalculateFirstShockDir()
{
// 第一次震动的方向
var angle = _shockConfig.FirstShockAngle;
if (_shockConfig.RandomFirstShockAngle == true)
{
angle = RandomUtils.Random(0, 360);
}

if (angle < 0)
{
angle = 360 + angle;
}

var radian = Mathf.Deg2Rad * angle;
var dir = new Vector3(Mathf.Cos(radian), Mathf.Sin(radian), 0);

int unitId = _targetList[0];
BattleUnitEntity defenderEntity = BattleUnitEntityManager.Instance.GetBattleUnitEntity(unitId);
if (defenderEntity.BattleUnit.Side == BattleUnit.ESideType.Attacker)
{
dir.x = dir.x * -1;
}
return dir;
}

/// <summary>
/// 当前方向的反方向
/// </summary>
/// <param name="dir">当前方向</param>
/// <param name="angleRandomOffset">随机角度范围</param>
/// <returns></returns>
Vector3 CalculateReverseDir(Vector3 dir, int angleRandomOffset)
{
if (angleRandomOffset == 0)
{
return -dir;
}

var radian = RandomUtils.Random(-angleRandomOffset, angleRandomOffset) * Mathf.Deg2Rad;
return -(Quaternion.Euler(0, radian, 0) * dir);
}

支持Timeline实时看

上面讲到的是程序怎么根据配置在游戏中表现这两个效果,美术在设定参数的过程中不能每次改一次配置运行一次游戏,这样会比较麻烦。

因为我们美术在做技能效果的时候用的就是Timeline来做的,所以这里也考虑把这两个配置嵌入到Timeline里面让美术直接配置,最好能直接逐帧可以看。

查了相关资料,Timeline自定义clip一般需要实现这几个功能:

  1. PlayableAsset,Timeline的clip的资源配置,它的属性可以在Timeline直接配置
  2. PlayableBehaviour,Timeline实际运行的clip,它有好多回调,用来实现clip的控制
  3. TrackAsset,用来更好的在Timline里面新建我们的自定义clip,比如说直接点Timeline的+号直接增加我们的Clip,或者绑定某个GameObject到我们clip
  4. TrackMixer,混合两个clip时候需要的操作

其中TrackMixer我们这里用不到,就没用。

现在以聚焦为例,讲解下我们怎么把聚焦嵌入到Timeline里面的。

首先是聚焦的资源,提供了刚刚说的一些聚焦配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public class CameraFocusAsset : PlayableAsset
{
/// <summary>
/// 聚焦类型
/// </summary>
public enum EFocusType
{
/// <summary>
/// 进入聚焦
/// </summary>
Focus,

/// <summary>
/// 退出聚焦
/// </summary>
ExitFocus
}
public EFocusType FocusType;

/// <summary>
/// 聚焦位置
/// </summary>
public int Position = 1;

/// <summary>
/// 曲线类型
/// </summary>
public Ease EasyType = Ease.Linear;

/// <summary>
/// 是否是聚焦左边
/// </summary>
public bool IsFocusLeft = false;

/// <summary>
/// 相机原始位置
/// </summary>
private Vector3 _originPos = new Vector3(0, 11.3f, -40);

public override Playable CreatePlayable(PlayableGraph graph, GameObject owner)
{
Debug.Log("-------CreatePlayable");

var playable = ScriptPlayable<CameraFocusPlayable>.Create(graph);

Vector3 startPosition;
Vector3 targetPosition;

if (FocusType == EFocusType.Focus)
{
startPosition = _originPos;
targetPosition = CameraFocusAction.FocusPosConfigDic[Position];

if (IsFocusLeft == false)
{
targetPosition.x = targetPosition.x * -1;
}
}
else
{
startPosition = CameraFocusAction.FocusPosConfigDic[Position];
targetPosition = _originPos;

if (IsFocusLeft == false)
{
startPosition.x = startPosition.x * -1;
}
}

playable.GetBehaviour().FocusType = FocusType;
playable.GetBehaviour().StartPosition = startPosition;
playable.GetBehaviour().TargetPosition = targetPosition;
playable.GetBehaviour().EasyType = EasyType;

return playable;
}
}

代码很简单,创建PlayableBehaviour,拿到对应的聚焦位置,然后把参数都传递给PlayableBehaviour。另外新增了一个IsFocusLeft,方便美术看右打左时候的聚焦效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class CameraFocusPlayable : PlayableBehaviour
{
/// <summary>
/// 聚焦类型
/// </summary>
public EFocusType FocusType;

/// <summary>
/// 曲线类型
/// </summary>
public Ease EasyType;

/// <summary>
/// 聚焦开始位置
/// </summary>
public Vector3 StartPosition;

/// <summary>
/// 聚焦移动的最终位置
/// </summary>
public Vector3 TargetPosition;

private Transform _transform;

public override void PrepareFrame(Playable playable, FrameData info)
{
Debug.Log("----------PrepareFrame");
}

public override void OnBehaviourPlay(Playable playable, FrameData info)
{
Debug.Log("-------------OnBehaviourPlay");
}

public override void OnBehaviourPause(Playable playable, FrameData info)
{
Debug.Log("OnBehaviourPause: " + playable.GetTime());

// 最后一帧Timeline不会回调给ProcessFrame,所以需要在Pause里面再插值一次
if (_transform)
{
Vector3 curPos = Vector3.Lerp(StartPosition, TargetPosition, DOVirtual.EasedValue(0, 1, (float)(playable.GetTime() / playable.GetDuration()), EasyType));
_transform.position = curPos;
}
}

public override void OnPlayableCreate(Playable playable)
{
Debug.Log("------------OnPlayableCreate");
}

public override void OnPlayableDestroy(Playable playable)
{
Debug.Log("----------------OnPlayableDestroy");
}

public override void ProcessFrame(Playable playable, FrameData info, object playerData)
{
if (_transform == null)
{
_transform = playerData as Transform;
}

Vector3 curPos = Vector3.Lerp(StartPosition, TargetPosition, DOVirtual.EasedValue(0, 1, (float)(playable.GetTime() / playable.GetDuration()), EasyType));
_transform.position = curPos;

Debug.Log("ProcessFrame: " + playable.GetTime());
}
}

主要实现了ProcessFrame,计算每帧摄像机应该在的位置进行设置。

这里主要有个问题ProcessFrame在最后一帧的时候是不会回调的,Unity给出解释,最后一帧有可能是下一个clip的开始,所以每个clip都应该是倒数第二帧结束的。

我理解的DOTween在开始帧的时候应该没有立刻变动位置,这里跟Timeline这边有点不太一样,最后一帧还是需要变动位置,所以我在OnBehaviourPause的时候把位置最后的位置设置过去。(放在这里可能会有一些其他问题,但不影响正常的观看)

重新设计一套UI框架

目前在用的是MotionFramework里面的一套简单框架,遇到几个问题:

  1. 这个说是框架其实就是一个界面显示和隐藏,我理解的框架应该包含基础界面显示隐藏外,还应该考虑一些常用的UI组件(滚动窗口,切换窗口,拖拽等)
  2. 框架不支持异步刷新功能,不如要加载某个界面,这个界面里需要显示滚动窗口,滚动窗口会异步加载子item,这时候如果我们需要等这个界面都加载完在显示的话,这个框架是做不到的,只能先给你显示出来界面,滚动区域异步加载完之后再显示

基于上面几个问题,所以我想重新设计一套框架,目前这套框架写完一版了,这个游戏可能用不上了,等下个项目再用,然后持续完善

Unity新增Layer无法热更新

测试发现Unity新增的Layer无法热更新,记录下,以后线上版本加Layer之前要慎重考虑下

特效Mesh Read/Write Enabled问题

特效如果在render里面引用了某个mesh,那这个mesh就一定要开启Mesh Read/Write Enabled,否则会在运行的时候报警告,同时这个特效也有可能显示不出来。

特效无法通过将scale设置成-1的方式翻转

因为我们游戏是回合制游戏,一般我们只做左打右的效果,如果有右打左的情况,我们会对他进行反转,包括模型,特效,反转使用的方式就是将scale的x值设置成-1。但是我们发现有些特效不支持这个反转,最后定位到是因为将特效的Scaling Mode设置成了Local,改成Hierarchy就好了。

统一的账号服务器

我们有个游戏在接账号,所以想设计一套完整的账号服务器,以后所有游戏都用这一套。

但遇到了几个困难:

  1. 同一个游戏可能会上不同渠道
  2. 同一个游戏可能会有不同的发行商

有些渠道和发行商可能会账号公用,有些可能独立。而且有些发行商独立的账号如果合同到期,有可能又会被合并回某个发行商。东西越想越复杂,因为合并回某个发行商我也不清楚具体会怎么表现,索性先抛弃合并的情况,只考虑账号公用和独立的情况。

我们先把我们的游戏进行分类:

  1. 产品Id
  2. 发行商Id
  3. 平台Id
  4. 渠道Id

当四个Id确定之后我们就能确定一个游戏了,然后根据着四个Id我们可以配置它的游戏Id,如果游戏Id一样,那就是同一个游戏,所有数据应该共享,否则的话就是两个单独数据的游戏。

根据这思路我将服务器分为两个,一个是游戏管理服务器,管理哪些游戏数据应该互通。另外一个就是账号服务器,每个账号都一个对应的游戏Id,以账号Id和游戏Id作为主键来存这个账号数据,这样就能做到账号公用和独立的情况了。