帧同步 相关

帧同步游戏开发基础分析:结合产品策划,让游戏流畅运行

什么游戏适合帧同步这种技术?

  在现代多人游戏中,多个客户端之间的通讯,多以同步多方状态为主要目标。为了实现这个目标,主要有两个方向的技术:

  一种叫状态同步:客户端发送游戏动作到服务器,服务器收到后,计算游戏行为的结果,然后通过广播下发游戏中各种状态,客户端收到状态后显示内容。这种做法类似于各个客户端都远程操作服务器上的软件。最早的mud,以及后来大量的国产网游,特别是回合制游戏,都是这种方式;

  另外一种叫帧同步:客户端发送游戏动作到服务器,服务器广播转发所有客户端的动作(或者客户端直接通过P2P技术发送),客户端根据收到的所有游戏动作来做游戏运算和显示。这种做法等于客户端之间互相远程控制其他客户端上的游戏软件。早期的IPX网络游戏,如红色警戒、帝国时代、星际争霸,甚至大量的支持网络连线双打的游戏机模拟器,都是这种方式。

  帧同步这种同步方式,主要依靠客户端的能力,服务器仅仅是做一个转发,甚至客户端可以无需服务器,仅仅通过P2P方式来转发数据。由于只转发游戏行为,所以广播的数据量比状态同步要小很多,非常时候游戏行为非常频繁的动作游戏,比如飞行射击、FPS、RTS这类游戏。由于状态同步要把整个游戏的状态都广播下去,如果游戏中的对象特别多,比如满屏幕的子弹,很多怪物,那么要广播的数据量就很大了,这个时候帧同步的优势就比较明显,因为不管有多少“机器控制的角色”,仅仅需要广播玩家角色有关的操作即可。反过来说,如果游戏里是大量玩家聚集起来进行游戏的,那么帧同步和状态同步的差异就不明显了。反而状态同步能得到更多安全性上的好处,因为游戏运算在服务器上,比较容易防止外挂。

   帧同步技术的基础概念

  帧同步技术最重要的基础概念是:

  相同的输入+相同的时机=相同的显示

  意思是如果我们的游戏,接受了来自网络的多个客户端的操作,如果这些操作在各个客户端是一样的,那么多个客户端的显示也就一样了,这就带来了“同步”的效果。所以在这种情况下,各个客户端的运算要绝对一致,不能依赖诸如本地时间、本地随机数等等“输入”,而要一切以网络来的操作数据为主。

在一般的帧同步系统中,会有一个Relay Server负责广播(转发)所有客户端的数据。为了让各个客户端能持续的运行,而不是卡住,所以需要定时的下发一个个“网络帧”数据来驱动各个客户端。因为客户端已经放弃了本地的时间,本地的循环驱动,所以这些“网络帧”就必不可少了。这些网络帧大部分实际上是“空”的,只有当玩家有输入的时候,才会把玩家的游戏操作的数据,填入到网络帧数据包中。对于客户端来说,就好像有很多键盘、鼠标、游戏手柄在通过网络操作自己一样。

一般来说,大多数的游戏客户端引擎,都会定时调用一个接口函数,这个函数由用户填写内容,用来修改和控制游戏中各种需要显示的内容。比如在Flash里面叫OnEnterFrame(),在Unity里面叫Update()。这类函数通常会在每帧画面渲染前调用,当用户修改了游戏中的各个角色的位置、大小后,就在下一帧画面中显示出来。而在帧同步的游戏中,这个Update()函数依然是存在,只不过里面大部分的内容,需要挪到另外一个类似的函数中,我们可以称之为UpdateByNet()函数——由网络层不断的接收服务器发来的“网络帧”数据包,每收到一个这样的数据包,就调用一次这个UpdateByNet()函数,这样游戏就从通过本地CPU的Update()函数的驱动,改为根据网络来的UpdateByNet()函数驱动了。显然,网络发过来的同步帧速度会明显比本地CPU要慢的多,这里就对我们的游戏逻辑开发提出了更高的要求——如何同步的同时,还能保证流畅?

