65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的游戏大厅系统

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.73/5 (29投票s)

2006 年 5 月 8 日

CPOL

8分钟阅读

viewsIcon

297295

downloadIcon

3659

一个简单的游戏大厅服务器,用于托管多个小型游戏,并允许玩家创建和加入多种类型的游戏。

引言

虽然如今将某种多人游戏功能集成到电脑游戏中非常普遍,但为了能够玩游戏,人们通常需要知道托管游戏的玩家的 IP 地址。这对于在本地网络上玩游戏来说是可以的,因为你们可以互相沟通来确定服务器在哪里,但要在互联网上组织游戏就需要使用一个单独的系统——电子邮件或即时通讯工具。

这个库允许你托管一个固定地址的服务器,这样你的游戏(或游戏)的玩家就可以加入它,互相聊天安排游戏,然后开始并控制它,而无需任何人知道主机玩家的地址。

必备组件

这段代码使用了我的 Sockets 库,该库也在 CodeProject 上 这里。UI 还使用了我的 LineEditor 自定义组件。我还没有时间为它写一篇正式的文章,所以你必须从 这里 获取。

设计

这段代码的目的是什么?什么是游戏大厅,我们需要写什么来创建一个?

好吧,首先,当然,会有一个网络库来处理服务器和客户端之间的通信。我使用的是我自己的消息模式,其中每个消息都包含一个类型代码。

什么是游戏大厅?

大厅本质上是玩家的集合和游戏的集合。每个玩家可能在一个或多个游戏中,每个游戏可能包含一个或多个玩家。玩家应该能够创建、加入、离开和开始游戏,并且应该能够看到哪些游戏已经可用以及哪些玩家在“房间”里,尽管这只是允许 UI 查看这些数据。当玩家加入或离开服务器,或者游戏状态改变时(例如,新玩家加入,或者从设置移到“进行中”),大厅也应该触发事件,以便 UI 知道何时更新。

我选择将大厅的数据处理部分作为一个基类,ClientServerLobby 类(它们通过网络函数通信以同步状态)从中派生。除了数据处理之外,ServerLobby 还必须在更新事件发生时(例如,新玩家到达或创建新游戏)向所有客户端发送更新消息。它显然也必须跟踪已连接的玩家,并管理尝试连接和登录的新客户端的授权。它还必须运行游戏并处理客户端命令——例如,启动游戏的请求。所有这些都是通过从网络层接收消息、处理它们,然后再次通过网络层发送响应和广播来完成的。

ClientLobby 只是服务器的一个“瘦客户端”视图,通过网络消息保持同步,并在终端用户尝试执行任何操作时发送请求消息。

UI

Lobby 类及其子类有意不包含 UI。它们被设计为可编程组件,可以从你自己的 UI 调用,并通过事件回调链接到其中。有一个示例客户端外壳(LobbyClient 文件夹),其中包含通用客户端所需的大部分内容。

客户端截图

游戏

大厅应该是游戏类型中立的,即它不应该关心在其上运行的游戏类型。这对于通用引擎至关重要。为此,客户端和服务器都使用插件系统,插件实现 Lobby.dll 中定义的接口。这些插件应该是实际游戏处理发生的地方。为了方便起见,通常最好由游戏的创建者“拥有”该游戏,因此所有处理都发生在一个客户端上,然后该客户端指示服务器向游戏中的其他所有人或特定玩家广播消息。服务器还应该能够存储和检索与特定游戏相关的数据,因此如果所有者离开,游戏可以以最少的干扰移交给新所有者。

这种在客户端处理游戏意味着服务器不需要了解正在玩的游戏,这很好,因为更新服务器比提供新的客户端下载要困难得多。然而,这意味着一个被破解的客户端可以随意作弊,完全改变游戏机制。出于这个原因,我还提供了一种在服务器上托管游戏并在此处进行处理的机制(同样,通过插件和接口)。然后 ServerLobby 将与该游戏相关的消息传递给插件。

实现

好吧,找出我如何做到的最简单方法就是查看源代码;)。但这是一种相当硬核的方式,我将在下面解释一些更通用的有用代码。

