Unity-MMORPG内容笔记-其一

这是一期内容非常多的讲解------有关一个MMORPG需要的内容我都会在这里做介绍,当然篇幅原因,我不会介绍得非常仔细,但至少会把基本的思路讲解清楚。

用户的登录和注册

如何实现MMORPG的登录和注册呢?

这是我们的架构,这个系统采用经典的客户端-服务端分离架构 ,使用Protobuf作为网络通信协议

用户在Unity客户端的UI界面(UILogin/UIRegister)输入账号密码信息后,经过本地验证(空值检查、密码确认)通过客户端UserService服务层将用户数据封装成Protobuf格式的网络消息(UserLoginRequest/UserRegisterRequest),通过TCP连接发送到GameServer服务端;服务端的UserService接收请求后,通过Entity Framework ORM框架查询SQL Server数据库中的TUser和TPlayer实体进行用户验证或创建新用户记录,并将处理结果封装成响应消息(UserLoginResponse/UserRegisterResponse)返回给客户端;客户端接收响应后根据结果状态进行相应处理------登录成功则跳转到角色选择界面并同步用户的角色列表信息,注册成功则提示用户登录,失败则显示错误消息------整个过程通过消息分发机制、会话管理和连接重试机制确保网络通信的稳定性和数据的一致性。

这里有很多新鲜的概念:如何理解客户端UserService服务层?Protobuf格式是什么?有其他格式吗?Entity Framework ORM框架是什么?

如何理解客户端UserService服务层?

服务层是一种软件架构模式,位于UI界面和底层数据之间,负责处理业务逻辑比如管理连接、错误处理、数据同步等等。

这里我们展示一下完整的客户端架构设计:

大体上介绍一下各个层级的含义:

UI层作为最上层负责接收用户的交互操作 (如点击登录按钮、输入账号密码)并展示游戏界面,当用户触发操作后数据流向服务层,服务层作为核心业务处理中心负责执行具体的业务逻辑 (如验证输入信息、管理连接状态、处理登录注册流程、协调各种游戏功能),服务层将需要网络通信的请求传递给网络层,网络层专门处理与GameServer的TCP连接、Protobuf消息的序列化反序列化以及网络异常处理 ,当服务端返回数据后网络层将响应传回服务层,服务层根据业务需求将数据更新到模型层(存储用户信息、角色数据等游戏状态 )并通知管理层,管理层负责统一管理各个子系统(如角色管理器、数据管理器、地图管理器)并缓存配置数据,同时实体层表示游戏世界中的具体对象(角色、NPC、物品等),而工具层(包含场景管理、资源管理、日志系统、辅助工具)为整个架构提供基础设施支持,最终所有处理结果通过回调机制反馈到UI层更新用户界面。

这是客户端架构的层间关系和数据流向。

Protobuf格式是什么?

Protocol Buffers(简称Protobuf)是Google开发的一种语言无关、平台无关的数据序列化格式,用于在不同系统之间高效地传输和存储数据。

同样是序列化方式,不同序列化方式的对比如图:

当然,我们还可以更细节地去区分:同样是二进制序列化格式,不同的二进制区别在哪?

不同二进制序列化格式的核心差异在于数据组织方式和编码策略 :Protobuf是基于Schema的结构化二进制格式,使用字段编号和变长编码实现高效压缩,需要预先定义数据结构但体积最小速度最快;MessagePack是自描述的二进制格式,将JSON的键值对直接转换为紧凑的二进制表示,无需Schema但包含更多元数据导致体积较大;自定义Binary格式完全由开发者控制数据布局,可以达到极致的空间和性能优化但缺乏标准化和兼容性支持;而Avro等其他格式则在Schema演进、跨语言支持等方面各有侧重,总的来说Protobuf通过牺牲数据自描述性换取了最优的性能和体积,MessagePack保持了灵活性但效率稍低,自定义格式追求极致性能但开发维护成本高

Entity Framework ORM框架是什么?

Entity Framework (EF) 是微软开发的ORM框架,ORM全称是Object-Relational Mapping(对象关系映射)。

ORM就像是数据库和程序代码之间的"翻译官":

  • 数据库:存储的是表格、行、列
  • 程序代码:操作的是对象、属性、方法
  • ORM:自动在两者之间转换

一言以蔽之:EF就是一个帮助我们直接根据C#对象在数据库查找对应内容的框架,通过抽象数据库操作,让开发者用面向对象的方式处理数据,大幅提升开发效率

这样整个用户的注册和登录的流程就差不多理清楚了:我们在客户端的UI层输入注册/登录信息,客户端的服务层将这些信息传递给网络层,网络层通过protobuf协议将这些信息转换成二进制格式的数据流之后通过TCP传递到服务器,服务器利用EF ORM框架在数据库中快速进行校验/添加操作之后返回相关信息到客户端。

角色创建与选择

实现了用户的登录和注册之后我们就要去实现创建角色的功能了:

复制代码
用户输入角色名和选择职业
    ↓
UI层验证输入并调用服务层
    ↓
