让游戏程序员在代码面前不再止步

热荐新书

本周推荐一本新书《游戏编程模式》,阅读群体:游戏领域的设计人员、开发人员,还是想要进入游戏开发领域的学生和普通程序员,都可以阅读本书。

让游戏程序员在高度复杂的代码库面前不再止步的一本书


全书共分20章,通过三大部分内容全面介绍了与游戏编程模式相关的各类知识点。第一部分介绍了基础知识和框架;第二部分深入探索设计模式,并介绍了模式与游戏开发之间的关联;第三部分介绍了13种有效的游戏设计模式。

本书和设计模式有什么联系

任何名字中带有“模式”的编程书籍都和经典图书《设计模式:可复用面向对象软件的基础》有所联系。这本书由Erich Gamma、Richard Helm、Ralph Johnson和John Vlissides编著(这4人也称为“Gang of Four”,即本书所提到的“GoF”四人组)。

设计模式一书本身也源自前人的灵感。创造一种模式语言来描述问题的开放性解决方案,该想法来自《A Pattern Language》 ,由 Christopher Alexander(和Sarah Ishikawa、Murray Silverstein一起)完成。

这是一本关于框架结构的书(就像真正的建筑结构中建筑与墙体和材料之间的关系),作者希望他人能够将其运用作其他领域问题的解决方案。设计模式(Design Patterns)正是GoF在软件领域的一个尝试。

本书的英文原名是Game Programming Design Patterns,并不是说GoF的书不适用于游戏。恰恰相反,在本书第2篇中介绍了众多来自GoF著作的设计模式,同时强调了在它们游戏开发中的运用。

从另一面说,我觉得这本书也适用于非游戏软件。我也可以把这本书命名为《More Design Patterns》 ,但我认为游戏开发有更多迷人的例子。难道你真的想要阅读的另外一本关于员工记录和银行账户例子的设计模式图书吗?

也就是说,尽管这里介绍的模式在其他软件中也是有用的,但我觉得它们特别适合应对游戏工程中普遍会遇到的挑战,例如:

时间和顺序往往是一个游戏的架构的核心部分。事情必须依照正确的顺序和正确的时间发生。

开发周期被高度压缩。众多程序员必须在不牵涉他人代码、不污染代码库的前提下对一套庞大而错杂的行为体系进行快速的构建与迭代。

所有这些行为被定义后,游戏便开始互动。怪物撕咬英雄,药水混合在一起,炸弹炸到敌人和朋友……诸如此类。这些交互必须很好地进行下去,可不能把代码库给搅成一团毛线球。

最后,性能在游戏中至关重要。游戏开发者永远在榨取平台性能这件事上赛跑。多削掉一个CPU周期,你的游戏就有可能从掉帧和差评迈入A级游戏和百万销量的天堂。


市面上已有的书籍

目前市面已经有数十多本游戏编程的书籍。为什么还要再写一本?

我见过的大多数游戏编程书籍无非两类。

关于特定领域的书籍。这些针对性较强的书籍带领你深入地探索游戏开发的一些特定方面。它们会教你3D图形、实时渲染、物理仿真、人工智能或音频处理。这些是众多游戏程序员在自己的职业生涯中所专注的领域。

关于整个游戏引擎的书籍。相反,这些图书试图涵盖整个游戏引擎的各个部分。它们的目标是构建一整套适合某个特殊游戏类型的引擎系统,这类通常是3D第一人称射击游戏。

我喜欢这两类书,但我觉得它们仍留下了一些空白。讲特定领域的书很少会谈及你的代码块如何与游戏的其他部分交互。你可能擅长物理和渲染,但是你知道如何优雅地将它们拼合起来吗?

这种分类讲解风格的另外一个例子,就是广受大家喜爱的《游戏编程精粹》系列。

第二类书籍涵盖了这类问题,但我往往发现这类书通常都太过庞大、太过空泛。特别是随着移动和休闲游戏的兴起,我们正处在众多类型的游戏共同发展的时代。我们不再只是照搬Quake

了。当你的游戏不适合这个模型时,这类阐述单一引擎的书籍就不再合适了。

相反,这里我想要做的,更倾向于分门别类。本书的每个章节都是一个独立的思路,你可以将它应用到你的代码里。你也可以针对自己制作的游戏来决定以最恰当的方式将它们进行混搭。