数据结构

也许设计中最重要的部分是用于存储数据的类和结构。对于这个问题,它们是玩家和游戏信息。

public class MemberInfo {
  public int ID; // the ID assigned to this used (by the server)
  public uint Flags;
  public string Username, DisplayName; // must be provided to enter
  public object Data; // app-specific stuff about this member
  
  public object InternalData;
  // stuff used by lobby classes
 }
 
 public struct MemberFlags {
  // Client flags: low word
  
  // Server flags: high word
  public const uint ServerControlled= 0xFFFF0000;
 }
 
 public class GameInfo {
  public int ID, CreatorID, MaxPlayers;
  public string Name;
  public String GameType, Version;
  // Reserved flags: 1 locked, 2 closed, 4 in progress
  public uint Flags;
  public int[] Players;
  public uint[] PlayerFlags;
  public String Password;
  public object Data;
  public bool Serverside;
  public IServersideGame Game;
 }
 
 public struct GameFlags {
  // Requires a password to enter
  public const uint Locked    = 0x00000001;
  // No-one can enter
  public const uint Closed    = 0x00000002;
  public const uint InProgress= 0x00000004;
 }
 
 public struct PlayerGameFlags {
  public const uint Ready            = 0x00000001; 
       // Player is content to have the game start
}

这一切都相当不言自明。请注意,所有可传输的数据都是简单类型;因为我的网络库只高效地处理数字和 string,所以它们必须这样传输,因此对我来说,将标志不作为标志位的 enum,而是作为 uint 是有意义的。

主要的 Info '结构'实际上是类,因此它们可以高效地存储在 Hashtable 中并在原地修改。

我还定义了一组事件处理程序供主应用程序连接。

public delegate void MemberEvent(BaseLobby lobby, MemberInfo mem);
public delegate void GameEvent(BaseLobby lobby, GameInfo game);
public delegate bool ProcessCodeEvent(BaseLobby lobby, MemberInfo mem,
            uint code, byte[] bytes, int len);
public delegate void LogEvent(object sender, string text);
public delegate void UnloadDataEvent(object sender, object container,
            object data);
public delegate bool UserJoinedEvent(ServerLobby sl, MemberInfo member,
            String password);

其中大部分是“更新事件”,允许 UI 在发生某些事情时做出反应。ProcessCodeEvent 允许应用程序覆盖或扩展消息的默认处理,并且通常是从插件挂接的。

沟通

如上所述,网络通信由包含消息类型代码的消息处理。这在一个大的 switch 语句中处理,该语句决定是立即处理消息,还是将其传递给 UI 或插件。

