当前位置:首页 >教程首页 > 游戏设计 > 游戏UI设计师班 >分享制作异步多人游戏的方法和经验(1)

分享制作异步多人游戏的方法和经验(1)

发布时间:2018-11-17 19:57:24
作者:Ross Przybylski

毫无疑问,异步多人游戏玩法(也就是允许玩家几天登录一次游戏)是手机游戏开发的新趋势。许多热门的多人游戏都是异步的,比如《填字游戏》(一种每次移步一步的拼字游戏)和《你猜我画》(Zynga以1.8亿美元收购的一款看图说词游戏)。

当我的跨平台多人游戏《英雄法师》在iOS上发布时,我以为人们会为它的玩法——允许使用PC或Android设备与朋友一起在线战斗,感到兴奋。让我惊讶的是,我收到的反馈中,绝大多数的意思是“这款游戏如果能够多名玩家不同时在线也能玩就太好了!”

正如大多数《英雄法师》的玩家所知道的,我并没有感到沮丧——我立即将下一个包括异步多人玩法的更新当作优先工作。我以前从来没有编写过“异步多人”的代码,所以我想我得从我一惯的做法入手:谷歌搜索。“如何编写异步多人游戏玩法的代码”的搜索结果并不实用:我发现有不少关于“异步玩法多么了不起”和“了解大量支持异步玩法的游戏”的文章,但它们并没有讲到“如何制作”的点子上。因此我想到“这对游戏开发者来说应该是很重要的资源”,所以我决定记录我制作《英雄法师》的异步多人玩法的过程,并发表出来,这样我们都能从我所希望写成的“如何制作”系列文章中获益。

《英雄法师》是用Adobe Flash制作的,所以我的程序代码案例是ActionScript 3格式的。但是,异步多人的设计和机制适用于任何开发语言。

向大师学习

学习如何编程的最好办法就是,研究成功地实现你想要的结果的应用。我的目标是在我的幻想风格、回合制、策略游戏中实现异步多人玩法。苹果应用商店里正好有一款类似的游戏,并且它的异步玩法做得非常棒。它就是Robot Entertainment的《英雄学院》。所以我花了一些时间玩这款游戏(这是最好的研究方法)。我发现,这款游戏将社交媒体如Facebook和Twitter,与有效的异步多人UI相结合,很好地解决了“孤立社区”的难题——这也是我自己的“在线即时”多人游戏遇到的困境。



以下是《英雄学院》的异步多人玩法的概述:

1、启动游戏时自动登录服务器

2、通过Facebook和Twitter邀请/挑战玩家

3、创建新游戏的选项或寻找随机对手的选项

A、如果玩家创建新游戏,则新游戏将被添加到内部游戏列表中,等待其他玩家加入。

B、如果玩家选择加入游戏,则玩家将加入在内部游戏列表中显示的随机游戏。加入的玩家得到第一回合。

4、玩家在自己的回合中,在提交命令以前,可以执行全部的5次移动或取消所有移动。

5、一旦回合提交,游戏的数据库将更新游戏状态,并“推送”一个提示给对手,告诉该玩家轮到他的回合。

6、对手有24个小时可完成他的回合,否则玩家可以宣布该回合失效。

7、玩家可以选择在这个游戏中轮流,或返回个人“游戏列表”中加载任何已经玩过的游戏。游戏以对手、创建数据、最后一次移动时间和状态(即胜利、失败、等待回合或就绪)为标签。

《英雄法师》的特殊要求

对《英雄学院》的研究使我深入了解了异步多人玩法,但《英雄法师》因为其特殊的游戏机制,还必须考虑到其他问题:

1、《英雄学院》具有取消功能,对异步玩法来说是非常棒的,因为玩家在提交最终选择以前,可以实验不同的移动组合。《英雄学院》能够这么设定,是因为游戏中的所有伤害量是固定的。而《英雄法师》要根据骰子数计算伤害,所以撤消的选项就不可行了,因为它会影响游戏的关键机制:运气。

2、《英雄学院》不支持即时多人玩法。所有移动和甚至玩家聊天都是通过数据库更新来记录游戏状态的。虽然有可能和其他人在同一个房间内玩《英雄学院》,但这种体验并不理想,因为你必须等待推送提示你对手完成他的命令。对于《英雄法师》,这个系统的改进办法是,当双方玩家均在线时,能够保存了“即时”游戏链接——这样你就能实时看到你的对手的移动和交流信息。