帧同步的技术要点

  帧同步游戏中,由于需要“每一帧”都要广播数据,所以广播的频率非常高,这就要求每次广播的数据要足够的小。最好每一个网络帧,能在一个MTU以下,这样才能有效降低底层网络的延迟。同样的理由,我们为了提高实时性,一般也倾向于使用UDP而不是TCP协议,这样底层的处理会更高效。但是,这样也会带来了丢包、乱序的可能性。因此我们常常会以冗余的方式——比如每个帧数据包,实际上是包含了过去2帧的数据,也就是每次发3帧的数据,来对抗丢包。也就是说三个包里面只要有一个包没丢,就不影响游戏。另外我们还会在RelayServer上保存大量的客户端上传的数据,如果客户端发现丢了包(如果乱序了也认为是丢包),那么就发起一次“下载”请求,从服务器上重新下载丢失了的帧数据包(这个可能会使用TCP)。这一切,都依赖于每个帧数据要足够的小。所以我们一般要求,每次客户端发送的数据,应该小于128字节。你可以大概计算一下,如果我们的游戏有4个玩家,我们的冗余是3帧,那么一个下行的网络帧数据包大小会到128x4x3=1536字节,而每秒我们发15个网络帧,那么占用的带宽会到1536x15=23,040字节/秒,加上一些底层协议包头也就是24kB/s,这个速度看起来已经要求手机是3G网络才能支持了(实测中GPRS一般很难稳定到这个速度)。

  我们使用的游戏引擎,特别是3D游戏引擎,里面使用的位置数据,大多数是浮点数,大家知道,一个浮点数需要占用8个字节,这可比简单的整数4个字节大了足足一倍。而我们需要广播的游戏操作,往往不需要那么高的精确度,所以我们应该把这些浮点数,想办法变成整数来广播。有时候我们甚至有可能只用1~2个字节(0-256-65535)来表达一个操作所需要的数字(比如按键值、鼠标坐标)。这样就能大大降低广播的数据长度。最简单的方法,就是把浮点数乘以1000或100然后取整。

  另外一个降低广播数据量的做法就是自己编写序列化函数:一般现代编程语言,特别是面向对象的语言,都带有把对象序列化和反序列化的功能。我们要广播游戏操作的时候,这些操作往往也是一个个的“对象”,因此最简单的方法就是使用编程语言自带的序列化库来把对象转换成字节数组去广播。但是这些编程语言的默认序列化功能,为了实现诸如反射等高级功能,会把很多游戏逻辑所“不必要”的数据也序列化了,比如对象的类名、属性名什么的。如果我们自己去针对特定的数据对象来编写序列化函数,就没有这个问题了,我们可以仅仅提取我们想要的数据,甚至能合并和裁剪一些数据项,达到最小化数据长度的目的。

在网络游戏中,各个客户端的运行条件和环境往往千差万别,有的硬件好一些,有的差一些,各方的网络情况也不一致;时不时玩家的网络还会在游戏过程中,发生临时的拥堵,我们称之为“网络抖动”。网络游戏有时候还会需要有中途加入游戏的需求(乱入),有游戏录像和观看、快进录像的功能。这些功能,都可能导致客户端收到“过去时间”里的一堆网络帧,因此,客户端必须要有处理这些堆积起来的网络数据的能力。最简单的做法就是加速播放(快进)——如果收到网络数据处理完游戏逻辑后,然后在同一个渲染帧(同一次Update()函数里)内,马上继续收下一个网络数据,然后又立刻处理。这样往往能在一个渲染帧的时间内,加速赶上服务器广播的最新游戏进度。但是这样做也会有副作用,如果客户端积累的包太多(比如游戏已经开始玩了10分钟,新的用户中途加入),会导致这个用户长时间卡住,因为程序正在疯狂的下载积累的帧同步包和运算快进。为了解决这个问题,有些程序员会限制每一个渲染帧中所快进的操作次数,这样用户还是能看到画面有活动。如果实在要快进的进度太多,就要采用“快照”技术,通过定时保存的游戏状态数据,来减少快进的进度了。这个快照功能这里就不展开了。