服务层封装Protobuf消息
    ↓
网络层发送到服务端
    ↓
服务端接收并创建角色实体
    ↓
Entity Framework保存到数据库
    ↓
返回角色列表给客户端
    ↓
客户端更新UI显示新角色

可以看到大体上的流程和我们实现角色的登录、注册差不多,因为它们都遵循相同的架构模式。

用户在Unity客户端的角色选择界面输入角色名称并选择职业(战士/法师/弓箭手),客户端服务层验证输入信息后通过UserService将角色创建请求封装成Protobuf格式的UserCreateCharacterRequest消息,网络层通过TCP连接将二进制数据发送到GameServer服务端,服务端接收请求后通过Entity Framework ORM框架在SQL Server数据库中创建新的TCharacter实体记录(包含角色名称、职业、初始等级1、初始金币10万、出生位置坐标、初始背包20格、初始物品等数据),同时建立角色与用户的关联关系并保存到数据库,服务端将创建结果和更新后的角色列表通过UserCreateCharacterResponse消息返回给客户端,客户端接收响应后更新本地角色数据模型并刷新UI界面显示新创建的角色,整个流程确保了角色数据的持久化存储和客户端服务端的数据同步。

创建完角色之后再次登录后就可以去选择之前创建的角色了:

cpp 复制代码
登录成功 → 获取角色列表 → 显示角色选择界面 → 
选择角色 → 发送进入游戏请求 → 服务端加载角色数据 → 
进入游戏世界 → 初始化所有系统

角色选择分为两个阶段:

  1. 登录时获取角色列表:服务端返回用户的所有角色
  2. 选择角色进入游戏:客户端选择特定角色进入游戏世界

用户在Unity客户端输入账号密码登录成功后,服务端UserService的OnLogin方法通过Entity Framework查询SQL Server数据库中该用户关联的所有TCharacter记录 ,将每个角色的基本信息(ID、名称、职业、等级等)封装成NCharacterInfo对象添加到UserLoginResponse的Player.Characters列表中,通过Protobuf协议序列化为二进制数据流返回给客户端,客户端UserService的OnUserLogin方法接收响应后将角色列表保存到User.Instance.Info.Player.Characters中,UICharacterSelect界面通过InitCharacterSelect方法遍历这个角色列表为每个角色创建对应的UI元素(显示角色名称、职业、等级等信息),并绑定点击事件让玩家可以选择角色。

玩家在角色选择界面点击某个角色后,UICharacterSelect的OnSelectCharacter方法记录选中的角色索引并更新UI高亮状态,点击"进入游戏"按钮时调用UserService的SendGameEnter方法将角色索引封装成UserGameEnterRequest消息通过Protobuf协议发送到服务端,服务端OnGameEnter方法根据角色索引从数据库中获取完整的TCharacter数据(包括位置、物品、装备、任务等),通过CharacterManager创建游戏中的Character对象并建立会话管理,同时调用MapManager让角色进入上次离线时的地图位置,服务端将完整的角色信息通过UserGameEnterResponse返回给客户端,客户端OnGameEnter方法接收后设置User.Instance.CurrentCharacter为当前角色并初始化所有游戏系统(物品管理器、背包管理器、装备管理器、任务管理器、好友管理器、公会管理器),完成角色进入游戏世界的完整流程。

从玩家登录到真正进入游戏有两次客户端和服务器的通信:第一次是客户端发起登录请求,然后客户端展示已有的角色信息,第二次则是选择角色进入游戏。

帧同步(Frame Sync)和状态同步(State Sync)

交代了我们是如何进行用户的登录、注册以及创建角色、选择角色之后,我们就要进入具体的游戏场景了。

这里我们还要提及一个概念就是我们的网络游戏的同步机制

那么在我们的项目中,角色的同步具体是怎么实现的呢?

当玩家在客户端进行移动、转向等操作时,客户端会将角色的最新状态(如位置、朝向、速度等)通过同步协议发送给服务器。服务器收到后,先更新自己维护的角色状态,然后将这些变化广播给同一地图内的其他玩家。其他玩家的客户端收到服务器的同步消息后,实时更新本地显示的对应角色状态。这样就实现了所有玩家在同一场景下的角色动作和位置的实时同步,这就是我们的状态同步。

Entity类实现

Entity类是游戏中所有"可见对象"的基础,包括玩家、NPC、怪物等。

最底层的Entity类封装了所有游戏对象(如玩家、怪物、NPC等)通用的空间属性和同步数据。其上通过CharacterBase扩展出角色的通用属性,再细分为Character(玩家)、Monster(怪物)等具体类型,分别实现各自的游戏逻辑。客户端和服务端的实体类结构高度对应,便于数据同步。实体的状态通过协议层(如NEntity、NCharacterInfo)在网络上传输,持久化则通过数据库实体(如TCharacter)实现。

地图管理器实现

项目中的地图管理器(MapManager)主要负责服务器端所有地图的加载、管理和统一调度。

MapManager 采用单例模式 ,内部通过一个以地图ID为键的字典集中管理所有地图对象。

