从零开始手搓联机弹幕PVP&PVE对战游戏《东方枪林弹雨》的个人总结

前言

从上次更新到现在已经有将近一个月了,在这一个月里,我制作了一款联机弹幕PVP&PVE对战游戏《东方枪林弹雨》,请看下方演示视频。

本篇博客将会简要介绍程序的框架、我的开发思路以及一些个人感想,由于我也是头一回开发联网游戏,可能存在思路上的不严谨,望各位读者海涵。

项目源码和发行版以及游玩教程

非技术部分

游戏设计

对局规则

最初设计该游戏的核心理念就是 “我希望设计一个多人联机的弹幕STG,以躲避弹幕为核心乐趣” ,所以最一开始我就设计了玩家可以在场地里发射弹幕的简单对战游戏模型:

  1. 每一局游戏中可以有一个或者多个玩家;
  2. 玩家可以在一个长方形场地内移动,并发射白色子弹攻击对手;
  3. 击倒对手、造成伤害都可以得分,每位玩家的目标是在一场比赛中取得尽可能高的分数,回合结束时分数最高的玩家获胜。

初版游戏窗口


不过我很快发现了这样的话游戏未免过于单调了,所以我添加了符卡系统,玩家每隔一段时间可以获得符卡并发射更强的弹幕:

  1. 每一局游戏中可以有一个或者多个玩家;
  2. 玩家可以在一个长方形场地内移动,并发射白色子弹攻击对手;
  3. 玩家每隔一段时间可以获得更强力的弹幕;
  4. 击倒对手、造成伤害都可以得分,每位玩家的目标是在一场比赛中取得尽可能高的分数,回合结束时分数最高的玩家获胜。

这样的话场上的弹幕就没那么单调了,玩家躲避弹幕的密度也上升了,但是这样设计也有很明显的缺点,就是无法适应“以躲避弹幕为核心乐趣”这个部分。

如果场上只有玩家这个对局要素的话,那么游戏的侧重点就会偏向“如何更好地向对手扔出弹幕”而不是“躲避场上的弹幕了”,尽管前者也是重要的对局要素,但是我希望后者可以在游戏中被强调,并且对局的形式也可以更多样化。


所以我添加了NPC(游戏中被称作妖怪)作为对局的一部分,并且把新弹幕获取的方式改为了击败妖怪掉落道具而非直接给予。

加入NPC一方面是增大了战场弹幕密度,一方面是让玩家分配精力去处理妖怪从而获取更好的弹幕,降低了玩家直接冲突的游戏占比。换句话说,这个改动可以使得玩家的冲突更加多元化(进攻、躲避、道具抢夺),而不仅仅是丢弹幕。

第一个加入了妖怪的版本截图

所以,该游戏最终的规则就是:

  1. 每一局游戏中可以有一个或者多个玩家;
  2. 玩家可以在一个长方形场地内移动,并发射白色子弹攻击对手;
  3. 每过一定时间,场地上就会刷新妖怪,用弹幕把它们解决掉后,它们会掉落道具;
  4. 玩家可以靠近道具拾取之,并获得更强力的弹幕;
  5. 击倒对手、妖怪,造成伤害都可以得分,每位玩家的目标是在一场比赛中取得尽可能高的分数,回合结束时分数最高的玩家获胜。

符卡设计

还有比较重要的一点就是玩家所持符卡的设计,游戏中所有的符卡列表如下:

武器名称 原型角色 备弹量 描述
Reimu Spell Rifle 博丽灵梦 30 发射具有制导能力的符纸
Marisa Star Machine Gun 雾雨魔理沙 50 快速散射星弹
Reimilia Scarlet Arrow 蕾米莉亚·斯卡雷特 8 发射具有制导能力的蝙蝠集群
Cirno Ice Rail Gun 琪露诺 12 发射一连串冰晶弹幕
Alice Doll Pair 爱丽丝·玛格特罗伊德 2 召唤上海和蓬莱,向周围发射弹幕
Keine Permanent Launcher 上白泽慧音 20 发射大玉状弹幕,并在一定时间后引爆为指向鼠标位置的两个小弹幕
Mokou Flame Thrower 藤原妹红 150 喷射近距离火焰弹
Sanae Star Shot Gun 东风谷早苗 7 一次性散射多枚星弹
Koishi Heartbeat Sniper 古明地恋 5 传导快速飞行的心跳流
Murasa Anchor Launcher 村纱水蜜 1 发射指向鼠标指针的船锚,船锚在到达鼠标位置时爆炸,散射出环形弹幕
Seija Backward Rapid Arrow 鬼人正邪 45 向所指方向的反方向发射箭矢
Doremy Dream Catcher 哆来咪·苏伊特 5 放置捕梦网,向四面八方发射弹幕
Okina WideSpread Star Rail Gun 摩多罗隐岐奈 2 在自身周围生成七颗星星,并召唤它们向指定方向飞去,并在沿途产生广阔的轨迹
Yachie Sheild Rifle 吉吊八千慧 25 发射三枚互相相差120度的弹幕,这些弹幕以同样的规则再分裂为3个子弹幕,龟甲地狱的青春版
Zanmu WideSpread Ghost Launcher 日白残无 80 围绕自身不断发射具有追踪能力的幽灵弹
Report this to developer! 冴月麟 -1 没有这个武器,如果你在武器栏看见了这行字,记得报告开发者!

如果你实际体验了游戏,你就会发现这些符卡在攻击敌人时并没有你想象中的那么“高效”:Reimu Spell Rifle的符卡飞行速度很慢,Cirno Ice Rail Gun的冰晶展开时间足以让敌人跑出攻击位置,Sanae Star Shot Gun的散射范围太大,Zanmu WideSpread Ghost Launcher根本无法瞄准。

实际上,这些符卡的作用是 压制对手 而非直接致死敌人:在适当时机放出符卡, 配合场上的弹幕分布情况压制对手逼迫其被弹 才是符卡的正确作用。

这也恰好符合游戏“以躲避弹幕为核心乐趣”的理念,而不是“把准心放在敌人头上按下鼠标左键他们就会被干掉”。我希望玩家可以针对每张符卡探索出它们的使用时机,以及反制手段,这是STG的乐趣所在。

左右都存在压制弹幕
被逼迫被弹

上图就是一个压制示例,虽然符纸飞行速度慢,但是结合环境,玩家左右都存在威胁,就会因此更容易失误。

无法命中
找准攻击时机

第一张图是一个Okina WideSpread Star Rail Gun攻击失利的例子,由于七星飞行速度慢,即便玩家之间很近,弹幕也可能会被躲开;第二张图是一个得手的例子,倒下的玩家本来正在尝试拾取道具,其移动目的暴露,所以就被下方玩家预判压制命中。

游戏美术

美术并不是这个游戏的关键,我用了尽量简洁的图像来代替游戏中的要素,保证场面信息的可读性。

所有弹幕的材质

例如上方的弹幕材质,清晰、分辨性合理。

PS:不过,还是得承认游戏的画面缺少细节,这主要是怪我不会画画。