一般来说,我们的客户端的渲染帧率都会大大高于网络帧的接收频率。如果我们每个渲染帧都去发送一次玩家操作(比如触摸屏上的手指位置),那么可能会导致发送的游戏操作远远大于收到的操作,这样做要么会让游戏操作堆积在服务器上,导致操作的严重延迟,要么导致下行的网络包非常大(服务器每次都把收到的所有操作一次下发),这样会让网络带宽占满,同样是会感觉延迟。不管怎么处理,都是不太好的结果。正确的做法应该是控制发包频率,最好是至少收到一个网络下行帧,才发送一个上行的游戏操作,避免堆积。另外,刚刚讲到的“快进”,如果我们在快速播放游戏逻辑的时候,每次播放同时也采集玩家输入去发送,那么同样会导致短时间内发送一大堆上行数据给服务器,而这些数据很可能客户端接收时产生大量的延迟。所以最好是在快进的时候不采集玩家的输入,因为玩家在看到快进过程中,实际上也很难有效的做出合理的反应,一个常见的做法,就是快进的时候,给游戏覆盖一个“等待”或“Loading”的蒙皮层,让玩家不可以输入操作。


关于流畅度的优化

  实时同步游戏最重要的是流畅,然而影响游戏流畅的因素很多,网络带宽的限制,CPU运算和渲染效率的限制,都是很大的问题。所幸游戏本身还是有很多可以取舍的因素,这让我们可以牺牲一些游戏不太重要的特性,去提高流畅度。

  第一个可以用来交换流畅度的是“一致性”特性。我们做帧同步的目标是各个客户端都能看到一致的显示。但是游戏内容有很多,有一部分内容是可以容忍“不一致”的,比如我们做飞行射击弹幕游戏,满屏幕有很多子弹,而每一颗子弹本身的存在的时间很短,如果我们不是做对打的游戏(而是一起打电脑),那么这些子弹是可以不一致的。又比如我们做一个横版过关的配合游戏,几个玩家一起打电脑控制的怪物,大家关心的是怪物是怎么被打死的,而玩法本身又比较容忍不一致(横版动作游戏的攻击范围往往比较大),所以就算有些不一致问题也不大。在以上的条件下,我们就可以尝试,把更多的游戏逻辑,从网络帧的UpdateByNet()函数里面拿出去,放回到单机游戏中的Update()函数里去。这样就算网络有点卡,起码整个画面里还是有很多东西是不会被“卡住”的。但是必须注意的是,一般玩家控制的角色的动作,包括当前客户端控制的角色,还是应该从网络帧里面获得行为数据,因为如果玩家爱控制角色不一致的太多,整个游戏场面就会差更多。很多游戏中的怪物AI都是根据玩家角色来设定的,所以一旦玩家角色的行为是同步的,那么大多数的怪物的表现还是一致的。
第二个可以用来交换流畅度的特性是实时性。一般来说,我们都希望游戏中的角色控制是灵敏的,实时的。我们的游戏角色往往在会玩家输入操作后的几十分之一秒内,就开始显示变化。在帧同步游戏中,我们可以让玩家一输入完操作,就立刻发包,然后尽快在下一个收到的网络帧中收到这个操作,从而尽快的完成显示。然而,网络并不是那么稳定,我们常常会发现一会快一会慢,这样玩家的操作体验就非常奇怪,无法预测输入动作后,角色会在什么时候起反应。这对于一些讲求操作实时性的游戏是很麻烦的。比如球类游戏,控制的角色跑的一会儿快一会儿慢,很难玩好“微操”。要解决这个问题,我们一般可以学习传输语音业务的做法,就是接收网络数据时,不立刻处理,而是给所有的操作增加一个固定的延迟,后在延迟的时间内,搜集多几个网络包,然后按固定的时间去播放(运算)。这样相当于做了一个网络帧的缓冲区,用来平滑那些一会儿快一会儿慢的数据包,改成匀速的运算。这种做法会让玩家感觉到一个固定延迟:输入操作后,最少要隔一段时间,才会起反应。但是起码这个延迟是固定的,可预计的,这对于游戏操作就便捷很多了,只要掌握了提前量,这个操作的感觉就好像角色有一定的“惯性”一样:按下跑并不立刻跑,松开跑不会立刻停,但这个惯性的时间是固定的。

