《游戏引擎架构》读书笔记(四)

本文是《游戏引擎架构》读书笔记的第四部分,主要探讨游戏性系统和运行时游戏性基础系统。游戏性系统包括静态和动态元素、游戏对象、数据驱动设计、游戏世界编辑器等功能。运行时游戏性基础系统涉及对象模型架构、世界组块数据格式、游戏对象的内存管理和更新、游戏存档、世界查询以及对象引用。文章深入讲解了游戏引擎中的一些关键技术,如对象的实时更新、事件与消息泵、脚本系统以及音频、视频、网络和玩家机制等重要组成部分。
摘要由CSDN通过智能技术生成

一.游戏性系统

(1)游戏世界一般分为静态元素和动态元素,静态元素和动态元素有时候没有特别明显的分界线。但是静态元素消耗资源较少,可以用笔刷等工具绘制静态元素。

(2)游戏世界一般分为各个组块,如关卡,地图,地区等。建立在这之上的是高级游戏流程,即玩家的目标,比如任务。

(3)游戏动态元素通常以面向对象的方式进行设计,通常称为游戏对象(Game Object),实体(entity),演员(actor)或者代理人(agent)等。

         a)游戏对象通常会扩展引擎本身的语言,加一些高级功能比如反射,并且会提供脚本语言比如lua的访问。

         b)对象模型在有的引擎中可能会分为工具方对象模型和运行时对象模型,不过这两者一定有联系,甚至是相同的实现。

(4)数据驱动游戏引擎:当游戏的行为可以全部或者部分由美术和策划所提供的数据所控制,而不是由程序员编写的软件完全控制,该引擎就称为数据驱动。数据驱动可以减少迭代次数,不需要修改游戏本身的代码。但是数据驱动也有很大的代价,需要提供足够强大的编辑工具以及容错能力。但是不管怎么样,数据驱动也是为了使游戏开发更简单,而不要过于盲目的复杂化,记住Kiss原则。

(5)游戏世界编辑器,游戏世界编辑器包括但不限于下面的功能:

         a)世界组块创建及管理:levelsystem

         b)可视化的游戏世界:所见即所得的编辑最适合美术和策划的开发啦。

         c)导航:在编辑器中必须要有灵活的摄像机,有绕物体旋转和自由飞翔等不同模式。

         d)选取:一般是以光线投射的方式进行拾取。最好支持框选多选,然后以视图的或者树形图表示出来。

f)图层:在一些编辑器中支持自定义分组,能够独立载入一组图层,在不同的图层安排不同的内容。

g)属性编辑:选中游戏世界中的物体后,可以通过编辑器调节对象的属性。可以考虑增加公共属性修改,修改一个,相同类型对象的对应属性值全部修改。

h)特殊对象模型:比如光源,粒子发射器,区域,样条等本来在游戏世界中不可见的东东,我们可以给其增加一个实际的包围盒等,让其在世界中可见,方便移动操作等。

i)读写世界组块:当然,编辑这么多,还是要把编辑完的东西存起来,供引擎读写。有的引擎使用二进制文件,有的引擎使用XML等文本格式,有的引擎将所有信息存储在一个文件中,也有的分组块存储。

j)快速迭代:当我们修改游戏世界后,越早能看到修改的效果越好。这点感觉CE3做的很好,CE3的编辑器中按下Ctrl + G后可以立即进入游戏模式,测试效果。感觉这个真的是灰常灰常好的设定!!

k)集成的资产管理工具:游戏中所有需要使用的资源最好可以随时浏览,方便查看与编辑,更高级的就是可以直接在这个资产管理工具中直接进行相关修改。

 

二.   运行时游戏性基础系统