这点要向ZUN看齐(

这点要向ZUN看齐(

技术部分:网络通信与游戏进程如何协调

最初的想法:完全分治

模型介绍

最简单的服务端/客户端模型

一个比较简单的想法即为:

  • 服务端跑游戏主循环,游戏状态一改变就向客户端发消息,客户端接到了消息就该变本地状态。
  • 客户端接受玩家操作,玩家一旦做出操作,客户端就把操作信息发送给服务端供其处理。
  • 客户端每帧都基于服务端传回的对局状态进行渲染,这样就完成了:客户端操作->发送给服务器->服务器处理计算->发回给客户端结果->客户端渲染的完整处理逻辑。

服务端

首先构建C++服务端,感谢libhv的易用性,WebSocket服务端的构建非常便捷。

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
void GameServer::startServer(string ip, int port, int maxPlayer) {
webSocket.onopen = [this](const WebSocketChannelPtr& channel, const HttpRequestPtr& req) {
printf("Game Client Connected: GET %s -> %s \n", req->Path().c_str(), req->client_addr.to_string().c_str());
clientChannels.push_back(channel);
clientChannelsUuid.push_back(req->Path());
sendGameLog(req->client_addr.to_string() + " joined the server.", responseBuffer);
};
webSocket.onmessage = [this](const WebSocketChannelPtr& channel, const std::string& msg) {
// printf("Game Client Json message: %.*s\n", (int)msg.size(), msg.data());
string data = msg.data();
LevelAction levelAction;
levelAction.deserializeAction(data);
pushToInteractBuffer(levelAction);
};
webSocket.onclose = [this](const WebSocketChannelPtr& channel) {
deleteDeadChannels();
};
WebSocketServer server(&webSocket);
server.setPort(port);
server.setThreadNum(maxPlayer);
printf("Starting server on %s\n", ip.c_str());
printf("Server active. Listening on port %d\n", port);
printf("Type \"help\" for more help.\n");
server.run(ip.c_str(), 1);
// 开启服务器
}

规定服务端将缓冲区数据输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void GameServer::boardcastResponse() {
mtxLock.lock();
while (!responseBuffer.empty()) {
string responseMessage = responseBuffer.front().serializeAction();
// printf("Server sent message : %s", responseMessage.c_str());
for (const WebSocketChannelPtr& channelPtr : clientChannels) {
if (channelPtr->isConnected()) {
channelPtr->send(responseMessage);
}
}
responseBuffer.pop();
}
mtxLock.unlock();
}

然后定义在游戏主循环中传出数据的函数。

1
2
3
4
5
6
7
8
9
10
11
void pushToResponseBuffer(string actionName, vector<string> paramsList,
queue<LevelAction>& responseBuffer) {
auto now = chrono::system_clock::now();
time_t now_c = chrono::system_clock::to_time_t(now);
auto start = chrono::system_clock::from_time_t(time_t(0));
auto duration = now - start;
long long unixTimeStamp = chrono::duration_cast<chrono::milliseconds>(duration).count();
// 获取时间戳(毫秒级)
LevelAction currAction { actionName, paramsList, unixTimeStamp };
responseBuffer.push(currAction);
}

接下来在Level的游戏逻辑处理循环中,就可以通过调用pushToResponseBuffer()来传出对局数据,例如同步弹幕的syncYoukaiData()方法。

1
2
3
4
5
6
void Level::syncYoukaiData(Youkai* youkai, queue<LevelAction>& responseBuffer) {
vector<string> youkaiInfo { youkai->uuid, youkai->youkaiType,
to_string(youkai->pos.x), to_string(youkai->pos.y),
to_string(youkai->velocity.x), to_string(youkai->velocity.y), to_string(youkai->friction) };
pushToResponseBuffer("cy", youkaiInfo, responseBuffer);
}

客户端

接着构建C#客户端,C#的系统库System.Net.WebSockets直接支持C#使用套接字通信的相关功能,依靠之也可以很方便地定义客户端行为,用StartRequestTask()启动客户端。

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
/// <summary>
/// 向服务器询问并建立收发线程
/// </summary>
public async Task StartRequestTask(string serverUri) {
this.serverUri = serverUri;
try {
await webSocket.ConnectAsync(new Uri(serverUri), CancellationToken.None);
}
catch (Exception e) {
disconnected = true;
Console.WriteLine("Error Occured when connecting to server -> " + e);
}
RequestGlobalSync();
var tasks = new List<Task> {
Task.Run(() => SendLevelData()),
Task.Run(() => ReceiveLevelData())
};
// 等待所有任务完成,但不会阻塞当前线程
try {
await Task.WhenAll(tasks);
}
catch {
Disconnect();
}
}

/// <summary>
/// 发送消息
/// </summary>
private async void SendLevelData() {
while (webSocket.State == WebSocketState.Open) {
try {
while (interactBuffer.Count > 0) {
LevelAction action;
lock (bufferLock) {
action = interactBuffer.Dequeue();
}
string actionJson = JsonConvert.SerializeObject(action);
await SendLevelActionData(actionJson);
}
await Task.Delay(10);
}
catch {
Console.WriteLine("Unable to Send Level Data.");
Disconnect();
break;
}
}
}

private async Task SendLevelActionData(string data) {
try {
if (webSocket.State == WebSocketState.Open) {
byte[] bytes = Encoding.UTF8.GetBytes(data);
await webSocket.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
}
}
catch {
Console.WriteLine("Unable to Send Level Action Data.");
Disconnect();
return;
}
}

/// <summary>
/// 收取消息
/// </summary>
private async void ReceiveLevelData() {
byte[] buffer = new byte[4096];
while (webSocket.State == WebSocketState.Open) {
try {
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close) {
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
}
else if (result.MessageType == WebSocketMessageType.Text) {
string data = Encoding.UTF8.GetString(buffer, 0, result.Count);
LevelAction action = JsonConvert.DeserializeObject<LevelAction>(data);
lock (bufferLock) {
actionsBuffer.Enqueue(action);
}
}
}
catch {
Console.WriteLine("Unable to Receive Level Data.");
Disconnect();
break;
}
}
}

接下来,让客户端可以获取用户操作并上传,定义AddInteractToBuffer()将用户行为推送到缓冲区。

1
2
3
4
5
6
7
8
9
/// <summary>
/// 将输入信息加入传出缓冲区
/// </summary>
public void AddInteractToBuffer(string actionName, string[] paramsList) {
lock (bufferLock) {
LevelAction levelAction = new LevelAction(actionName, paramsList);
interactBuffer.Enqueue(levelAction);
}
}

PlayerController对象中获取玩家操作,并将其推送,例如:

1
2
3
4
if (input.GetTrackingKey(Keys.W).fired) {
level.LevelClient.AddInteractToBuffer("k", new string[3] { ControlledPlayer.uuid, "f", "w"} );
controlledPlayer.keyWPressed = true;
}

同时,客户端也具备处理服务端传入数据并改变本地对局状态的能力,定义在Level游戏主循环中有下列处理代码。

1
2
3
4
5
6
7
8
9
public void Update(GameTime gameTime) {
lock (levelClient.bufferLock) {
while (LevelClient.ActionsBuffer.Count > 0) {
var interact = LevelClient.ActionsBuffer.Dequeue();
interact.HandleLevelAction(this);
}
// 对缓冲区的所有动作作用到本地游戏
}
}

由此,我们成功构建了客户端和服务端的通讯。但是这样的通讯方法有严重问——在实际的广域网测试中,该程序的通讯吞吐量极大,完全摧毁了我那峰值带宽只有2MiB/s的服务器。

服务器信息
当时本地吞吐量统计

实际上,这个3.5Mbps在保持了很短一段时间后就下降到了2.0Mbps以下,而我的客户端也发生了严重的延迟,这说明基于当前的算法,数据交换量过大,是不可接受的,应当修改数据同步算法。

通讯改进:降低同步频率

以下方对掉落道具Item的网络更新为例,规定每30游戏刻才进行一次同步。

1
2
3
4
5
6
7
8
9
10
11
void Item::Update(const float rate, queue<LevelAction> &responseBuffer) {
// 只需要指示位置的改变
pos.x += velocity.x * rate;
pos.y += velocity.y * rate;
if (gameTick % 30 == 0) {
vector<string> posInfo { uuid, to_string(pos.x),
to_string(pos.y), to_string(velocity.x), to_string(velocity.y) };
pushToResponseBuffer("ia", posInfo, responseBuffer);
}
lifeTime -= rate;
}

这也就意味着,传输道具信息的消息频率下降到了原来的1/30,但是这样会带来其它问题,在这30帧之间的29帧是没有服务端消息的,在这期间如何确定道具的位置?

客户端改进:预测机制

在之前的模型中,我们一直只是让客户端接受服务端的消息,这样的客户端被称作Dumb Client,它只负责渲染而不参与游戏逻辑计算。据说曾今把德国Boy逼疯的游戏《虚幻竞技场》就使用的是类似算法。

这样做的好处是在一定程度上保证了游戏数据的准确性,也有一定的防作弊效果,但是缺点在于客户端有相当的算力浪费。对于我的破服务器而言,我需要调动客户端的算力来减轻我的服务器压力。

我们知道,游戏中掉落的道具会保持匀速直线运动。所以如果我同步了道具的速度信息,我就可以在客户端中预测接下来道具的运动轨迹,从而弥补中间帧的空白,这就是预测机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public void HandleServerRequest(string[] paramsList) {
if (!(paramsList[0].ToString() == uuid)) { return; }
try {
posCorrectSwift = new Vector2(float.Parse(paramsList[1]),
float.Parse(paramsList[2])) - pos;
velocity = new Vector2(float.Parse(paramsList[3]),
float.Parse(paramsList[4]));
}
catch (Exception e) {
Console.WriteLine("Error When Handle Request -> " + e);
}
}
// 注意看,上面是Item对象接受服务器信息的代码,velocity被同步

public void Update(GameTime gameTime) {
// 处理本地的一些更新,例如计算角度,速度
pos += (float)gameTime.ElapsedGameTime.TotalSeconds * velocity;
selfDrawable.pos = pos;
selfDrawable.angle += (float)gameTime.ElapsedGameTime.TotalSeconds * MathF.PI / 6;
}
// 本地客户端也调用Update方法,使用速度数据预测坐标

需要注意,尽管有预测机制,但是这并不意味着我们可以完全不进行坐标同步,因为服务器对客户端传递的信息存在潜在的延迟,这对于速度会改变的对象——比如一些弹幕而言很不友好。

一次发射弹幕

上图是对一次弹幕发射过程的模拟,试想在传输中存在延迟,则在服务器位于t1'时刻时发送同步消息时,服务器的弹幕经历了t1' - t1时间的减速,而客户端的弹幕经历了t2' - t0时间的减速,由此一来,客户端模拟的弹幕和服务端的弹幕位置便不相同。所以,为了保持服务端和客户端同步性,时刻同步还是有必要的。

客户端改进:平滑插值

上文提到,服务端数据和客户端预测存在误差,所以不可避免地,收到服务端同步信息时游戏会出现跳帧现象:具体表现为本来处在这个位置的弹幕突然跳到另外一个其行进轨迹上的位置。

要解决跳帧现象,我们可以通过服务端传来信息的时间戳和当前延迟信息进行一些程度的回放推演来减小这种误差,即尽量让本地的弹幕更新时间接近t1' - t1而非本地的t2' - t0

不过考虑到在该弹幕游戏中,与服务器的延迟一般在50ms~100ms内,我们没有太大的必要纠结于这一部分误差。为了缓解跳帧现象,我们要做的是让跳帧不要在一帧内完成,将位置的突变分散到几帧内进行,这就是平滑插值算法

1
2
3
4
5
6
7
8
9
10
11
public void HandleServerRequest(string[] paramsList) {
try {
posCorrectSwift = new Vector2(float.Parse(paramsList[1]),
float.Parse(paramsList[2])) - pos;
velocity = new Vector2(float.Parse(paramsList[3]),
float.Parse(paramsList[4]));
}
catch (Exception e) {
Console.WriteLine("Error When Handle Request -> " + e);
}
}

插值简介

如图所示,本地预测位置和服务器位置的偏差值即为图中蓝色箭头所指,我们令程序每次同步时记录该偏差值为posCorrectSwift

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void CorrectCurrPos(GameTime gameTime) {
// 平滑插值使得对象移动同步偏差量
float distance = posCorrectSwift.Length();
float approachVelocity = 30f + distance * 1.5f + distance * distance * 0.5f;
approachVelocity *= (float)gameTime.ElapsedGameTime.TotalSeconds;
if (distance <= approachVelocity) {
pos += posCorrectSwift;
posCorrectSwift = Vector2.Zero;
}
else {
Vector2 currSwift = posCorrectSwift;
currSwift.Normalize(); currSwift *= approachVelocity;
pos += currSwift;
posCorrectSwift -= currSwift;
}
}

接下来,在单位的Update()方法中执行CorrectCurrPos()方法,根据偏差距离计算出回正速度approachVelocity,并使弹幕在正常移动的同时也拥有一个回归速度。

学过物理的都知道,出于速度的可分解性,回归速度带来的运动和正常预测带来的运动合成后是可以让弹幕回到正确的位置上的,于是我们温和地解决了由跳帧带来的问题。

后记

到此为止,游戏的网络通信框架建成。其实在实际开发中的问题远远不止这些,我只是挑出了比较有讨论价值的问题写了这些总结。

回顾开发流程,我很惊诧,因为在我刚开始的时候,我只有两个最简单的服务端和客户端程序,而前者能做的也只有从后者处读取按键操作而已。

刚开始

我已经忘了我是怎么一步一步开发到现在的,我不断抱着“下一步添加这个功能应该能够成功”的心态,似乎只是纯粹想要验证我的个人猜想、完成我的个人设计而已,往程序里添加模块。不知不觉间,我就从上面这个蓝色屏幕开始做成了整个游戏。

我很享受这个过程,我在开发的过程中把我的好奇心、求知欲发挥到了我认为完美且充分的地步,或许有一天我会站到更高的平台上,做更有意义的项目,不过我永远也不会忘记好奇心和求知欲是我往上攀的基石。

引用和感谢

博客内容为我原创,允许转载,但请注明出处。

项目文档里已经有引用与感谢信息了,就不重复感谢了。