第三个用来交换流畅性的特性是公平性,这个特性其实和一致性有所类似。我们和其他玩家一起游戏的时候,有时候不希望对方因为电脑速度比较快,网络比较好,而能比我们更早的看到游戏的运行结果,从而提早作出操作。这一点在格斗对打游戏(如《街霸》)里面非常关键,在一些RTS(《星际争霸》)里面,提早看到游戏运行结果也是很有竞争优势的。因此我们为了让网络、硬件不一样的玩家能公平游戏,往往会使用一种叫“锁步”的策略:就好像一串绑着脚镣的囚犯,他们只能一起抬起左脚,然后再一起抬起右脚的走路,谁也不能走的更快。技术上的实现,就是每个客户端都定时(每N个渲染帧)发送一个网络帧到服务器上,就算玩家没操作,也类似心跳的这样发送空数据帧,所有客户端都要完整的收到所有的其他客户端的“心跳帧”才能开始运算一次游戏逻辑。这就是让所有的客户端,都互相等待,如果任何一个客户端卡了,其他的客户端都立刻就能知道,然后弹出界面让玩家停止输入来等待。因此在很多场合,帧同步的技术也被成为“锁步”技术,事实上,在没有统一的Relay Server服务器的时代(IPX局域网连机对战的时代),帧同步的网络帧其实就是上面所说的某个客户端的“心跳帧”,是由某个客户端产生并广播的(比如以前的局域网游戏,都会由一个客户端充当Host主机)。在《星际争霸》连机游戏中,如果有一个玩家掉线了,所有其他玩家就会发现有一个界面弹出来挡住画面,表示在等某某某。这种做法实际上是牺牲了流畅度的,因为你会发现一旦有网络、硬件卡的玩家加入游戏,所有其他玩家都受他的影响。为了减少这种对流畅度的影响,我们可以在需要“锁步”的时候,尽量少锁一点,比如不是发现缺了一帧就停下来,而是缺了若干帧,还是可以以“不公平”的方式继续玩一会儿(比如几秒),如果这段时间内还是没有补齐所缺的帧,才宣布锁住游戏等待。当然这个“容忍”的帧数我们可以调节到“最大”——就是没有。那么一个完全不锁步的游戏,肯定不是一个公平的游戏,但是也会在流畅性产生最大的好处,就是完全不受其他玩家影响。在那些不是PVP(玩家对战)的帧同步游戏中,不公平这个往往问题不大。我们完全可以在游戏的不同玩法里,打开、调整、甚至关闭这个“锁步”的机制,从而让游戏最大程度的平衡公平性和流畅性。

总结

  帧同步游戏技术,并不存在一种可以让游戏流畅的通用做法,而是需要和游戏具体做很多结合,在减少数据包,优化游戏快进体验,控制发包速度上尽量调优。同时还需要和游戏产品策划一起,平衡一致性、实时性、公平性的策略,才能真正达到流畅游戏的目的。



