多个客户端同时连入游戏
这篇会从以下五个部分讲解:
同步的基本概念
完善角色进入及离开处理
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传入一个character
Character 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);
else
CharacterManager.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里面get
EntityController 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里面get
EntityController 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就是Entity
EntityManager.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.x
float 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