任务切换结构-TSS

(TSS)Task Switch Structure 一种任务切换结构,它包含有限状态机(FSM)行为树(BT)
在实际开发中尤其在游戏开发中,我们通常需要给角色添加自动化机制,为了让角色的自动化行为不至于太蠢,我们还需要让角色根据实际情况做出合理的反应。这一些列问题总结下来就是:“我接下来该做什么?”。每一个角色每时每刻都应该结合自身所能获取的一切数据信息不断的问自己我接下来要做什么?相同的很多角色在一个场景中,不同身份的角色有着各自不同的数据权限,还有不同的目标,角色的行为又会让场景中的数据发生变化,以此整个场景就会合理动态的运转起来。构建出这样一个场景不是一件容易的事情。

为了能更好的把合理的自动行为机制组织起来,介绍两种TSS的结构,有限状态机和行为树,他们完全不同,各有优缺点,接下来会一一介绍。
我想用两种方式去分别实现一组TSS,来看一下各自在实现起来有什么不同。

实现一下这样的一种行为,一个步兵的巡逻和预警逻辑:步兵在一个半径为R米的圆内巡逻,在视线r米范围内遇到地精,他会和对方打招呼,当在r米的视线范围内看到兽人时,他会冲上去战斗,如果自己血量低于50%就会逃跑。当逃跑一段距离后(超过5倍视线后)他会再次进入巡逻状态。

FSM: 有限状态机(Finite State Machine)

优点:简单,直接,易实现。
缺点:不能很好的处理新加入的状态,可扩展性不够好。

在有限多个状态之间,当状态与状态之间的切换条件满足即可切换。可以想象一个状态有多个转换条件指向不同的状态,转换条件的排列顺序其实很重要,他决定了该状态会优先响应哪一种变化。例如在上述步兵的例子中,当步兵在巡逻的过程中与兽人战斗这个转换条件应该排在与地精打招呼之前(如果他是一个正经的战士),下面来看一下状态图

任务切换结构-TSS-有限状态机
首先需要定义4中状态的枚举
打招呼–SayHi
巡逻–Patrol
战斗–Battle
逃跑–Escape

另外我想用一种声明式的方式去设计FSM框架,这种形式可以做到代码简洁层次清晰。

1
2
3
4
5
6
7
enum State
{
SayHi,//打招呼
Patrol,//巡逻
Battle,//战斗
Escape//逃跑
}
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
// Create a state machine
var machine = new StateMachine<State>();
const int r = 5;
bool sayHiComplete = false;
int hp = 10;
int orcHp = 20;
int distanceBetweenOrc = 10;
int distanceBetweenGoblin = 12;
machine
.NewState(State.Patrol)
.Initialize(() => Console.WriteLine("Init Patral State"))
.Enter(() => Console.WriteLine("Enter Patral State"))
.Update(() =>
{
distanceBetweenOrc--;
distanceBetweenGoblin--;
hp++;
Console.WriteLine($"Patral ING.... distanceBetweenOrc:{distanceBetweenOrc} distanceBetweenGobin:{distanceBetweenGoblin} Hp:{hp} orcHp:{orcHp}");
})
.Translate(() => distanceBetweenOrc < r && orcHp>0).To(State.Battle)
.Translate(() => distanceBetweenGoblin < r&&!sayHiComplete).To(State.SayHi)
.End()
.NewState(State.Battle)
.Initialize(() => Console.WriteLine("Init Battle State"))
.Enter(() => Console.WriteLine("Enter Battle State"))
.Update(() =>
{
hp--;
orcHp--;
Console.WriteLine("Fight ING.... Current HP " + hp+" ocrHp "+orcHp);
})
.Translate(() => hp < 5).To(State.Escape)
.Translate(()=> orcHp < 0).To(State.Patrol)
.End()
.NewState(State.Escape)
.Initialize(() => Console.WriteLine("Init Escape State"))
.Enter(() => Console.WriteLine("Enter Escape State"))
.Update(() =>
{
distanceBetweenOrc++;
distanceBetweenGoblin++;
Console.WriteLine($"Escape ING.... distanceBetweenOrc:{distanceBetweenOrc} distanceBetweenGobin:{distanceBetweenGoblin} Hp:{hp} orcHp:{orcHp}");
})
.Translate(() => distanceBetweenOrc > 5 * r).To(State.Patrol)
.End()
.NewState(State.SayHi)
.Initialize(() => Console.WriteLine("Init SayHi State"))
.Enter(() => Console.WriteLine("Enter SayHi State"))
.Update(() =>
{
sayHiComplete = true;
Console.WriteLine("Say Hi to Goblin");
})
.Translate(() => sayHiComplete).To(State.Patrol)
.End()
.Initialize()
.Start(State.Patrol);

