开启辅助访问
 找回密码
 注册帐号

扫一扫,访问微社区

开发者专栏

关注:1904

当前位置:游戏蛮牛 技术专区 开发者专栏

__________________________________________________________________________________
开发者干货区版块规则:

  1、文章必须是图文形式。(至少2幅图)
      2、文章字数必须保持在1500字节以上。(编辑器右下角有字数检查)
      3、本版块只支持在游戏蛮牛原创首发,不支持转载。
      4、本版块回复不得无意义,如:顶、呵呵、不错......【真的会扣分的哦】
      5、......
__________________________________________________________________________________
查看: 1876|回复: 43

[蛮牛干货] 谈一款MOBA类游戏《码神联盟》的服务端架构设计与实现

[复制链接]  [移动端链接]
排名
3
昨日变化

5498

主题

5960

帖子

2万

积分

Rank: 16

UID
1231
好友
183
蛮牛币
2965
威望
30
注册时间
2013-7-29
在线时间
2689 小时
最后登录
2017-10-18

社区QQ达人活力之星原创精华达人突出贡献奖财富之证游戏蛮牛QQ群会员蛮牛妹VIP

发表于 2017-7-24 14:06:23 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有帐号?注册帐号

x
  一、前言
  《码神联盟》是一款为技术人做的开源情怀游戏,每一种编程语言都是一位英雄。客户端和服务端均使用C#开发,客户端使用,数据库使用MySQL。这个MOBA类游戏是笔者在学习时期和客户端美术策划的小伙伴一起做的游戏,笔者主要负责游戏服务端开发,客户端也参与了一部分,同时也是这个项目的发起和负责人。这次主要分享这款游戏的服务端相关的设计与实现,从整体的架构设计,到服务器网络通信底层的搭建,通信协议、模型定制,再到游戏逻辑的分层架构实现。同时这篇博客也沉淀了笔者在游戏公司实践五个月后对游戏架构与设计的重新审视与思考。
  这款游戏自去年完成后笔者曾多次想写篇博客来分享,也曾多次停笔,只因总觉得灵感还不够积淀还不够思考还不够,现在终于可以跨过这一步和大家分享,希望可以带来的是干货与诚意满满。由于目前关于游戏服务端相关的介绍文章少之又少,而为数不多的几篇也都是站在游戏服务端发展历史和架构的角度上进行分享,很少涉及具体的实现,这篇文章我将尝试多从实现的层面上加以介绍,所附的代码均有详尽注释,篇幅较长,可以关注收藏后再看。学习时期做的项目可能无法达到工业级,单服承载大概9000人,笔者在和小伙伴做这款游戏时农药还没有现在这般火。  : )
  二、服务器架构
938125-20170722151828950-1095635978.png

  上图为这款游戏的服务器架构和主要逻辑流程图,笔者将游戏的代码实现分为三个主要模块:Protocol通信协议、NetFrame服务器网络通信底层的搭建以及LOLServer游戏的具体逻辑分层架构实现,下面将针对每个模块进行分别介绍。
  三、通信协议
1.png

  先从最简单也最基本的通信协议部分说起,我们可以看到这部分代码主要分为xxxProtocol、xxxDTO和xxxModel、以及xxxData四种类型,让我们来对它们的作用一探究竟。
  1.Protocol协议