public bool ClientDoCode(ClientInfo ci, uint code, byte[] bytes, int len){
   // Public so games can fake messages, warnings etc without bouncing off
   // the server
   MemberInfo mem = (MemberInfo)members[MyID];
   ByteBuilder b = new ByteBuilder(bytes), b_out = new ByteBuilder();
   int pi = 0;
   bool handled = true;
   try {
    switch(code){
     case ReservedCodes.YouAre:
      myid = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      mem = (MemberInfo)members[MyID];
      break;
     case ReservedCodes.SignInChallenge:
      string msg = Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
      int failures = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      if(failures == 0){
       b_out.AddParameter(Encoding.UTF8.GetBytes(Username),
           ParameterType.String);
       b_out.AddParameter(Encoding.UTF8.GetBytes(Password),
           ParameterType.String);
       ci.SendMessage(ReservedCodes.SignIn, b_out.Read(0, b_out.Length), 0);
      } else ci.Close();
      break;
     case ReservedCodes.MemberUpdate:
      // Add the member to the members table
      MemberInfo mi = new MemberInfo();
      mi.ID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      MemberInfo miold = (MemberInfo)members[mi.ID];
      mi.Flags = (uint)ClientInfo.GetInt(
                       b.GetParameter(ref pi).content, 0, 4);
      mi.Username = 
         Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
      mi.DisplayName = 
         Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
      if(miold != null){
       // Copy stuff we are keeping attached to this item
       mi.Data = miold.Data;
       mi.InternalData = miold.InternalData;
      }
      members[mi.ID] = mi;
      break;
     case ReservedCodes.MemberLeft:
      int ID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      if(UnloadData != null){
       mi = (MemberInfo)members[ID];
       if(mi.Data != null) UnloadData(this, mi, mi.Data);
       if(mi.InternalData != null)
           UnloadData(this, mi, mi.InternalData);
      }
      members.Remove(ID);
      break;
     case ReservedCodes.GameUpdate:
      ID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      GameInfo gi = (GameInfo)games[ID];
      if(gi == null) gi = new GameInfo();
      gi.ID = ID;
      gi.Flags = (uint)ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      gi.CreatorID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      gi.Serverside = (gi.CreatorID < 0);
      gi.MaxPlayers = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      gi.GameType = Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
      gi.Version = Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
      gi.Name = Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
      gi.Players = ClientInfo.GetIntArray(b.GetParameter(ref pi).content);
      gi.PlayerFlags = ClientInfo.GetUintArray(b.GetParameter(ref pi).content);
      games[gi.ID] = gi;
      break;
     case ReservedCodes.GameClosed:
      ID = ClientInfo.GetInt(b.GetParameter(ref pi).content, 0, 4);
      if(UnloadData != null){
       gi = (GameInfo)games[ID];
       if(gi.Data != null) UnloadData(this, gi, gi.Data);
      }
      games.Remove(ID);
      break;
      // No-ops, but we need to set the flag to say we recognise them
      // Useful for the wrapper app to catch for UI updates, mostly
     case ReservedCodes.MemberJoined:
      break;
     default: handled = false; break;
    }
    if(ProcessCode != null)
     handled |= ProcessCode(this, mem, code, bytes, len);
   } catch(ArgumentException ae) {
    Console.WriteLine("Internal error (invalid message sent from server +
                      "or error in code handler)."+
                      " Code was "+code.ToString("X8")+". Error was "+ae);
   }
   return handled;
}

ByteBuilder 是一个实用类(在 Sockets 项目中),它可以从“参数”构建或解构字节数组,或将字节数组拆解为“参数”——已知类型的长度检查字节块。b.GetParameter(ref pi) 获取“next”参数。每个消息的格式在 ReservedCode 结构上的注释中定义。

请注意,在 resync 处理完成后会调用 ProcessCode 事件,以便应用程序可以采取额外措施响应消息。

服务器的 DoCode 函数结构类似,并且太长,无法在此处全部发布。

管理玩家