作者介绍


Robert Nystrom是一位具备超过20年职业编程经验的开发者,而其中大概一半时间用于从事游戏开发。在艺电(Electronic Arts)的8年时间里,他曾参与劲爆美式足球(Madden)系列这样庞大的项目,也曾投身于亨利•海茨沃斯大冒险(Henry Hatsworth in the Puzzling Adventure)这样稍小规模的游戏开发之中。他所开发的游戏遍及PC、GameCube、PS2、XBox、X360以及DS平台。但最傲人之处在于,他为开发者们提供了开发工具和共享库。他热衷于寻求易用的、漂亮的代码来延伸和增强开发者们的创造力。

Robert与他的妻子和两个女儿定居于西雅图,在那里你很有可能会见到他正在为朋友们下厨,或者在为他们上啤酒。

章节欣赏:状态模式(选摘)

“允许一个对象在其内部状态改变时改变自身的行为。对象看起来好像是在修改自身类。”

交代一下:我写的有些过头了,我在本章里面添加了太多东西。表面上这一章是介绍状态模式[的,但是我不能抛开游戏里面的有限状态机(finite state machines,FSM)而单独只谈“状态模式”。不过,当我讲到FSM的时候,我发觉我还有必要再介绍一下层次状态机(hierarchical state machine)和下推自动机(pushdown automata)。

因为有太多东西需要讲,所以我试图压缩本章的内容。本章中的代码片断没有涉及很细节的东西,所以,这些省略的部分需要靠读者来脑补。我希望它们仍然足够清楚到能让你掌握关键点(big picture)。

层次状态机和下推自动机这对术语指的是早期的人工智能。在20世纪50年代和60年代,大部分AI研究关注的是语言处理。许多现在用来解析编程语言的编译器被发明用来解析人类语言。

如果你从未听说过状态机,也不要感到沮丧。它们对于人工智能领域的开发者和编译器黑客来说非常熟悉,不过在其他编程领域可能不是那么被人熟知了。我觉得它应该被更多的人了解,因此,我将从一个不同的应用领域的视角来介绍它。

7.1 我们曾经相遇过

假设我们现在正在开发一款横版游戏。我们的任务是实现女主角——游戏世界中玩家的图像。我们需要根据玩家的输入来控制主角的行为。当按下B键的时候,她应该跳跃。我们可以这样实现:

void Heroine::handleInput(Input input){ if (input == PRESS_B) {  yVelocity_ = JUMP_VELOCITY;  setGraphics(IMAGE_JUMP); }}

找找看,bug在哪里?

这里应该还有如果主角着地将isJumping_设置回false的代码。为了简洁起见,我省略了。

我们没有阻止主角“在空中跳跃”——当主角跳起来后持续按下B键。这样会导致她一直飘在空中,简单的修复方法可以是:在Heroine类中添加一个isJumping_布尔值变量来跟踪主角的跳跃,然后这么做:

void Heroine::handleInput(Input input){ if (input == PRESS_B) {  if (!isJumping_)  {   isJumping_ = true;   // Jump...  } }}

接下来,我们想实现主角的闪避动作。当主角站在地面上的时候,如果玩家按下下方向键,则躲避,如果松开此键,则站立。

void Heroine::handleInput(Input input){ if (input == PRESS_B) {  // Jump if not jumping... } else if (input == PRESS_DOWN) {  if (!isJumping_)  {   setGraphics(IMAGE_DUCK);  } } else if (input == RELEASE_DOWN) {  setGraphics(IMAGE_STAND); }}

找找看,bug在哪里?

通过上面的代码,玩家可以:

1.按下方向键来闪避。

2.按B键从闪避的状态直接跳起来。

3.玩家还在空中的时候松开下键。

此时,当女主角在跳跃状态的时候,显示的是站立的图像。是时候添加另外一个布尔标志位来解决该问题了······

void Heroine::handleInput(Input input){ if (input == PRESS_B) {  if (!isJumping_ && !isDucking_)  {   // Jump...  } } else if (input == PRESS_DOWN) {  if (!isJumping_)  {   isDucking_ = true;   setGraphics(IMAGE_DUCK);  } } else if (input == RELEASE_DOWN) {  if (isDucking_)  {   isDucking_ = false;   setGraphics(IMAGE_STAND);  } }}

接下来,如果我们的主角可以在跳起来的过程中,按下方向键进行一次俯冲攻击那就太酷了,代码如下:

void Heroine::handleInput(Input input){ if (input == PRESS_B) {  if (!isJumping_ && !isDucking_)  {   // Jump...  } } else if (input == PRESS_DOWN) {  if (!isJumping_)  {   isDucking_ = true;   setGraphics(IMAGE_DUCK);  }  else  {   isJumping_ = false;   setGraphics(IMAGE_DIVE);  } } else if (input == RELEASE_DOWN) {  if (isDucking_)  {   // Stand...  } }}

你崇拜一些程序员,他们总是看起来会编写完美无瑕的代码,然而他们并非超人。相反,他们有一种直觉会意识到哪种类型的代码容易出错,然后避免编写出这种代码。

复杂的分支和可变的状态——随时间变化的字段,这是两种容易出错的代码,上面的例子就是这样。

又到寻找bug的时间了。找到了吗?

我们发现主角在跳跃状态的时候不能再跳,但是在俯冲攻击的时候却可以跳跃。又要添加一个成员变量······

很明显,我们的这种做法有问题。每次我们添加一些功能的时候,都会不经意地破坏已有代码的功能。而且,我们还有很多“行走”等动作没有添加。如果我们还是采用类似的做法,那bug可能会更多。

7.2 救星:有限状态机

为了消除你心中的疑惑,你可以准备一张纸和一支笔,让我们一起来画一张流程图。对于女主角能够进行的动作画一个“矩形”:站立、跳跃、躲避和俯冲。当你可以按下一个键让主角从一个状态切换到另一个状态的时候,我们画一个箭头,让它从一个矩形指向另一个矩形。同时在箭头上面添加文本,表示我们按下的按钮。

恭喜,你刚刚已经成功创建了一个有限状态机。有限状态机借鉴了计算机科学里的自动机理论(automata theory)中的一种数据结构(图灵机)思想。有限状态机(FSMs)可以看作是最简单的图灵机(如图7-1所示)。


图7-1 一张状态机的图表

其表达的是:

关于有限状态机我最喜欢的比喻就是它是像Zork一样的古老的文字冒险游戏。游戏中有着由出口连接着的一些房间。你可以通过输入像“往北前进”这样的命令来进行探索。

这其实就是一个状态机:每一个房间是一个状态。你所在的房间就是当前的状态。每个房间的出口就是它的转换,导航命令就是输入。

  • 你拥有一组状态,并且可以在这组状态之间进行切换。比如:站立、跳跃、躲避和俯冲。

  • 状态机同一时刻只能处于一种状态。女主角无法同时跳跃和站立。事实上,防止同时存在两个状态是我们使用有限状态机的原因。

  • 状态机会接收一组输入或者事件。在我们这个例子中,它们就是按钮的按下和释放。

  • 每一个状态有一组转换,每一个转换都关联着一个输入并指向另一个状态。当有一个输入进来的时候,如果输入与当前状态的其中一个转换匹配上,则状态机便会转换状态到输入事件所指的状态。

在我们的例子中,在站立状态的时候如果按下向下方向键,则状态转换到躲避状态。如果在跳跃状态的时候按下向下方向键,则会转换到俯冲攻击状态。如果对于每一个输入事件没有对应的转换,则这个输入就会被忽略。

简而言之,整个状态机可以分为:状态、输入和转换。你可以通过画状态流程图来表示它们。不幸的是,编译器并不认识状态图,所以,我们接下来要介绍如何实现。GoF的状态模式是一种实现方法,但是让我们先从更简单的方法开始。

7.3 枚举和分支

一个问题是,Heroine类有一些布尔类型的成员变量:isJumping_isDucking_,但是这两个变量不应该同时为true。当你有一系列的标记成员变量,而它们只能有且仅有一个为true时,这表明我们需要把它们定义成枚举(enum)。

在这个例子当中,我们的有限状态机的每一个状态可以用一个枚举来表示,所以,让我们定义以下枚举:

enum State{ STATE_STANDING, STATE_JUMPING, STATE_DUCKING, STATE_DIVING};

这里没有大量的标志位,Heroine类只有一个state_成员。我们也需要调换分支语句的顺序。在前面的代码中,我们先判断输入事件,然后才是状态。那种代码可以让我们集中处理每一个按键相关的逻辑,但是,它也让每一种状态的处理代码变得很乱。我们想把它们放在一起来处理,因此,我们先判断状态。代码如下:

void Heroine::handleInput(Input input){ switch (state_) {  case STATE_STANDING:   if (input == PRESS_B)   {    state_ = STATE_JUMPING;    yVelocity_ = JUMP_VELOCITY;    setGraphics(IMAGE_JUMP);   }   else if (input == PRESS_DOWN)   {    state_ = STATE_DUCKING;    setGraphics(IMAGE_DUCK);   }   break;  // Other states... }}

我们可以像下面设置其他状态:

void Heroine::handleInput(Input input){ switch (state_) {  // Standing state...  case STATE_JUMPING:   if (input == PRESS_DOWN)   {    state_ = STATE_DIVING;    setGraphics(IMAGE_DIVE);   }   break;  case STATE_DUCKING:   if (input == RELEASE_DOWN)   {    state_ = STATE_STANDING;    setGraphics(IMAGE_STAND);   }   break; }}

这样看起来虽然很普通,但是它却是对前面的代码的一个提升。我们仍然有一些条件分支语句,但是我们简化了状态的处理。所有处理单个状态的代码都集中在一起了。这是实现状态机最简单的方法,而且在某些情况下,这样做也挺好的。

重要的是,我们的女主角再也不可能处于一个无效的状态了。通过布尔值标识,会存在一些没有意义的值。但是,使用枚举,则每一个枚举值都是有意义的。

你的问题可能也会超过此方案能解决的范围。比如,我们想在主角下蹲躲避的时候“蓄能”,然后等蓄满能量之后可以释放出一个特殊的技能。那么,当主角处于躲避状态的时候,我们需要添加一个变量来记录蓄能时间。

如果你猜这是更新方法模式,那么恭喜你,你猜中了!

我们可以在Heroine类中添加一个chargeTime_成员来记录主角蓄能的时间长短。假设,我们已经有一个update()方法了,并且这个方法会在每一帧被调用。在那里,我们可以使用如下代码片断能记录蓄能的时间:

void Heroine::update(){ if (state_ == STATE_DUCKING) {  chargeTime_++;  if (chargeTime_ > MAX_CHARGE)  {   superBomb();  } }}

我们需要在主角躲避的时候重置这个蓄能时间,所以,我们还需要修改handleInput()方法:

void Heroine::handleInput(Input input){ switch (state_) {  case STATE_STANDING:   if (input == PRESS_DOWN)   {    state_ = STATE_DUCKING;    chargeTime_ = 0;    setGraphics(IMAGE_DUCK);   }   // Handle other inputs...   break;   // Other states... }}

总之,为了添加蓄能攻击,我们不得不修改两个方法,并且添加一个chargeTime_成员变量给主角,尽管这个成员变量只有在主角处于躲避状态的时候才有效。其实我们真正想要的是把所有这些和与之相关的数据和代码封装起来。接下来,我们介绍GoF的状态模式来解决这个问题。

7.4 状态模式

对于熟知面向对象方法的人来说,每一个条件分支都可以用动态分发来解决(换句话说,都可以用C++里面的虚函数来解决)。但是,如果这样做,你可能会把简单问题复杂化。有时候,一个简单的if语句就足够了。

状态模式的由来也有一些历史原因。许多面向对象设计的拥护者—— GoF和重构的作者Martin Fowler都是Smalltalk出身。在那里,如果有一个ifThen语句,我们便可以用一个表示truefalse的对象来操作。

但是,在我们这个例子当中,我们发现面对对象设计也就是状态模式更合适。

GoF描述的状态模式在应用到我们的例子中时如下。

7.4.1 一个状态接口

首先,我们为状态定义一个接口。每一个与状态相关的行为都定义成虚函数。在我们的例子中,就是handleInput()update()函数。

class HeroineState{public: virtual ~HeroineState() {} virtual void handleInput(Heroine& heroine,                 Input input) {} virtual void update(Heroine& heroine) {}};

7.4.2 为每一个状态定义一个类

对于每一个状态,我们定义了一个类并继承此状态接口。它的方法定义主角对应此状态的行为。换句话说,把之前的switch语句里面的每一个case语句里的内容放置到它们对应的状态类里面去。比如:

class DuckingState : public HeroineState{public: DuckingState() : chargeTime_(0) {} virtual void handleInput(Heroine& heroine,                 Input input) {  if (input == RELEASE_DOWN)  {   // Change to standing state...   heroine.setGraphics(IMAGE_STAND);  } } virtual void update(Heroine& heroine) {  chargeTime_++;  if (chargeTime_ > MAX_CHARGE)  {   heroine.superBomb();  } }private: int chargeTime_;};

注意,我们这里chargeTime_Heroine类中移到了DuckingState(躲避状态)类中。这样非常好,因为这个变量只是对躲避状态有意义,现在把它定义在这里,正好显式地反映了我们的对象模型。

7.4.3 状态委托

接下来,我们在主角类中定义一个指针变量,让它指向当前的状态。我们把之前那个很大的switch语句去掉,并让它去调用状态接口的虚函数,最终这些虚方法就会动态地调用具体子状态的相应函数。

状态委托看起来很像策略模式和类型对象模式(第13章)。在这三个模式中,你会有一个主对象委托给另外的附属对象。它们三者的区别主要在于目的不同:

  • 策略模式的目标是将主类与它的部分行为进行解耦。

  • 类型对象模式的目标是使得多个对象通过共享相同类型对象的引用来表现出相似性。

  • 状态模式的目标是通过改变主对象代理的对象来改变主对象的行为。

class Heroine{public: virtual void handleInput(Input input) {  state_->handleInput(*this, input); } virtual void update() { state_->update(*this); } // Other methods...private: HeroineState* state_;};

为了修改状态,我们需要把state_指针指向另一个不同的HeroineState状态对象。至此,我们的状态模式就讲完了。

7.5 状态对象应该放在哪里呢

我这里忽略了一些细节。为了修改一个状态,我们需要给state_指针赋值为一个新的状态,但是这个新的状态对象要从哪里来呢?我们之前的枚举方法是定义一些数字。但是,现在我们的状态是类,我们需要获取这些类的实例。通常来说,有两种实现方法。

7.5.1 静态状态

如果一个状态对象没有任何数据成员,那么它的唯一数据成员便是虚表指针了。那样的话,我们就没有必要创建此状态的多个实例了,因为它们的每一个实例都是相同的。

在那种情况下,我们可以定义一个静态实例。即使你有一系列的FSM在同时运转,所有的状态机也能同时指向这一个唯一的实例。

如果你的状态类没有任何数据成员,并且只有一个虚函数方法。那么我们还可以进一步简化此模式。我们可以使用一个普通的状态函数来替换状态类。这样的话,我们的state_变量就变成一个状态函数指针。

这个就是享元模式。(第3章)

你把静态方法放置在哪里,这个由你自己来决定。如果没有任何特殊原因的话,我们可以把它放置到基类状态类中:

class HeroineState{public: static StandingState standing; static DuckingState ducking; static JumpingState jumping; static DivingState diving; // Other code...};

每一个静态成员变量都是对应状态类的一个实例。如果我们想让主角跳跃,那么站立状态应该是这样子:

if (input == PRESS_B){ heroine.state_ = &HeroineState::jumping; heroine.setGraphics(IMAGE_JUMP);}

7.5.2 实例化状态

有时候上面的方法可能不行。一个静态状态对于躲避状态而言是行不通的。因为它有一个chargeTime_成员变量,所以这个具体取决于每一个躲避状态下的主角类。如果我们的游戏里面只有一个主角的话,那么定义一个静态类也是没有什么问题的。但是,如果我们想加入多个玩家,那么此方法就行不通了。

当你为状态实例动态分配空间时,你不得不考虑碎片化问题了。对象池模式(第19章)可以帮助到你。

在那种情况下,我们不得不在状态切换的时候动态地创建一个躲避状态实例。这样,我们的有限状态机就拥有了它自己的实例。当然,如果我们又动态分配了一个新的状态实例,则要负责清理老的状态实例。这里必须相当小心,因为修改状态的函数是在当前状态里面,所以我们需要小心地处理删除的顺序。

另外,我们也可以选择在HeroineState类中的handleInput()方法里面可选地返回一个新的状态。当这个状态返回的时候,主角将会删除老的状态并切换到这个新的状态,如下所示:

void Heroine::handleInput(Input input){ HeroineState* state = state_−>handleInput(        *this, input); if (state != NULL) {  delete state_;  state_ = state; }}

那样的话,我们只有在从handleInput方法返回的时候才有可能去删除前面的状态对象。现在,站立状态可以通过创建一个躲避状态的实例来切换状态了。

HeroineState* StandingState::handleInput(        Heroine& heroine, Input input){ if (input == PRESS_DOWN) {  // Other code...  return new DuckingState(); } // Stay in this state. return NULL;}

通常情况下,我倾向于使用静态状态。因为它们不会占用太多的CPU和内存资源。

7.6 进入状态和退出状态的行为

状态模式的目标就是将每个状态相关的所有的数据和行为封装到相关类里面。万里长征,我们仅仅迈出去了一步,我们还有更多路要走。

当主角更改状态的时候,我们也会切换它的贴图。现在,这段代码包含在它要切换的状态的上一个状态里面。当她从躲避状态切换到站立状态时,躲避状态将会修改它的图像:

HeroineState* DuckingState::handleInput(        Heroine& heroine, Input input){ if (input == RELEASE_DOWN) {  heroine.setGraphics(IMAGE_STAND);  return new StandingState(); } // Other code...}

我们希望的是,每一个状态控制自己的图像。我们可以通过给每一个状态添加一个entey行为。

class StandingState : public HeroineState{public: virtual void enter(Heroine& heroine) {  heroine.setGraphics(IMAGE_STAND); } // Other code...};

回到Heroine类,我们修改代码来处理状态切换的情况:

void Heroine::handleInput(Input input){ HeroineState* state = state_->handleInput(        *this, input); if (state != NULL) {  delete state_;  state_ = state;  // Call the enter action on the new state.  state_->enter(*this); }}

这样也可以让我们简化躲避状态的代码:

HeroineState* DuckingState::handleInput(        Heroine& heroine, Input input){ if (input == RELEASE_DOWN) {  return new StandingState(); } // Other code...}

它所做的就是切换到站立状态,然后站立状态会自己设置图像。现在,我们的状态已经封装好了。entry动作的一个最大的好处就是它不用关心上一个状态是什么,它只需要根据自己的状态来处理图像和行为就可以了。

大部分的真实状态图里面,我们有多个状态对应同一个状态。比如,我们的女主角会在她俯冲或者跳跃之后站立在地面上。这意味着,我们可能会在每一个状态发生变化的时候重复写很多代码。但是,entry动作帮我们很好地解决了这个问题。

当然,我们也可以扩展这个功能来支持退出状态的行为。我们可以定义一个exit函数来定义一些在状态改变前的处理。

7.7 有什么收获吗

一个有限状态机甚至都不是图灵完备的。自动机理论使用一系列抽象的模型来描述计算,并且每一个模型都比先前的模型更复杂。而图灵机只是这里面最具有表达力的模型之一。

“图灵完备”意味着一个系统(通常指的是一门编程语言)是足够强大的,强大到它可以实现一个图灵机。这也意味着,所有图灵完备的编程语言,在某些程度上其表达力是相同的。但有限状态机由于其不够灵活,并不在其中。

我已经花了大量的时间来介绍有限状态机。现在我们一起来捋一捋。到目前为止,我跟你讲的所有事情都是对的,有限状态机对于某些应用来讲是非常合适的。但是,最大的优点往往也是最大的缺点。

状态机帮助你把千丝万缕的逻辑判断代码封装起来。你需要的只是一组调整好的状态,一个当前状态和一些硬编码的状态切换。

如果你想要用一个状态机来表示一些复杂的游戏AI,则可能会面临这个模型的一些限制。幸运的是,我们的前辈们已经发现了一些不错的解决方案。我将会在本章的最后简单地介绍它们。

喜欢看书和分享的朋友可以加群--> 程序员书屋群:255082518

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值