(1)运行时对象模型架构:

         a)以对象为中心的架构:每个游戏对象都是单个类实例,每个对象含有一组属性及行为,被封装在对象中。

          这种类型的架构最简单的就是单一庞大的类层次结构,一个基类,向下派生出N多子类。不过随着派生的层数越深,整个系统就越难理解维护和修改。(这里涉及到一个冒泡效应,即本来是下层类需要的功能,但是又增加了另一个类需要这个功能,为了适应这个功能,即把这个功能提到上层的类中,导致上层的类越来越庞大。)

         进一步的话,可以不仅仅扩展类的深度,而是从派生的宽度来扩展。不过这种情况容易造成多重继承,这个就比价麻烦了。

         更好的办法是使用聚合,而不是传统的继承方式。即使用Has a来代替Is a。将一些对象具有的功能提取出来,作为一个单独的类,然后游戏对象只要包含这个类的对象就具有了这种功能或者特性。

         聚合再进化一下,就变成了传说中的组件模型。各种功能和性质都属于一个组件,而GameObject是由若干个这样的组件构成。每个组件可以独立的维护扩充或者重构,而不影响其他功能。最简单的就是GameObject含有所有可能有的组件的指针,在初始化的时候可以根据参数甚至是配置文件来决定这个对象需要哪些组件。

         组件的终极进化的话就是只有组件没有GameObjec,或者GameObject中只包含一个GUID,这些组件中也包含GUID,不过还是有GameObject好一点,如果没有这个,组件间的通信等问题就会比较棘手。

         b)以属性为中心的对象模型架构:

这种的话,主要就是存储各个对象的各个属性,这些属性都存储在同一个地方,通过对象的唯一索引来获得这个属性。与对象为中心的架构的区别在于:这种情况为数组的结构,即整个游戏的对象属性为一个结构体,而该结构体的每个子项都是一个数组,通过各个对象的索引获得该值。而对象为中心的情况则为结构的数组:每个对象的属性为一个结构体,整个游戏世界是有这个结构体的数组构成的。

(2)世界组块的数据格式:

对于游戏世界中的各种东东,我们在编辑器中编辑好之后,都需要存储起来,并且在游戏引擎中重现出来。有二进制的序列化方式,但是更好的方法是只存储游戏状态属性到文本文件中,比如XML文档。

由于C++没有反射机制,所以对于创建对象通常采用类工厂等方式,根据数据表创建不同的类对象。

         在编辑器中需要和游戏中有相同的表现,有时候是将引擎的接口直接暴露给编辑器,这样的好处是编辑器和游戏世界中完全一样,而且减少了工作量,更有可能直接在编辑器中开启模拟游戏的方式(比如CE3)。有时候,如果不需要其他的功能,也可以直接使用生成器,即将引擎中的对象的一部分属性等提取出来,不必要的不添加到编辑器中。

         对于各种对象的属性最好添加一个默认的属性,而且需要提供一次性修改所有对象属性的方法。

(3)游戏世界的加载和串流:

a)  简单的关卡加载:一般使用堆栈分配器,进入一关前,集中加载本关的资源,使用完后,直接全部清除,然后进入一个过场画面(loading界面),等资源加载完成后,继续游戏。

b)  阻隔室:这个是为了避免加载动画使用的一种简单的方法,不过需要配合游戏的玩法。最简单的办法是将为资源分配的内存分为两块,一块为当前场景的,然后在后台加载下一个关卡的。而如果玩家返回原来的场景就会穿帮…而阻隔室就是应对这种情况的。当玩家进入阻隔室后,原来的资源卸载,加载新的资源,让玩家在阻隔室不要闲下来就好。

c)  游戏世界的串流:这个技术赶脚吊炸天啊…

串流需要做的是一方面保证玩家要玩的资源在内存中,另一方面还需要保证资源加载不能出现内存碎片的问题。这时就需要更细的粒度,设置多个缓冲区,循环加载。

在每个组块设置一个包围盒,保证玩家不会看到穿帮镜头。

 (4)对象生成和内存管理:

         游戏资源载入内存后,需要管理世界对象中的对象生成。而动态分配内存可能很慢,而且游戏对象大小不同,可能会造成内存碎片。所以内存管理是很重要的。

a)  对象的离线内存分配:说离线其实是一种极端情况,这种分配的特点是一次性全部分配内存,然后不需要的对象保持为休眠状态或者不可见。使用时再激活,模拟生成的情形。但是这种方法比较死板,不能真正的动态生成对象。

b)  动态内存分配:主要要对付的问题就是内存碎片的问题。

对于相同大小的对象,可以采用池分配器。但是游戏对象各种各样,所以一种方法是采用多种池分配器,为每种对象设置一个池分配器。不过这个需要把握好,防止有的对象内存池不够,而有的对象内存池剩余。