在 TCP 系统下跟踪玩家非常简单:一个连接映射到一个玩家,如果连接丢失,则认为玩家已离开。玩家可以做三件事:

  1. 连接到服务器。此时,他们还没有“登录”,但我们需要将新的套接字添加到我们的内部客户端列表中,以便在他们登录时知道他们是谁。网络层(Server 类存储所有连接到它的客户端)会为我们处理这一点,所以我们在 ServerLobby 中需要做的就是请求客户端登录,并为新连接附加事件处理程序。
    bool connect(Server server, ClientInfo ci){
       ci.OnReadMessage = new ConnectionReadMessage(ClientReadMessage);
       ci.OnClose = new ConnectionClosed(ClientClosed);
       ci.MessageType = MessageType.CodeAndLength;
       // Send it a version info message and a challenge message
       ci.SendMessage(ReservedCodes.Version,
              Encoding.UTF8.GetBytes(Strings.Version), 
              ParameterType.String);
       ci.SendMessage(ReservedCodes.SignInChallenge,
              PrepareChallenge(Strings.InitialChallenge, 0), 0);
       DoLog("New connection "+ci.ID+" was accepted from "+
             ci.Socket.RemoteEndPoint);
       return true;
    }
  2. 登录到服务器。这由 ClientReadMessage 函数处理,该函数在大多数时候只是将其信息传递给 ServerDoCode 函数,但如果连接尚未与玩家关联,它将处理登录尝试。此函数相当长但很简单;它只是检查玩家是否已登录,尝试的登录信息是否有效,如果是,则将新成员添加到玩家列表中,并向他们发送有关服务器上还有谁以及有哪些游戏的信息。
    void ClientReadMessage(ClientInfo ci, uint code, byte[] bytes, int len){
       MemberInfo mi = (MemberInfo)members[ci.ID];
       if(mi != null){
        ServerDoCode(ci, mi, code, bytes, len);
        return;
       }
       // Not actually signed in, so we need to try to get them to join
       // Only valid thing at this stage is SignIn
       // To do: allow request for encrypted connection
       if(code != ReservedCodes.SignIn){
        ci.SendMessage(ReservedCodes.Error,
                   Encoding.UTF8.GetBytes(Strings.MustSignIn),
                   ParameterType.String);
        return;
       }
       ByteBuilder b = new ByteBuilder(bytes);
       int pi = 0;
       try {
        String username =
            Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
        String password =
            Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
        
        // Make sure they're not already logged in
        foreach(MemberInfo mi2 in members.Values){
         if(mi2.Username == username){
          ci.SendMessage(ReservedCodes.SignInChallenge, PrepareChallenge(
                  String.Format(Strings.AlreadyLoggedIn, username), 1
                 ), 0);
          ci.Close();
          return;
         }
        }
        
        mi = new MemberInfo();
        mi.ID = ci.ID;
        mi.Flags = DefaultMemberFlags;
        mi.Username = mi.DisplayName = username;
        
        if(UserJoined != null){
         if(!UserJoined(this, mi, password)){
          // Allow infinite retries (just send a Challenge)
          // NB This is a DOS hole, technically,
          // as server can be spammed with
          // infinite requests to join
          ci.SendMessage(ReservedCodes.SignInChallenge,
                  PrepareChallenge(Strings.BadLoginChallenge, 1), 0);
          return;
         }
        }
        // Validated OK (or no user blocks at all) so add the
        // client and send them
        // all member and game information (them first, though!)
        DoLog("Member "+mi.Username+" ("+mi.ID+") signed in");
        members.Add(ci.ID, mi);
        server.BroadcastMessage(ReservedCodes.MemberUpdate,
                     PrepareMemberInfo(mi), 0);
        server.BroadcastMessage(ReservedCodes.MemberJoined,
                     Encoding.UTF8.GetBytes(mi.DisplayName),
                     ParameterType.String);
        
        // Now send all other members
        foreach(MemberInfo mi2 in members.Values){
         if(mi2.ID == mi.ID) continue;
         ci.SendMessage(ReservedCodes.MemberUpdate, 
                        PrepareMemberInfo(mi2), 0);
        }
        // Tell them what their ID was
        ci.SendMessage(ReservedCodes.YouAre, 
               ClientInfo.IntToBytes(ci.ID),
               ParameterType.Int);
        
        // Tell them what games are in progress
        foreach(GameInfo gi in games.Values){
         ci.SendMessage(ReservedCodes.GameUpdate, 
                        PrepareGameInfo(gi), 0);
        }
       } catch(ArgumentException) {
        ci.SendMessage(ReservedCodes.Error,
                       Encoding.UTF8.GetBytes(
                         String.Format(Strings.InvalidFormat, code)
                       ),
                       ParameterType.String);
        ci.Close();
       }
    }
    
  3. 从服务器断开连接。虽然离开的玩家不需要发送任何信息,但他们可能会留下大量的清理工作:应该告诉每个人他们已经离开,并且他们所在的任何游戏都需要更新并可能转交给新所有者。
    void ClientClosed(ClientInfo ci){
       MemberInfo mi = (MemberInfo)members[ci.ID];
       if(mi != null){
        // Check for any games owned by this player,
        // or which this player has joined.
        // Any he owns are passed on
        // Any he is in, he leaves and the game is updated
        Hashtable games2 = (Hashtable)games.Clone();
        foreach(GameInfo gi in games2.Values){
         if(MemberInGame(mi.ID, gi.ID)) {
          RemovePlayerFromGame(mi.ID, gi);
         }
        }
        server.BroadcastMessage(ReservedCodes.MemberLeft,
                 PrepareMemberInfo(mi), 0);
        members.Remove(mi.ID);
        DoLog("Member "+mi.Username+" ("+mi.ID+") left");
       } else {
        DoLog("Unknown connection "+ci.ID+" was closed");
       }
    }
      
    public void CloseGame(GameInfo gi){
       DoLog("Game "+gi.Name+" ("+gi.ID+") closed");
       if(gi.Serverside)
        gi.Game.Close();
       games.Remove(gi.ID);
       server.BroadcastMessage(ReservedCodes.GameClosed,
                 ClientInfo.IntToBytes(gi.ID), ParameterType.Int);
    }

    RemovePlayerFromGame 方法处理了大部分棘手的部分。

    public void RemovePlayerFromGame(int mem, GameInfo gi){
       if((!gi.Serverside) && (gi.Players.Length <= 1)){
        CloseGame(gi);
        return;
       }
       
       if(gi.Serverside) gi.Game.Left(mem);
       
       int[] newplayers = new int[gi.Players.Length - 1];
       int npi = 0;
       for(int i = 0; i < gi.Players.Length; i++)
        if(gi.Players[i] != mem) newplayers[npi++] = gi.Players[i];
       gi.Players = newplayers;
       if(mem == gi.CreatorID){
        gi.CreatorID = gi.Players[0];
        gi.PlayerFlags[0] |= PlayerGameFlags.Ready;
       }
       games[gi.ID] = gi;
       server.BroadcastMessage(ReservedCodes.GameUpdate,
               PrepareGameInfo(gi), 0);
    }