cs 复制代码
// Src/Server/GameServer/GameServer/Managers/MapManager.cs
class MapManager : Singleton<MapManager>
{
    Dictionary<int, Map> Maps = new Dictionary<int, Map>();

    public void Init()
    {
        foreach (var mapdefine in DataManager.Instance.Maps.Values)
        {
            Map map = new Map(mapdefine);
            Log.InfoFormat("MapManager.Init > Map:{0}:{1}", map.Define.ID, map.Define.Name);
            this.Maps[mapdefine.ID] = map;
        }
    }

    public Map this[int key]
    {
        get { return this.Maps[key]; }
    }

    public void Update()
    {
        foreach(var map in this.Maps.Values)
        {
            map.Update();
        }
    }
}

服务器启动时,DataManager 会从配置文件(如 MapDefine.txt)加载所有地图的基础信息,MapManager 随后为每个地图创建对应的 Map 实例并存入字典。

cs 复制代码
// Src/Server/GameServer/GameServer/Managers/DataManager.cs
public class DataManager : Singleton<DataManager>
{
    internal Dictionary<int, MapDefine> Maps = null;

    internal void Load()
    {
        string json = File.ReadAllText(this.DataPath + "MapDefine.txt");
        this.Maps = JsonConvert.DeserializeObject<Dictionary<int, MapDefine>>(json);
        // ...加载其他配置
    }
}

每个 Map 对象自身包含地图配置、角色管理(如当前地图内的所有玩家和怪物)、刷怪与怪物管理器,并负责处理角色的进入、离开、实体同步和事件广播等具体逻辑。

cs 复制代码
// Src/Server/GameServer/GameServer/Models/Map.cs
class Map
{
    internal MapDefine Define;
    Dictionary<int, MapCharacter> MapCharacters = new Dictionary<int, MapCharacter>();
    SpawnManager SpawnManager = new SpawnManager();
    public MonsterManager MonsterManager = new MonsterManager();

    internal Map(MapDefine define)
    {
        this.Define = define;
        this.SpawnManager.Init(this);
        this.MonsterManager.Init(this);
    }

    internal void Update()
    {
        SpawnManager.Update();
    }

    internal void CharacterEnter(NetConnection<NetSession> conn, Character character)
    {
        character.Info.mapId = this.ID;
        this.MapCharacters[character.Id] = new MapCharacter(conn, character);
    }

    internal void CharacterLeave(Character character)
    {
        this.MapCharacters.Remove(character.Id);
    }
}

游戏主循环中,MapManager 会统一调度所有地图的 Update 方法,实现全局的地图逻辑更新。玩家切换地图时,MapManager 负责角色在不同地图间的迁移。整体架构实现了地图的集中管理、独立运行和高效扩展,所有地图相关的数据都来源于服务器启动时加载的配置文件。

cs 复制代码
// Src/Server/GameServer/GameServer/GameServer.cs
public void Update()
{
    var mapManager = MapManager.Instance;
    while (running)
    {
        Time.Tick();
        Thread.Sleep(100);
        mapManager.Update();
    }
}

地图服务

客户端

客户端的 MapService 负责监听服务器发来的地图进入、离开、实体同步等消息,并根据这些消息驱动本地场景和角色的变化

当收到服务器的地图进入消息时,MapService 会根据地图ID查找本地配置,加载对应的Unity场景资源,并播放相应的背景音乐;进入新地图时,MapService 会根据服务器下发的角色列表,调用 CharacterManager 添加或移除角色,保证本地场景中的角色与服务器一致;还会监听并处理服务器下发的实体同步消息(如其他玩家移动、转向等),实时更新本地场景中其他角色的位置和状态。

cpp 复制代码
// 监听服务器消息
public MapService()
{
    MessageDistributer.Instance.Subscribe<MapCharacterEnterResponse>(this.OnMapCharacterEnter);
    MessageDistributer.Instance.Subscribe<MapCharacterLeaveResponse>(this.OnMapCharacterLeave);
    MessageDistributer.Instance.Subscribe<MapEntitySyncResponse>(this.OnMapEntitySync);
}

// 进入地图时加载场景
private void OnMapCharacterEnter(object sender, MapCharacterEnterResponse response)
{
    foreach (var cha in response.Characters)
    {
        CharacterManager.Instance.AddCharacter(cha);
    }
    if (CurrentMapId != response.mapId)
    {
        this.EnterMap(response.mapId);
        this.CurrentMapId = response.mapId;
    }
}

public void EnterMap(int mapId)
{
    if (DataManager.Instance.Maps.ContainsKey(mapId))
    {
        MapDefine map = DataManager.Instance.Maps[mapId];
        User.Instance.CurrentMapData = map;
        SceneManager.Instance.LoadScene(map.Resource);
        SoundManager.Instance.PlayMusic(map.Music);
    }
}

// 处理实体同步
private void OnMapEntitySync(object sender, MapEntitySyncResponse response)
{
    foreach (var sync in response.entitySyncs)
    {
        // 更新本地角色/怪物的状态
        EntityManager.Instance.OnEntitySync(sync);
    }
}