// update machine
bool running = true;
ThreadPool.QueueUserWorkItem(_ => { var key = Console.ReadKey(); running = false; });
while (running)
{
machine.Update();
Thread.Sleep(100);
}
Console.WriteLine(Guid.NewGuid().ToString());

machine.Stop();
Console.WriteLine("FSM Stop");

这是一个控制台程序,上述代码描述的行为在经过一段时间执行后结果是如下这样的,因为本示例中在击杀完兽人之后不会有其他兽人进来,因此步兵最后的状态是一直巡逻。我们来看看日志吧。

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
Init Patral State
Init Battle State
Init Escape State
Init SayHi State
Enter Patral State
Patral ING.... distanceBetweenOrc:9 distanceBetweenGobin:11 Hp:11 orcHp:20
Patral ING.... distanceBetweenOrc:8 distanceBetweenGobin:10 Hp:12 orcHp:20
Patral ING.... distanceBetweenOrc:7 distanceBetweenGobin:9 Hp:13 orcHp:20
Patral ING.... distanceBetweenOrc:6 distanceBetweenGobin:8 Hp:14 orcHp:20
Patral ING.... distanceBetweenOrc:5 distanceBetweenGobin:7 Hp:15 orcHp:20
Patral ING.... distanceBetweenOrc:4 distanceBetweenGobin:6 Hp:16 orcHp:20
Enter Battle State
Fight ING.... Current HP 15 ocrHp 19
Fight ING.... Current HP 14 ocrHp 18
Fight ING.... Current HP 13 ocrHp 17
Fight ING.... Current HP 12 ocrHp 16
Fight ING.... Current HP 11 ocrHp 15
Fight ING.... Current HP 10 ocrHp 14
Fight ING.... Current HP 9 ocrHp 13
Fight ING.... Current HP 8 ocrHp 12
Fight ING.... Current HP 7 ocrHp 11
Fight ING.... Current HP 6 ocrHp 10
Fight ING.... Current HP 5 ocrHp 9
Fight ING.... Current HP 4 ocrHp 8
Enter Escape State
Escape ING.... distanceBetweenOrc:5 distanceBetweenGobin:7 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:6 distanceBetweenGobin:8 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:7 distanceBetweenGobin:9 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:8 distanceBetweenGobin:10 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:9 distanceBetweenGobin:11 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:10 distanceBetweenGobin:12 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:11 distanceBetweenGobin:13 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:12 distanceBetweenGobin:14 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:13 distanceBetweenGobin:15 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:14 distanceBetweenGobin:16 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:15 distanceBetweenGobin:17 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:16 distanceBetweenGobin:18 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:17 distanceBetweenGobin:19 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:18 distanceBetweenGobin:20 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:19 distanceBetweenGobin:21 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:20 distanceBetweenGobin:22 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:21 distanceBetweenGobin:23 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:22 distanceBetweenGobin:24 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:23 distanceBetweenGobin:25 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:24 distanceBetweenGobin:26 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:25 distanceBetweenGobin:27 Hp:4 orcHp:8
Escape ING.... distanceBetweenOrc:26 distanceBetweenGobin:28 Hp:4 orcHp:8
Enter Patral State
Patral ING.... distanceBetweenOrc:25 distanceBetweenGobin:27 Hp:5 orcHp:8
Patral ING.... distanceBetweenOrc:24 distanceBetweenGobin:26 Hp:6 orcHp:8
Patral ING.... distanceBetweenOrc:23 distanceBetweenGobin:25 Hp:7 orcHp:8
Patral ING.... distanceBetweenOrc:22 distanceBetweenGobin:24 Hp:8 orcHp:8
Patral ING.... distanceBetweenOrc:21 distanceBetweenGobin:23 Hp:9 orcHp:8
Patral ING.... distanceBetweenOrc:20 distanceBetweenGobin:22 Hp:10 orcHp:8
Patral ING.... distanceBetweenOrc:19 distanceBetweenGobin:21 Hp:11 orcHp:8
Patral ING.... distanceBetweenOrc:18 distanceBetweenGobin:20 Hp:12 orcHp:8
Patral ING.... distanceBetweenOrc:17 distanceBetweenGobin:19 Hp:13 orcHp:8
Patral ING.... distanceBetweenOrc:16 distanceBetweenGobin:18 Hp:14 orcHp:8
Patral ING.... distanceBetweenOrc:15 distanceBetweenGobin:17 Hp:15 orcHp:8
Patral ING.... distanceBetweenOrc:14 distanceBetweenGobin:16 Hp:16 orcHp:8
Patral ING.... distanceBetweenOrc:13 distanceBetweenGobin:15 Hp:17 orcHp:8
Patral ING.... distanceBetweenOrc:12 distanceBetweenGobin:14 Hp:18 orcHp:8
Patral ING.... distanceBetweenOrc:11 distanceBetweenGobin:13 Hp:19 orcHp:8
Patral ING.... distanceBetweenOrc:10 distanceBetweenGobin:12 Hp:20 orcHp:8
Patral ING.... distanceBetweenOrc:9 distanceBetweenGobin:11 Hp:21 orcHp:8
Patral ING.... distanceBetweenOrc:8 distanceBetweenGobin:10 Hp:22 orcHp:8
Patral ING.... distanceBetweenOrc:7 distanceBetweenGobin:9 Hp:23 orcHp:8
Patral ING.... distanceBetweenOrc:6 distanceBetweenGobin:8 Hp:24 orcHp:8
Patral ING.... distanceBetweenOrc:5 distanceBetweenGobin:7 Hp:25 orcHp:8
Patral ING.... distanceBetweenOrc:4 distanceBetweenGobin:6 Hp:26 orcHp:8
Enter Battle State
Fight ING.... Current HP 25 ocrHp 7
Fight ING.... Current HP 24 ocrHp 6
Fight ING.... Current HP 23 ocrHp 5
Fight ING.... Current HP 22 ocrHp 4
Fight ING.... Current HP 21 ocrHp 3
Fight ING.... Current HP 20 ocrHp 2
Fight ING.... Current HP 19 ocrHp 1
Fight ING.... Current HP 18 ocrHp 0
Fight ING.... Current HP 17 ocrHp -1
Enter Patral State
Patral ING.... distanceBetweenOrc:3 distanceBetweenGobin:5 Hp:18 orcHp:-1
Patral ING.... distanceBetweenOrc:2 distanceBetweenGobin:4 Hp:19 orcHp:-1
Enter SayHi State
Say Hi to Goblin
Enter Patral State
Patral ING.... distanceBetweenOrc:1 distanceBetweenGobin:3 Hp:20 orcHp:-1
Patral ING.... distanceBetweenOrc:0 distanceBetweenGobin:2 Hp:21 orcHp:-1
Patral ING.... distanceBetweenOrc:-1 distanceBetweenGobin:1 Hp:22 orcHp:-1
Patral ING.... distanceBetweenOrc:-2 distanceBetweenGobin:0 Hp:23 orcHp:-1
Patral ING.... distanceBetweenOrc:-3 distanceBetweenGobin:-1 Hp:24 orcHp:-1
Patral ING.... distanceBetweenOrc:-4 distanceBetweenGobin:-2 Hp:25 orcHp:-1
Patral ING.... distanceBetweenOrc:-5 distanceBetweenGobin:-3 Hp:26 orcHp:-1
Patral ING.... distanceBetweenOrc:-6 distanceBetweenGobin:-4 Hp:27 orcHp:-1
Patral ING.... distanceBetweenOrc:-7 distanceBetweenGobin:-5 Hp:28 orcHp:-1
......