管理游戏

游戏也很容易处理,因为它们仅因从客户端发送的特定消息(或如上所述的客户端断开连接)而更改。可以使用 CreateGame 函数创建游戏。

public GameInfo CreateGame(int cid, int maxplayers, string gametype,
       string version, uint flags, string name, string pwd){
   GameInfo gi = new GameInfo();
   gi.ID = nextGameID++;
   gi.CreatorID = cid;
   gi.MaxPlayers = maxplayers;
   gi.GameType = gametype;
   gi.Version = version;
   gi.Flags = flags;
   gi.Name = name;
   gi.Password = pwd;
   gi.Serverside = cid < 0;
   if(gi.Serverside){
    gi.Players = new int[0];
    gi.PlayerFlags = new uint[0];
   } else {
    gi.Players = new int[]{cid};
    gi.PlayerFlags = new uint[]{PlayerGameFlags.Ready};
   }
   gi.Game = null;
   games[gi.ID] = gi;
   server.BroadcastMessage(ReservedCodes.GameUpdate, 
                           PrepareGameInfo(gi), 0);
   return gi;
}

...可以使用 AddToGame 函数将玩家添加到游戏中。

public void AddToGame(GameInfo gi, ClientInfo caller, int id, uint flags){
   int[] newplayers = new int[gi.Players.Length + 1];
   uint[] newpf = new uint[gi.PlayerFlags.Length + 1];
   for(int i = 0; i < gi.Players.Length; i++){
    if(gi.Players[i] == id){
     caller.SendMessage(ReservedCodes.Error,
                        Encoding.UTF8.GetBytes(Strings.AlreadyJoined),
                        ParameterType.String);
     return;
    }
    newplayers[i] = gi.Players[i];
    newpf[i] = gi.PlayerFlags[i];
   }
   newplayers[gi.Players.Length] = id;
   newpf[gi.Players.Length] = flags;
   gi.Players = newplayers;
   gi.PlayerFlags = newpf;
   server.BroadcastMessage(ReservedCodes.GameUpdate, 
                           PrepareGameInfo(gi), 0);
   // If the game is in progress,
   // the new player needs to get started!
   if((gi.Flags & GameFlags.InProgress) != 0)
    caller.SendMessage(ReservedCodes.StartGame,
          PrepareTwoIDs(gi.ID, (int)gi.Flags), 0);
}