[C#] 纯文本查看 复制代码
LOLServer\Protocol\Protocol.cs

using System;
using System.Collections.Generic;
using System.Text;


namespace GameProtocol
{
   public class Protocol
    {
       public const byte TYPE_LOGIN = 0;//登录模块
       public const byte TYPE_USER = 1;//用户模块
       public const byte TYPE_MATCH = 2;//战斗匹配模块
       public const byte TYPE_SELECT = 3;//战斗选人模块
       public const byte TYPE_FIGHT = 4;//战斗模块
    }
}


  从上述的代码举例可以看到,在Protocol协议部分,我们主要是定义了一些常量用于模块通信,在这个部分分别定义了用户协议、登录协议、战斗匹配协议、战斗选人协议以及战斗协议。
  2.DTO数据传输对象
  DTO即数据传输对象,表现层与应用层之间是通过数据传输对象(DTO)进行交互的,需要了解的是,数据传输对象DTO本身并不是业务对象。数据传输对象是根据UI的需求进行设计的,而不是根据领域对象进行设计的。比如,User领域对象可能会包含一些诸如name, level, exp, email等信息。但如果UI上不打算显示email的信息,那么UserDTO中也无需包含这个email的数据。
  简单来说Model面向业务,我们是通过业务来定义Model的。而DTO是面向界面UI,是通过UI的需求来定义的。通过DTO我们实现了表现层与Model之间的解耦,表现层不引用Model,如果开发过程中我们的模型改变了,而界面没变,我们就只需要改Model而不需要去改表现层中的东西。
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Text;

namespace GameProtocol.dto
{
    [Serializable]
   public class UserDTO
   {
       public int id;//玩家ID 唯一主键
       public string name;//玩家昵称
       public int level;//玩家等级
       public int exp;//玩家经验
       public int winCount;//胜利场次
       public int loseCount;//失败场次
       public int ranCount;//逃跑场次
       public int[] heroList;//玩家拥有的英雄列表
       public UserDTO() { }
       public UserDTO(string name, int id, int level, int win, int lose, int ran,int[] heroList)
       {
           this.id = id;
           this.name = name;
           this.winCount = win;
           this.loseCount = lose;
           this.ranCount = ran;
           this.level = level;
           this.heroList = heroList;
       }

    }
}


  3.Data属性配置表
  这部分的实现主要是为了将程序功能与属性配置分离,后面可以由策划来配置这部分内容,由导表工具自动生成配表,从而减轻程序的开发工作量,扩展游戏的功能。
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Text;

namespace GameProtocol.constans
{
    /// <summary>
    /// 英雄属性配置表
    /// </summary>
   public class HeroData
    {

       public static readonly Dictionary<int, HeroDataModel> heroMap = new Dictionary<int, HeroDataModel>();
       /// <summary>
       /// 静态构造 初次访问的时候自动调用
       /// </summary>
       static HeroData() {
           create(1, "西嘉迦[C++]", 100, 20, 500, 300, 5, 2, 30, 10, 1, 0.5f, 200,200, 1, 2, 3, 4);
           create(2, "派森[Python]", 100, 20, 500, 300, 5, 2, 30, 10, 1, 0.5f, 200, 200, 1, 2, 3, 4);
           create(3, "扎瓦[Java]", 100, 20, 500, 300, 5, 2, 30, 10, 1, 0.5f, 200, 200, 6, 2, 3, 4);
           create(4, "琵欸赤貔[PHP]", 100, 20, 500, 300, 5, 2, 30, 10, 1, 0.5f, 200, 200, 3, 2, 3, 4);
       }
       /// <summary>
       /// 创建模型并添加进字典
       /// </summary>
       /// <param name="code"></param>
       /// <param name="name"></param>
       /// <param name="atkBase"></param>
       /// <param name="defBase"></param>
       /// <param name="hpBase"></param>
       /// <param name="mpBase"></param>
       /// <param name="atkArr"></param>
       /// <param name="defArr"></param>
       /// <param name="hpArr"></param>
       /// <param name="mpArr"></param>
       /// <param name="speed"></param>
       /// <param name="aSpeed"></param>
       /// <param name="range"></param>
       /// <param name="eyeRange"></param>
       /// <param name="skills"></param>
       private static void create(int code,
           string name,
           int  atkBase,
           int  defBase,
           int  hpBase,
           int  mpBase,
           int  atkArr,
           int  defArr,
           int  hpArr,
           int  mpArr,
           float speed,
           float aSpeed,
           float range,
           float eyeRange,
           params int[] skills) {
               HeroDataModel model = new HeroDataModel();
               model.code = code;
               model.name = name;
               model.atkBase = atkBase;
               model.defBase = defBase;
               model.hpBase = hpBase;
               model.mpBase = mpBase;
               model.atkArr = atkArr;
               model.defArr = defArr;
               model.hpArr = hpArr;
               model.mpArr = mpArr;
               model.speed = speed;
               model.aSpeed = aSpeed;
               model.range = range;
               model.eyeRange = eyeRange;
               model.skills = skills;
               heroMap.Add(code, model);
       }
    }

       public partial class HeroDataModel
       {
           public int code;//策划定义的唯一编号
           public string name;//英雄名称
           public int atkBase;//初始(基础)攻击力
           public int defBase;//初始防御
           public int hpBase;//初始血量
           public int mpBase;//初始蓝
           public int atkArr;//攻击成长
           public int defArr;//防御成长
           public int hpArr;//血量成长
           public int mpArr;//蓝成长
           public float speed;//移动速度
           public float aSpeed;//攻击速度
           public float range;//攻击距离
           public float eyeRange;//视野范围
           public int[] skills;//拥有技能
       }
    
}

  四、服务器通信底层搭建
  这部分为服务器的网络通信底层实现,也是游戏服务器的核心内容,下面将结合具体的代码以及代码注释一一介绍底层的实现,可能会涉及到一些C#的网络编程知识,对C#语言不熟悉没关系,笔者对C#的运用也仅仅停留在使用阶段,只需通过C#这门简单易懂的语言来窥探整个服务器通信底层搭建起来的过程,来到我们的NetFrame网络通信框架,这部分干货很多,我将用完整的代码和详尽的注释来阐明其意。
938125-20170722165220106-337423722.png

  1.四层Socket模型
938125-20170722180550793-1908429635.png


  将SocketModel分为了四个层级,分别为:
  (1)type:一级协议 用于区分所属模块,如用户模块
  (2)area:二级协议 用于区分模块下的所属子模块,如用户模块的子模块为道具模块1、装备模块2、技能模块3等
  (3)command:三级协议  用于区分当前处理逻辑功能,如道具模块的逻辑功能有“使用(申请/结果),丢弃,获得”等,技能模块的逻辑功能有“学习,升级,遗忘”等;
  (4)message:消息体 当前需要处理的主体数据,如技能书
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NetFrame.auto
{
   public class SocketModel
    {
       /// <summary>
       /// 一级协议 用于区分所属模块
       /// </summary>
       public byte type {get;set;}
       /// <summary>
       /// 二级协议 用于区分 模块下所属子模块
       /// </summary>
       public int area { get; set; }
       /// <summary>
       /// 三级协议  用于区分当前处理逻辑功能
       /// </summary>
       public int command { get; set; }
       /// <summary>
       /// 消息体 当前需要处理的主体数据
       /// </summary>
       public object message { get; set; }

       public SocketModel() { }
       public SocketModel(byte t,int a,int c,object o) {
           this.type = t;
           this.area = a;
           this.command = c;
           this.message = o;
       }

       public T GetMessage<T>() {
           return (T)message;
       }
    }
}


  同时封装了一个消息封装的方法,收到消息的处理流程如图所示:
938125-20170722180947153-277273363.png


  2.对象序列化与反序列化为对象
  序列化: 将数据结构或对象转换成二进制串的过程。
  反序列化:将在序列化过程中所生成的二进制串转换成数据结构或者对象的过程。
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.Serialization.Formatters.Binary;
using System.Text;
using System.Threading.Tasks;

namespace NetFrame
{
   public class SerializeUtil
    {
       /// <summary>
       /// 对象序列化
       /// </summary>
       /// <param name="value"></param>
       /// <returns></returns>
       public static byte[] encode(object value) {
           MemoryStream ms = new MemoryStream();//创建编码解码的内存流对象
           BinaryFormatter bw = new BinaryFormatter();//二进制流序列化对象
           //将obj对象序列化成二进制数据 写入到 内存流
           bw.Serialize(ms, value);
           byte[] result=new byte[ms.Length];
           //将流数据 拷贝到结果数组
           Buffer.BlockCopy(ms.GetBuffer(), 0, result, 0, (int)ms.Length);
           ms.Close();
           return result;
       }
       /// <summary>
       /// 反序列化为对象
       /// </summary>
       /// <param name="value"></param>
       /// <returns></returns>
       public static object decode(byte[] value) {
           MemoryStream ms = new MemoryStream(value);//创建编码解码的内存流对象 并将需要反序列化的数据写入其中
           BinaryFormatter bw = new BinaryFormatter();//二进制流序列化对象
           //将流数据反序列化为obj对象
           object result= bw.Deserialize(ms);
           ms.Close();
           return result;
       }
    }
}


  3.消息体序列化与反序列化
  相应的,我们利用上面写好的序列化和反序列化方法将我们再Socket模型中定义的message消息体进行序列化与反序列化
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NetFrame.auto
{
   public class MessageEncoding
    {
       /// <summary>
       /// 消息体序列化
       /// </summary>
       /// <param name="value"></param>
       /// <returns></returns>
       public static byte[] encode(object value) {
           SocketModel model = value as SocketModel;
           ByteArray ba = new ByteArray();
           ba.write(model.type);
           ba.write(model.area);
           ba.write(model.command);
           //判断消息体是否为空  不为空则序列化后写入
           if (model.message != null)
           {
               ba.write(SerializeUtil.encode(model.message));
           }
           byte[] result = ba.getBuff();
           ba.Close();
           return result;
       }
       /// <summary>
       /// 消息体反序列化
       /// </summary>
       /// <param name="value"></param>
       /// <returns></returns>
       public static object decode(byte[] value)
       {
           ByteArray ba = new ByteArray(value);
           SocketModel model = new SocketModel();
           byte type;
           int area;
           int command;
           //从数据中读取 三层协议  读取数据顺序必须和写入顺序保持一致
           ba.read(out type);
           ba.read(out area);
           ba.read(out command);
           model.type = type;
           model.area = area;
           model.command = command;
           //判断读取完协议后 是否还有数据需要读取 是则说明有消息体 进行消息体读取
           if (ba.Readnable) {
               byte[] message;
               //将剩余数据全部读取出来
               ba.read(out message, ba.Length - ba.Position);
               //反序列化剩余数据为消息体
               model.message = SerializeUtil.decode(message);
           }
           ba.Close();
           return model;
       }
    }
}


  4.将数据写入成二进制
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;

namespace NetFrame
{
    /// <summary>
    /// 将数据写入成二进制
    /// </summary>
   public class ByteArray
    {
       MemoryStream ms = new MemoryStream();

       BinaryWriter bw;
       BinaryReader br;
       public void Close() {
           bw.Close();
           br.Close();
           ms.Close();
       }

       /// <summary>
       /// 支持传入初始数据的构造
       /// </summary>
       /// <param name="buff"></param>
       public ByteArray(byte[] buff) {
           ms = new MemoryStream(buff);
           bw = new BinaryWriter(ms);
           br = new BinaryReader(ms);
       }

       /// <summary>
       /// 获取当前数据 读取到的下标位置
       /// </summary>
       public int Position {
           get { return (int)ms.Position; }
       }

       /// <summary>
       /// 获取当前数据长度
       /// </summary>
       public int Length
       {
           get { return (int)ms.Length; }
       }
       /// <summary>
       /// 当前是否还有数据可以读取
       /// </summary>
       public bool Readnable{
           get { return ms.Length > ms.Position; }
       }

       /// <summary>
       /// 默认构造
       /// </summary>
      public ByteArray() {
           bw = new BinaryWriter(ms);
           br = new BinaryReader(ms);
       }

      public void write(int value) {
          bw.Write(value);
      }
      public void write(byte value)
      {
          bw.Write(value);
      }
      public void write(bool value)
      {
          bw.Write(value);
      }
      public void write(string value)
      {
          bw.Write(value);
      }
      public void write(byte[] value)
      {
          bw.Write(value);
      }

      public void write(double value)
      {
          bw.Write(value);
      }
      public void write(float value)
      {
          bw.Write(value);
      }
      public void write(long value)
      {
          bw.Write(value);
      }


      public void read(out int value)
      {
          value= br.ReadInt32();
      }
      public void read(out byte value)
      {
          value = br.ReadByte();
      }
      public void read(out bool value)
      {
          value = br.ReadBoolean();
      }
      public void read(out string value)
      {
          value = br.ReadString();
      }
      public void read(out byte[] value,int length)
      {
          value = br.ReadBytes(length);
      }

      public void read(out double value)
      {
          value = br.ReadDouble();
      }
      public void read(out float value)
      {
          value = br.ReadSingle();
      }
      public void read(out long value)
      {
          value = br.ReadInt64();
      }

      public void reposition() {
          ms.Position = 0;
      }

       /// <summary>
       /// 获取数据
       /// </summary>
       /// <returns></returns>
      public byte[] getBuff()
      {
          byte[] result = new byte[ms.Length];
          Buffer.BlockCopy(ms.GetBuffer(), 0, result, 0, (int)ms.Length);
          return result;
      }
    }
}


  5.粘包长度编码与解码
  粘包出现原因:在流传输中出现(UDP不会出现粘包,因为它有消息边界)
  1 发送端需要等缓冲区满才发送出去,造成粘包
  2 接收方不及时接收缓冲区的包,造成多个包接收
  所以这里我们需要对粘包长度进行编码与解码,具体的代码如下:

[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NetFrame.auto
{
   public class LengthEncoding
    {
       /// <summary>
       /// 粘包长度编码
       /// </summary>
       /// <param name="buff"></param>
       /// <returns></returns>
       public static byte[] encode(byte[] buff) {
           MemoryStream ms = new MemoryStream();//创建内存流对象
           BinaryWriter sw = new BinaryWriter(ms);//写入二进制对象流
           //写入消息长度
           sw.Write(buff.Length);
           //写入消息体
           sw.Write(buff);
           byte[] result = new byte[ms.Length];
           Buffer.BlockCopy(ms.GetBuffer(), 0, result, 0, (int)ms.Length);
           sw.Close();
           ms.Close();
           return result;

       }
       /// <summary>
       /// 粘包长度解码
       /// </summary>
       /// <param name="cache"></param>
       /// <returns></returns>
       public static byte[] decode(ref List<byte> cache) {
           if (cache.Count < 4) return null;

           MemoryStream ms = new MemoryStream(cache.ToArray());//创建内存流对象,并将缓存数据写入进去
           BinaryReader br = new BinaryReader(ms);//二进制读取流
           int length = br.ReadInt32();//从缓存中读取int型消息体长度
           //如果消息体长度 大于缓存中数据长度 说明消息没有读取完 等待下次消息到达后再次处理
           if (length > ms.Length - ms.Position) {
               return null;
           }
           //读取正确长度的数据
           byte[] result = br.ReadBytes(length);
           //清空缓存
           cache.Clear();
           //将读取后的剩余数据写入缓存
           cache.AddRange(br.ReadBytes((int)(ms.Length - ms.Position)));
           br.Close();
           ms.Close();
           return result;
       }
    }
}

  6.delegate委托声明
  delegate 是表示对具有特定参数列表和返回类型的方法的引用的类型。 在实例化委托时,可以将其实例与任何具有兼容签名和返回类型的方法相关联。通过委托实例调用方法。委托相当于将方法作为参数传递给其他方法,类似于 C++ 函数指针,但它们是类型安全的。
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NetFrame
{
    public delegate byte[] LengthEncode(byte[] value);
    public delegate byte[] LengthDecode(ref List<byte> value);

    public delegate byte[] encode(object value);
    public delegate object decode(byte[] value);
}


  7.用户连接对象UserToken
  SocketAsyncEventArgs介绍
  SocketAsyncEventArgs是微软提供的高性能异步Socket实现类,主要为高性能网络服务器应用程序而设计,主要是为了避免在在异步套接字 I/O 量非常大时发生重复的对象分配和同步。使用此类执行异步套接字操作的模式包含以下步骤:
  (1)分配一个新的 SocketAsyncEventArgs 上下文对象,或者从应用程序池中获取一个空闲的此类对象。
  (2)将该上下文对象的属性设置为要执行的操作(例如,完成回调方法、数据缓冲区、缓冲区偏移量以及要传输的最大数据量)。
  (3)调用适当的套接字方法 (xxxAsync) 以启动异步操作。
  (4)如果异步套接字方法 (xxxAsync) 返回 true,则在回调中查询上下文属性来获取完成状态。
  (5)如果异步套接字方法 (xxxAsync) 返回 false,则说明操作是同步完成的。可以查询上下文属性来获取操作结果。
  (6)将该上下文重用于另一个操作,将它放回到应用程序池中,或者将它丢弃。
  SocketAsyncEventArgs.UserToken 属性
  获取或设置与此异步套接字操作关联的用户或应用程序对象。
  命名空间:   System.Net.Sockets
[C#] 纯文本查看 复制代码
 public object UserToken { get; set; }


  备注:
  此属性可以由应用程序相关联的应用程序状态对象与 SocketAsyncEventArgs 对象。 首先,此属性是一种将状态传递到应用程序的事件处理程序(例如,异步操作完成方法)的应用程序的方法。
  此属性用于所有异步套接字 (xxxAsync) 方法。
  UserToken类的完整实现代码如下,可以结合代码注释加以理解:
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

namespace NetFrame
{
    /// <summary>
    /// 用户连接信息对象
    /// </summary>
   public class UserToken
    {
       /// <summary>
       /// 用户连接
       /// </summary>
       public Socket conn;
       //用户异步接收网络数据对象
       public SocketAsyncEventArgs receiveSAEA;
       //用户异步发送网络数据对象
       public SocketAsyncEventArgs sendSAEA;

       public LengthEncode LE;
       public LengthDecode LD;
       public encode encode;
       public decode decode;


       public delegate void SendProcess(SocketAsyncEventArgs e);

       public SendProcess sendProcess;

       public delegate void CloseProcess(UserToken token, string error);

       public CloseProcess closeProcess;

       public AbsHandlerCenter center;

       List<byte> cache = new List<byte>();

       private bool isReading = false;
       private bool isWriting = false;
       Queue<byte[]> writeQueue = new Queue<byte[]>();

       public UserToken() {
           receiveSAEA = new SocketAsyncEventArgs();
           sendSAEA = new SocketAsyncEventArgs();
           receiveSAEA.UserToken = this;
           sendSAEA.UserToken = this;
           //设置接收对象的缓冲区大小
           receiveSAEA.SetBuffer(new byte[1024], 0, 1024);
       }
       //网络消息到达
       public void receive(byte[] buff) {
           //将消息写入缓存
           cache.AddRange(buff);
           if (!isReading)
           {
               isReading = true;
               onData();
           }
       }
       //缓存中有数据处理
       void onData() {
           //解码消息存储对象
           byte[] buff = null;
           //当粘包解码器存在的时候 进行粘包处理
           if (LD != null)
           {
               buff = LD(ref cache);
               //消息未接收全 退出数据处理 等待下次消息到达
               if (buff == null) { isReading = false; return; }
           }
           else {
               //缓存区中没有数据 直接跳出数据处理 等待下次消息到达
               if (cache.Count == 0) { isReading = false; return; }
               buff = cache.ToArray();
               cache.Clear();
           }
           //反序列化方法是否存在
           if (decode == null) { throw new Exception("message decode process is null"); }
           //进行消息反序列化
           object message = decode(buff);
           //TODO 通知应用层 有消息到达
           center.MessageReceive(this, message);
           //尾递归 防止在消息处理过程中 有其他消息到达而没有经过处理
           onData();
       }

       public void write(byte[] value) {
           if (conn == null) {
               //此连接已经断开了
               closeProcess(this, "调用已经断开的连接");
               return;
           }
           writeQueue.Enqueue(value);
           if (!isWriting) {
               isWriting = true;
               onWrite();
           }
       }

       public void onWrite() {
           //判断发送消息队列是否有消息
           if (writeQueue.Count == 0) { isWriting = false; return; }
           //取出第一条待发消息
           byte[] buff = writeQueue.Dequeue();
           //设置消息发送异步对象的发送数据缓冲区数据
           sendSAEA.SetBuffer(buff, 0, buff.Length);
           //开启异步发送
           bool result = conn.SendAsync(sendSAEA);
           //是否挂起
           if (!result) {
               sendProcess(sendSAEA);
           }
       }

       public void writed() {
           //与onData尾递归同理
           onWrite();
       }
       public void Close() {
           try
           {
               writeQueue.Clear();
               cache.Clear();
               isReading = false;
               isWriting = false;
               conn.Shutdown(SocketShutdown.Both);
               conn.Close();
               conn = null;
           }
           catch (Exception e) {
               Console.WriteLine(e.Message);
           }
       }
    }
}

  8.连接池UserTokenPool
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NetFrame
{
   public class UserTokenPool
    {
       private Stack<UserToken> pool;

       public UserTokenPool(int max) {
           pool = new Stack<UserToken>(max);
       }
       /// <summary>
       /// 取出一个连接对象 --创建连接
       /// </summary>
       public UserToken pop() {

           return pool.Pop();
       }
       //插入一个连接对象---释放连接
       public void push(UserToken token) {
           if (token != null)
               pool.Push(token);
       }
       public int Size {
           get { return pool.Count; } 
       }
    }
}


  9.抽象处理中心AbsHandlerCenter
  在这里我们定义了客户端连接、收到客户端消息和客户端断开连接的抽象类,标记为抽象或包含在抽象类中的成员必须通过从抽象类派生的类来实现。
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NetFrame
{
   public abstract class AbsHandlerCenter
    {
       /// <summary>
       /// 客户端连接
       /// </summary>
       /// <param name="token">连接的客户端对象</param>
       public abstract void ClientConnect(UserToken token);
       /// <summary>
       /// 收到客户端消息
       /// </summary>
       /// <param name="token">发送消息的客户端对象</param>
       /// <param name="message">消息内容</param>
       public abstract void MessageReceive(UserToken token, object message);
       /// <summary>
       /// 客户端断开连接
       /// </summary>
       /// <param name="token">断开的客户端对象</param>
       /// <param name="error">断开的错误信息</param>
       public abstract void ClientClose(UserToken token, string error);
    }
}

  10.HandlerCenter实现类
  接下来具体实现客户端连接、断开连接以及收到消息后的协议分发到具体的逻辑处理模块,代码如下:
[C#] 纯文本查看 复制代码
using GameProtocol;
using LOLServer.logic;
using LOLServer.logic.fight;
using LOLServer.logic.login;
using LOLServer.logic.match;
using LOLServer.logic.select;
using LOLServer.logic.user;
using NetFrame;
using NetFrame.auto;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace LOLServer
{
   public class HandlerCenter:AbsHandlerCenter
    {
       HandlerInterface login;
       HandlerInterface user;
       HandlerInterface match;
       HandlerInterface select;
       HandlerInterface fight;
       
       public HandlerCenter() {
           login = new LoginHandler();
           user = new UserHandler();
           match = new MatchHandler();
           select = new SelectHandler();
           fight = new FightHandler();
       }

        public override void ClientClose(UserToken token, string error)
        {
            Console.WriteLine("有客户端断开连接了");

            select.ClientClose(token, error);
            match.ClientClose(token, error);
            fight.ClientClose(token, error);
            //user的连接关闭方法 一定要放在逻辑处理单元后面
            //其他逻辑单元需要通过user绑定数据来进行内存清理 
            //如果先清除了绑定关系 其他模块无法获取角色数据会导致无法清理
            user.ClientClose(token, error);
            login.ClientClose(token, error);
        }

        public override void ClientConnect(UserToken token)
        {
            Console.WriteLine("有客户端连接了");
        }

        public override void MessageReceive(UserToken token, object message)
        {
            SocketModel model = message as SocketModel;
            switch (model.type) { 
                case Protocol.TYPE_LOGIN:
                    login.MessageReceive(token, model);
                    break;
                case Protocol.TYPE_USER:
                    user.MessageReceive(token, model);
                    break;
                case Protocol.TYPE_MATCH:
                    match.MessageReceive(token, model);
                    break;
                case Protocol.TYPE_SELECT:
                    select.MessageReceive(token, model);
                    break;
                case Protocol.TYPE_FIGHT:
                    fight.MessageReceive(token, model);
                    break;
                default:
                    //未知模块  可能是客户端作弊了 无视
                    break;
            }
        }
    }
}


  11.启动服务器
  写到这里,服务器终于可以启来了,不管你激不激动,反正坐在这里写写画画了一天我是激动了,总算要大功告成了。 : )
  启动服务器->监听IP(可选)->监听端口,服务器处理流程如下图:
938125-20170722201937856-1094732745.png

  让我们来具体看看代码实现,均给了详细的注释:
[C#] 纯文本查看 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace NetFrame
{
   public class ServerStart
    {
       Socket server;//服务器socket监听对象
       int maxClient;//最大客户端连接数
       Semaphore acceptClients;
       UserTokenPool pool;

       public LengthEncode LE;
       public LengthDecode LD;
       public encode encode;
       public decode decode;

       /// <summary>
       /// 消息处理中心,由外部应用传入
       /// </summary>
       public AbsHandlerCenter center;
       /// <summary>
       /// 初始化通信监听
       /// </summary>
       /// <param name="port">监听端口</param>
       public ServerStart(int max) {
           //实例化监听对象
           server = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
           //设定服务器最大连接人数
           maxClient = max;
           
       }

       public void Start(int port) {
           //创建连接池
           pool = new UserTokenPool(maxClient);
           //连接信号量
           acceptClients = new Semaphore(maxClient, maxClient);
           for (int i = 0; i < maxClient; i++)
           {
               UserToken token = new UserToken();
               //初始化token信息               
               token.receiveSAEA.Completed += new EventHandler<SocketAsyncEventArgs>(IO_Comleted);
               token.sendSAEA.Completed += new EventHandler<SocketAsyncEventArgs>(IO_Comleted);
               token.LD = LD;
               token.LE = LE;
               token.encode = encode;
               token.decode = decode;
               token.sendProcess = ProcessSend;
               token.closeProcess = ClientClose;
               token.center = center;
               pool.push(token);
           }
           //监听当前服务器网卡所有可用IP地址的port端口
           // 外网IP  内网IP192.168.x.x 本机IP一个127.0.0.1
           try
           {
               server.Bind(new IPEndPoint(IPAddress.Any, port));
               //置于监听状态
               server.Listen(10);
               StartAccept(null);
           }
           catch (Exception e)
           {
               Console.WriteLine(e.Message);
           }
       }
       /// <summary>
       /// 开始客户端连接监听
       /// </summary>
       public void StartAccept(SocketAsyncEventArgs e) {
           //如果当前传入为空  说明调用新的客户端连接监听事件 否则的话 移除当前客户端连接
           if (e == null)
           {
               e = new SocketAsyncEventArgs();
               e.Completed += new EventHandler<SocketAsyncEventArgs>(Accept_Comleted);
           }
           else {
               e.AcceptSocket = null;
           }
           //信号量-1
           acceptClients.WaitOne();
           bool result= server.AcceptAsync(e);
           //判断异步事件是否挂起  没挂起说明立刻执行完成  直接处理事件 否则会在处理完成后触发Accept_Comleted事件
           if (!result) {
               ProcessAccept(e);
           }
       }

       public void ProcessAccept(SocketAsyncEventArgs e) {
           //从连接对象池取出连接对象 供新用户使用
           UserToken token = pool.pop();
           token.conn = e.AcceptSocket;
           //TODO 通知应用层 有客户端连接
           center.ClientConnect(token);
           //开启消息到达监听
           StartReceive(token);
           //释放当前异步对象
           StartAccept(e);
       }

       public void Accept_Comleted(object sender, SocketAsyncEventArgs e) {
           ProcessAccept(e);
       }

       public void StartReceive(UserToken token) {
           try
           {
               //用户连接对象 开启异步数据接收
               bool result = token.conn.ReceiveAsync(token.receiveSAEA);
               //异步事件是否挂起
               if (!result)
               {
                   ProcessReceive(token.receiveSAEA);
               }
           }
           catch (Exception e) {
               Console.WriteLine(e.Message);
           }
       }

       public void IO_Comleted(object sender, SocketAsyncEventArgs e)
       {
           if (e.LastOperation == SocketAsyncOperation.Receive)
           {
               ProcessReceive(e);
           }
           else {
               ProcessSend(e);
           }
       }

       public void ProcessReceive(SocketAsyncEventArgs e) {
           UserToken token= e.UserToken as UserToken;
           //判断网络消息接收是否成功
           if (token.receiveSAEA.BytesTransferred > 0 && token.receiveSAEA.SocketError == SocketError.Success)
           {
               byte[] message = new byte[token.receiveSAEA.BytesTransferred];
               //将网络消息拷贝到自定义数组
               Buffer.BlockCopy(token.receiveSAEA.Buffer, 0, message, 0, token.receiveSAEA.BytesTransferred);
               //处理接收到的消息
               token.receive(message);
               StartReceive(token);
           }
           else {
               if (token.receiveSAEA.SocketError != SocketError.Success)
               {
                   ClientClose(token, token.receiveSAEA.SocketError.ToString());
               }
               else {
                   ClientClose(token, "客户端主动断开连接");
               }
           }
       }
       public void ProcessSend(SocketAsyncEventArgs e) {
           UserToken token = e.UserToken as UserToken;
           if (e.SocketError != SocketError.Success)
           {
               ClientClose(token, e.SocketError.ToString());
           }
           else { 
            //消息发送成功,回调成功
               token.writed();
           }
       }

       /// <summary>
       /// 客户端断开连接
       /// </summary>
       /// <param name="token"> 断开连接的用户对象</param>
       /// <param name="error">断开连接的错误编码</param>
       public void ClientClose(UserToken token,string error) {
           if (token.conn != null) {
               lock (token) { 
                //通知应用层面 客户端断开连接了
                   center.ClientClose(token, error);
                   token.Close();
                   //加回一个信号量,供其它用户使用
                   pool.push(token);
                   acceptClients.Release();                   
               }
           }
       }
    }
}

  至此,服务器的通信底层已经搭建完毕,可以进一步进行具体的游戏逻辑玩法开发了。
  五、游戏服务端逻辑分层实现
0.png


  逻辑处理主要分层架构如下:
938125-20170722205730700-1599468008.png

  (1)logic逻辑层:逻辑处理模块,异步的逻辑处理,登录、用户处理、匹配、选人、战斗的主要逻辑都在这里,Moba类游戏是典型的房间服务器架构,AbsOnceHandler用于单体消息发送的处理,AbsMulitHandler用于群发;
  AbsOnceHandler代码如下:
[C#] 纯文本查看 复制代码
using LOLServer.biz;
using LOLServer.dao.model;
using NetFrame;
using NetFrame.auto;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace LOLServer.logic
{
   public class AbsOnceHandler
    {
      public IUserBiz userBiz = BizFactory.userBiz;

       private byte type;
       private int area;

       public void SetArea(int area) {
           this.area = area;
       }

       public virtual int GetArea() {
           return area;
       }

       public void SetType(byte type)
       {
           this.type = type;
       }

       public new virtual byte GetType()
       {
           return type;
       }

       /// <summary>
       /// 通过连接对象获取用户
       /// </summary>
       /// <param name="token"></param>
       /// <returns></returns>
       public USER getUser(UserToken token)
       {
           return userBiz.get(token);
       }

       /// <summary>
       /// 通过ID获取用户
       /// </summary>
       /// <param name="token"></param>
       /// <returns></returns>
       public USER getUser(int id)
       {
           return userBiz.get(id);
       }

       /// <summary>
       /// 通过连接对象 获取用户ID
       /// </summary>
       /// <param name="token"></param>
       /// <returns></returns>
       public int getUserId(UserToken token){
           USER user = getUser(token);
           if(user==null)return -1;
           return user.id;
       }
       /// <summary>
       /// 通过用户ID获取连接
       /// </summary>
       /// <param name="id"></param>
       /// <returns></returns>
       public UserToken getToken(int id) {
           return userBiz.getToken(id);
       }


       #region 通过连接对象发送
       public void write(UserToken token,int command) {
           write(token, command, null);
       }
       public void write(UserToken token, int command,object message)
       {
           write(token,GetArea(), command, message);
       }
       public void write(UserToken token,int area, int command, object message)
       {
           write(token,GetType(), GetArea(), command, message);
       }
       public void write(UserToken token,byte type, int area, int command, object message)
       {
           byte[] value = MessageEncoding.encode(CreateSocketModel(type,area,command,message));
           value = LengthEncoding.encode(value);
           token.write(value);
       }
       #endregion

       #region 通过ID发送
       public void write(int id, int command)
       {
           write(id, command, null);
       }
       public void write(int id, int command, object message)
       {
           write(id, GetArea(), command, message);
       }
       public void write(int id, int area, int command, object message)
       {
           write(id, GetType(), area, command, message);
       }
       public void write(int id, byte type, int area, int command, object message)
       {
           UserToken token= getToken(id);
           if(token==null)return;
           write(token, type, area, command, message);
       }

       public void writeToUsers(int[] users, byte type, int area, int command, object message) {
           byte[] value = MessageEncoding.encode(CreateSocketModel(type, area, command, message));
           value = LengthEncoding.encode(value);
           foreach (int item in users)
           {
               UserToken token = userBiz.getToken(item);
               if (token == null) continue;
                   byte[] bs = new byte[value.Length];
                   Array.Copy(value, 0, bs, 0, value.Length);
                   token.write(bs);
               
           }
       }


       #endregion





       public SocketModel CreateSocketModel(byte type, int area, int command, object message)
       {
           return new SocketModel(type, area, command, message);
       }
    }
}

  AbsMulitHandler继承自AbsOnceHandler,实现代码如下:
[C#] 纯文本查看 复制代码
using NetFrame;
using NetFrame.auto;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace LOLServer.logic
{
   public class AbsMulitHandler:AbsOnceHandler
    {
       public List<UserToken> list = new List<UserToken>();
       /// <summary>
       /// 用户进入当前子模块
       /// </summary>
       /// <param name="token"></param>
       /// <returns></returns>
       public bool enter(UserToken token) {
           if (list.Contains(token)) {
               return false;
           }
           list.Add(token);
           return true;
       }
       /// <summary>
       /// 用户是否在此子模块
       /// </summary>
       /// <param name="token"></param>
       /// <returns></returns>
       public bool isEntered(UserToken token) {
           return list.Contains(token);
       }
       /// <summary>
       /// 用户离开当前子模块
       /// </summary>
       /// <param name="token"></param>
       /// <returns></returns>
       public bool leave(UserToken token) {
           if (list.Contains(token)) {
               list.Remove(token);
               return true;
           }
           return false;
       }
       #region 消息群发API

       public void brocast(int command, object message,UserToken exToken=null) {
           brocast(GetArea(), command, message, exToken);
       }
       public void brocast(int area, int command, object message, UserToken exToken = null)
       {
           brocast(GetType(), area, command, message, exToken);
       }
       public void brocast(byte type, int area, int command, object message, UserToken exToken = null)
       {
           byte[] value = MessageEncoding.encode(CreateSocketModel(type, area, command, message));
           value = LengthEncoding.encode(value);
           foreach (UserToken item in list)
           {
               if (item != exToken)
               {
                   byte[] bs = new byte[value.Length];
                   Array.Copy(value, 0, bs, 0, value.Length);
                   item.write(bs);
               }
           }
       }
       #endregion
    }
}

  (2)biz事务层:事务处理,保证数据安全的逻辑处理,如账号、用户信息相关的处理,impl是相关的实现类;
  (3)cache缓存层:读取数据库中的内容放在内存中,加快访问速度;
  (4)dao数据层:服务器和数据库之间的中间件;
  (5)工具类:一些实用的工具类放在这里,如定时任务列表,用来实现游戏中的刷怪,buff等;
  逻辑处理流程如下:
938125-20170722210006950-223400344.png

  六、写在最后
  好了,这篇文章就分享到这里,从项目的制作周期,到沉淀积累,到重构设计,到总结与反思,到怎么把整个架构的设计与实现分享出来,再到写出一篇文章确实经历了很长的一段时间,有些图由于比较长经常需要把电脑屏幕来回旋转绘制,希望对您有所帮助,感谢阅读,篇幅有限不能一一详述,如有问题欢迎留言讨论。下一篇可能会开始剖析开源MMORPG游戏服务端引擎KBEngine的源码,也可能写C++或python相关,如果您也对这些内容感兴趣,或者对笔者感兴趣,可以继续关注我的后续文章。^_^
  上篇博客后有不少小伙伴给笔者发了私信,大多是技术生涯的一些迷茫与选择,最后有一句笔者很喜欢的话分享给大家:。衡量一个人才的标准,在于一个人在有限的时间内所展现出来的成长速度。持续学习,持续进步,持续成长,才能持续幸运,持续实现价值


来源:千年风雅


评分

参与人数 7鲜花 +32 收起 理由
bce + 5 真正的干货总是让人欣喜的
Kmori + 5 赞一个!
易玄风 + 2 赞一个!
nmjkl + 5 赞一个!
godlewis123 + 5 很给力!
deng77777778 + 5 很给力!
jskymoon + 5 赞一个!

查看全部评分


跟我念“站长妹纸萌萌哒!”我说站长,你说YO!爱你们么么哒~
回复

使用道具 举报

7日久生情
3174/5000
排名
174
昨日变化

0

主题

351

帖子

3174

积分

Rank: 7Rank: 7Rank: 7Rank: 7

UID
2484
好友
2
蛮牛币
2725
威望
0
注册时间
2013-8-23
在线时间
1045 小时
最后登录
2017-10-18
发表于 2017-7-24 14:29:49 | 显示全部楼层
非常不错!希望以后能经常有如此精彩的文章可以看到!

回复 支持 反对

使用道具 举报

7日久生情
3110/5000
排名
198
昨日变化
1

13

主题

263

帖子

3110

积分

Rank: 7Rank: 7Rank: 7Rank: 7

UID
67323
好友
1
蛮牛币
479
威望
0
注册时间
2015-1-10
在线时间
1106 小时
最后登录
2017-10-18
发表于 2017-7-24 14:48:17 | 显示全部楼层
这套已经完全吃透

回复 支持 反对

使用道具 举报

2初来乍到
139/150
排名
10477
昨日变化
16

0

主题

28

帖子

139

积分

Rank: 2Rank: 2

UID
213765
好友
0
蛮牛币
29
威望
0
注册时间
2017-3-22
在线时间
39 小时
最后登录
2017-10-18
发表于 2017-7-24 15:55:40 | 显示全部楼层

非常不错!虽然并不知道这是什么鬼!

回复 支持 反对

使用道具 举报

5熟悉之中
816/1000
排名
3319
昨日变化
2

4

主题

164

帖子

816

积分

Rank: 5Rank: 5

UID
182321
好友
0
蛮牛币
-3
威望
0
注册时间
2016-11-9
在线时间
306 小时
最后登录
2017-8-31
发表于 2017-7-24 16:18:43 | 显示全部楼层
幸运榜首,蛮牛刀郎,游戏之才,得之可得天下。

回复 支持 反对

使用道具 举报

7日久生情
1916/5000
排名
907
昨日变化
1

19

主题

354

帖子

1916

积分

Rank: 7Rank: 7Rank: 7Rank: 7

UID
55374
好友
16
蛮牛币
3537
威望
0
注册时间
2014-11-14
在线时间
613 小时
最后登录
2017-10-11
QQ
发表于 2017-7-24 16:30:17 | 显示全部楼层
非常棒!!非常棒!!

回复

使用道具 举报

5熟悉之中
661/1000
排名
4819
昨日变化
26

0

主题

54

帖子

661

积分

Rank: 5Rank: 5

UID
67892
好友
0
蛮牛币
517
威望
0
注册时间
2015-1-12
在线时间
383 小时
最后登录
2017-10-17
发表于 2017-7-24 17:45:21 | 显示全部楼层
这个代码不就是 游戏蛮牛英雄联盟  里的代码吗?
[发帖际遇]: 一个袋子砸在了 大。坨坨坨坨 头上,大。坨坨坨坨 赚了 1 蛮牛币. 幸运榜 / 衰神榜

回复 支持 1 反对 0

使用道具 举报

5熟悉之中
927/1000
排名
2329
昨日变化
10

0

主题

236

帖子

927

积分

Rank: 5Rank: 5

UID
180505
好友
2
蛮牛币
627
威望
0
注册时间
2016-11-3
在线时间
207 小时
最后登录
2017-10-18
QQ
发表于 2017-7-24 17:45:55 | 显示全部楼层
虽然感觉特别有用,可是  看不下去啊

回复 支持 反对

使用道具 举报

5熟悉之中
538/1000
排名
4018
昨日变化
3

0

主题

40

帖子

538

积分

Rank: 5Rank: 5

UID
102666
好友
0
蛮牛币
651
威望
0
注册时间
2015-5-23
在线时间
220 小时
最后登录
2017-10-11
QQ
发表于 2017-7-24 18:00:57 | 显示全部楼层
赞赞赞,游戏好久能上线?

回复 支持 反对

使用道具 举报

5熟悉之中
919/1000
排名
1690
昨日变化
8

1

主题

79

帖子

919

积分

Rank: 5Rank: 5

UID
136441
好友
0
蛮牛币
1131
威望
0
注册时间
2016-2-10
在线时间
219 小时
最后登录
2017-10-18
发表于 2017-7-24 18:23:12 | 显示全部楼层
就是蛮牛英雄联盟的  看一遍这个 再看视屏可以更加深入理解了 蛮好的!!!!!
[发帖际遇]: 一个袋子砸在了 Anothery 头上,Anothery 赚了 1 蛮牛币. 幸运榜 / 衰神榜

回复 支持 反对

使用道具 举报

排名
20544
昨日变化
34

0

主题

5

帖子

29

积分

Rank: 1

UID
161836
好友
0
蛮牛币
55
威望
0
注册时间
2017-5-3
在线时间
10 小时
最后登录
2017-7-25
发表于 2017-7-24 19:02:26 | 显示全部楼层
很不错的分享 希望更多

回复 支持 反对

使用道具 举报

7日久生情
1801/5000
排名
1667
昨日变化
3

8

主题

669

帖子

1801

积分

Rank: 7Rank: 7Rank: 7Rank: 7

UID
68430
好友
3
蛮牛币
6905
威望
0
注册时间
2015-1-14
在线时间
468 小时
最后登录
2017-10-18
发表于 2017-7-24 19:03:22 | 显示全部楼层
6666666666666666~~收藏了·~~

回复 支持 反对

使用道具 举报

7日久生情
2435/5000
排名
427
昨日变化
1

1

主题

126

帖子

2435

积分

Rank: 7Rank: 7Rank: 7Rank: 7

UID
69456
好友
6
蛮牛币
2671
威望
0
注册时间
2015-1-19
在线时间
1000 小时
最后登录
2017-10-18
QQ
发表于 2017-7-24 19:57:57 | 显示全部楼层
好文章,.
[发帖际遇]: 一个袋子砸在了 61875256 头上,61875256 赚了 1 蛮牛币. 幸运榜 / 衰神榜

回复

使用道具 举报

6蛮牛粉丝
1359/1500
排名
1268
昨日变化
3

0

主题

85

帖子

1359

积分

Rank: 6Rank: 6Rank: 6

UID
3793
好友
0
蛮牛币
2159
威望
0
注册时间
2013-9-13
在线时间
530 小时
最后登录
2017-10-17
发表于 2017-7-24 19:59:24 | 显示全部楼层
厉害了, 感觉不错, 先收藏了

回复 支持 反对

使用道具 举报

4四处流浪
401/500
排名
7912
昨日变化
191

1

主题

231

帖子

401

积分

Rank: 4

UID
119154
好友
0
蛮牛币
635
威望
0
注册时间
2015-8-21
在线时间
55 小时
最后登录
2017-10-17
发表于 2017-7-24 22:08:09 | 显示全部楼层
谢谢楼主大大。

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册帐号

本版积分规则

快速回复 返回顶部 返回列表