可以看到步兵在切换状态的过程中满足我们既定的想法,步兵的确做到了他该做到的。代码中用到的一些变量在实际项目中会以黑板数据的形式提供,有状态机的角色共同享有这些数据的访问权和修改权。

BT: 行为树 (Behaviour Tree)

优点:扩展性比状态机好,能处理更多复杂情况。
缺点:不直接,难度大,实现起来困难。

首先需要解释一下各个节点的含义:
任务切换结构-TSS-有限状态机

一棵行为树的根节点,有且只有一个。并且根的子节点最多只有一个。

任务切换结构-TSS-有限状态机

一种Decorator节点,该类型的节点起到控制流程的作用。循环节点也是其中一种派生类型,该类型的节点也只有一个子节点。还有很多节点也是Decorator的子类,他们有取反,循环直至,返回Success,返回Failure等待。

任务切换结构-TSS-有限状态机

一种Composite节点,选择节点也是其中一种派生类型,该类型的节点可以包含多个子节点,以选择节点为例,该子节点的执行过程会受到当前节点的制约,当有一个子节点返回Success的时候不再继续执行子节点了。

任务切换结构-TSS-有限状态机

一种Composite节点,顺序节点也是其中一种派生类型,该类型的节点可以包含多个子节点,以顺序节点为例,该子节点的执行过程会受到当前节点的制约,当有一个子节点返回Failure的时候不再继续执行子节点了。