另一种方法是采用类似操作系统的内存分配模式,设置一组内存分配器,从小到大,遇到需要分配的时候,先从小的找起,直至大的,如果还没有,直接转堆内存,因为大块内存碎片问题相对小块内存碎片还不是那么严重。

最残暴的一种方法就是内存重定位,隔一段时间,将内存整理一下。

(5)游戏存档:

a)存档点:这个的好处在于玩家到达该存档点时,游戏状态是确定的,所以存档的内容可以相对较少。

         b)任何地点都可以存档:这个难度就要比上个大了,存储的状态也要多许多。

不过不管是哪种情况,都要注意尽可能的不存储无关信息。

(6)世界查询:

游戏对象需要提供某种唯一标识符,使各种对象能够区分,并且能在运行时找到对象。而找到对象的需求也不同,比如直接查询,搜寻范围内的敌人等等。

a)  以唯一标识符搜寻游戏对象:将游戏的指针或句柄存储于以游戏的唯一标识符为健值的散列表或者二叉查找树中。

b)  将游戏对象预先排序,存储在不同的链表中。比如玩家周围的怪物链表。

c)  搜寻投射路径碰撞物体:通常使用碰撞系统实现。

d)  搜寻空间中的某区域范围内的对象:可以使用一些空间散列数据结构去存储游戏对象,或者使用传说中的四叉树,八叉树等等。

(7)对象引用:

a)最简单的就是使用指针,不过指针也有危险的地方,容易造成孤立对象,或者空指针异常,无效指针等等问题,所以在使用指针的时候要格外小心。

b)更保险的做法是使用智能指针,智能指针一般采用引用计数,并且添加了一些判空操作。智能指针实现比较简单,但是要实现得完整却比较复杂,强烈不推荐自己实现。使用boost库为我们提供的智能指针也可以。

c)更加高级的办法是使用句柄,句柄其实就是一个索引表,内部保存的还是游戏对象的指针,不过这个索引表可以加入更多的东西,而且这个索引表也可以作为游戏世界的索引表。

(8)实时更新游戏对象:

         游戏运行时需要实时的更新游戏内部的对象的状态,一般就是对象的属性。说是实时更新,其实还是连续的离散的时间点。

a)  最简单的方式是遍历:每个对象有一个update()的虚函数,通过多态调用,将游戏对象的状态更新到下一个离散的时刻。并且传递给其一个距离上一帧的时间差,使对象可以考虑已经流失的时间,对将要做出的操作进行判断。

而怎样管理游戏对象也是一个问题,一般的游戏都有一个GameObjManager单例,如果有动态生成对象的功能,则就需要用类似链表的数据结构管理对象,而如果没有动态生成对象的话,就用数组就可以管理游戏对象。

使用直接遍历的方式,所有的操作都集中在对象的update函数中,但这不是一个好办法。

b)  性能限制及批次更新:将对象的逻辑属性更新,各个子系统的功能不在对象的update中更新,对象仅仅更新关于子系统功能的状态值,然后所有对象更新完后。按照顺序,更新各个子系统。

这样做的好处在于:子系统缓存一致性,进行最少的重复计算,减少资源的再分配,高效的流水线。

对于更新时,有可能会有对象间的相互依赖,这时候就需要按照批次顺序来更新,或者将一组相关的对象一起更新。

但是有个问题就是有可能这个对象的状态是上一帧的状态,但是在本帧状态还未更新,比如现在更新状态,更新到A了,B还没有更新,A需要查看B的状态,B的状态是上一帧的状态。一个好的方法是加上时间戳,让对象查询对象的时间戳,来判断是上一帧还是本帧。

关于游戏更新还有要注意的就是不要使用阻塞型函数,要尽可能使用非阻塞型函数,把阻塞的功能放到另一个线程去做。

(1)      事件与消息泵:游戏是由事件驱动的,事件是游戏过程中发生,希望关注的事情。事件处理系统很重要,主要做的是两件事:第一是将事件通知给那些关注该事件的对象,即所谓的消息分发。第二是安排对象回应所关注的事件,即所谓的事件处理。

 