客户端的 MapService 其实是一个"地图相关消息的中转站"和"场景切换控制器",它不负责地图逻辑和实体管理,只负责:

  • 接收服务器消息
  • 加载/切换场景
  • 通知角色管理器增删角色
  • 驱动本地实体的同步

所有地图的核心逻辑和全局管理都在服务器端,客户端只做表现和同步。

服务端

服务端的 MapService 作为地图相关网络请求的处理中心,负责监听客户端发起的地图同步、传送等请求,并调用地图管理器等模块完成实际的逻辑处理。它会根据请求内容校验合法性、更新角色状态、切换地图,并将最新的地图和角色信息通过响应消息同步回客户端。

消息分发器

之前的代码中出现了一个MessageDistributer,这个就是我们的消息分发器,作用是消息分发/事件派发,也就是把收到的网络消息分发给对应的处理方法,实现"解耦"和"订阅-发布"机制。

cs 复制代码
// 注册监听
MessageDistributer.Instance.Subscribe<MapCharacterEnterResponse>(this.OnMapCharacterEnter);

// 取消监听
MessageDistributer.Instance.Unsubscribe<MapCharacterEnterResponse>(this.OnMapCharacterEnter);

// 分发消息(伪代码)
public void Dispatch<T>(T message)
{
    if (this.handlers.ContainsKey(typeof(T)))
    {
        foreach (var handler in this.handlers[typeof(T)])
        {
            handler(message);
        }
    }
}
  • 注册监听:各个服务(如 MapService、UserService 等)通过 Subscribe<T>(Action<T>) 方法注册自己关心的消息类型和回调函数。
  • 收到消息:当网络层收到一条消息后,会调用 MessageDistributer 的分发方法。
  • 分发处理:MessageDistributer 会根据消息类型,找到所有注册了该类型的回调函数,并依次调用,实现"群发"效果。

消息分发器(MessageDistributer)本质上就是一个"信息中转站",它独立于客户端和服务器的具体业务逻辑,专门负责接收各种消息,并把这些消息分发给已经注册了监听的对象或方法。

  • 谁关心什么消息,就向分发器注册一个回调;
  • 消息来了,分发器自动把消息"广播"给所有关心它的对象;
  • 这样,消息的发送者和接收者完全解耦,互不需要知道对方是谁。

角色管理器

服务器端 CharacterManager 代码结构:

cs 复制代码
// Src/Server/GameServer/GameServer/Managers/CharacterManager.cs
class CharacterManager : Singleton<CharacterManager>
{
    public Dictionary<int, Character> Characters = new Dictionary<int, Character>();

    public Character AddCharacter(TCharacter cha)
    {
        Character character = new Character(CharacterType.Player, cha);
        EntityManager.Instance.AddEntity(cha.MapID, character);
        character.Info.EntityId = character.entityId;
        this.Characters[character.Id] = character;
        return character;
    }

    public void RemoveCharacter(int characterId)
    {
        if (this.Characters.ContainsKey(characterId))
        {
            var cha = this.Characters[characterId];
            EntityManager.Instance.RemoveEntity(cha.Data.MapID, cha);
            this.Characters.Remove(characterId);
        }
    }

    public Character GetCharacter(int characterId)
    {
        Character character = null;
        this.Characters.TryGetValue(characterId, out character);
        return character;
    }

    public void Clear()
    {
        this.Characters.Clear();
    }
}

采用单例模式,保证全局唯一、用 Dictionary<int, Character> 存储所有角色对象、提供添加、移除、查找、清空等常用方法且添加/移除角色时会与 EntityManager 协作,保证实体同步。

客户端 CharacterManager 代码结构:

cs 复制代码
// Assets/Scripts/Managers/CharacterManager.cs
class CharacterManager : Singleton<CharacterManager>, IDisposable
{
    public Dictionary<int, Character> Characters = new Dictionary<int, Character>();
    public UnityAction<Character> OnCharacterEnter;
    public UnityAction<Character> OnCharacterLeave;

    public void AddCharacter(NCharacterInfo cha)
    {
        Character character = new Character(cha);
        this.Characters[cha.EntityId] = character;
        EntityManager.Instance.AddEntity(character);
        OnCharacterEnter?.Invoke(character);
    }

    public void RemoveCharacter(int entityId)
    {
        if (this.Characters.ContainsKey(entityId))
        {
            EntityManager.Instance.RemoveEntity(this.Characters[entityId].Info.Entity);
            OnCharacterLeave?.Invoke(this.Characters[entityId]);
            this.Characters.Remove(entityId);
        }
    }

    public void Clear()
    {
        int[] keys = this.Characters.Keys.ToArray();
        foreach(var key in keys)
        {
            this.RemoveCharacter(key);
        }
        this.Characters.Clear();
    }
}

也是单例,管理本地所有角色对象;通过事件(如 OnCharacterEnter、OnCharacterLeave)与UI、场景等模块联动;角色的增删完全依赖服务器同步消息。

