概述
tModLoader 的网络同步基于一种混合的 权威服务器模型(Steam开服时主机即服务器)确保多人模式下的一致性。
同步内容
内容 | 权威方 |
---|---|
Player | 客机 |
Projectile(有主) | 客机 |
NPC | 主机 |
Projectile(无主) | 主机 |
世界状态 | 主机 |
两种基本同步
客机同步: 源客机 => 服务器 => 所有客机(除源客机) 在此同步中,数据权威方为源客机。数据包首先自源客机发送到服务器,而后服务器作为中转站将包转发到所有已连接的客机(除源客机)。
服务器同步: 服务器 => 所有客机 在此同步中,数据权威方为服务器。服务器将数据包发送到所有已连接的客机。
同步的实现情况
层级 | 同步内容 | 同步方式 | 特点 |
---|---|---|---|
基础层 | 基础实体(Player/Item等) | 自动同步 | - |
Mod层 | Mod实体 (ModPlayer/ModItem等) | 部分自动同步,其他需手动实现 | 仅需关注读写,有现成的方法 |
自定义层 | Mod特定游戏逻辑 | 需手动实现 | 需要编写全部内容 |
前置知识
收发包基础
tModLoader通过网络包在不同端(服务器/客户端)之间传输数据,借助BinaryWriter
和BinaryReader
类来读写信息。
使用BinaryReader和BinaryWriter进行数据读写时,必须严格遵守顺序一致性原则。
- 写入顺序必须与读取顺序完全一致 发送端写入数据的顺序必须严格匹配接收端读取数据的顺序。如果写入顺序是:包ID、玩家ID、位置坐标,那么读取顺序也必须是:先读包ID、再读玩家ID、最后读位置坐标。任何顺序错位都会导致数据解析错误。
- 字段数量必须严格对应 发送端写入的每个字段都必须在接收端有对应的读取操作。不能多写一个字段,也不能少读一个字段。如果发送端写了3个字段而接收端只读取了2个,或者发送端写了2个字段而接收端试图读取3个,都会导致数据流错位。
- 数据类型必须完全匹配 写入的数据类型必须与读取时声明的数据类型严格一致。错误的读取类型会导致解析出无意义数据甚至崩溃,并影响后续的读取。
Test.cs
// 发送包
BinaryWriter writer;
{
writer.Write(123); // int
writer.Write("text"); // string
writer.WriteVector2(position); // Vector2
}
// 接收包
BinaryReader reader;
{
int id = reader.ReadInt32();
string text = reader.ReadString();
Vector2 pos = reader.ReadVector2();
}
⚠️ 重要限制:
- 数据包最大尺寸:65,535字节
- 避免高频发送(>20包/秒可能引发延迟)
内置同步
内置同步仅关注读写的部分即可
ModPlayer
public override void SyncPlayer(int toWho, int fromWho, bool newPlayer);
public override void CopyClientState(ModPlayer targetCopy);
public override void SendClientChanges(ModPlayer clientPlayer);
ModSystem
public override void NetSend(BinaryWriter writer);
public override void NetReceive(BinaryReader reader);
ModItem
public override void NetSend(BinaryWriter writer);
public override void NetRecieve(BinaryReader reader);
ModProjectile
public override void SendExtraAI(BinaryWriter writer);
public override void ReceiveExtraAI(BinaryReader reader);
自定义同步
自定义同步是Mod内各机制系统同步的基础,该类同步通过ModPacket来完成 注: 此Packet以提前经过基于Mod的分类处理,不需要担心多Mod冲突。
tModLoader在Mod
类中提供了ModPacket统一接收处理的方法,我们需要在此对不同的包进行分离并进行对应的处理。以下是一个基于枚举ID分类的简单实现:
// 定义消息类型枚举(包ID)
public enum PacketID : byte
{
PlayerPositionUpdate, // 0
// ... 其他消息类型
}
// 发送 - 可以在很多种位置调用
public void SendPositionUpdate(Vector2 newPosition)
{
ModPacket packet = Mod.GetPacket();
packet.Write((byte)PacketID.PlayerPositionUpdate); // 写入包ID
packet.WriteVector2(newPosition); // 写入位置数据
packet.Send(toClient: -1); // 发送包
}
// 接收 - Mod.HandlePacket(BinaryReader reader, int whoAmI)
public class TestMod : Mod
{
// ...
public override void HandlePacket(BinaryReader reader, int whoAmI)
{
PacketID id = (PacketID)reader.ReadByte(); // 首先读取包ID
switch (id)
{
case PacketID.PlayerPositionUpdate:
HandlePositionUpdate(reader, whoAmI);
break;
// ... 其他消息处理
}
}
private void HandlePositionUpdate(BinaryReader reader, int sender)
{
// 处理位置数据
Vector2 position = reader.ReadVector2();
// ...
}
}
本模组内
在上一段落中可以看到,通过枚举ID可以简单的实现对于不同包的分流,但也导致了一个痛点——每个包类型都需要一个枚举键,进而导致枚举类的膨胀,并且难以扩展——这就是Everglow的网络同步系统希望解决的部分。
本模组提供了PacketResolver
类用于统一处理包,并使用IPacket
与IPacketHandler
来简化包的操作,使编码者仅需关注读写部分,达到与tModLoader提供的接口类似的效果。
示例
// 统一同步玩家的鼠标位置
public void SyncMousePosition(bool fromServer)
{
PacketResolver.Send(new MousePositionSyncPacket(mouseWorld), fromServer, Player);
}
// 包类
public class MousePositionSyncPacket : IPacket
{
public MousePositionSyncPacket()
{
}
public MousePositionSyncPacket(Vector2 mouseWorld)
{
this.mouseWorld = mouseWorld;
}
public Vector2 mouseWorld;
// 在此仅进行数据读取与保存,不使用
public void Receive(BinaryReader reader, int whoAmI)
{
mouseWorld = reader.ReadVector2();
}
public void Send(BinaryWriter writer)
{
writer.WriteVector2(mouseWorld);
}
}
// 包处理器类
[HandlePacket(typeof(MousePositionSyncPacket))] // 通过Attribute绑定处理器对应的包类型
public class MousePositionSyncPacketHandler : IPacketHandler
{
public void Handle(IPacket packet, int whoAmI)
{
var player = Main.player[whoAmI];
var mp = player.GetModPlayer<EverglowPlayer>();
mp.mouseWorld = ((MousePositionSyncPacket)packet).mouseWorld;
if (NetUtils.IsServer)
{
mp.SyncMousePosition(true); // 将其转发给各个客户端
}
}
}