在帧同步模型中,每个客户端都会对整个游戏世界进行模拟。这种方法的好处在于减少了需要发送的信息。帧同步只需要发送用户的输入信息,而对于反过来的中心服务器模型来说,单位的信息则发送越频繁越好。

  比如说你在游戏世界中移动角色。在中心服务器模型中,物理模拟只会在服务器执行。客户端告诉服务器,角色要往哪个方向移动。服务器会执行寻路而且开始移动角色。服务器紧接着就会尽可能频繁地告知每个客户端该角色的位置。对于游戏世界中的每个角色都要运行这样的过程。对于实时策略游戏来说,同步成千上万的单位在中心服务器模型中几乎是不可能的任务。

  在帧同步模型中,在用户决定移动角色之后,就会告诉所有客户端。每个客户端都会执行寻路以及更新角色位置。只有用户输入的时候才需要通知每个客户端,然后每个客户端都会自己更新物理以及位置。

  这个模型带来了一些问题。每个客户端的模拟都必须执行得一模一样。这意味着,物理模拟必须执行同样的更新次数而且每个动作都需要同样的顺序执行。如果不这么做,其中一个客户端就会跑在其他客户端之前或者之后,然后在新的命令发出之后,跑得太快或者太慢的客户端走出的路径就会不同。这些不同会根据不同的游戏玩法而不同。

  另一个问题就是跨不同的机器和平台的确定性问题。计算上很小的不同都会对游戏造成蝴蝶效应。这个问题会在后续的文章中讲到。

  这里的实现方案灵感来自于这篇文章:《1500个弓箭手》。每个玩家命令都会在后续的两个回合中执行。在发送动作与处理动作之间存在延迟有助于对抗网络延迟。这个实现还给我们留下了根据延迟以及机器性能动态调整每回合时长的空间。这部分在这里先不讨论,会在后续文章再说。

  对于这个实现,我们有如下定义:

  帧同步回合:帧同步回合可以由多个游戏回合组成。玩家在一个帧同步回合执行一个动作。帧同步回合长度会根据性能调整。目前硬编码为200ms。

  游戏回合:游戏回合就是游戏逻辑和物理模拟的更新。每个帧同步回合拥有的游戏回合次数是由性能控制的。目前硬编码为50ms,也就是每次帧同步回合有4次游戏回合。也就是每秒有20次游戏回合。

  动作:一个动作就是玩家发起的一个命令。比如说在某个区域内选中单位,或者移动选中单位到目的地。

  注意:我们将不使用unity3d的物理引擎。而是使用一个确定性的自定义引擎。在后续文章中会有实现。

  游戏主循环

  Unity3d的循环是运行在单线程下的。可以通过在这两个函数插入自定义代码:

  1. Update()
  2. FixedUpdate()
复制代码

  Unity3d的主循环每次遍历更新都会调用Update()。主循环会以最快速度运行,除非设置了固定的帧率。
  FixedUpdate()会根据设置每秒执行固定次数。在主循环遍历中,它会被调用零次或多次,取决于上次遍历所花费的时间。FixedUpdate()有着我们想要的行为,就是每次帧同步回合都执行固定时长。但是,FixedUpdate()的频率只能在运行之前设置好。而我们希望可以根据性能调节我们的游戏帧率。

   游戏帧回合

  这个实现有着与FixedUpdate()在Update()函数中执行所类似的逻辑。主要不同的地方在于,我们可以调整频率。这是通过增加”累计时间”来完成的。每次调用Update()函数,上次遍历所花费的时间会添加到其中。这就是Time.deltaTime。如果累计时间大于我们的固定游戏回合帧率(50ms),那么我们就会调用gameframe()。我们每次调用gameframe()都会在累计时间上减去50ms,所以我们一直调用,知道累计时间小于50ms。

  1. private float AccumilatedTime = 0f;

  2. private float FrameLength = 0.05f; //50 miliseconds

  3. //called once per unity frame
  4. public void Update() {
  5.     //Basically same logic as FixedUpdate, but we can scale it by adjusting FrameLength
  6.     AccumilatedTime = AccumilatedTime + Time.deltaTime;

  7.     //in case the FPS is too slow, we may need to update the game multiple times a frame
  8.     while(AccumilatedTime > FrameLength) {
  9.         GameFrameTurn ();
  10.         AccumilatedTime = AccumilatedTime - FrameLength;
  11.     }
  12. }
复制代码

  我们跟踪当前帧同步回合中游戏帧的数量。每当我们在帧同步回合中达到我们想要的游戏回合次数,我们就会更新帧同步回合到下一轮。如果帧同步还不能到下一轮,我们就不能增加游戏帧,而且我们会在下一次同样执行帧同步检查。

  1. private void GameFrameTurn() {
  2.     //first frame is used to process actions
  3.     if(GameFrame == 0) {
  4.         if(LockStepTurn()) {
  5.             GameFrame++;
  6.         }
  7.     } else {
  8.         //update game

  9.         //...
  10.          
  11.         GameFrame++;
  12.         if(GameFrame == GameFramesPerLocksetpTurn) {
  13.             GameFrame = 0;
  14.         }
  15.     }
  16. }