总的来说:

  • 角色管理器是角色对象的集中管理中心,负责角色的生命周期管理和全局调度。
  • 服务器端负责全局所有角色的逻辑管理,客户端负责当前场景内角色的本地表现。
  • 角色的增删、查找、同步等都通过角色管理器实现,极大提升了系统的可维护性和扩展性。

游戏对象管理器

这个管理器是负责管理Unity场景中的所有"可见游戏对象"(GameObject),比如角色的3D模型、怪物、NPC等,比如负责根据角色数据创建、初始化、销毁对应的Unity GameObject以及处理角色对象的显示、动画、UI挂载等与"表现层"相关的内容。

监听角色管理器事件,创建/销毁GameObject:

cs 复制代码
// 监听角色进入和离开事件
protected override void OnStart()
{
    CharacterManager.Instance.OnCharacterEnter += OnCharacterEnter;
    CharacterManager.Instance.OnCharacterLeave += OnCharacterLeave;
}

// 角色进入时创建GameObject
void OnCharacterEnter(Character cha)
{
    CreateCharacterObject(cha);
}

// 角色离开时销毁GameObject
void OnCharacterLeave(Character character)
{
    if (Characters.ContainsKey(character.entityId))
    {
        Destroy(Characters[character.entityId]);
        Characters.Remove(character.entityId);
    }
}

创建角色GameObject并初始化:

cs 复制代码
private void CreateCharacterObject(Character character)
{
    if (!Characters.ContainsKey(character.entityId) || Characters[character.entityId] == null)
    {
        Object obj = Resloader.Load<Object>(character.Define.Resource);
        if(obj == null)
        {
            Debug.LogErrorFormat("Character[{0}] Resource[{1}] not existed.",character.Define.TID, character.Define.Resource);
            return;
        }
        GameObject go = (GameObject)Instantiate(obj, this.transform);
        go.name = "Character_" + character.Id + "_" + character.Name;
        Characters[character.entityId] = go;

        UIWorldElementManager.Instance.AddCharacterNameBar(go.transform, character);
    }
    this.InitGameObject(Characters[character.entityId], character);
}

初始化GameObject的Transform、控制器等:

cs 复制代码
private void InitGameObject(GameObject go, Character character)
{
    go.transform.position = GameObjectTool.LogicToWorld(character.position);
    go.transform.forward = GameObjectTool.LogicToWorld(character.direction);
    EntityController ec = go.GetComponent<EntityController>();
    if (ec != null)
    {
        ec.entity = character;
        ec.isPlayer = character.IsCurrentPlayer;
        ec.Ride(character.Info.Ride);
    }
    PlayerInputController pc = go.GetComponent<PlayerInputController>();
    if (pc != null)
    {
        if (character.IsCurrentPlayer)
        {
            User.Instance.CurrentCharacterObject = pc;
            MainPlayerCamera.Instance.player = go;
            pc.enabled = true;
            pc.character = character;
            pc.entityController = ec;
        }
        else
        {
            pc.enabled = false;
        }
    }
}

游戏对象管理器(GameObjectManager)是客户端表现层的核心模块 ,专门负责管理场景中所有角色、怪物等可见对象的生命周期 。它通过监听角色管理器的事件,自动根据角色数据的增删动态创建或销毁对应的Unity GameObject,并完成位置、朝向、控制器、UI等初始化。这样实现了数据与表现的解耦,保证了场景中所有可见对象与角色数据的实时同步,极大提升了系统的可维护性和扩展性。

玩家控制器/实体控制器

从之前的角色管理器到现在的玩家控制器是一个有层次的管理结构:

PlayerInputController(玩家控制器)是挂载在玩家GameObject上的组件,专门负责处理玩家的输入(如键盘、鼠标),并将输入转化为角色的移动、转向、跳跃等行为,同时负责将这些行为通过事件和网络同步到服务器和其他客户端。它是玩家与游戏世界交互的桥梁,确保玩家操作能够实时反馈到角色表现和全局同步,是客户端控制层的核心。

cs 复制代码
public class PlayerInputController : MonoBehaviour {

    public Rigidbody rb;
    public Character character;
    public float rotateSpeed = 2.0f;
    public float turnAngle = 10;
    public int speed;
    public EntityController entityController;