任务切换结构-TSS-有限状态机

一种Action节点,该节点没有子节点,需要实现判定逻辑以提供父节点决定是否需要执行后续节点。

任务切换结构-TSS-有限状态机

一种Action节点,该节点没有子节点,主要实现逻辑行为。

根据步兵和行为可以列出行为树结构如下图:

任务切换结构-TSS-有限状态机

同样的根据行为树的结构,用代码实现如下

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
BehaviourTree tree = new BehaviourTree();
const int r = 5;
bool sayHiComplete = false;
int hp = 10;
int orcHp = 20;
int distanceBetweenOrc = 10;
int distanceBetweenGoblin = 12;

tree.Builder
.Repeat()
.Select()
.Sequence()
.Do()
.Start(() => Console.WriteLine("far way 5r from orc"))
.GetResult(() =>
{
if (distanceBetweenOrc <= 5 * r)
return Node.NodeResult.Success;
return Node.NodeResult.Failure;
})
.End()
.Do()
.Start(() => Console.WriteLine("Hp is low?"))
.GetResult(() =>
{
if (hp < 5)
return Node.NodeResult.Success;
return Node.NodeResult.Failure;
})
.End()
.Do()
.Start(() => Console.WriteLine("Yes ,Hp is low"))
.Update(() =>
{
distanceBetweenGoblin++;
distanceBetweenOrc++;
ShowDetails();
})
.GetResult(() =>
{
return Node.NodeResult.Success;
})
.End()
.End()
.Sequence()
.Do()
.Start(()=>Console.WriteLine("Orc is alive?"))
.GetResult(() =>
{
if (orcHp > 0)
return Node.NodeResult.Success;
return Node.NodeResult.Failure;
})
.End()
.Do()
.Start(() => Console.WriteLine("Orc is near?"))
.GetResult(() =>
{
if (distanceBetweenOrc < r)
return Node.NodeResult.Success;
return Node.NodeResult.Failure;
})
.End()
.Do()
.Start(() => Console.WriteLine("Yes, Orc is near"))
.Update(() =>
{
hp--;
orcHp--;
ShowDetails();
})
.GetResult(() =>
{
return Node.NodeResult.Success;
})
.End()
.End()
.Sequence()
.Do()
.Start(() => Console.WriteLine("Goblin is near?"))
.GetResult(() =>
{
if (distanceBetweenGoblin < r)
return Node.NodeResult.Success;
return Node.NodeResult.Failure;
})
.End()
.Do()
.Start(() => Console.WriteLine("Has said hi?"))
.GetResult(() =>
{
if (!sayHiComplete)
return Node.NodeResult.Success;
return Node.NodeResult.Failure;
})
.End()
.Do()
.Start(() => Console.WriteLine("Say Hi"))
.Update(() =>
{
sayHiComplete = true;
Console.WriteLine("Say Hi to Gobin!!");
ShowDetails();
})
.GetResult(() => Node.NodeResult.Success)
.End()
.End()
.Sequence()
.Do()
.Start(() => Console.WriteLine("hp++"))
.Update(() =>
{
distanceBetweenGoblin--;
distanceBetweenOrc--;
hp++;
ShowDetails();
})
.GetResult(() => Node.NodeResult.Success)
.End()
.End()
.End()
.End();

