本文主要是介绍MMO移动同步(1),希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
多个客户端同时连入游戏
这篇会从以下五个部分讲解:
同步的基本概念
完善角色进入及离开处理
CharacterManager(C/S)
EntityManager(C/S)
打包运行Win客户端
同步基本概念
同步:角色信息,位置,状态同步;客户端和服务端的数据是一致的;//只有网络游戏有同步,单机游戏没有
两种同步对比
详情请看:两种同步模式
状态同步 | 帧同步 | |
消息传输量 | 低 | 高 |
回放 | 较难还原 | 容易 |
安全性 | 较多逻辑在服务器,安全 | 主要逻辑在客户端,难以避免外挂 |
战斗校验 | 较难精确校验 | 服务器可进行完整战斗模拟 |
服务器压力 | 重逻辑,大 | 转发为主,小 |
网络卡顿表现 | 瞬移,闪回,血量不一致,各种异常 | 战斗卡顿 |
断线重连 | 无负担 | 游戏时长越长恢复压力越大 |
实现难点 | 客户端需要做一些插值,或者行为预测等方式来优化卡顿体验。较多的逻辑要在服务器实现,调测压力较大 | 需要规避辉昂宿问题,逻辑要与表现进行分离,对设计有一定要求 |
消息传输量:帧同步要定时的发送消息,需要高帧率(15b/s)
//做帧同步为回合制下的,一个回合发送一帧,也是合理的
战斗回放:MMO很难做到,RTS容易做到//RTS:是实时游戏而不采回合制
战斗校验:状态同步:每个状态的发送其他人收到是有延迟的
网络卡顿表现:帧同步:一个人掉线所有人都在等(必须保证每个人的战斗状态是一致的,不能模拟某个人)//有的游戏会在玩家掉线时委托AI承担游戏行为,并断线重连
以下的讲解的同步都是状态同步
同步什么?
//扩展数据:只有打开某些界面,才会请求这些详细信息时,才把信息拉过来;正常情况下信息不会同步
逻辑图
打包发布准备
点击右下角Build ;创建一个Bin文件夹
等待把Window包打包出来
打包完这样:
双击启动,服务端也启动
发现卡住不动了,为了看到错误信息,要在运行目录下配置日志;
把日志复制到运行目录下
我们可以在这里查看日志文件
显示找不到地图配置表
因为我们的Bin下面根本没有Data目录 ;从客户端拷进来
//这个时候可以成功进入,并运行到我们恰好写到的小地图系统
角色离开逻辑
在UI界面有个离开按钮
给它添加逻辑
打开UIMainCity.cs//即前面这个框对应的逻辑所在的脚本
BackToCharSelect()
加载角色选择场景;
告诉服务器角色离开;
//离开服务器可以换个角色重新进入
public void BackToCharSelect()
{SceneManager.Instance.LoadScene("CharSelect");UserService.Instance.SendGameLeave();
}
把事件绑在按钮上
Client:UserService:SendGameLeave
public void SendGameLeave()
{Debug.Log("UserGameLeaveRequest");NetMessage message = new NetMessage();message.Request = new NetMessageRequest();message.Request.gameLeave = new UserGameLeaveRequest();NetClient.Instance.SendMessage(message);
}
Client:OnGameLeave
void OnGameLeave(object sender, UserGameLeaveResponse response){MapService.Instance.CurrentMapId = 0;User.Instance.CurrentCharacter = null;Debug.LogFormat("OnGameLeave:{0} [{1}]", response.Result, response.Errormsg);}
//Add:鼠标右键调摄像机视角(未做
GameServer:UserService:OnGameLeave
订阅UserGameLeaveRequest协议下的OnGameLeave
在UserService()中加上
MessageDistributer<NetConnection<NetSession>>.Instance.Subscribe<UserGameLeaveRequest>(this.OnGameLeave);
OnGameLeave:
//离开时:RemoveCharacter;MapManagerLeave
//进入地图时:AddCharacter;MapMangaer.CharacterEnter
准备信息发给客户端
void OnGameLeave(NetConnection<NetSession> sender, UserGameLeaveRequest request){
//由Sender传入一个characterCharacter character = sender.Session.Character;Log.InfoFormat("UserGameLeaveRequest:characterID:{0}:{1} Map:{2}",character.Id,character.Info.Name,character.Info.mapId);//收到了游戏的请求,从游戏管理器中把角色移掉CharacterManager.Instance.RemoveCharacter(character.Id);MapManager.Instance[character.Info.mapId].CharacterLeave(character.Info);NetMessage message=new NetMessage();message.Response = new NetMessageResponse();message.Response.gameLeave=new UserGameLeaveResponse();message.Response.gameLeave.Result = Result.Success;message.Response.gameLeave.Errormsg = "None";byte[] data=PackageHandler.PackMessage(message);sender.SendData(data,0,data.Length);}
转到定义MapManager:Map
Map:CharacterLeave
输出哪个角色离开了哪张地图
把角色从MapCharacters字典中移除
给所有还在地图中的角色广播此角色离开的信息SendCharacterLeaveMap
internal void CharacterLeave(NCharacterInfo cha)
{//离开时输出日志,哪个角色离开了那张地图Log.InfoFormat("CharacterLeave: Map:{0} characterId:{1}", this.Define.ID, cha.Id);this.MapCharacters.Remove(cha.Id);foreach (var kv in this.MapCharacters){this.SendCharacterLeaveMap(kv.Value.connection, cha);}
}
SendCharacterLeaveMap:
把角色Id传入,此角色离开地图
//此信息发到服务器窗口上
private void SendCharacterLeaveMap(NetConnection<NetSession> conn,NCharacterInfo character)
{NetMessage message = new NetMessage();message.Response = new NetMessageResponse();message.Response.mapCharacterLeave = new MapCharacterLeaveResponse();message.Response.mapCharacterLeave.characterId = character.Id;byte[] data=PackageHandler.PackMessage(message);conn.SendData(data,0,data.Length);}
演示
测试依赖windows端
每次发布之前删掉ExtremeWorld_Data,ExtrememWorld.exe,这两个
会发现刚刚删掉的那两个,又生成了新的
//Windows运行时;可以用Unity运行查看报错
//两边可以登录不同的账号
启动服务器:
运行Windows端
直接选择现有的角色
点击进入游戏
打开Unity登录另一个账号
点击创建角色
点击开始冒险
跳转到选择角色的面板:
选择新建的角色,点击进入游戏
这里不知道为啥被销毁了//
可以移动新疆炒米粉
回到Windows窗口
现在只能操控新疆炒米粉了
在unity的界面点击返回角色选择的按钮
选择懒羊羊大王
再进入游戏
这里是因为退出去有重新选择角色进入,但是原先的角色没有被销毁
Windows上操作,发现移动的依然是新疆炒米粉
出现多个角色,离开逻辑未完善
Client:MapService:OnMapCharacterLeave()
如果有玩家离开了地图,判断当前的玩家是否是自己
是:所有的角色都要销毁掉(因为我已经不再地图中了)
不是:把哪个角色 移除(保证角色管理器的角色都是再地图中的
private void OnMapCharacterLeave(object sender, MapCharacterLeaveResponse response)
{Debug.LogFormat("OnMapCharacterLeave: CharID:{0}", response.characterId);if (response.characterId != User.Instance.CurrentCharacter.Id)CharacterManager.Instance.RemoveCharacter(response.characterId);elseCharacterManager.Instance.Clear();}
在Character Manager中需要监听角色离开;
CharacterManager:RemoveCharacter
在角色离开的位置RemoveCharacter做通知;
在现有的角色列表中找到离开的角色
角色离开告知OnCharacterLeave
public void RemoveCharacter(int characterId)
{Debug.LogFormat("RemoveCharacter:{0}", characterId);//this.Characters.Remove(characterId);if(this.Characters.ContainsKey(characterId)){//EntityManager.Instance.RemoveEntity(this.Characters[characterId].Info.Entity);if(OnCharacterLeave!=null)OnCharacterLeave(this.Characters[characterId]);this.Characters.Remove(characterId);}
}
GameObjectManager:OnCharacterLeave
游戏对象管理器GameObjectManager负责游戏对象的进入;和离开
销毁掉角色;
//判断是否在角色字典中;因为游戏对有时候是在其他地方被删掉的;所以要判空
void OnCharacterLeave(Character character)
{if (!Characters.ContainsKey(character.entityId))return;if (Characters[character.entityId] != null){Destroy(Characters[character.entityId]);this.Characters.Remove(character.entityId);}
}
把GameObjectManager改成单例类
关于切换场景时游戏对象被销毁;
用单例,单例是全局的;
单例类里面有逻辑保证单例是不被销毁的
这个,改成这个
!!注意使用给单例类必须把Start函数给重载掉
//变成单例类,Start时:+
Destroy时:-
protected override void OnStart()
{//管理器在主城加载完之后启动StartCoroutine(InitGameObjects());//订阅了角色进入的事件CharacterManager.Instance.OnCharacterEnter += OnCharacterEnter;CharacterManager.Instance.OnCharacterLeave += OnCharacterLeave;
}private void OnDestroy()
{//CharacterManager.Instance.OnCharacterEnter = null;CharacterManager.Instance.OnCharacterEnter -= OnCharacterEnter; CharacterManager.Instance.OnCharacterLeave -= OnCharacterLeave;
}
所有单例类创建的角色要在它的子节点下
修改这个里的实例化的位置:
GameObject go = (GameObject)Instantiate(obj);
改成:
GameObject go = (GameObject)Instantiate(obj,this.transform);
//关于entityId
前面使用DBid是因为数据库里面只有角色没有怪物;到后面有怪物时就不能用DBId;因此再这里用EntityId
entityId是一种在内存中出现的每次进入游戏不同的Id;用来标识唯一性;
//GameObjectManager:
改成这样:
//对于某些会反复使用的代码;我们把CreateCharacterObject抽出一些代码做成InitGameObject
原来的CreateCharacterObject
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.entityId+ "_" + character.Info.Name;//角色实体坐标(服务器返回过来的坐标)要转换成世界坐标go.transform.position = GameObjectTool.LogicToWorld(character.position);go.transform.forward = GameObjectTool.LogicToWorld(character.direction);Characters[character.entityId] = go;//以下是绑定了两个对象的脚本,取出来并对它们赋值//当然也可以在Start里面getEntityController ec = go.GetComponent<EntityController>();if (ec != null){ec.entity = character;ec.isPlayer = character.IsPlayer;}PlayerInputController pc = go.GetComponent<PlayerInputController>();if (pc != null){if (character.entityId == Models.User.Instance.CurrentCharacter.Id){//如果是当前角色User.Instance.CurrentCharacterObject = go;MainPlayerCamera.Instance.player = go;pc.enabled = true;pc.character = character;pc.entityController = ec;}else{//不是当前玩家禁用角色控制器pc.enabled = false;}}UIWorldElementManager.Instance.AddCharacterNameBar(go.transform, character);}
}
把要创建角色,游戏体的处理部分做成init;Create在引用它
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.entityId+ "_" + character.Info.Name;Characters[character.entityId] = go;UIWorldElementManager.Instance.AddCharacterNameBar(go.transform, character);}this.InitGameObject(Characters[character.entityId],character);
}private void InitGameObject(GameObject go,Character character)
{//角色实体坐标(服务器返回过来的坐标)要转换成世界坐标go.transform.position = GameObjectTool.LogicToWorld(character.position);go.transform.forward = GameObjectTool.LogicToWorld(character.direction);//以下是绑定了两个对象的脚本,取出来并对它们赋值//当然也可以在Start里面getEntityController ec = go.GetComponent<EntityController>();if (ec != null){ec.entity = character;ec.isPlayer = character.IsPlayer;}PlayerInputController pc = go.GetComponent<PlayerInputController>();if (pc != null){if (character.entityId == Models.User.Instance.CurrentCharacter.Id){//如果是当前角色User.Instance.CurrentCharacterObject = go;MainPlayerCamera.Instance.player = go;pc.enabled = true;pc.character = character;pc.entityController = ec;}else{//不是当前玩家禁用角色控制器pc.enabled = false;}}
}
//原先是角色存在时做初始化逻辑;若角色进入又离开(会被删除掉)但是entityId有可能会在多次创建中删除中出现重复的;那么原先的if条件下的角色的属性就不能创建
//可能会导致角色切换回来的时候找不到原来的角色
EntityManager
GameServer:EntityManager
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Common;
using SkillBridge.Message;
using GameServer.Entities;
namespace GameServer.Managers
{class EntityManager:Singleton<EntityManager>{private int idx = 0;//用一个类似的类型维护所有entity 的列表public List<Entity> AllEntities=new List<Entity>();//某个地图的entity有哪些//一个地图id对应一张存实体的列表public Dictionary<int,List<Entity>> MapEntities=new Dictionary<int, List<Entity>>();public void AddEntity(int mapId,Entity entity){AllEntities.Add(entity);//每个entity都有唯一的idx//类似与数组模拟链表的索引entity.EntityData.Id=++this.idx;List<Entity> entities = null;if(!MapEntities.TryGetValue(mapId,out entities)){//判断mapid是那张地图//没有地图,加进去entities = new List<Entity>();MapEntities[mapId] = entities;}entities.Add(entity);}public void RemoveEntity(int mapId,Entity entity){//从总实体列表和地图实体列表移除this.AllEntities.Remove(entity);this.MapEntities[mapId].Remove(entity); }}
}
Client:EntityManager
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Entities;
using JetBrains.Annotations;
using SkillBridge.Message;
namespace Managers
{interface IEntityNofity{void OnEntityRemoved();}class EntityManager:Singleton<EntityManager>{Dictionary<int,Entity> entities = new Dictionary<int,Entity>();//用接口来实现一个事件:例如:要通知entity removed;//好处:一个接收者可以接收多种事件Dictionary<int,IEntityNofity>notifiers=new Dictionary<int, IEntityNofity>(); //在这里注册,它接收通知(删除entity的通知)public void RegisterEntityChangeNotify(int entityId,IEntityNofity nofity){this.notifiers[entityId] = nofity;}public void AddEntity(Entity entity){entities[entity.entityId] = entity;}public void RemoveEntity(NEntity entity){this.entities.Remove(entity.Id); if(notifiers.ContainsKey(entity.Id)){notifiers[entity.Id].OnEntityRemoved();notifiers.Remove(entity.Id);} }}
}
//这里面 的通知主要是用来通知Entity Controller ;在EntityController派生接口
并实现接口:
public void OnEntityRemoved()
{//把血条删掉;把自己删掉//服务器告知entity要删除;在这可以接收移动通知if (UIWorldElementManager.Instance != null)UIWorldElementManager.Instance.RemoveCharacterNameBar(this.transform);Destroy(this.gameObject);
}
在Start时注册:
若entity 不为空,则把方法放在这,并根据id传入RegisterEntityChangeNotify
总结:移动同步
派生接口,实现接口, 把通知注册到管理器中
那么管理器就能通过这个方式调用
//不要关心谁注册,只要知道有人注册,就可以通知
这就是通知机制;保证有人离开后对象可以删除掉
图示:
在Client:CharacterMangaer中的Add Remove Character
添加Entity Manager管理entity
public void AddCharacter(SkillBridge.Message.NCharacterInfo cha)
{Debug.LogFormat("AddCharacter:{0}:{1} Map:{2} Entity:{3}", cha.Id, cha.Name, cha.mapId, cha.Entity.String());Character character = new Character(cha);this.Characters[cha.Id] = character;//添加角色时,把角色放进管理器;因为Character是Entity的子类;Character就是EntityEntityManager.Instance.AddEntity(character);if (OnCharacterEnter != null){OnCharacterEnter(character);}
}public void RemoveCharacter(int characterId)
{Debug.LogFormat("RemoveCharacter:{0}", characterId);//this.Characters.Remove(characterId);if (this.Characters.ContainsKey(characterId)){EntityManager.Instance.RemoveEntity(this.Characters[characterId].Info.Entity);if (OnCharacterLeave != null)OnCharacterLeave(this.Characters[characterId]);//?this.Characters.Remove(characterId);}
}
Clear()//
删除时要通知给entity;注册事件的和管理器之类
在Clear时,查看当前角色列表Characters都有谁;把它们都remove
public void Clear()
{int[] keys=this.Characters.Keys.ToArray();foreach (int key in keys){//清除掉;通知事件接收者,角色离开this.RemoveCharacter(key);}this.Characters.Clear();
}
//关于minimap为空
当minmapBoundingBox或playerTransform先被删除了;当前的小地图还没被删除;小地图会为空
add:
if(minmapBoundingBox==null||playerTransform==null) return;
完整的UIMinmap:Update()
void Update()
{//if (this.playerTransform == null) playerTransform = MinimapManager.Instance.PlayerTransform;//组件与组件之间互相引用时,必须检查为空if(minmapBoundingBox==null||playerTransform==null) return;//Scale.xfloat realWidth = minmapBoundingBox.bounds.size.x;float realHeight=minmapBoundingBox.bounds.size.z;//这里用玩家相对与世界地图左下角的距离float realX = playerTransform.position.x - minmapBoundingBox.bounds.min.x;float realY = playerTransform.position.z - minmapBoundingBox.bounds.min.z;float pivotX=realX/realWidth;float pivotY=realY/realHeight;this.minimap.rectTransform.pivot=new Vector2(pivotX,pivotY);//minimap相对于父物体mask的位置this.minimap.rectTransform.localPosition = Vector2.zero;//顺着世界空间角色的y轴旋转;而箭头是xy平面轴的,因此是绕Z轴this.arrow.transform.eulerAngles = new Vector3(0, 0, -playerTransform.eulerAngles.y);
}
//关于PlayerInputController的LateUpdate
中的
//在MainPlayerCamera中
在这里;有的玩家初始化时机不同,找不到摄像机(摄像机没有在角色身上:可以修正
修正:把User.Instance.CurrentCharacterObject给player
这篇关于MMO移动同步(1)的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!