Home

Awesome

帧同步开发框架

将帧同步项目 https://github.com/dudu502/LittleBee 帧同步核心部分提取出SDK,本SDK基于netstandard2.0。提供服务端,客户端的SDK部分。目前主要的代码案例用Unity编写客户端,dotnetcore控制台程序为服务端。

项目结构如下

说明

API

SDK中找到Context类,这是框架的主类,这个类很重要它是整个SDK的入口,在客户端和服务端中都用这个类作为程序入口,都是相似API的用法,默认Context的name分为服务端和客户端,使用的时候根据需要选择client或server类型来构造Context实例。

public const string CLIENT = "client";
public const string SERVER = "server";

在构造方法里传入name来指定对应的Context:

Context clientContext = new Context(Context.CLIENT);
Context serverContext = new Context(Context.SERVER);

对应的获取方法:

Context context = Context.Retrieve(name);

在SDK内或自定义游戏中均可以通过上述的方法获取Context对象。再通过Context对象可以方便获取其他对象和数据。

这是Context的类关系图,Context中包含SDK所有提供给开发者的功能模块入口。

classDiagram
    class INetworkServer{
      +Send(IPEndPoint ep,ushort messageId,byte[] data)
      +Send(int clientId,ushort messageId,byte[] data)
      +Send(int[] clientIds,ushort messageId,byte[] data)
      +Send(ushort messageId,byte[] data)
      +Run(int port)
      +int GetActivePort()
    }
    class INetworkClient{
      +Send(ushort messageId,byte[] data)
      +Connect()
      +Connect(string ip,int port,string key)
      +Close()
      int GetActivePort()
    }
    class Context {  
        +INetworkServer Server  
        +INetworkClient Client  
        +ILogger Logger  
        +string name
        +Context(string name,INetworkClient client,ILogger logger)
        +Context(string name,INetworkServer server,ILogger logger)
        +Context(string name,ILogger logger)
        +static Retrieve(string name)
        +Context SetSimulationController(SimulationController controller)
        +SimulationController GetSimulationController()
        +GetMeta(string name,string defaultValue)
        +SetMeta(string name,string value)
        +Context SetModule(AbstractModule module)
        +M GetModule<M>()
        +Context RemoveModule(Type type)
    }  

ContextMetaId中定义的内置类型如下,这些类型会在SDK内部使用,用户还可以通过Context.SetMeta和Context.GetMeta来存储和读取自定义类型。

public sealed class ContextMetaId
{
    public const string USER_ID = "user_id";
    public const string SERVER_ADDRESS = "server_address";
    public const string MAX_CONNECTION_COUNT = "max_connection_count";
    public const string ROOM_MODULE_FULL_PATH = "room_module_full_path";
    public const string STANDALONE_MODE_PORT = "standalone_mode_port";
    public const string GATE_SERVER_PORT = "gate_server_port";
    public const string SELECTED_ROOM_MAP_ID = "selected_room_map_id";
    public const string PERSISTENT_DATA_PATH = "persistent_data_path";
}

服务端代码案例

服务器分Gate和Battle两部分,Gate服务旨在提供用户一个大厅服务,让用户登录进来,每一个用户有一个不同的uid,这个uid目前仅用不同的字符串区别测试就可以,正式项目应该是数据库中的guid以确保每一个用户的id是唯一的。在这个Gate服务中提供用户创建房间,加入房间,离开房间等有关组队的服务。当多用户在房间内开启战斗的时候Gate服务会开启Battle服务的新进程并告知这些用户进入Battle服务。每一个战斗都会开启一个新的Battle进程。在Battle服务中具体实现了同步关键帧给所有用户的服务。

如下是Gate服务端的代码案例:

const string TAG = "gate-room";
static void Main(string[] args)
{
    // 使用Context.SERVER 构造Context
    Context context = new Context(Context.SERVER, new LiteNetworkServer(TAG), new DefaultConsoleLogger(TAG))
        .SetMeta(ContextMetaId.ROOM_MODULE_FULL_PATH, "battle_dll_path.dll")// the battle project's build path.
        .SetMeta(ContextMetaId.MAX_CONNECTION_COUNT,"16")
        .SetMeta(ContextMetaId.SERVER_ADDRESS,"127.0.0.1")
        .SetModule(new RoomModule());
    context.Server.Run(9030);
    Console.ReadLine();
}

接下来是Battle项目案例:

static void Main(string[] args)
{
    string key = "SomeConnectionKey";
    int port = 50000;
    uint mapId = 1;
    ushort playerNumber = 100;
    int gsPort = 9030;
    // 一些从Gate服务中传入的参数
    if (args.Length > 0)
    {
        if (Array.IndexOf(args, "-key") > -1) key = args[Array.IndexOf(args, "-key") + 1];
        if (Array.IndexOf(args, "-port") > -1) port = Convert.ToInt32(args[Array.IndexOf(args, "-port") + 1]);
        if (Array.IndexOf(args, "-mapId") > -1) mapId = Convert.ToUInt32(args[Array.IndexOf(args, "-mapId") + 1]);
        if (Array.IndexOf(args, "-playernumber") > -1) playerNumber = Convert.ToUInt16(args[Array.IndexOf(args, "-playernumber") + 1]);
        if (Array.IndexOf(args, "-gsPort") > -1) gsPort = Convert.ToInt32(args[Array.IndexOf(args, "-gsPort") + 1]);
    }
    Context context = new Context(Context.SERVER, new LiteNetworkServer(key), new DefaultConsoleLogger(key))
        .SetMeta(ContextMetaId.MAX_CONNECTION_COUNT, playerNumber.ToString())
        .SetMeta(ContextMetaId.SELECTED_ROOM_MAP_ID, mapId.ToString())
        .SetMeta(ContextMetaId.GATE_SERVER_PORT, gsPort.ToString())
        .SetModule(new BattleModule());
    SimulationController simulationController = new SimulationController();
    simulationController.CreateSimulation(new Simulation(),new ISimulativeBehaviour[] { new ServerLogicFrameBehaviour() });
    context.SetSimulationController(simulationController);
    context.Server.Run(port);
    Console.ReadKey();
}

客户端代码案例

客户端部分的代码也和服务端的类似,都是通过Context主类来设置的,下面是案例1代码:

void Awake(){
  // 用Context.CLIENT构造Context,客户端的Context还需要指定SimulationController等,因此比服务端的Context稍微复杂一些
  MainContext = new Context(Context.CLIENT, new LiteNetworkClient(), new UnityLogger("Unity"));
  MainContext.SetMeta(ContextMetaId.STANDALONE_MODE_PORT, "50000")
              .SetMeta(ContextMetaId.PERSISTENT_DATA_PATH, Application.persistentDataPath);
  MainContext.SetModule(new GateServiceModule())      //大厅组队相关服务
              .SetModule(new BattleServiceModule());  //帧同步服务
  // 构造模拟器控制器,SDK提供了一个Default版本的控制器,一般情况下用Default就可以了
  DefaultSimulationController defaultSimulationController = new DefaultSimulationController();
  MainContext.SetSimulationController(defaultSimulationController);
  defaultSimulationController.CreateSimulation(new DefaultSimulation(),new EntityWorld(),
      new ISimulativeBehaviour[] {
          new FrameReceiverBehaviour(),     //收取服务器的帧数据并处理
          new EntityBehaviour(),            //执行ECS中System
      },
      new IEntitySystem[]
      {
          new AppearanceSystem(),           //外观显示系统
          new MovementSystem(),             //自定义移动系统,计算所有Movement组件
          new ReboundSystem(),              //自定义反弹系统
      });
  EntityWorld entityWorld = defaultSimulationController.GetSimulation<DefaultSimulation>().GetEntityWorld();
  entityWorld.SetEntityInitializer(new GameEntityInitializer(entityWorld));       // 用于初始化和构造Entity
  entityWorld.SetEntityRenderSpawner(new GameEntityRenderSpawner(entityWorld,GameContainer));     //ECSR中Renderer的构造
}