void ShowDetails()
{
Console.WriteLine($"Hp {hp} OrcHp {orcHp} DistOrc {distanceBetweenOrc} DistGobin {distanceBetweenGoblin} Has Say Hi {sayHiComplete}");
}

while (true)
{
tree.Execute();
Thread.Sleep(100);
}
结论

可以看到FSM的结构还是比较清晰的,感觉起来FSM处理问题更加直观便利。行为树在实现起来还是比较困难的,理清思路是第一步。两种方式都是很不错的任务切换结构,具体使用哪一种还是需要具体问题具体分析。

OOP 的局限性

试想存在这么一个场景: 如何移动物体的问题

有人这个对象,人可以移动,用OOP的设计思想就是这个对象Human。在Human这个class里有一个Move的方法,调用这个方法就可以让这个对象移动。当我们发现一条狗也可以移动的时候,同样的我们需要在Dog这个class里也加一个Move的方法。其实这两个方法里很大一部分逻辑都一样,这时候我们想把这两个类抽象出一个叫Animal的基类,在这个基类里添加Move的方法实现一部分共有逻辑再允许子类去复写。这样是不是就是完美了呢?一俩车也可以移动啊?这就说明不管怎么向上抽象都不足以涵盖今后发生的扩展问题。那么把Move方法写在IMovable接口里呢?所有的对象都去实现IMovable接口,这样的话很多重复逻辑没法合并。那么用一个实现了IMovable接口的类再提供给运动对象去调用呢?类似于 HumanMove类,DogMove类,CarMove类,如此类推,今后要扩展的物体都需要做Move类的扩展。而且接口也是可以继承的,必然会遇到Animal同样的问题。

再举一个游戏开发中遇到的问题,开发初期我们想好了兵种有,步兵,骑兵,弓箭手,这三个类型的兵种,步兵的Attack方法里是‘大力挥砍’,弓箭手的Attack方法里是‘瞄准发射’,骑兵的Attack方法里是‘快速突刺’ 。随着项目的进行,后来发现骑兵可以升级,他能获得骑射的能力,就是说他的Attack方法是‘瞄准发射’,并且还有了近战时候的‘大力挥砍’的行为。此时再重新设计类结构就会异常麻烦,而且还无法兼顾今后的扩展。当然这种情况用上述的策略模式解决一部分问题,把攻击行为做成类。让不同的对象添加不同的攻击策略。但是如果出现另一种藤甲兵他像步兵,但是又有轻便的装备,移动速度比弓箭手还快,藤甲步兵虽然移动速度快但是相比步兵又有非常怕火的属性。我们想到的方法是让藤甲兵继承步兵,这样一个子类继承一个基类需要修改大部分的方法才能达到想要的要求。这样的写法本身就不优化。

但我今天想说的是尝试ECS的方式。还是关于移动的问题。人,浮萍,狗,汽车,卫星,星球,甚至整个太阳系他们都有移动的能力,在ECS的世界里,他们都只是一个唯一ID值,即EntityID。把属性写在Component里,在Component里没有方法只有数据。比如NameComponent{ Name=“Human”}… 用来表示称呼。所有的移动问题归根结底是速度和方向问题。这样就有了MovementComponent{Speed=1,Direction=[1,1,1]}。一辆车从自家停车场开到公司停下来这一路上都可以通过修改这个MovementComponent里的Speed和Direction来完成。起步和刹车是个变速过程,连续修改Speed的值就能实现。静止的时候Speed就是0。再需要一个PositionComponent{Position=[0,0,0],Forward=[1,0,0]}的组件。一个物体需要位置和朝向就能把它固定下来。这时候需要System来实现各种功能了,在System里没有属性变量只有一个Execute的方法,在这个方法里修改指定的Component。现在我们需要一个MoveSystem,在这个系统里面会找出所有MovementComponent并计算出对应移动参数作用下的位置数据:PositionComponent+=MovementComponent类似于这样的操作。这样整个移动过程就完成了,再有像汽车起步和刹车的行为需要修改MovementComponent里的属性,那么需要在MoveSystem执行前执行额外的AccelerationSystem。行星的话需要执行GravitationalFieldSystem。所有的System在一个SystemGroup中依次执行完毕,交予显示部分就可以了。整个过程中我们没有过多关注移动物体本身的类型,也不用关心类的继承关系。后续有新的移动逻辑只需要添加对应的System就好了,不需要修改已有的逻辑。