...并使用 RemovePlayerFromGame 方法(见上文)将其移除。当玩家请求加入游戏时,该请求必须通过某些标准:游戏是否开放?他们是否提供了正确的密码?是否有空间容纳另一个玩家?

   case ReservedCodes.RequestJoinGame:
      // Just pass it on to the game owner
      id = ClientInfo.G
            etInt(b.GetParameter(ref pi).content, 0, 4);
      int reqcode = ClientInfo.GetInt(
        b.GetParameter(ref pi).content, 0, 4);
      String pwd = 
        Encoding.UTF8.GetString(b.GetParameter(ref pi).content);
      gi = (GameInfo)games[id];
      if(gi == null){
       caller.SendMessage(ReservedCodes.Error,
         Encoding.UTF8.GetBytes(
             String.Format(Strings.UnknownGame, id)),
             ParameterType.String);
       break;
      }
      // Make sure they're not already in this game!
      bool found = false;
      for(int i = 0; i < gi.Players.Length; i++)
       if(gi.Players[i] == mem.ID){
       caller.SendMessage(ReservedCodes.Error,
              Encoding.UTF8.GetBytes(Strings.AlreadyJoined),
              ParameterType.String);
       found = true;
       break;
      }
      if(found) break;
      if(gi.Players.Length >= gi.MaxPlayers){
       // Automatically send a rejection if the server is full
       caller.SendMessage(ReservedCodes.PlayerResponse,
             PrepareResponse(gi.CreatorID, reqcode, 0, 
                             Strings.GameFull), 0);
       break;
      }
      if((gi.Flags & GameFlags.Closed) != 0){
       // Automatically send a rejection if the server is closed
       caller.SendMessage(ReservedCodes.PlayerResponse,
           PrepareResponse(gi.CreatorID, reqcode, 0, 
                           Strings.GameClosed), 0);
       break;
      }
      if((gi.Flags & GameFlags.Locked) != 0){
       // Automatically send a rejection
       // if the server is locked and the
       // password was wrong
       if(pwd != gi.Password){
        caller.SendMessage(ReservedCodes.PlayerResponse,
               PrepareResponse(gi.CreatorID, reqcode, 0, 
                               Strings.GameLocked), 0);
        break;
       }
      }
      
      if(gi.Serverside){
       // Server-hosted game. Allow the plugin to decide
       String cjmsg;
       if(gi.Game.CanJoin(mem.ID, reqcode, pwd, out cjmsg)){
        caller.SendMessage(ReservedCodes.PlayerResponse,
           PrepareResponse(gi.CreatorID, reqcode, 1, cjmsg), 0);
        AddToGame(gi, caller, mem.ID, PlayerGameFlags.Ready);
        gi.Game.Joined(mem.ID);
       } else
        caller.SendMessage(ReservedCodes.PlayerResponse,
           PrepareResponse(gi.CreatorID, reqcode, 0, cjmsg), 0);
       break;
      }
      
      cito = server[gi.CreatorID];
      if(cito != null){
       output.AddParameter(ClientInfo.IntToBytes(reqcode), 
                           ParameterType.Int);
       output.AddParameter(ClientInfo.IntToBytes(mem.ID), 
                           ParameterType.Int);
       output.AddParameter(ClientInfo.IntToBytes(gi.ID), 
                           ParameterType.Int);
       cito.SendMessage(ReservedCodes.RequestJoinGame,
            output.Read(0, output.Length), 0);
      }
      break;

如果这些初步测试通过,请求将被转发给游戏所有者,由其决定是否允许新玩家加入游戏。

在实际运行游戏时,服务器仅充当消息的传输者,这些消息被发送给游戏所有者或广播给游戏中的所有人。后者由 GameBroadcast 方法完成。

public void GameBroadcast(int gameid, uint code, byte[] msgbytes,
            byte paramType){
   GameInfo gito = (GameInfo)games[gameid];
   if(gito == null) return;
   foreach(int p in gito.Players){
    ClientInfo cito = server[p];
    if(cito != null)
        cito.SendMessage(code, msgbytes, paramType);
   }
}

所有实际的游戏处理都由相关的游戏类型插件(在客户端或服务器上)完成。

历史

  • 2007 年 2 月 17 日 - 更新源代码下载
  • 2008 年 10 月 21 日 - 更新源代码下载
© . All rights reserved.