案例1视频

https://github.com/user-attachments/assets/75d00d10-824e-459d-87e1-5000da1ee7cf

这个案例展示了多个实体在两个客户端中同步运行的结果。在运行过程中物体不能被控制,因此这个案例也是最简单的案例,所有物体在初始化那一刻他们的运动随逻辑帧的变化都是确定的。

案例2视频

https://github.com/user-attachments/assets/66622bac-50d7-44ad-8afa-cac510a5132b

这个案例展示用户可以控制物体运动,并且在多个客户端中都能同步物体运动的变化。

案例测试API

所有的案例项目均引用了 https://github.com/omid3098/OpenTerminal 库用来显示测试命令方便调试API,按下键盘 '`' 打开命令行。

OpenTerminal命令SDK中的API备注
setuidcontextInst.SetMeta(ContextMetaId.USER_ID, uid);设置Context中的用户数据
connectcontextInst.Client.Connect(127.0.0.1, 9030, "gate-room");默认连接到服务器
connect-ip-port-keycontextInst.Client.Connect(ip, port, key);连接到指定服务器
createcontextInst.GetModule<GateServiceModule>().RequestCreateRoom(mapid);连接到服务器后创建房间
joincontextInst.GetModule<GateServiceModule>().RequestJoinRoom(roomId);加入到指定房间
leavecontextInst.GetModule<GateServiceModule>().RequestLeaveRoom();离开指定房间
roomlistcontextInst.GetModule<GateServiceModule>().RequestRoomList();刷新房间列表
launchcontextInst.GetModule<GateServiceModule>().RequestLaunchGame();开始游戏
updateteamcontextInst.GetModule<GateServiceModule>().RequestUpdatePlayerTeam(roomId, userId, teamId);在房间内更新房间数据
updatemapcontextInst.GetModule<GateServiceModule>().RequestUpdateMap(roomId, mapId, maxPlayerCount);在房间内更换地图
stop请查看Sample.cs及其子类中Stop方法结束游戏
drawmap请查看Sample.cs及其子类中DrawMap方法绘制地图
saverep请查看Sample.cs及其子类中SaveReplay方法保存回放(录像)
playrep请查看Sample.cs及其子类中PlayReplay方法保存回放(录像)

数据结构

下面列出SDK中一些关键的数据结构,这些也可以成为协议结构体,这些可以序列化和反序列化并且支持字段可选数据结构在SDK中大量使用。可以通过Docs/Protocols/中的工具生成,形如:

public class PtMyData
{
  //Fields
  public static byte[] Write(PtMyData value){}
  public static PtMyData Read(byte[] bytes){}
}
类名字段备注
PtFramestring EntityId<br>PtComponentUpdaterList Updater<br>byte[] NewEntitiesRaw一个关键帧数据
PtFramesint FrameIdx<br>List<PtFrame> KeyFrames在某一帧的所有关键帧集合
PtMapstring Version<br>EntityList Entities地图数据
PtReplaystring Version<br>uint MapId<br>List<EntityList> InitEntities<br>List<PtFrames> Frames录像(回放)数据

录像(回放)机制

录像(回放)机制是帧同步技术中最有特点的机制,也是一个绕不开的点。SDK中也有录像的保存加载。

帧同步模拟器

帧同步模拟器是SDK中执行帧同步的关键部分,重点需要不同设备在启动后经过一段时间后所有的设备都能保持一致的帧数,这就需要在每次tick的时候通过DateTime校准,要做到这点需要解决本地时间流逝和逻辑TICK之间的关系。详细代码可以打开SimulationController.cs 文件查看。

项目任务

引用