组合优于继承。

帧同步

帧同步与状态同步

  • 帧同步和状态同步都是一种能让多个客户端在相对同一时刻表现一致的方法,当然只有外观表现一致还远远不够,或者说还不够精确,这时候还需要在数据层面也要保持一致,用数据的变化来驱动外在表现的变化。这点不管是帧同步或状态同步都是一样的。
  • 关于数据驱动显示的话题,这里有一个反面事例,我想起了在若干年以前我曾经听一个同事说他一个俄罗斯方块游戏的开发经历,他分享了在开发过程中就方块与方块间图像碰撞检测的心得,并以此彰显开发在这个游戏的不易,以及自己的小聪明。
  • 帧同步和状态同步没有孰优孰劣,都是很好的同步方案,但同时也都有自己的局限。像星际争霸,魔兽争霸等老牌RTS游戏都采用的帧同步模式。而传奇,奇迹等RPG游戏都采用的状态同步模式,这些大作都是成功的典范。讨论哪一种方式碾压另一种没有意义,具体还是要看需求场景。

优缺点

  • 以下列举的优缺点只是一般情况下:
    帧同步 状态同步
    一致性 设计层面决定了必然一致 可以保证一致性
    玩家数 对多玩家支持有限 多玩家有优势
    跨平台 需要考虑浮点运算的一致性 由于主要的计算都在服务器,因此不存在跨平台问题
    防作弊 容易作弊,但是可以优化 可以很好的防作弊
    断线重连 实现起来比较难,但不是不能 只需要重新发送一次信息即可,好实现
    回放需求 能完美实现 无法实现
    暂停游戏 好实现 不好实现
    网络传输量 比较小 比较大
    开发难度 相对复杂 相对简单
    RTS类游戏 适合 不适合
    格斗类游戏 适合 不适合
    MOBA类游戏 适合 不适合
    MMO类游戏 不适合 适合

帧同步需要克服的难点

  • 浮点计算一致性,我使用的是TrueSync浮点运算库。可以保证浮点数在不同设备上运算一致。
  • 逻辑帧的驱动,我需要不同设备在启动后经过一段时间后所有的设备都能保持一致的帧数,这就需要在每次tick的时候通过DateTime校准。
  • 客户端的逻辑帧只在逻辑帧结束时发送用户的手动命令给逻辑帧服务器,逻辑帧服务器在收到客户端发来的命令后将命令逻辑帧设置为当前服务器的逻辑帧编号,这是为了广播给所有客户端做准备。
  • 客户端收到服务器发来的逻辑帧命令后,根据客户端自身的逻辑帧编号进行回滚并重新计算整个游戏世界,因此客户端需要对过往的N帧历史数据进行快照保存。
  • 断线重连是一个相对复杂的功能,这需要验证断线玩家的身份以及断线时刻的进度。这部分可以分两步由浅入深实现,简单一点的第一步是断线的玩家重新进入游戏世界需要根据服务器提供的历史逻辑帧命令从头计算至当前游戏进度,因此会让玩家在进入游戏世界的时候等待比较长的时间,这个时间会随着游戏进度的深入而增加,体验不够人性化。第二种方法就是客户端每隔一段时间保存游戏世界快照到文件,让需要重新进入游戏的玩家就近载入一段快照并且从快照定义的逻辑帧往后计算至当前游戏进度,这个方法可以节省很多等待时间,体验也更好。
  • 作弊问题在帧同步游戏中比较难避免,因为所有的数据以及运算都在客户端进行,理论上客户端里的数据可以被任意修改,甚至包括修改自己客户端里其他玩家的数据。导致游戏出现不同步现象。关于防作弊的思路,我想到的方法是,每个客户端可以每隔N帧发送给服务器一次当前游戏世界快照的校验码,服务器收到后判断是否一致性,这个方法虽然可以发现作弊现象但是具体是那个客户端作弊就不好判断了,只能通知所有客户端这件作弊事件,等待玩家做决定。