复制代码

  在游戏回合中,物理模拟会更新而且我们的游戏逻辑也会更新。游戏逻辑是通过接口(IHasGameFrame)来实现的,而且添加这个对象到集合中,然后我们就可以进行遍历。

  1. private void GameFrameTurn() {
  2.     //first frame is used to process actions
  3.     if(GameFrame == 0) {
  4.         if(LockStepTurn()) {
  5.             GameFrame++;
  6.         }
  7.     } else {
  8.         //update game
  9.         SceneManager.Manager.TwoDPhysics.Update (GameFramesPerSecond);
  10.          
  11.         List<IHasGameFrame> finished = new List<IHasGameFrame>();
  12.         foreach(IHasGameFrame obj in SceneManager.Manager.GameFrameObjects) {
  13.             obj.GameFrameTurn(GameFramesPerSecond);
  14.             if(obj.Finished) {
  15.                 finished.Add (obj);
  16.             }
  17.         }
  18.          
  19.         foreach(IHasGameFrame obj in finished) {
  20.             SceneManager.Manager.GameFrameObjects.Remove (obj);
  21.         }
  22.          
  23.         GameFrame++;
  24.         if(GameFrame == GameFramesPerLocksetpTurn) {
  25.             GameFrame = 0;
  26.         }
  27.     }
  28. }
复制代码

  IHasGameFrame接口有一个方法叫做GameFrameTurn,它以当前每秒游戏帧的个数为参数。一个具体的带游戏逻辑的对象应该基于GameFramesPerSecond来计算。比如说,如果一个单位正在攻击另一个单位,而且他攻击频率为每秒钟10点伤害,你可能会通过将它除以GameFramesPerSecond来添加伤害。而GameFramesPerSecond会根据性能进行调整。

  IHasGameFrame接口也有属性标记着结束。这使得实现IHasGameFrame的对象可以通知游戏帧循环自己已经结束。一个例子就是一个对象跟着路径行走,而在到达目的地之后,这个对象就不再需要了。

   帧同步回合

  为了与其他客户端保持同步,每次帧同步回合我们都要问以下问题:

  我们已经收到了所有客户端的下一轮动作了吗?

  每个客户端都确认得到我们的动作了吗?

  我们有两个对象,ConfirmedActions和PendingActions。这两个都有各自可能收到消息的集合。在我们进入下一个回合之前,我们会检查这两个对象。

  1. private bool NextTurn() {       
  2.     if(confirmedActions.ReadyForNextTurn() && pendingActions.ReadyForNextTurn()) {
  3.         //increment the turn ID
  4.         LockStepTurnID++;
  5.         //move the confirmed actions to next turn
  6.         confirmedActions.NextTurn();
  7.         //move the pending actions to this turn
  8.         pendingActions.NextTurn();
  9.          
  10.         return true;
  11.     }
  12.      
  13.     return false;
  14. }
复制代码

   动作

  动作,也就是命令,都通过实现IAction接口来通信。有着一个无参数函数叫做ProcessAction()。这个类必须为Serializable。这意味着这个对象的所有字段也是Serializable的。当用户与UI交互,动作的实例就会创建,然后发送到我们的帧同步管理器的队列中。队列通常在游戏太慢而用户在一个帧同步回合中发送多于一个命令的时候用到。虽然每次只能发送一个命令,但没有一个会忽略。

  当发送动作到其他玩家的时候,动作实例会序列化为字节数组,然后被其他玩家反序列化。一个默认的”非动作”对象会在用户没有执行任何操作的时候发送。而其他则会根据特定游戏逻辑而定。这里是一个创建新单位的动作:

  1. using System;
  2. using UnityEngine;

  3. [Serializable]
  4. public class CreateUnit : IAction
  5. {
  6.     int owningPlayer;
  7.     int buildingID;
  8.      
  9.     public CreateUnit (int owningPlayer, int buildingID) {
  10.         this.owningPlayer = owningPlayer;
  11.         this.buildingID = buildingID;
  12.     }
  13.      
  14.     public void ProcessAction() {
  15.         Building b = SceneManager.Manager.GamePieceManager.GetBuilding(owningPlayer, buildingID);
  16.         b.SpawnUnit();
  17.     }
  18. }
复制代码

  这个动作会依赖于SceneManager的静态引用。如果你不喜欢这个实现,可以修改IAction接口,使得ProcessAction接收一个SceneManager实例。

  实例代码可以在这里找到:Bitbucket – Sample Lockstep


  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值