    void FixedUpdate()
    {
        if (character == null) return;
        if (InputManager.Instance != null && InputManager.Instance.IsInputMode) return;

        float v = Input.GetAxis("Vertical");
        if (v > 0.01)
        {
            // 前进
            this.character.MoveForward();
            this.SendEntityEvent(EntityEvent.MoveFwd);
            this.rb.velocity = this.rb.velocity.y * Vector3.up + GameObjectTool.LogicToWorld(character.direction) * (this.character.speed + 9.81f) / 100f;
        }
        else if (v < -0.01)
        {
            // 后退
            this.character.MoveBack();
            this.SendEntityEvent(EntityEvent.MoveBack);
            this.rb.velocity = this.rb.velocity.y * Vector3.up + GameObjectTool.LogicToWorld(character.direction) * (this.character.speed + 9.81f) / 100f;
        }
        else
        {
            // 停止
            this.rb.velocity = Vector3.zero;
            this.character.Stop();
            this.SendEntityEvent(EntityEvent.Idle);
        }

        if (Input.GetButtonDown("Jump"))
        {
            this.SendEntityEvent(EntityEvent.Jump);
        }

        float h = Input.GetAxis("Horizontal");
        if (h < -0.1 || h > 0.1)
        {
            this.transform.Rotate(0, h * rotateSpeed, 0);
            character.SetDirection(GameObjectTool.WorldToLogic(this.transform.forward));
            rb.transform.forward = this.transform.forward;
            this.SendEntityEvent(EntityEvent.None);
        }
    }

    private void LateUpdate()
    {
        if (this.character == null) return;

        Vector3 offset = this.rb.transform.position - lastPos;
        this.speed = (int)(offset.magnitude * 100f / Time.deltaTime);
        this.lastPos = this.rb.transform.position;

        Vector3Int goLogicPos = GameObjectTool.WorldToLogic(this.rb.transform.position);
        float logicOffset = (goLogicPos - this.character.position).magnitude;
        if (logicOffset > 100)
        {
            this.character.SetPosition(GameObjectTool.WorldToLogic(this.rb.transform.position));
            this.SendEntityEvent(EntityEvent.None);
        }
        this.transform.position = this.rb.transform.position;
    }

    public void SendEntityEvent(EntityEvent entityEvent, int param = 0)
    {
        if (entityController != null)
            entityController.OnEntityEvent(entityEvent, param);
        MapService.Instance.SendMapEntitySync(entityEvent, this.character.EntityData, param);
    }

    // 其他成员变量和初始化略
}

比较传统的角色控制器实现。

玩家摄像机

cs 复制代码
public class MainPlayerCamera : MonoSingleton<MainPlayerCamera>
{
    public Camera camera;
    public Transform viewPoint;
    public GameObject player;

    private void LateUpdate()
    {
        if (player == null && User.Instance.CurrentCharacterObject != null)
        {
            player = User.Instance.CurrentCharacterObject.gameObject;
        }

        if (player == null)
            return;

        this.transform.position = player.transform.position;
        this.transform.rotation = player.transform.rotation;
    }
}

摄像机会在每一帧的 LateUpdate 阶段自动将自身的位置和旋转与当前玩家角色的 GameObject 保持一致,实现摄像机跟随玩家的效果。

角色信息显示

这是角色的UI部分的内容,主要需要实现的就是UI和数据的同步以及UI跟随玩家两个部分。

实现的思路:每当角色GameObject被创建时,会为其挂载一个"世界UI元素"(如名字条、血条等),这些UI元素通常是Unity的UI组件(如Canvas、Text、Image等),通过专门的管理器(如UIWorldElementManager)进行统一管理和更新,UI元素会实时跟随角色的位置,并根据角色数据(如名字、血量等)动态更新显示内容。

在 GameObjectManager 创建角色GameObject时,会调用如下代码:

cs 复制代码
// GameObjectManager.cs
UIWorldElementManager.Instance.AddCharacterNameBar(go.transform, character);

UIWorldElementManager 负责为每个角色创建和管理头顶UI(如名字条):

cs 复制代码
public void AddCharacterNameBar(Transform target, Character character)
{
    // 实例化名字条UI预制体
    var nameBar = Instantiate(nameBarPrefab, uiRoot);
    // 绑定到角色
    nameBar.SetTarget(target);
    // 设置显示内容
    nameBar.SetName(character.Name);
    // 其他如血条、状态等也可在这里设置
}

名字条等UI元素通常会有一个脚本,每帧将UI的位置同步到角色头顶,并根据角色数据动态更新内容:

cs 复制代码
void LateUpdate()
{
    if (target != null)
    {
        // 将UI位置设置为角色头顶的屏幕坐标
        Vector3 screenPos = Camera.main.WorldToScreenPoint(target.position + offset);
        this.transform.position = screenPos;
        // 动态更新名字、血量等
        nameText.text = character.Name;
        // 血条等其他信息也可在这里更新
    }
}

这里读取的角色数据是直接读取自角色的"数据对象",也就是Character类的实例:每个角色(Character)对象都包含了自己的核心数据,比如名字、血量、等级等。

小地图

我们具体是如何实现每个场景中的小地图的呢?

每张地图在配置文件(如 MapDefine.txt)中都指定了自己的小地图图片资源,小地图图片资源通常是Unity的Sprite,存放在 Assets/UI/Minimap/ 目录下。

客户端有专门的小地图UI组件(如 UIMinimap),负责在界面上显示小地图图片和玩家、NPC等标记,UIMinimap 会在初始化时加载当前地图对应的小地图图片,并设置到UI上,这样就实现了小地图的加载了:

cs 复制代码
public class UIMinimap : MonoBehaviour {
    public Image minimap;
    public Text mapName;