帧同步与ECS的结合

了解了帧同步开发过程中需要克服的难点,我们接下来就要考虑选用一种比较好的实现方式,或者说是一种开发框架。由于帧同步开发非常需要数据和表现分离,分离到什么程度呢?就是数据计算部分甚至可以放在一个单独的线程里。这样编写逻辑的好处还可以让服务器运行以达到快速复盘游戏的功能,能做到这种程度的分离我想只有ECS了。帧同步加上ECS绝对是完美搭档。

ECS说明

首先要介绍一下ECS,ECS并非一种全新的技术,也不是Unity首先提出来的。这种名词的出现非常早,而近几年突然火爆,是因为暴雪的《守望先锋》。《守望先锋》的服务器和客户端框架完全基于ECS构建,在游戏机制、网络、渲染方面都有非常出色的表现。坦白地说ECS不像一种设计模式,我们以前用的设计模式都是在面向对象设计下谈论的,ECS都不是面向对象。Unity也有ECS,其实Unity本身的组件也是一种ECS,只不过还不够纯粹。ECS特别适合做Gameplay。关于ECS的变种也有很多,我这里也是稍微做了一些修改的ECS。

  • ECS中的E代表Entity,不过也可以不需要,因为E表示的是一个唯一物体,完全可以用int来搞定。
  • C代表Component,这个Component和Unity里的Component不一样,这里的Component用来存储数据,这是一个没有具体方法的类型,主要表示属性,当然如果有一些简单的方法如ToString ,或者对自身数据的处理我想也可以。
  • S代表的是System,在这里只有方法,用于修改Component属性。
  • 当然还可以加上R,R代表的是Renderer,Renderer只读取感兴趣的Component并负责显示正确的行为。E-C-S这三部分在线程里运行,R这部分在主线程运行,如此最大限度的提升性能。

网络通信

这里推荐RevenantX/LiteNetLib,这个库很强大并且用法很简洁,它提供了可靠UDP传输,这正是我想要的。
网络通信的数据协议可以选择的有很多,我这里使用的是自制二进制流协议,主要实现的功能是序列化与反序列化,结构体内的字段支持可选。
就像这个PtRoom结构:

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
//Template auto generator:[AutoGenPt] v1.0
//Creation time:2021/1/28 16:43:48
using System;
using System.Collections;
using System.Collections.Generic;
namespace Net.Pt
{
public class PtRoom
{
public byte __tag__ { get;private set;}

public uint RoomId{ get;private set;}
public byte Status{ get;private set;}
public uint MapId{ get;private set;}
public string RoomOwnerUserId{ get;private set;}
public byte MaxPlayerCount{ get;private set;}
public List<PtRoomPlayer> Players{ get;private set;}

public PtRoom SetRoomId(uint value){RoomId=value; __tag__|=1; return this;}
public PtRoom SetStatus(byte value){Status=value; __tag__|=2; return this;}
public PtRoom SetMapId(uint value){MapId=value; __tag__|=4; return this;}
public PtRoom SetRoomOwnerUserId(string value){RoomOwnerUserId=value; __tag__|=8; return this;}
public PtRoom SetMaxPlayerCount(byte value){MaxPlayerCount=value; __tag__|=16; return this;}
public PtRoom SetPlayers(List<PtRoomPlayer> value){Players=value; __tag__|=32; return this;}

public bool HasRoomId(){return (__tag__&1)==1;}
public bool HasStatus(){return (__tag__&2)==2;}
public bool HasMapId(){return (__tag__&4)==4;}
public bool HasRoomOwnerUserId(){return (__tag__&8)==8;}
public bool HasMaxPlayerCount(){return (__tag__&16)==16;}
public bool HasPlayers(){return (__tag__&32)==32;}

public static byte[] Write(PtRoom data)
{
using(ByteBuffer buffer = new ByteBuffer())
{
buffer.WriteByte(data.__tag__);
if(data.HasRoomId())buffer.WriteUInt32(data.RoomId);
if(data.HasStatus())buffer.WriteByte(data.Status);
if(data.HasMapId())buffer.WriteUInt32(data.MapId);
if(data.HasRoomOwnerUserId())buffer.WriteString(data.RoomOwnerUserId);
if(data.HasMaxPlayerCount())buffer.WriteByte(data.MaxPlayerCount);
if(data.HasPlayers())buffer.WriteCollection(data.Players,(element)=>PtRoomPlayer.Write(element));

return buffer.Getbuffer();
}
}

public static PtRoom Read(byte[] bytes)
{
using(ByteBuffer buffer = new ByteBuffer(bytes))
{
PtRoom data = new PtRoom();
data.__tag__ = buffer.ReadByte();
if(data.HasRoomId())data.RoomId = buffer.ReadUInt32();
if(data.HasStatus())data.Status = buffer.ReadByte();
if(data.HasMapId())data.MapId = buffer.ReadUInt32();
if(data.HasRoomOwnerUserId())data.RoomOwnerUserId = buffer.ReadString();
if(data.HasMaxPlayerCount())data.MaxPlayerCount = buffer.ReadByte();
if(data.HasPlayers())data.Players = buffer.ReadCollection( (rBytes)=>PtRoomPlayer.Read(rBytes) );

return data;
}
}
}
}