a)  最简单的办法是采用函数绑定:给每个对象加一个对应事件的响应函数,在基类设定一个虚函数(或者空实现的),子类继承这个虚函数,然后轮询每个子类,看是否需要响应这个消息。但是这样做弊端很大,基类需要对所有类型的事件都有对应的函数,扩展十分困难。再者,不是所有消息都需要轮询的,所以这样做也不合适。

b)  更好的方法是把事件封装成对象:事件对象由两部分构成,一部分是事件类型,另一部分是参数。有些引擎将事件称之为命令,事实上,将事件发给对象,实质上就是向对象发送命令。

将事件封装成类有很多好处:第一是单个是事件处理函数,仅需要一个虚函数OnEvent就可以处理所有的事件。第二,像上面的函数调用是瞬间的,而事件有持久性,可以存储在队列中,稍后处理,也可以用于广播。第三,事件可以被转发,而转发的对象可以不需要知道事件的具体内容。

c)  事件类型:最简单的事件类型定义的方法就是在一个文件中,把所有游戏中需要的事件类型定义了,用一个枚举类型映射至唯一的整数。这样做一是方便统一管理,二是比较简单,三是开销小。But也有很多缺点,第一,破坏了封装性,第二,事件类型是硬编码的,不利于扩展,第三,枚举的索引是次序,如果添加了新的事件类型(在中间插入的),那么次序就会改变,如果在游戏中对应的话那是没有问题的,但是,如果还有文件中存储与之对应的内容的话,次序就会错乱。所以,枚举类型统一定义事件类型,在小型程序中很好,但大规模或利于扩展的程序中不适合这种。

另一种事件对应方式是字符串,这种方式扩展很自由,但是有几个问题,第一,开销大,第二,容易产生命名冲突,第三,容易出现拼写错误。解决性能问题的话,可以采用字符串散列标识符。但是命名冲突的话,就需要额外注意了。或者增加一个判断重复的操作。

d)  事件参数:事件的参数和函数参数类似,可能有多个,也可能有不同类型的参数。

最简单的方式是基类木有参数,仅有消息的类型,对应无参数的命令。而其他类型的消息作为该类的派生类,然后将参数硬编码到其中。

第二种方式是使用variant集合,存储多种数据类型。最麻烦的方式是使用键值对作为参数,可以避免参数顺序的问题。

e)  事件处理器:当游戏对象接受了一个命令,就要以某种方式进行回应,称为事件处理。事件处理器通常是一个原生的虚函数或者脚本函数。能处理所有的事件,通常包含一系列的switch语句。

事件处理器需要取出事件提供的参数,参数有两种方式,第一种是由消息包本身附带提取参数的函数,不过这样做不太好,因为基类的消息包需要含有所有消息包的提取虚函数,换句话说,消息包本身需要对功能结构了解。第二种是处理函数手工提取消息,并将其转化为需要的类型,这样有可能会有安全问题,但是既然知道是什么消息包了,处理好就不会有问题。

职责链:游戏对象几乎都要有依赖关系,对象关系中转发事件是一种常用的设计模式,称之为职责链。职责链要处理这样一件事:当一个事件发给链头时,链头先自己进行判断,自己是否需要处理,然后判断是否需要向下转发,可以进行继续转发,也可以把消息包吃掉,不再转发。

事件转发也用于多播,比如爆炸发生时,需要把事件传递给范围内的多个对象。过程是先查询范围内的对象,然后把事件发送给这些对象。

登记对事件的关注:大部分对象都不需要接受所有的事件,只会关注一小部分事件的集合,所以多播或者广播就是很低效的事。进行登记的话,简单的方式是事件类型包含一个链表,内部维护关注该事件的对象,或者每个对象维护一个数位组,每一位表示该对象是否关注某种事件,再或者要发送事件前的查询缩小范围,仅查询要关注该事件的对象。