    void UpdateMap()
    {
        this.mapName.text = User.Instance.CurrentMapData.Name;
        this.minimap.overrideSprite = MinimapManager.Instance.LoadCurrentMinimap();
        this.minimap.SetNativeSize();
        this.minimap.transform.localPosition = Vector3.zero;
    }
}

现在的问题是:如何将玩家的位置同步到小地图上呢?

小地图UI会根据玩家在世界中的实际位置,动态调整小地图上的"玩家指针"位置;通过将玩家的世界坐标转换为小地图上的像素坐标,实现玩家在小地图上的实时移动。

cs 复制代码
void Update () {
    if (playerTransform == null)
        playerTransform = MinimapManager.Instance.PlayerTransform;

    if (minimapBoundingBox == null || playerTransform == null) return;
    float realWidth = minimapBoundingBox.bounds.size.x;
    float realHeight = minimapBoundingBox.bounds.size.z;

    float relaX = playerTransform.position.x - minimapBoundingBox.bounds.min.x;
    float relaY = playerTransform.position.z - minimapBoundingBox.bounds.min.z;

    float pivotX = relaX / realWidth;
    float pivotY = relaY / realHeight;

    this.minimap.rectTransform.pivot = new Vector2(pivotX, pivotY);
    this.minimap.rectTransform.localPosition = Vector2.zero;
    this.arrow.transform.eulerAngles = new Vector3(0, 0, -playerTransform.eulerAngles.y);
}

总结一下就是:每个场景都配置有专属的小地图图片资源,场景初始化时会自动加载并显示到小地图UI上。玩家在大地图中的实际位置会通过读取其Transform,并结合地图边界信息,计算出玩家在整个地图范围内的相对位置。然后,程序将这个相对位置映射到小地图UI的坐标系上,动态调整小地图上玩家指针的位置和朝向,从而实现玩家在小地图上的实时同步和准确显示。

移动同步

移动同步,就是让所有玩家在同一张地图上时,能够实时看到彼此的移动和位置变化,保证每个人看到的世界是一致的。移动同步的背后,是客户端与服务器实现同步的手段。

当玩家在客户端进行移动操作时,玩家控制器会实时将角色的最新状态(如位置、朝向、速度等)通过网络消息发送给服务器,服务器收到后会校验并更新该角色的状态,然后将这个状态广播给同一地图内的所有其他玩家。其他玩家的客户端收到服务器的同步消息后,会立即更新本地场景中对应角色的显示位置和动作,从而实现所有玩家在同一地图下看到的每个角色的移动和位置始终保持一致,保证了多人在线世界的实时互动体验。

客户端:玩家操作并上报状态

cs 复制代码
// PlayerInputController.cs
void FixedUpdate()
{
    // ...(省略输入检测)
    if (v > 0.01)
    {
        this.character.MoveForward();
        this.SendEntityEvent(EntityEvent.MoveFwd);
    }
    // ...(省略其他方向)
}

public void SendEntityEvent(EntityEvent entityEvent, int param = 0)
{
    if (entityController != null)
        entityController.OnEntityEvent(entityEvent, param);
    MapService.Instance.SendMapEntitySync(entityEvent, this.character.EntityData, param);
}
...
...
// MapService.cs(客户端)
public void SendMapEntitySync(EntityEvent entityEvent, NEntity entity, int param)
{
    NetMessage message = new NetMessage();
    message.Request = new NetMessageRequest();
    message.Request.mapEntitySync = new MapEntitySyncRequest();
    message.Request.mapEntitySync.entitySync = new NEntitySync()
    {
        Id = entity.Id,
        Event = entityEvent,
        Entity = entity,
        Param = param
    };
    NetClient.Instance.SendMessage(message);
}

服务器:接收同步请求并广播

cs 复制代码
// MapService.cs(服务器端)
private void OnMapEntitySync(NetConnection<NetSession> sender, MapEntitySyncRequest request)
{
    Character character = sender.Session.Character;
    MapManager.Instance[character.Info.mapId].UpdateEntity(request.entitySync);
}
...
...
// Map.cs(服务器端)
internal void UpdateEntity(NEntitySync entity)
{
    foreach (var kv in this.MapCharacters)
    {
        if (kv.Value.character.entityId == entity.Id)
        {
            kv.Value.character.Position = entity.Entity.Position;
            kv.Value.character.Direction = entity.Entity.Direction;
            kv.Value.character.Speed = entity.Entity.Speed;
        }
        else
        {
            MapService.Instance.SendEntityUpdate(kv.Value.connection, entity);
        }
    }
}

客户端:接收服务器广播并更新显示

cs 复制代码
// MapService.cs(客户端)
private void OnMapEntitySync(object sender, MapEntitySyncResponse response)
{
    foreach (var sync in response.entitySyncs)
    {
        EntityManager.Instance.OnEntitySync(sync);
    }
}
...
...
// EntityManager.cs(客户端,伪代码)
public void OnEntitySync(NEntitySync sync)
{
    if (entities.ContainsKey(sync.Id))
    {
        entities[sync.Id].SetEntityData(sync.Entity);
    }
}

