C#中的游戏大厅系统(译文)
By robot-v1.0
本文链接 https://www.kyfws.com/games/a-game-lobby-system-in-csharp-zh/
版权声明 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
- 12 分钟阅读 - 6003 个词 阅读量 0C#中的游戏大厅系统(译文)
原文地址:https://www.codeproject.com/Articles/14050/A-Game-Lobby-System-in-Csharp
原文作者:BobJanova
译文由本站 robot-v1.0 翻译
前言
A simple lobby server for hosting multiple small games and allowing players to create and join games of many types.
一个简单的大厅服务器,用于托管多个小型游戏,并允许玩家创建和加入多种类型的游戏.
介绍(Introduction)
尽管如今将某种多人游戏能力集成到计算机游戏中是很常见的,但人们通常必须知道托管游戏的玩家的IP才能玩.这适合在本地网络上玩,因为您可以自己进行对话以确定服务器的位置,但是要通过Internet组织游戏,则需要使用单独的系统(电子邮件或即时通讯程序).(While it is quite common to integrate some sort of multiplayer capacity into computer games these days, one often has to know the IP of the player who is hosting a game in order to be able to play. This is fine for playing on a local network, as you can just talk between yourselves to determine where the server will be, but to organize games over the Internet requires one to use a separate system – email or instant messenger.) 该库使您可以托管大厅服务器,该大厅服务器将保持在已知的固定地址,以便您的游戏玩家可以加入该游戏,彼此聊天以安排游戏,然后在没有任何人的情况下开始和控制它必须知道主持人的地址.(This library allows you to host a lobby server, which will stay at a fixed known address, so that players of your game (or games) can join it, chat with each other to arrange a game, and then begin and control it without anyone having to know the address of the host player.)
先决条件(Prerequisites)
这段代码利用了我的(This code makes use of my) Sockets
库,也位于CodeProject上(library, which is also up on CodeProject) 这里(here) .用户界面还使用了(. The UI also uses my) LineEditor
自定义组件.我没有时间为此写适当的文章,所以您必须从中获取它.(custom component. I haven’t had time to write a proper article for it, so you have to get it from) 这里(here) .(.)
设计(Design)
此代码旨在做什么?什么是大厅,创建大厅需要写些什么?(What is this code designed to do? What is a lobby, and what do we need to write to create one?) 好吧,首先,当然会有一个网络库来处理服务器和客户端之间的通信.我将自己的消息传递操作模式与每个消息包含类型代码一起使用.(Well, first, of course, there will be a network library, to handle communication between the server and the clients. I use my own with the messaged mode of operation that includes a type code with each message.)
什么是大堂?(What Is a Lobby?)
大厅本质上是玩家和游戏的集合.每个玩家可能参与一个或多个游戏,并且每个游戏可能包含一个或多个玩家.玩家应该能够创建,加入,离开和开始游戏,他们应该能够看到哪些游戏已经可用以及哪些玩家在"房间"中,尽管这只是允许用户界面查看该数据的一种情况.当玩家加入或离开服务器,或者游戏状态发生变化(例如,新玩家加入,或者从设置转移到"进行中")时,大厅还应引发事件,以便UI可以知道何时更新.(A lobby is in essence a collection of players and a collection of games. Each player may be in one or more games, and each game may contain one or more players. Players should be able to create, join, leave and start games, and they should be able to see which games are already available and which players are in the ‘room’, although this is simply a case of allowing the UI to see that data. A lobby should also fire events when players join or leave the server, or the state of a game changes (i.e., a new player joins, or it moves from setup to ‘in play’), so that a UI can know when to update.)
我选择将大厅的数据处理部分作为基类,从中(I chose to make the data-handling parts of the lobby a base class, from which the) Client
-和(- and) ServerLobby
派生类(通过网络功能进行通信以同步状态).除了数据处理外,(classes (which communicate via network functions to synchronise state) derive. In addition to its data-handling, the) ServerLobby
必须在发生更新事件(例如新玩家到来或正在创建新游戏)时向所有客户端发送更新消息.显然,它还必须跟踪已连接的玩家,并管理尝试连接和登录的新客户端的授权.它还必须运行游戏并处理客户端命令,例如启动游戏的请求.通过从网络层接收消息,对其进行处理,然后再次通过网络层发送响应和广播,可以完成所有这些操作.(has to send update messages to all clients when updating events happen, such as a new player arriving or a new game being created. It clearly also has to keep track of the players that are connected, and manage authorisation of new clients who are trying to connect and sign in. It also has to run the games and process client commands – requests to start a game, for example. This is all done by receiving messages from the network layer, processing them and then sending responses and broadcasts, again via the network layer.)
的(The) ClientLobby
只是服务器的"瘦客户端"视图,通过网络消息保持同步,并在最终用户尝试执行任何操作时发送请求消息.(is simply a ‘thin client’ view of the server, kept in sync via network messages and sending request messages when the end user attempts to do anything.)
用户界面(UI)
的(The) Lobby
类及其子类有意不包含UI.它们被设计为用作编程组件,可从您自己的UI调用并通过事件回调链接到该组件.有一个示例客户端外壳((class and its subclasses intentionally contain no UI. They are designed to be used as programmatic components, called from your own UI and linked in to it via the event callbacks. There is a sample client shell (the)**大堂客户(LobbyClient)**文件夹),其中包含通用客户端需要的大部分内容.(folder) which contains most of the things one would need for a general purpose client.)
客户端截图(Screenshot of the client)
游戏类(Games)
大厅应该是游戏类型中立的,即它应该不在乎正在运行哪种类型的游戏.这对于通用引擎至关重要.为此,客户端和服务器都在插件系统上工作,其中插件实现了在(The lobby should be game-type neutral, i.e., it shouldn’t care what type of game is being run on it. This is essential for a general purpose engine. To this end, both client and server work on a plug-in system, with plug-ins implementing the interfaces which are defined in)大厅文件(Lobby.dll).这些插件应该是实际游戏处理的地方.为了方便起见,通常最好是游戏由其创建者"拥有",因此所有处理都在一个客户端上进行,然后客户端指示服务器将消息广播给游戏中的其他所有人,或者可能仅广播给特定玩家.服务器还应该能够存储和检索与特定游戏相关的数据,因此,如果所有者离开,则可以在不造成任何干扰的情况下将游戏移交给新所有者.(. These plug-ins should be where the actual game processing takes place. For convenience, it is usually best if the game is ‘owned’ by its creator, so all the processing takes place on one of the clients who then instructs the server to broadcast messages to everyone else in the game, or maybe just to particular players. The server should also be able to store and retrieve data associated with a particular game, so if the owner leaves, the game can be handed over to a new owner with minimal disruption.)
在客户端对游戏的这种处理意味着服务器根本不需要任何有关正在玩的游戏的知识,这很好,因为更新服务器要比提供新的客户端下载困难得多.但是,这意味着被黑的客户可以随意作弊,从而彻底改变了游戏机制.出于这个原因,我还提供了一种在服务器上托管游戏并在服务器上完成处理的机制(同样,通过插件和接口).的(This handling of the games on the client side means that the server doesn’t need any knowledge of the games being played at all, which is good as updating servers is a lot harder than providing a new client download. However, it means that a hacked client could cheat at will, completely changing the game mechanics. For this reason, I also provide a mechanism for hosting games on the server and having the processing done there (again, through plug-ins and interfaces). The) ServerLobby
然后将与该游戏有关的消息传递给插件.(then hands off messages relating to that game to the plug-in.)
实作(Implementation)
好吧,找出我如何做的最简单方法是查看源代码;).但是,这是一种非常严格的方法,在这里我将解释一些更通用的代码.(Well, the simplest way to find out how I’ve done it is to look at the source ;). That is a pretty hardcore way, however, and I’ll explain some of the more generally useful code here.)
数据结构(Data Structures)
在任何设计中,最重要的部分也许是用于存储数据的类和结构.对于此问题,这些是有关玩家和游戏的信息:(Perhaps the most important part of any design is the classes and structures used to store data. For this problem, those are for player and game information:)
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
}
这一切都是不言而喻的.注意,所有可传输的数据都是简单类型.因为我的网络库只能有效地处理数字和(This is all fairly self-explanatory. Note that all the transferable data is simple types; because my network library only deals efficiently with numbers and) string
s,它们必须像这样进行传输,因此,对于我来说,拥有标志不是明智的(s, they have to be transferred like that, and it therefore makes sense to me to have flags not as flag-wise) enum
s但为(s but as) uint
s.(s.)
主要的信息"结构"实际上是类,因此可以将它们有效地存储在(The main Info ‘structures’ are in fact classes, so they can be stored efficiently in a) Hashtable
并修改到位.(and modified in place.)
我还定义了用于主应用程序连接到的事件处理程序的集合:(I also define a collection of event handlers for the main application to connect to:)
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在发生某些事情时做出反应.的(Most of these are ‘update events’, allowing the UI to react when something happens. The) ProcessCodeEvent
允许应用程序覆盖或扩展消息的默认处理,并且通常与插件挂钩.(allows the application to override or extend the default handling of messages, and is usually hooked from plug-ins.)
通讯(Communication)
如上所述,网络通信由包含消息类型代码的消息处理.这是一个大处理(The network communication, as mentioned above, is handled by messages that contain a message type code. This is handled in a large) switch
决定是否立即处理消息,还是将消息传递给UI或插件的语句.(statement that decides whether to process the message immediately, or whether to pass it to the UI or a plugin.)
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;
}
的(The) ByteBuilder
是一个实用程序类(在(is a utility class (in the) Sockets
项目),可以从"参数"或"参数"(长度检查后的已知类型字节)建立或解构字节数组.(project) that can build up or deconstruct an array of bytes from or to ‘parameters’ – length-checked blocks of bytes of known type.) b.GetParameter(ref pi)
得到'(gets the ‘) next
‘参数.每条消息的格式在以下内容的注释中定义:(’ parameter. The format of each message is defined in the comments on the) ReservedCode
结构.(struct.)
请注意(Note how the) ProcessCode
重新同步处理完成后,将调用event,以允许应用程序响应消息进行更多操作.(event is called once the resync processing is done, to allow the application to do extra things in response to the message.)
服务器的(The server’s) DoCode
功能在结构上相似,并且过长而无法在此处发布所有功能.(function is similar in structure, and too long to post all of it here.)
管理球员(Managing Players)
玩家很容易在TCP系统下进行跟踪:一个连接映射到一个玩家,如果连接断开,则认为玩家已经离开.玩家可以做三件事:(Players are quite simple to keep track of under a TCP system: one connection maps to one player, and if the connection drops, the players are considered to have left. There are three things a player can do:)
- 连接到服务器.此时,它们尚未"登录",但是我们需要将新套接字添加到我们的内部客户端列表中,以便我们知道他们登录时是谁.这是由我们在网络层完成的((Connect to the server. At this point, they are not ‘logged in’, but we need to add the new socket to our internal list of clients so we know who they are when they do log in. That is done for us by the network layer (the)
Server
类存储与其连接的所有客户端),因此我们在(class stores all the clients that are connected to it), so all we have to do in the)ServerLobby
请求客户端登录,并将事件处理程序附加到新连接:(is request that the client log in, and attach event handlers to the new connection:) - 登录服务器.这是由(Log in to the server. This is handled by the)
ClientReadMessage
功能,大部分时间只是将其信息传递给(function, which most of the time just passes its information on to the)ServerDoCode
功能,但是如果连接尚未链接到播放器,则将处理尝试登录的过程.它只是检查玩家是否已经登录,尝试的登录信息是否有效,如果是,则将新成员添加到玩家列表中,并向他们发送有关谁在服务器上还有哪些游戏的信息.(function but if a connection is not linked to a player yet will process an attempt to log in. This function is quite long but simple; it just checks if the player is already logged in, whether the attempted sign-in information is valid, and if so adds the new member to the list of players and sends them information about who else and what games are present on the server.) - 与服务器断开连接.尽管离开的玩家不需要发送任何信息,但他们可能需要做大量的清理工作:应告知每个人他们已经离开,所有需要更新的游戏都可能需要更新并可能转移到新主人.(Disconnect from the server. While the player who leaves doesn’t need any information sending, they may leave a good deal of cleaning up to do: everyone should be told they’ve left, and any games which they were in need to be updated and potentially transferred to a new owner.)的(The)
RemovePlayerFromGame
方法处理大多数尴尬的部分:(method deals with most of the awkward part:)
管理游戏(Managing Games)
游戏也很容易处理,因为它们仅是由于从客户端发送特定消息(或如上所述,客户端断开连接)才进行更改.可以使用(Games are also easy to deal with, because they are only changed as a result of particular messages being sent from clients (or a client disconnecting, as above). A game can be created with the) CreateGame
功能:(function:)
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;
}
…玩家可以通过以下方式添加到游戏中(… a player can be added to the game with the) AddToGame
功能:(function:)
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);
}
…并与(… and removed with the) RemovePlayerFromGame
方法(请参见上文).当玩家请求加入游戏时,该请求必须通过某些条件:游戏是否开放?他们提供了正确的密码吗?是否有其他玩家可以使用的空间?(method (see above). When a player requests to join a game, the request has to pass certain criteria: is the game open? Have they provided the right password? Is there space for another player?)
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;
如果这些初步测试通过,则该请求将传递给游戏所有者,后者可以选择允许还是不允许新玩家进入游戏.(If these preliminary tests pass, the request is passed on to the game owner who can choose to allow the new player into the game or not.)
在实际运行游戏时,服务器只是充当消息的管道,这些消息被发送到游戏所有者或广播给游戏中的每个人.后者是由(When actually running the game, the server simply acts as a conduit for messages, which are sent to the game owner or broadcast to everyone in the game. This latter is done by the) GameBroadcast
方法:(method:)
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);
}
}
所有实际的游戏处理均由相关的游戏类型插件(在客户端或服务器上)完成.(All actual game processing is done by the relevant game-type plug-in (either on the client or the server).)
历史(History)
- 17(17)日(th)2007年2月-更新了源代码下载(February, 2007 - Updated source download)
- 21(21)圣(st)2008年10月-更新了源代码下载(October, 2008 - Updated source download)
许可
本文以及所有相关的源代码和文件均已获得The Code Project Open License (CPOL)的许可。
C# .NET Windows Visual-Studio Dev 新闻 翻译