完结

下一篇我将更新如何用帧同步开发一个太空大战的游戏,就像Steam上的Worbital

国战编辑器

经典的SLG游戏都离不开国战的玩法,尤其以三国类题材居多,我在几年前有幸参与了一款经典的三国类SLG游戏的开发,其中的国战玩法相当典型。包含这几点元素:

#1大地图(整个中国版图相当宏伟);

#2城池数量庞大城与城之间包含复杂的连接组成庞大的城市网络;

#3路线的曲线各种各样;

#4丰富的地形设计;

#5单个城池的信息复杂多样;

那么该如何将这么多复杂的信息整合在一起呢,毕竟光200多个的城池详细信息要让策划录入配置表就已经是一件不可能完成的事情了,考虑到效率和人文关怀。我为策划同事们开发了一款国战大底图编辑器。这个编辑器现在可以在我的网盘中下载(979s)。

我设计编辑器的主要功能还是便于策划和我们程序开发,因此可视化所见所得是必须的,当我们打开CountryMapEditor.exe的时候,界面是这样的:

国战编辑器

这个界面中的大地图铺面了整个场景,缩略图窗口显示出当前的视窗位置:位于整个大地图的西北角,如此大的地图素材,需要在打开游戏或编辑器的时候对地图进行分块裁切,这部分工作在另外的工具中已经完成了,所有的地图分块保存着工具目录的asset目录下(这里把大地图切成了100块相同尺寸的数据块)待程序启动根据当前视窗大小和位置与数据分块中记录的数据比较进而选择加载适当的分块数据。这点也一些地图app类似。

地图数据的加载与释放同样都很重要,这与性能息息相关。在视窗远离某些分块时候做适当的数据释放,这点很重要。

接下来的这一步是编辑城池:

国战编辑器

我在这里已经编辑了一些城池,从上图我们可以看到城池与城池之间的红色贝塞尔曲线就是他们之间的道路,用两个点可以调整贝塞尔曲线的曲率,使得曲线与地面道路尽可能贴合,这样单条贝塞尔曲线的点集合M描述了城池A与城池B之间的路径长度或者说是权重,记为M{A,B} = Value;Value值就是点与点之间的长度和,这个Value很重要,它将在迪杰斯特拉算法中充当权重的角色,因此这个值不光前端需要,后端也需要这个值。在实际游戏中某个城池会出现毁坏状态,此时经过这个城池的所有道路将处于失效状态。这个时候也可以通过修改权重达到效果。

我们编辑完成后可以点击Output按钮这样我们就把数据保存了下来;我们去找一下MapNode文件夹

国战编辑器

如图所示,文件夹内有两个文件,其中xml文件简单明了

国战编辑器

将所有的关系都已经列举了出来,这种配置文件信息后端很需要,另外一个二进制文件是前端用的,里面不止包含了xml中的信息还有一些其他敏感数据信息。

到这里这个国战编辑器就算完成了,这部分的工作使得策划可以更安心的处理游戏细节,而不必受困于生产工具。需要项目工程的可以联系我。