如何制作异步多人玩法

了解了《英雄学院》的UI,以及认真考虑《英雄法师》的特殊要求后,我想到以下执行异步多人玩法的必要步骤:

1、想办法将游戏状态保存到在线数据库中。

2、编写一个数据库查询,用来加载玩家的游戏列表,同时通过点击列表上的项目,使玩家载入和恢复游戏。

3、在载入游戏时,点击可查看该游戏是否可以“即时”玩。如果可以,则加入该游戏,并与目前在线的玩家关联。如果不可以,则创建一个“即时”游戏房间,并载入该游戏。

4、想办法回放任何自玩家上一次登录后没有“看到”的动画。

5、在实际游戏中,使数据库中已保存的游戏状态更新玩家的命令(这与《英雄学院》是不同的,因为《英雄学院》要求你等到对方按下回合结束键,数据库才更新)。通过编写持续的、更小的更新,可以节省带宽,而且可以很自然地从异步过渡到即时玩法。

6、想办法将即时玩法元素(回合计时器、掉落计时器、游戏持续时间表、AI变化控制器)过渡到异步玩法(游戏邦注:这是《英雄法师》的特殊要求,不适用于其他异步游戏)。

7、设计一个UI,用于浏览和加入异步多人游戏。

8、制作一个匹配系统,允许玩家选择军队参数、对手类型等,还可以将玩家与数据库中的可用对手相匹配。

9、当游戏回合结束时,通过邮件或设备推送提示发送回合开始的信息给下一个对手。


异步多人游戏允许两个或以上的玩家参与游戏,不需要同时登录。支持异步玩法的关键是,将游戏状态保存到在线数据库中,这样你和你的对手才能在自己的回合时重新取回游戏。本文将解释我如何实现游戏状态储存和重新载入,并且提供实用的代码案例,希望在你为游戏设计相同的玩法时能派上用场。

本文将介绍:

1、如何通过简单的2D网格表现法和基于该表现法的命令记录表现游戏状态。

2、如何使用Smart Fox Server Pro的服务器端扩展将游戏数据写入在线数据库。

要求

前提:

开发回合制游戏的经验

熟悉ActionScript 3.0

知道如何设置MySQL数据库

知道如何编写Smart Fox服务器扩展

必需产品:

Flash Professional

Smart Fox Server Pro

MySQL Database

用户水平:

中级到高级

将游戏状态表现为数据

游戏状态是由所有定义游戏面板当前状态的元素组成的:游戏面板的布局、游戏部件的位置、游戏中的所有角色的当前属性和作用、各玩家手中的卡片,以及(如果对游戏很重要)产生游戏当前状态的一系列移动。如何根据这些因素的复杂度良好地展示游戏数据。在《英雄法师》中,我使用了两种办法——简单的2D网格表现法和基于表现法的命令记录。

简单的2D网格表现法

可以使用用二维数组表现游戏部件在游戏面板网格上的位置。例如,一个简单三连棋游戏可以表现如下:

//CODE EXAMPLE 1: TIC TAC TOE REPRESENTED AS 2D ARRAY
var ticTacToeGameState:Array = [];
ticTacToeGameState[0] = [X, O, X];
ticTacToeGameState[1] = [X, X, O];
ticTacToeGameState[2] = [O, X, O];

这个基于表现法的数据在程序代码的情况下是足够的,但对于异步游戏,必须使用平面数据结构将这个表现法保存到在线数据库中。当表现游戏状态时,为了节约带宽和服务器空间,最好使用尽可能少的信息。

假设我们知道三连棋总是3×3,那么这个游戏可以使用平面字符串表示如下:

//CODE EXAMPLE 2: TIC TAC TOE REPRESENTED AS FLAT STRING
var ticTacToeGameState:String = “XOXXXOOXO”;

在取回游戏状态数据时,我们可以再次建立如上所示的二维数组:

//CODE EXAMPLE 3: CONVERT 2D GAME STRING TO 2D ARRAY
var ticTacToeGameGrid:Array = [];
for(var i:int = 0; i < 3; i++){
var gridRow:Array = [];
for(var j:int = 0; j < 3; j++){
gridRow.push(ticTacToeGameState.charAt(i+j));
}
ticTacToeGameGrid.push(gridRow);
}