当玩家在客户端进行移动等操作时,玩家控制器会先触发一个事件 ,客户端检测到后会将角色的最新状态(如位置、朝向、速度等)通过网络协议(如TCP,封装为同步消息)发送一个请求到服务器。服务器收到这些信息后,统一进行状态的校验和更新,然后将最新的角色状态广播给同一地图内的所有客户端。各个客户端收到服务器的同步消息后,立即更新本地场景中对应角色的显示状态。整个过程实现了以服务器为权威的状态同步,保证了所有玩家在同一地图下看到的每个角色的移动和位置都是一致的,从而实现了多人在线世界的实时互动和同步体验。

如何实现地图的传送

地图传送的实现流程是:

客户端发起传送请求 → 服务器校验并处理传送逻辑(离开旧地图、进入新地图、设置新位置)→ 服务器同步新状态 → 客户端加载新地图并刷新显示。

客户端发起传送请求

当玩家在游戏中触发传送(比如点击传送门、与NPC对话等),客户端会向服务器发送一个地图传送请求(MapTeleportRequest),请求内容通常包含当前传送点的ID。

cs 复制代码
// 客户端发起传送请求(伪代码)
NetMessage message = new NetMessage();
message.Request = new NetMessageRequest();
message.Request.mapTeleport = new MapTeleportRequest() { teleporterId = 传送点ID };
NetClient.Instance.SendMessage(message);

服务器处理传送请求

服务器端的 MapService 监听并处理 MapTeleportRequest,流程如下:

  • 校验传送点是否合法(如ID是否存在、是否允许传送等)。
  • 获取目标传送点的地图ID、目标位置和朝向。
  • 让角色离开当前地图(调用 CharacterLeave),并将角色的位置和朝向设置为目标传送点的数据。
  • 让角色进入目标地图(调用 CharacterEnter),并同步角色的新状态给客户端。
cs 复制代码
// 服务器端 MapService.cs
void OnMapTeleport(NetConnection<NetSession> sender, MapTeleportRequest request)
{
    Character character = sender.Session.Character;
    // 校验传送点
    TeleporterDefine source = DataManager.Instance.Teleporters[request.teleporterId];
    TeleporterDefine target = DataManager.Instance.Teleporters[source.LinkTo];

    // 角色离开当前地图
    MapManager.Instance[source.MapID].CharacterLeave(character);

    // 设置新位置和朝向
    character.Position = target.Position;
    character.Direction = target.Direction;

    // 角色进入目标地图
    MapManager.Instance[target.MapID].CharacterEnter(sender, character);
}

客户端接收传送结果并切换场景

服务器处理完毕后,会通过地图进入响应(MapCharacterEnterResponse)等消息通知客户端。客户端收到后:

  • 加载目标地图的场景资源(如Unity场景)。
  • 更新玩家的地图数据和位置。
  • 刷新小地图、UI等相关内容。
cs 复制代码
// 客户端 MapService.cs
private void OnMapCharacterEnter(object sender, MapCharacterEnterResponse response)
{
    // 加载新地图场景
    this.EnterMap(response.mapId);
    // 刷新角色、UI等
}

所有传送点的信息(如ID、目标地图、目标位置、目标朝向等)都在配置文件(如 TeleporterDefine.txt)中定义,服务器和客户端都会加载这些配置。

在这里我需要补充说明一下配置文件:

总结来说:当玩家在客户端触发地图传送事件时,客户端会向服务器发送传送请求,服务器收到请求后会根据本地配置校验传送的合法性,并确定目标地图及位置等信息。校验通过后,服务器将新的地图ID、目标位置等传送相关信息通过响应消息返回给客户端,客户端再根据服务器返回的数据重新加载并初始化对应的地图场景、刷新UI和小地图等内容,从而完成一次完整的地图传送流程。

相关推荐
Zlzxzw1 小时前
使用unity创建项目,进行动画制作
unity·游戏引擎
X_StarX6 小时前
【Unity笔记01】基于单例模式的简单UI框架
笔记·ui·unity·单例模式·游戏引擎·游戏开发·大学生
九班长6 小时前
Golang服务端处理Unity 3D游戏地图与碰撞的详细实现
3d·unity·golang
ysn111119 小时前
NGUI实现反向定位到层级面板结点
unity
Thomas_YXQ16 小时前
Unity3D DOTS场景流式加载技术
java·开发语言·unity
向宇it1 天前
【unity游戏开发——网络】网络游戏通信方案——强联网游戏(Socket长连接)、 弱联网游戏(HTTP短连接)
网络·http·游戏·unity·c#·编辑器·游戏引擎
qq_168278951 天前
Protobuf在游戏开发中的应用:TypeScript + Golang 实践
服务器·golang·游戏引擎
切韵11 天前
Unity编辑器扩展:UI绑定复制工具
ui·unity·编辑器
11 天前
Lua复习之何为闭包
开发语言·unity·游戏引擎·lua·交互