关于事件队列:引擎要提供立即处理刚刚发出事件的机制,除此以外,还有的提供了事件队列,可以将事件缓存起来。这样做增加了灵活性,但是也增加了复杂性。不过个人感觉事件队列还是很有用的。好处如下:第一,可以控制事件处理的时机,有些需要立即处理,而有些则不着急,那么可以搞一个慢速队列存储这些不着急的东东,而且处理的顺序我们也可以根据权值进行排序,或者插入的时候直接按照顺序插入。第二,甚至可以向未来投递事件,消息包中设置一个投递时间,可以延后几帧或者几秒处理,甚至可以处理完再发送一个相同的包达到周期性的效果。这需要在事件包中加入一个时间戳,仅当游戏时间大于等于该时间,才进行处理。使用优先队列或者插入时进行排序,这样,处理完前面需要处理的事件之后,后面的就不需要处理了。每帧至少调用一次事件分发队列,至少会处理一次。而事件的优先次序也是很重要的,因为时间会量化为帧数,那么在同一帧的事件就是相同时间了,消除这个歧义的方法是设置事件的优先次序,可以更好的控制事件处理顺序。

         当然,事件队列也会带来一系列的问题。第一就是复杂,这个没什么说的了。第二,正常我们处理一个事件,可以在产生事件时,设置一系列参数,然后传递这个对象的引用到事件处理系统,由于没有出作用域,在处理完事件后,事件本身才会被销毁,但是,如果放到队列中,就涉及到一个问题,函数作用域结束了,队列中的事件也就没了,保留的仅是一个空的引用,所以就需要进行深复制,这就涉及到动态内存分配,而动态内存分配又有可能导致内存碎片,需要内存池分配器。(不过我在想,可不可以在事件处理队列中,不使用引用,直接采用值传递进行呢?虽然会有开销的问题,但是把事件直接建立在队列中,不是和之前的在外部建立一个对象一样嘛,而且还省去了管理内存的麻烦)第三,就是调试困难,事件的发送和处理不在一起,甚至不在一帧,这个肯定调试起来麻烦得多。

         而非排队的事件处理系统也是会有问题的,就是可能会导致堆栈调用非常深,甚至达到栈溢出的情况。

         总之,事件处理系统非常非常的灵活,比直接使用C/C++的对象关联能做的事多得多,也灵活得多,但是上面的那些做法还是不够灵活,更加强大的是真正的使用数据驱动事件传递系统,不使用硬编码,在编辑器中对对象,提供各种选项,使之能响应不同的事件。一种做法是提供脚本语言接口,可以定义不同的事件类型,提供脚本回调等等,更加牛的一种方式是提供流程图,连接流程图来定义对象流程。这种流程图的话,一个事件可能对应很多响应对象,但是事件本身可能并不知道,一种方法是将事件类型去掉,给对象提供一个输入端口,然后事件连接这个端口,如果端口中输入了特定的消息,那么就进行相关的处理操作。

f)  脚本:

脚本可以让引擎用户进行简单编程,来控制引擎,比如加个Mod之类的,是很方便的一种方法。

脚本分为两种,一种是定义数据的,用于读取数据,相比硬编码更加灵活。而另一种才是真正的脚本,可以控制程序的流程,脚本可以在游戏流程中执行。

脚本语言一般采用解释型语言,由一个虚拟机解释脚本语言,这种虚拟机一般比较轻量,支持快速迭代,不需要重新编译程序,修改之后立刻就能看到效果,而且比较简单易用。

         脚本所需要的架构:

1.      回调脚本:在这种架构下,引擎的主要功能大部分是由原生编程语言硬编码的,只有关键部分的小功能设计成可定制的,这些部分一般设计成回调脚本,即脚本写出来的函数,由程序调用。比如更新对象的时候,写一些可选的功能,这些功能采用脚本回调。这个回调函数可以由原生语言写,脚本控制用不用,也可以直接用脚本语言回调。

2.      事件处理器脚本:也是一种特殊的钩子函数,作用是令游戏对象回应游戏世界发生的相关事件,或者回应引擎本身的事件。

3.      以脚本扩展游戏对象类型或者生成新类型:把脚本绑定到游戏对象上,根据继承或者聚合来控制游戏对象类型。

4.      组件或属性脚本:在基于组件模型的对象模型中,需要用脚本来决定对象需要哪些组件,这样才能灵活的控制生成对象的类型和拥有的属性。

5.      脚本驱动的引擎系统:可以用脚本驱动整个引擎的运作,比如对象模型完全用脚本编写,仅当需要一些底层功能时才使用原生引擎代码。

6.      脚本驱动的游戏:原生代码仅作为程序库,脚本代码是游戏运行的主体。

脚本需要的功能:

1.      和原生语言相互调用:脚本语言调用原生语言,原生语言也能调用脚本语言,这样就能非常灵活的控制游戏的进行。