《英雄法师》使用这个简单的2D网格表现法将地图布局保存成一系列X和O。X表示墙,O表示开放空间,起始位置是一系列表示玩家和特殊单位类型放置区域的数值组合。

基于表现法的命令日志

如上所示,拉成一条单行文本串的二组数组可以用来表现许多基于网格的游戏。那种根据特定游戏活动发生时间的游戏很适合用基于现表法的命令日志来表示。

基于表现法的命令日志的好处

命令日志表现法的作用是,使游戏引擎通过提供产生当前状态的游戏命令列表,重制保存好的游戏状态。命令被储存成简化符号,以节省文件空间和带宽。当接收命令日志时,动画将不可播放,这样游戏就可以立即重制了。

使用命令日志重制游戏确保所有必要的游戏细节:游戏卡片、面板部件和这些部件的状态(游戏邦注:准确地表现为完全相同的样式,这个样式产生最初的游戏状态)。另外,命令日志显示了完整的游戏历史。对于异步游戏来说,这是极其有益的,因为玩家可以回顾活动列表,然后想起他们的当前状态是如何产生的,从而制定相应的策略。玩家还可以恢复自己没有机会看到的活动。

缩略的游戏符号

编写有效的基于表现法的命令日志,困难的地方在于设计一种既简单又准确的符号形式。为命令格式定义一系列预期标准也是很重要的。以下是我为《英雄法师》制作的句法:

1、各个独立命令放在“<c>command</c>”内

2、命令内容由“|”分开,内容分配由“=”表示

3、所有命令都包含定义命令类型(“cT”)的内容。不同的命令类型来自相应函数名称的缩写符号。例如,指示单位执行某活动的命令可以表示为“cT=uA”。

4、复杂的数据结构如单位和活动由特殊的数值id表示。

A、根据单位被添加到游戏面板的顺序分配id。

B、根据咒语在面板数组的索引来分配id。

C、根据能力在单位能力数组的索引来分配id。

5、命令的目标用逗号隔开;网格坐标(X和Y)用冒号隔开。

通过遵守严格的符号和用数值id引用复杂的实例对象,游戏命令可以用来表示简单的字符串,如下所示:

//CODE EXAMPLE 4: OBJECT TO STRING FUNCTION
function objectToString(object:Object, separator:String, valAssignment:String):String{
var string:String = “”;
for (var prop:* in object){
string += prop + valAssignment + object[prop] + separator;
}
string = string.substr(0, string.length – separator.length);
return string;
}

如果你的对象包含嵌套的数组,你就必须首先使用特殊分离器和分配字符将那些数组编码成字符串:

//CODE EXAMPLE 5: CONVERT ARRAY TO STRING
var myArray:Array = [4, 5, 6, 7];
var myStringArray:String = myArray.toString();

以下是《英雄法师》中的完整游戏命令:

//CODE EXAMPLE 6: GENERATE GAME COMMAND
function generateUseActionCommandString():String{
//Create a new object to store command properties
var HM_UseAction:Object = new Object();

//Store the command type: “uA” represents “useAction”
HM_UseAction.cT = “uA”;

//The abilityUser is a complex, custom datatype
//So, we store the id of the unit using ability
HM_UseAction.uId = abilityUser.unitId;

//The unit’s ability is also a complex, custom datatype
//So, we store the id that represents its index in the abilities array
HM_UseAction.i = abilityIndex;

//pT represents the primary targets
//In actual game, a function discerns between target types (units, spaces)
//Here, we simply convert the array of choices to a comma deliniated string
HM_UseAction.pT = primaryTargetsToActOn.toString();

//Encapsulate the command within c-tags
var strCmd:String = “<c>”+objectToString(HM_UseAction, “|”, “=”)+”</c>”;

//A preview of the assembled command
trace(strCmd) //<c>cT=uA|uId=1|i=1|pT=4,5,6″</c>

//Return the command
return strCmd;
}

作为参考,下列函数可以用来将字符串转换回对象:

//CODE EXAMPLE 7: STRING TO OBJECT FUNCTION
function stringToObject(string:String, separator:String, valAssigment:String):Object{
var object:Object = new Object();
var props:Array = string.split(separator);
for(var i:int = 0; i < props.length; i++){
var vals:Array = props[i].split(valAssigment);
object[vals[0]] = vals[1];
}
return object;
}