2.      游戏对象的句柄:由于脚本语言不能直接操作原生语言中的指针,所以怎样获得一个游戏对象就需要考虑了,一般是使用句柄来引用游戏对象。句柄的话,之前也提到过,一种是使用数值型GUID,虽然占用空间小,但是不够直觉,比如想直接用脚本操作某个对象,找不到GUID就比较麻烦。另一种是字符串型句柄,这种就很直接了,想要用脚本进行某个操作的话,直接使用对象名称即可操作对象。但是这样做就有性能的代价,字符串肯定慢得多,而且如果有人修改了编辑器中的对象名,而没有修改脚本中的名称的话,就有可能出现问题。还有一种折中的办法,就是使用字符串散列标识符,既能像字符串那样阅读,又能有整数那种运行时的性能。

3.      接收及处理事件:事件处理是引擎的核心功能之一,如果将事件处理交给脚本的话,那么就能更加灵活的控制游戏的流程。事件通常关系到一个对象,有的引擎采用一类对象绑定一类脚本的办法,但是这个是灵活的,比如一类对象用同一种脚本,然后每个对象可以绑定特定的脚本,提供各种脚本作为组件供调用。脚本当然不仅仅是绑定在对象上的,绑定在引擎本身处理引擎的事件,或者绑定在一个区域,或者作为独立的Trigger等等。更加灵活的就是,采用状态机的思想,脚本分为不同的状态,进行不同的操作。或者当有事件发生时,可以考虑采用原生的语言处理,也可以选择使用脚本,通过状态机进行判断,如果是这个状态,执行脚本,否则忽略脚本。

4.      发送事件:引擎调脚本处理已经很灵活的了,那么脚本反过来调用引擎无疑是更加灵活。脚本可以向引擎传递引擎中定义好的消息,然后引擎进行处理。甚至可以这样,脚本完全定义新的事件类型,另一个对象的脚本定义接受这个事件类型的方法,那么就完全实现了脚本之间的控制,灵活度更高。

最后一种就是一个高层次的脚本,说是高层次其实也不算,不是控制引擎的,而是控制任务系统的脚本,这个系统通常实现为一个状态机,每个状态表示玩家的任务目标,这种脚本控制游戏在失败时或者达成任务时会发生什么。更高层的就是并行任务系统,这种就不能用简单的状态机来实现了。

 

 

三.其他内容:

最后一章了,这章主要介绍了一些其他的引擎需要的部件,虽然不如上面那些重要,但是也是必不可少的部件:

a)音频系统:游戏世界的第四维度

b)视频接口:CG动画棒棒哒

c)网络接口:网游辣么火,当然必不可少了

d)玩家机制:游戏是很多对象的集合,但是我们控制的人物才是最重要的,也是最核心的部分。

f)摄像机:摄像机是我们观察游戏世界的窗口,其重要程度可想而知。游戏一般有这几种摄像机:

         1.注视摄像机:围绕一个物体旋转,并且可以拉远拉近

         2.跟随摄像机:常用于平台游戏,第三人称游戏,赛车类游戏,聚焦于玩家本身,需要注意的就是摄像机本身的碰撞,不要出现穿过物体的穿帮镜头,而且还要给玩家一定调节摄像机操作的灵活度。

         3.第一人称摄像机:这个是第一人称游戏的必备之物,固定于角色的眼睛位置,可以通过鼠标进行控制摄像机的方向。

         4.即时战略类摄像机:传说中的上帝视角,可以模拟浮于地形上的摄像机,角度朝下,玩家可以控制摄像机在地形上水平移动,但是不能控制摄像机的偏航角和俯仰角。

5.电影摄像机:track view,比如播放剧情时,运镜效果。

g)人工智能:这个也是游戏中的一个大头。AI主要包括:寻路,感知系统,视线系统,环境理解,记忆功能等等。

h)其实游戏世界远远不止这些,我的感觉,游戏就是一个世界,现实中有的,游戏中都可以有,游戏开发永无止境!

 

 

 用了一个半月时间,终于把这本书读完了,感觉真的是一本好书,让我真正知道了游戏引擎都包含哪些内容,感觉游戏真的是深不可测,真的由衷的佩服那些写出引擎的大牛们。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值