将游戏写入数据库

如果游戏应用可以用简单的文本文件表现它的保存状态,那么下一步就是将游戏的保存数据写入在线数据库,这样其他玩家就可以随时恢复游戏状态。为此,你需要一个带MySQL、SQL或其他形式的数据库的网上服务器,以及一个网上服务或服务器来与数据库交流、运行必要的查询,和发送/接收来自应用的数据。

《英雄法师》使用Smart Fox服务器完成实时多人连接(在线聊天和异步玩法),所以我使用服务器端代码来处理与数据库的交流活动。《英雄法师》的数据储存在MySQL数据库中,我已经通过我的主机供应商GoDaddy.com提前做好这个数据库了。我喜欢使用SmartFox服务器,是因为我可以通过ActionScript 1.0直接使用MySQL,而不必担心不懂PHP或其他服务器语言的问题。

定义储存游戏数据的表格

定义将用来把游戏信息保存在数据库中的表格也非常重要。为此,《英雄法师》使用两套表格:

表格“hm_games”用来保存所有相关的游戏数据。“cmdLog”栏保存游戏引擎将用于重建游戏状态的符号命令的实际列表。


表格“hm_gameresults”用来保存与游戏相关的玩家特定信息。某一游戏的所有玩家都通过ID_GAME与hm_games表格关联起来。这个表格保存结果(无论玩家是胜利还是失败)、排名变化(如果游戏有排名的话),同时还要进一步更新,以帮助决定再次加入游戏的玩家必须看到的动画。


创建新游戏记录

《英雄法师》具有异步多人玩法,但我还没有给异步匹配系统做过UI。然而,令人兴奋的游戏创建屏幕非常适合用来解释如何将新游戏记录保存到数据库。

游戏客户端和在线服务之间的基本通信运作如下:

1、如果游戏主机已配置所有游戏选项,并且玩家觉得满意,则他们会按下“Start Game”。

2、游戏客户端将游戏背景格式化为符号游戏命令,发送命令给服务器,并等待回应。

3、服务器端脚本接收命令并创建游戏记录,执行一个MySQL声明,以便在hm_gameresults表格中创建新入口,和在hm_gameresults表格中为各名玩家创建新记录。

4、如果服务器的数据库运行完全顺利,则服务器对客户端作出回应,反馈新创建的游戏记录的ID_GAME。如果服务器运行失败,客户端接收到新游戏无法创建的反馈。

5、如果客户端接收ID_GAME,游戏主机就用这个内容重新装配命令符号,并发送开始游戏命令给所有玩家。如果收到“操作失败”,则游戏客户端将显示错误信息。

注:如果你需要学习如何给Smart Fox Server Pro编写服务器端扩展,请参考其他网上教程。

当符号化游戏命令装配完毕,就使用以下函数发送命令给服务器端扩展:

//CODE EXAMPLE 8: SEND CREATE GAME COMMAND
private function sendStartGameCommand(HM_GameVars:Object):void{
//Store a reference to the created game settings object for use later
gameVarObj = HM_GameVars;

//Check to see that smartFox is connected and that there are at least 2 players
if(smartFox.isConnected == true && playerSettingsList.length > 1){
//Send the command to create new game record to Smart Fox Server extension
//Commands can be sent as string or xml; normally, I use string for speed
//In this case, I use xml to save the work of encoding to string
smartFox.sendXtMessage(“HMServer”, “CreateGameRecord”, HM_GameVars, “xml”);
}
else{
//If this is a practice game with only 1 player, no need to store to database
//Fire game up immediately
fireUpGameWithRecordID(-1);
}
}

在服务器端,我给“Create Game”命令添加了新条件,用来在数据库中插入新游戏记录:

//CODE EXAMPLE 9: CREATE GAME RECORD IN DATABASE
function handleRequest(cmd, params, user, fromRoom, protocol){
if(protocol == “xml”){
//….CODE FOR OTHER EXTENSION COMMANDS OMITTED….

else if(cmd == “CreateGameRecord”){
//EXTRACT THE PLAYER INFORMATION AND TURN ORDER FROM THE RECEIVED COMMAND
var players = String(params.pS).split(“,”);
var randomTurnOrders = params.rTO.split(“,”);

//GENERATE THE MYSQL STATEMENT TO ADD NEW GAME RECORD BASED ON GAME SETTINGS
var gameRecordSQL = “INSERT into hm_games (ranked, timeCreated, version, status, timeRecorded, whoseTurn, cmdLog) VALUES (”
gameRecordSQL += “‘” + params.r + “‘, “                               //ranked
gameRecordSQL += “‘” + Math.floor(getTimer() / 1000) + “‘, “     //timeCreated
gameRecordSQL += “‘” + params.v + “‘, “                               //version
gameRecordSQL += “‘” + 1 + “‘, “                                      //status
gameRecordSQL += “‘” + Math.floor(getTimer() / 1000) + “‘, “     //timeRecorded
gameRecordSQL += “‘” + stringToObject(players[randomTurnOrders[0]], “;”, “:”).hmId + “‘, “          //whoseTurn
gameRecordSQL += “‘” + “<c>” + objectToString(params, “|”, “=”) + “</c>” + “‘”  //cmdLog
gameRecordSQL += “)”;

//EXECUTE MYSQL COMMAND AND CHECK IF IT WAS SUCCESSFUL
success = dbase.executeCommand(gameRecordSQL);
if(success == false){
//IF THIS FAILS, WE NEED TO REPORT BACK AN ERROR TO CLIENT
trace(“UNABLE TO CREATE GAME RECORD”);
response.error = “Unable to create new game record in database”;
}
else{
//ONCE GAME RECORD IS ADDED, GRAB ITS ID (WE KNOW ITS THE LAST INSERTED RECORD)
sql = “SELECT LAST_INSERT_ID()”
var queryRes = dbase.executeQuery(sql);
var dataRow = queryRes.get(0);

//STORE THE GAME RECORD ID IN OUR RESPONSE OBJECT
response.id = dataRow.getItem(“LAST_INSERT_ID()”);

//CREATE A STATEMENT TO INSERT A NEW RECORD IN GAME RESULTS TABLE FOR EACH PLAYER
var gameResultsSQL = “INSERT into hm_gameresults (ID_GAME, ID_MEMBER, result, ratingChange) VALUES “;
for(var i = 0; i < players.length; i++){
//CONVERT THE PLAYER OPTIONS FROM STRING TO OBJECT
//THIS IS SO WE CAN EXTRACT PROPERTIES LIKE PLAYER ID
var playerOptions = stringToObject(players[i], “;”, “:”)

gameResultsSQL += “(LAST_INSERT_ID(), ‘” + playerOptions.hmId + “‘, ‘” + “-2″ + “‘, ‘” + “0″ + “‘)”
if(i < players.length – 1){
gameResultsSQL += “, “;
}
}
success = dbase.executeCommand(gameResultsSQL);
if(success == false){
//IF THIS FAILS, WE NEED TO REPORT BACK AN ERROR TO CLIENT
trace(“UNABLE TO CREATE GAME RESULTS RECORD IN DATABASE”);
response.error = “Unable to create game results records in database”;
}
}
}

//….CODE FOR OTHER EXTENSION COMMANDS OMITTED….
}
}

返回客户端,我给来自服务器的“Create Game”命令的响应添加了新条件:

//CODE EXAMPLE 10: RECEIVE SERVER SIDE RESPONSE
private function onExtensionResponse(evt:SFSEvent):void{
//EXTRACT RESPONSE TYPE AND RESPONSE DATA FROM SFSEvent
var type:String = evt.params.type;
var dataObj:Object = evt.params.dataObj;

//….CODE OMITTED….

//EXTRA COMMAND FROM RETURNED DATA OBJECT
cmd= dataObj.cmd;
var error:String = dataObj.error;

//….CODE OMITTED….

if(error != “”){
//IF RESPONSE RETURNS AN ERROR, SHOW USER A MESSAGE PROMPT
showPrompt(“HM_MessagePrompt”, cmd + ” Error”, error);
}
else{
//….CODE OMITTED….

//ADD CONDITION FOR SERVER RESPONSE CREATE GAME RECORD
else if(cmd == “CreateGameRecord”){
//INSTRUCT GAME OPTION SCREEN TO FIRE UP GAME RECORD
gameOptionsScreen.fireUpGameWithRecordID(dataObj.id);
}
//….CODE OMITTED….
}
}

这个调用游戏大厅的最后一个函数来发出开始游戏的命令:

//CODE EXAMPLE 11: FIRE UP GAME
public function fireUpGameWithRecordID(gameRecordId:int):void{
//Recall in previous step we stored gameVarObj for future use
//Here, we add the database id for the new game record
gameVarObj.gId = gameRecordId;

//Assemble the game command into abbreviated string notation
var HM_Command:String = Utils.objectToString(gameVarObj, “|”, “=”);

//Add the command to client side que
addToCommandQue(HM_Command);

//Send the command to start game to any live players
if(smartFox.isConnected == true){
smartFox.sendCmd(HM_Command);
}
}

更新游戏状态

当创建在在线数据库中的游戏记录和游戏客户端可以获得引用记录的ID时,游戏状态的改变就可以由附加的新游戏命令轻松记录到命令日志栏中。

在本文的前半部分,我想到游戏状态的更新应该根据游戏创建的方式来决定。在Robot Entertainment的《英雄学院》一例中,玩家在提交回合以前可以选择取消,更新游戏状态自然要在回合提交后发生。相反地,《英雄法师》允许玩家秘各个可用单位互动、施放咒语和发动攻击(根据骰子数决定伤害程度)。因为结果的随机性,《英雄法师》就不能使用取消功能了。因此,我决定,玩家每发送一次命令,游戏的命令日志就更新一次。

因为《英雄法师》也可以即时玩,所以我决定把我现在的服务器扩展(用于交换即时玩家之间的游戏命令)也更新了,使它也能处理储存在数据库中的游戏状态。这样,只需要让客户端发送一次命令给服务器,我就可以最有效地利用带宽。

以下是处理游戏状态更新的代码:

//CODE EXAMPLE 12: UPDATE GAME RECORD
function handleRequest(cmd, params, user, fromRoom, protocol){
if(protocol == “str”){

//GENERATE LIST OF RECIPIENTS THE SERVER WILL SEND THIS COMMAND TO
//….CODE OMITTED….

//params[2] stores game record id
//If this game record id is included, we need to write this command to stored game log
if(params[2] != undefined){
if(params[1].indexOf(“cT=eT”) != -1){//If this is an end turn command
//Convert notated command into object
var cmdObj = stringToObject(params[1]+”", “|”, “=”);
//Get the id of player whose turn is next
var nextTurnId = cmdObj.nId;
//Write update to game record in database
sql = “UPDATE hm_games set cmdLog = CONCAT(cmdLog, ‘<c>” + params[1] + “</c>’), timeRecorded = ” + Math.floor(getTimer() / 1000) + “, timeLastTurn  = ” + Math.floor(getTimer() / 1000) +”, whoseTurn = “+nextTurnId+” WHERE ID_GAME = ” + params[2];
}
else{
//Write update to game record in database
sql = “UPDATE hm_games set cmdLog = CONCAT(cmdLog, ‘<c>” + params[1] + “</c>’), timeRecorded = ” + Math.floor(getTimer() / 1000) +” WHERE ID_GAME = ” + params[2];
}
success = dbase.executeCommand(sql);
if(success == false){
//THE DATABASE DID NOT RECORD THE MOVE CORRECTLY
//CREATE A NEW RESPONSE TO NOTIFY GAME CLIENT OF THE ERROR
}
}
_server.sendResponse([params[1]], -1, null, recipients, “str”);
return;
}
}

总结

本文介绍了制作一个异步多人游戏的最基本的步骤:将游戏状态表现为数据,并保存到在线数据库中。在下一篇文章中,我将分享如何从数据库中恢复游戏状态,以及如何使用Flash、ActionScript和Smart Fox服务器拓展在异步多人模式和即时在线模式之间无缝地转换。
学员作品赏析
  • 2101期学员李思庭作品

    2101期学员李思庭作品

  • 2104期学员林雪茹作品

    2104期学员林雪茹作品

  • 2107期学员赵凌作品

    2107期学员赵凌作品

  • 2107期学员赵燃作品

    2107期学员赵燃作品

  • 2106期学员徐正浩作品

    2106期学员徐正浩作品

  • 2106期学员弓莉作品

    2106期学员弓莉作品

  • 2105期学员白羽新作品

    2105期学员白羽新作品

  • 2107期学员王佳蕊作品

    2107期学员王佳蕊作品

专业问题咨询

你担心的问题,火星帮你解答
×

确定