Code in C# and build 3D games with Unity读书笔记

Code in C# and build 3D games with Unity读书笔记

变量

•从概念上讲,变量是编程的最基本单位,就好比原子,一切都基于变量,没有变量,程序就可能存在。

•从技术角度看,变量是用于存储指定值的一小部分计算机内存。变量会跟踪信息的存储位置(称为内存地址)以及值与类型(例如,数字、单词、列表)。

•实际上,变量就相当于容器。可以随意创建新变量,然后赋值,移动位置,并在需要的位置引用。即便空的变量也是有用的。

变量需要满足以下基本要求:

•需要指定变量存储的数据类型

•变量必需具有唯一的名称

•为变量指定的值必须匹配指定的类型

•变量声明必须以分号结尾

dataType uniqueName = value;

访问修饰符

任何未标记为public的变量都不会显示在Unity的Inspector面板中。

accessModifier dataType uniqueName = value;

选择安全级别

C#中有四个访问修饰符,但是作为初学者,最常用的是下面两个。

•public:对任何脚本开放,不受限制。

•private:变量仅在创建它们的类(称为所属类)中可用。没有使用访问修饰符声明的任何变量默认都是私有的。

另外两个访问修饰符如下:

•protected:变量在所属类或派生类中可以访问。

•internal:仅在当前程序集中可用。

类型转换

•隐式转换

•显式转换

•C#提供了用于把数值转换为通用类型的内置方法,任何类型都可以通过ToString方法转换为字符串。另外Convert类可以处理更复杂的转换。

推断式声明

C#能够从赋值中推断出变量的类型。var关键词使程序知晓currentAge变量的类型由值32决定:

C#
var currentAge = 32;

推断式声明在某些情况下很方便,但不要陷入这种习惯。

自定义变量

类、结构、和枚举都可以存储为变量。

类型综述

•所有变量都需要具有指定的类型(无论是现实的还是推断式的)

•变量只能保存指定类型的值(比如,不能将字符串赋值给int变量)

•每种类型都有一组能用和不能用的运算符(不能用另一个值减去布尔值)。

•如果一个变量需要用其他类型的变量进行复制或组合使用的话,那么需要它们进行转换(隐式转换或显式转换)

•C#编译器可以使用var关键字从变量的值推断出变量的类型,但仅应在变量类型未知时使用。

命名变量

•命名变量的第一条原则就是变量名要有意义,其次是要使用驼峰式命名风格。

如果声明如下变量来存储玩家的健康情况:

public int health = 100;

你的脑海中应该会出现一连串的问题。谁的健康状况?存储的是最大值还是最小值?当值发生更改时,其他代码会受到什么影响?这些问题都可以通过使用一个有意义的变量名来轻松的回答。

使用如下方式命名变量可能更合适

public int maxCharacterHealth = 100;

只有类变量可以在Inspector面板中查看,局部或全局变量则不能。

方法

•从概念上讲,方法是在应用程序中完成工作的方式。

•从技术上讲,方法是包含可执行语句的代码块,这些语句在调用方法时运行。方法可以接受参数,并且需要在方法的作用域内使用。

•实际上,方法是每次执行时都会运行一组指令的容器。这些容器可以接收变量作为输入,这些变量只能在方法内部使用。

基本语法

同变量一样,方法的声明也有一些基本要求:

•需要返回数据类型

•必须具有以大写字母开头的唯一名称

•方法名的后面需要有一对括号

•需要使用一对花括号标记方法体(指令的存储位置)

把以上所有这些要求放在一起,就可以得到如下简单的方法蓝图:

returnType UniqueName()

{

method body

}

修饰符和参数

与变量和输入参数一样,方法也有4个可用的访问修饰符。参数是变量占位符,可以传递到方法中并在方法内部访问。输入参数在数量上没有限制,但每个参数都要用逗号进行分割,并且都有自己的数据类型和唯一的名称。

可以把方法的参数看作变量占位符,它们的值可以在方法体中使用

更新后的方法蓝图如下:

C#
accessModifier returnType UniqueName(parameterType parameterName)

{

method body

}

如果没有显式的访问修饰符,则方法默认为私有的。与私有变量一样,私有方法不能从其他脚本中调用。

在本文中,我们把方法的创建或声明称为定义方法。同样,把运行或执行方法称为调用方法。

方法名总是以大写字母开头,并且后续任何单词的首字母也需要大写。这种命名风格又称为帕斯卡大小写(PascalCase)

指定参数

方法的每个参数都是一条指令,需要满足以下要求:

•拥有显式的类型

•拥有唯一的名称

可以根据需要定义尽可能多的参数。不管编写自定义方法还是使用内置方法,其中定义的参数都是方法执行指定任务所必需的。

常见的Unity方法

与自定义方法不同,Unity引擎会根据它们各自的规则自动调用术语MonoBehaviour类的方法。在大多数情况下,脚本中至少要有一个MonoBehaviour方法来启动自己的代码,这很重要

Start方法

Unity在启动脚本的第一帧调用的就是Start方法。MonoBehavior脚本几乎总是被附加到场景中的游戏对象上,当你单击Play按钮时,它们的附加脚本在加载的同时也将被启用。Start方法主要用于在Update方法运行之前第一次设置变量或者执行需要发生的逻辑。

到目前为止,所有示例都适用了Start方法,即使他们没有执行设置操作,但这通常不是使用Start方法的最佳方式。然后,Start方法只需要触发一次,这使其成为在控制台中显示一次性信息的绝佳工具。

Update方法

如果有足够多的时间查看参考脚本中的实例代码,就会注意到绝大多数代码是用Update方法执行的。当游戏运行时,场景每秒会刷新很多次,这称为帧率或每秒传输帧数(Frames Per Second,FPS)

在显示每一帧之后,Unity会调用Update方法,Update的方法是游戏中执行次数最多的方法之一,这使其非常适合用来检测鼠标和键盘输入或运行游戏逻辑。

如果对计算机的FPS感到好奇,请在Unity中单击Play按钮,然后打开Game视图中右上角的Stats选项卡

•从概念上讲,类能将相关信息、操作、行为存储到单个容器中,类之间甚至可以相互通信。

•从技术上讲,类是数据结构,其中包含类变量、方法和其他编程信息。创建完类的对象后,就可以引用类中的信息。

•实际上,类就是蓝图。类为创建对象制定了规则。

默认情况下,Unity创建的每个脚本都是类,这可以从脚本中的class关键字看出。

public class LearingCurve: MonoBehaviour

MonoBehaviour用于将类附加到Unity场景中的游戏对象上。C#中的类可以是单独存在的

对于Unity资源来说,有时候可以互换使用脚本和类这两个术语。为了保持一致,如果把C#文件附加到游戏对象上,则称它们为脚本;单独的C#文件则称为类。

在Inspector面板更改变量

当修改变量的值时,存在如下两种模式

•Play模式

•开发模式

在Play模式下,所有的更改将马上生效,这对于测试和微调游戏很有用。但需要注意的是,当停止游戏并返回开发模式时,Play模式下的所有更改都会丢失。

在开发模式下,Unity会保留变量的所有更改。这意味着如果重启Unity,那么之前的更改仍会保留。

在Inspector面板中,我们对变量所做的更改不会同步更新到脚本中。更改脚本中的这些变量的值的唯一方法就是在visual Studio中编辑他们各自的值。Inspector面板中显示的值会覆盖脚本中分配的值。

如果需要撤销在Inspector面板中所做的任何更改,可以把脚本重置为默认值(有时称为初始值)。单击组件最右边的齿轮图标,从弹出菜单中选择Reset即可。

来自MonoBehaviour的帮助

我们的脚本继承子MonoBehaviour类,这让Unity获知这个C#类可以转换成组件。它将自已的一些变量和方法提供给我们的脚本。

简单的调试技术

•对于简单的文本或单个变量,请使用Debug.Log()方法。文本必需在括号内,并且变量可以直接使用而无需添加字符:

C#
Debug.Log(“Text goes here.”);

Debug.Log(yourVariable);

•对于更复杂的调试,可以使用Debug.LogFormat()方法。通过使用一对花括号标识的占位符,可以在打印文本中放置变量。每组花括号都包含一个从0开始的索引,对应一个顺序变量。

在下面的示例中,{0}占位符被替换为变量variable1的值,{1}占位符被替换为变量variable2的值,以此类推

C#
Debug.LogFormat(“Text goes here, and {0} and {1} as variable placeholders”,variable1,variable2);

流程控制

选择语句

最复杂的编程问题通常可以归结为对游戏或程序进行评估并执行的一系列简单选择。因为Visual Studio 和 Unity 无法自己做出这些选择,所以这些决定要由我们来做。通过使用if-else 和 switch 选择语句,我们可以基于一个或多个条件以及每种情况下将要执行的操作来指定分支路径,

传统上,这些条件包括:

•检测用户输入

•计算表达式和布尔逻辑

•比较变量或字面值

处理逻辑结果的责任完全由程序员承担,程序要需要自行决定代码执行的分支或结果。

switch语句

嵌套过多的代码最终将难以理解,且很难修改。switch语句能让我们为每种可能的结果编写代码。但格式相比if-else语句更为简洁

1.基本语法

switch语句需要具备以下条件

•switch关键字,后跟随一对用来放置条件的括号。

•一对花括号

•以冒号结尾的每种可能情况的case字句

○单独的代码行或方法,后跟break关键字和分号

•默认的default字句以冒号结尾

○单独的代码行或方法,后跟break关键字和分号

以蓝图的形式看起来语法如下:

C#
switch(matchExpression)

{

case matchValue1:

    Executing code block;

    break;

case matchValue2:

    Executing code block;

    break;

default:

    Executing code block;

    break;

}

集合一览

到目前为止,我们只需要使用变量来存储单个值,但很多情况下我们需要存储一组值。此时,集合就派上用场了。C#中的集合类型包括数组(Array)、字典(Dictionary)和列表(List),它们各有优缺点。

数组

数组是C#提供的最基本的集合。可以将数组视为一组值的容器,这些值在编程术语中被称为元素,每个值都可以单独访问或修改。

•数组可以存储任何类型的值,但其中的所有元素必须是同一类型。

•数组的长度或元素个数是在创建时设置的,且之后不能再修改。

•如果在创建时没有分配初始值的话,那么每个元素会使用默认值。存储数字类型的数组的默认值是0,而存储其他类型数据的数组的默认值为null。

数组是C#中最不灵活的集合类型,主要是因为数组在创建后不能添加或删除。不过,当需要存储不太可能改变的信息时,数组特别有用。

1.基本语法

声明数组类似于声明我们之前使用的其他变量类型,但也有一些差别。

•数组需要指定的元素类型、一对方括号和唯一的名称。

•new关键字用于在内存中创建数组,后跟值的类型和另一对方括号

•数组将要存储的元素数量则放在第二对方括号中

以蓝图的形式看起来语法如下:

C#
elementType[] name = new elementType[numberOfElements];

例如,我们需要在游戏中存储得分的前三名:

C#
int[] topPlayerScores = new int[3];

topPlayerScores是存储了3个整型元素的整形数组,由于我们没有添加任何初始值,因此topPlayerScores数组中的3个元素默认都为0.

C#为此提供了两种有效的语法:普通语法和速记语法

C#
// 普通语法

int[] topPlayerScores = new int[] {713, 549, 984};

// 速记语法

int[] topPlayerScores = {713, 549, 984};

使用速记语法初始化数组很常见,但如果想让自己想起细节,请随时使用含义明确的用词。

2.索引和下标

每个数组元素都按分配的顺序进行存储,这称为索引。数组元素从0开始索引,这意味着数组元素的顺序从0开始而不是1开始。

下标运算符是一对包含元素索引的方括号,各个元素可通过下标运算符按索引进行定位。

例如int score = topPlayerScores[1];

使用下标运算符还可以直接修改数组中的值,就像其他变量一样,甚至可以将自己作为表达式来传递:

C#
topPlayerScores[1] = 1001;

Debug.Log(topPlayerScores[1]);

好的编程习惯要求我们通过检查所需值是否在数组索引范围内以避免异常

列表

列表与数组密切相关,列表能将相同类型的多个值收集到单个变量中。但是,在添加、删除和更新元素时,列表要灵活的多,这使列表在大多数情况下成为首选。

1.基本语法

列表类型的变量需要具有以下条件:

•List关键字,元素类型定义在后面的一对尖括号中,此外还要有唯一的名称。

•new关键字,用于初始化内存中的列表以及List关键字后面的一对尖括号中的元素类型。

•以分号结尾的一对括号。

以蓝图的形式看起来语法如下:

List name = new List();

列表的长度总是可以修改的。因此,不需要在创建时就指定列表最终存储的元素数量

与数组一样,可以在变量的声明中通过在花括号内添加元素值来初始化列表:

List name = new List() { value1, value2 };

元素从索引0开始按照添加的顺序进行存储,并且可以使用下标运算符进行访问。

C#
void Start()

{

List<string> questPartyMembers = new List<string>()

    { "Grim the Barbarian", "Merlin the wise", "Sterling the knight"};



Debug.LogFormat("Party Members: {0}", questPartyMembers.Count);

}

2.常用方法

只要索引在列表范围内,就可以像数组那样使用下标运算符和索引访问并修改列表元素。但是,List类提供了多个用来扩展列表功能的方法,从而添加、插入和删除元素。

继续使用questPartyMembers列表。下面将一个新的成员添加到这个列表中:

questPartyMembers.Add(“Craven the Necromancer”);

Add方法可以将新元素追加到列表的末尾,这将使questPartyMembers列表的长度变为4,并使元素的顺序变为如下形式:

{“Grim the Barbarian”, “Merlin the wise”, “Sterling the knight”, “Craven the Necromancer”};

要在列表中的特定位置添加元素,可以向Insert方法传递想要添加的索引和值:

questPartyMembers.Insert(1,“Tanis the Thief”);

当把一个元素插入之前已占用的索引位置时,列表中后方元素的索引都将加1。在这里,"Tanis the Thief"的索引现在为1,这意味着"Merlin the Wise"现在的索引是2而不是1,以此类推

删除元素非常简单,只需提供索引或字面值,List类即可完成工作:

C#
questPartyMembers.RemoveAt(0);

questPartyMembers.Remove(“Grim the Barbarian”);

用于检查值的List类的方法还有很多,完整的方法列表及相关描述可通过微软文档获得

字典

与数组和列表相比,字典在每个元素中存储值对而不是单个值。字典中的元素称为键值对:键充当对应值的索引。与数组和列表不同,字典是无序的。但是,字典可以在创建以后以各种配置进行排序。

1.基本语法

声明字典和声明列表几乎一样,但是许多细节(比如键和值的类型)需要在尖括号中进行指定:

C#
Dictionary<KeyType, valueType> name = new Dictionary<KeyType, valueType>()

{

{key1, value1},

{key2, value2}

};

选择键值时,每个键必须唯一,且不能更改。如果需要更新键值,请在变量声明中更改对应的值,或者删除后重新添加。

就像使用数组和列表一样,可以在一行中初始化字典,这在Visual Studio中没有问题。但是,与前面的示例一样,在每一行中写出完整的键值对是一种良好习惯,这有利于提高代码的可读性。

C#
// 创建了一个名为 itemInventory 的字典,并使用三个键值对进行初始化。我们指定键为字符串类型、对应的值为整形,然后打印 itemInventory字典中元素的数量

void Start()

{

Dictionary<string, int> itemInventory = new Dictionary<string, int>()

{

    {"Potion", 5},

    {"Antidote", 7},

    {"Aspirin", 1}

};



Debug.LogFormat("Items:{0}", itemInventory.Count);

}

2.使用字典对

可以使用下标和类方法在字典中添加、删除和访问键值对。

要检索元素的值,请使用带元素键的下标运算符。

C#
// numberOfPotions 将被赋值为5:

int numberOfPotions = itemInventory[“Potion”];

元素值可以使用同样的方法更改。

C#
itemInventroy[“Potion”] = 10;

// 与“Potion”关联的值现在是10

元素可以通过两种方法添加到字典中:使用Add方法或下标运算符

Add方法接收键和值,并使用它们创建新的键值对元素,只要类型与字典声明中的一致即可:

C#
itemInventory.Add(“Throwing Knife”, 3);

如果使用下标运算符为字典中不存在的键赋值,编译器将自动把它们添加为键值对。

例如,如果想添加新的元素“Bandages”,可以使用一下代码

C#
itemInventory[“Bandages”] = 5;

这就引出如下关于引用键值对的关键问题:最好在尝试访问一个元素之前确定这个元素是否存在,以免错误地添加新的键值对。将ContainsKey方法与if语句配对是一种简单的解决方案,因为ContainsKey方法会根据键是否存在返回一个布尔值。在以下代码中,我们确保“Aspirin”键存在后才更改对应的值:

C#
if(itemInventory.ContainsKey(“Aspirin”))

{

itemInventory["Aspirin"] = 3;

}

最后,可以使用Remove方法从字典中删除键值对,只需要传入键作为参数即可:

C#
itemInventory.Remove(“Antidote”);

与列表一样,字典也提供了很多方法和功能,从而使开发变得更容易,可以在微软找到相关文档。

迭代语句

当需要逐个元素地遍历整个集合时,在编程中,这被称为迭代(iteration)

for循环

for循环语句的蓝图如下:

C#
for (initializer; condition; iterator)

{

code block;

}

•整个结构从for关键字开始,后面跟着一对括号

•括号内是三个表达式——初始化表达式、条件表达式和迭代表达式

•循环从初始化表达式开始,可创建局部变量来追踪循环执行的次数——设置为0,因为集合类型是从0开始索引的。

•接下来检查条件表达式,如果为true,就进行迭代

•迭代表达式用于初始化变量的增减,这意味着下一次循环计算条件表达式初始化变量会有所不同。

C#
List questPartyMembers = new List()

    { "Grim the Barbarian", "Merlin the wise", "Sterling the knight"};

for (int i = 0; i < questPartyMembers.Count; i++)

{

Debug.LogFormat("Index: {0} - {1}",i, questPartyMembers[i]);

}

///

///初始化表达式将名为i的局部变量设置为0

///确保只有当i小雨questPartyMember中元素的数量时,for循环才会执行

///for循环每执行一次就使用++运算符将i加1

///在for循环内部,我们使用i打印出索引和索引处的列表元素。请注意,i与集合元素的索引将保持一致,因为它们都是从0开始的

///

foreach循环

foreach循环能够获取集合中的每个元素并将其存储到局部变量中,从而可以在语句中访问它们。局部变量的类型必须与集合元素的类型匹配才能正常工作。foreach循环可以与数组和列表一起使用,但是它们对于字典来说尤为有用,因为它们不是以数字索引的。

以蓝图的形式看起来语法如下:

C#
foreach(elementType localName in collectionVariable)

{

code block;

}

下面继续使用questPartyMembers列表示例,对其中的每个派对成员进行点名:

C#
List questPartyMembers = new List()

{ "Grim the Barbarian", "Merlin the wise", "Sterling the knight"};

foreach(string partyMember in questPartyMembers)

{

Debug.LogFormat("{0} - Here!",partyMember);

}

下面稍作分析:

•元素类型被声明为字符串,从而能够与questPartyMembers列表中的值匹配。

•创建一个名为partyMember的局部变量,用于在每次循环中保存元素

•in关键字后面是想要遍历的集合,在本例中是questPartyMembers列表

这比for循环要简单的多,但是在处理字典时,我们需要指出如下重要的区别:如何将键值对作为局部变量处理

遍历键值对

为了在局部变量中捕获键值对,需要使用KeyValuePair类型,同时分配键和值的类型以匹配字典中相应的类型。KeyValuePair由于能够作为自身的类型,因此可与其他任何元素类型一样充当局部变量。

C#
Dictionary<string, int> itemInventory = new Dictionary<string, int>()

{

{"Potion", 5};

{"Antidote", 7};

{"Aspirin", 1}

};

foreach(KeyValuePair<string, int> kvp in itemInventory)

{

Debug.LogFormat("Item: {0} - {1}g", kvp.Key, kvp.Value);

}

我们已经指定来命名为kvp的KeyValuePair局部变量,这个变量的作用类似于for循环的初始化表达式中的i,用于将键和值的类型设置为字符串和整数以匹配itemInventory。

要访问局部变量kvp的值,可以使用kvp.Key, kvp.Value

While循环

while循环与if语句的相似之处在于,只要单个表达式或条件为true,它们就可以运行。值的比较结果和布尔变量可以用作while条件,你也可以使用逻辑非运算符修改条件,

while循环的语法如下:当条件为true时,就会一直运行代码块。

initializer

while(condition)

{

code block;

iterator;

}

在while循环中,通常需要声明一个初始化变量,就像在for循环中一样,然后在代码块的末尾手动对这个初始变量进行增减。根据自身的情况,初始化表达式通常是循环条件的一部分。

C#
// 示例——追踪玩家是否还活着

void Start()

{

int playerLives = 3;



while(playerLives > 0)

{

    Debug.Log("Still alive!");

    playerLives--;

}



Debug.Log("Player KO'd...");

}

超越无限

我们需要理解关于迭代语句的一个极其重要的概念:无限循环。顾名思义,无限循环指的就是循环无法停止,因而将在程序中一直运行。

在for和while循环中,当迭代变量不增加或减少时,通常会发生无限循环。例如,在之前的while循环示例中,如果去掉playerLives–代码行,Unity就会崩溃,因为playerLives始终为3,循环会永远执行下去。

另外,在for循环中,永远不会通过或计算为false的设置条件,也可可能导致无限循环。在遍历键值对时,如果将for循环条件设置为i>=0而不是i<questPartyMembers.Count,那么i永远不会小于0,for循环会一直运行下去直到Unity崩溃。

OOP

LearningCurve脚本是类,而且Unity知道LearningCurve脚本可以附加至场景中的游戏对象上。关于类,最重要的是记住它们是引用类型:当它们被赋值或传递给另一个变量时,系统引用了原始对象而不是创建一份新的副本。在讨论完结构体后,我们将对此进行探讨。在此之前,我们首先需要理解如何创建类。

定义类

基本语法

如何在C#中创建并使用类——类都是通过使用关键字class创建的:

C#
accessModifier class UniqueName

{

Variables

Constructors

Methods

}

为了使教程中的示例尽可能统一,我们将创建并修改典型游戏中都会有的Character类。

创建Character类

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class Character

{

}

Character类现在已注册为公共类,这意味着项目中的任何类都能用它来创建角色。但是,这些还只是指令。为了实际创建出角色,还需要执行一些额外的步骤,这个过程被称为实例化。

实例化类对象

实例化就是根据一组特定的指令来创建对象的行为,创建的对象被称为实例。假如类是蓝图,那么实例就是根据蓝图中的指令建造的房屋。Character类的每一个新实例也都是Character类自己的对象,就像按同样的规划建造的两幢房屋。

创建新角色

Character类被声明为公共类,这意味着可以在其他任何类中创建Character示例,下面在LearningCurve脚本的Start方法中声明一个名为hero的Character类型的变量:

C#
Character hero = new Character();

•变量类型被指定为Character意味着变量是Character类的实例。

•变量名为hero,然后使用类new关键字后跟Character类名及两个括号进行初始化。这样便在内存中创建了真实的实例,即使Character类还是空白的。

现在就可以使用hero变量,就像到目前为止已经使用过的其他变量一样。在Character类有了自己的字符和方法后,便可以通过点符号来获取它们。

添加类字段

向自定义类添加变量或字段的方式与之前在LearningCurve脚本中使用的方法相比没有声明不同。概念是一样的,包括访问修饰符、变量作用域和赋值。然而,任何属于类的变量都是随着类的实例而创建的,这意味着如果没有赋任何值,它们将默认保持为0或使用空值。通常,如何设置初始值取决于它们将存储哪些信息:

•一个变量如果无论何时创建实例都需要有相同的值,那么设置初始值是个好主意。

•一个变量如果需要在每一个实例中进行自定义,那么不用赋值,使用类的构造函数即可(后面讨论)

填充角色的细节信息

C#
//Character.cs

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class Character

{

// Character类现在已注册为公共类,这意味着项目中的任何类都能用它来创建角色。

public string name;

public int exp = 0;

}

//LearningCurve.cs

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

// Start is called before the first frame update

void Start()

{

    Character hero = new Character();

    

    Debug.LogFormat("Hero: {0} - {1} EXP",hero.name,hero.exp);

}





// Update is called once per frame

void Update()

{

    

}

}

在初始化hero后,name被设置为空,因而在调试日志中显示为空白,exp则显示为0。此时Character类已经可用,但现在只有空值,并无实际用处,可通过Character类的构造函数来修改值。

使用构造函数

构造函数是一类特殊的方法,可在创建类的实例时自动调用,运行方法类似于LearningCurve脚本中的Start方法,顾名思义,构造函数会根据蓝图来构造类。

•如果未显式指定,C#会生成默认的构造函数。默认的构造函数会将任何值设置为它们的类型默认值:数值型变量会设置为0,其他类型的变量则设置为null

•就像任何其他方法一样,可以使用参数自定义构造函数,这些参数用于在初始化时设置类变量的值

•一个类可以有多个构造函数

构造函数的编写方式类似于常规方法,但也有一些区别。构造函数需要是公共的,没有返回类型,并且名称要与类名一致。例如,如果向Character类添加一个不带参数的基本构造函数,那么这个构造函数会将name设置为不是null的值。将这些新代码直接放在类变量的下面,如下所示:

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class Character

{

// Character类现在已注册为公共类,这意味着项目中的任何类都能用它来创建角色。

public string name;

public int exp;



public Character()

{

    name = "Not assigned";

}

}

指定初始属性

添加另一个构造函数,设置初始化名称并赋给name字段。

C#
// Character.cs

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class Character

{

// Character类现在已注册为公共类,这意味着项目中的任何类都能用它来创建角色。

public string name;

public int exp;



public Character()

{

    name = "Not assigned";

}



public Character(string name)

{

    this.name = name;

}

}

//LearningCurve.cs

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

// Start is called before the first frame update

void Start()

{

    Character hero = new Character();

    Character heroine = new Character("Agatha");

    

    Debug.LogFormat("Hero: {0} - {1} EXP",hero.name,hero.exp);

    Debug.LogFormat("Hero: {0} - {1} EXP",heroine.name,heroine.exp);

}





// Update is called once per frame

void Update()

{

    

}

}

声明类方法

将方法添加到自定义类中与将方法添加到LeaningCurve脚本中相比并没有什么不同。然而,这是一次讨论良好编程习惯的好机会。DRY(Don‘t Repeat Yourself)是判定所有良好代码的重要标准。事实上,如果发现自己在不断重复同一行代码或多行代码,那就表示需要多思考并重新组织代码。可采用声明新方法的方式来处理重复代码,从而使得在其他地方修改和调用统一功能变得更加容易。

用编程术语来讲,就是从中抽象出方法或特性。

打印角色数据

C#
// Character.cs

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class Character

{

// Character类现在已注册为公共类,这意味着项目中的任何类都能用它来创建角色。

public string name;

public int exp;



public Character()

{

    name = "Not assigned";

}



public Character(string name)

{

    this.name = name;

}



public void PrintStatsInfo()

{

    Debug.LogFormat("Hero:{0} - {1} EXP",name,exp);

}

}

//LearningCurve.cs

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

// Start is called before the first frame update

void Start()

{

    Character hero = new Character();

    Character heroine = new Character("Agatha");

    

    hero.PrintStatsInfo();

    heroine.PrintStatsInfo();

}





// Update is called once per frame

void Update()

{

    

}

}

这种行为比直接在LearningCurve脚本中使用调试日志更合适。将功能集合到类中并通过方法进行调用是个好主意。这能使代码更具有可读性——因为Character对象在打印调试日志时将给出命令而不是重复的代码,

结构体

结构体与类相似,也是要在程序中创建对象的蓝图。主要区别在于:结构体是值类型,这意味着它们是通过值而不是引用(例如类)进行传递的。我们首先来了解结构体如何工作,以及在创建它们时需要遵循的特殊规则。

基本语法

结构体的声明与类相似,并且可以容纳字段、方法和构造函数

C#
accessModifier struct UniqueName

{

Variables

Constructors

Methods

}

但是,结构体存在以下一些限制:

•变量无法在结构体声明的内部进行初始化,除非对他们使用static或const进行修饰,详暂略。

•结构体不支持无参构造函数

•结构体带有默认构造函数,从而能够根据类型自动将所有变量设置为默认值。

创建Weapon结构体

C#
// Character.cs

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class Character

{

// Character类现在已注册为公共类,这意味着项目中的任何类都能用它来创建角色。

public string name;

public int exp;



public Character()

{

    name = "Not assigned";

}



public Character(string name)

{

    this.name = name;

}



public void PrintStatsInfo()

{

    Debug.LogFormat("Hero:{0} - {1} EXP",name,exp);

}

}

public struct Weapon

{

public string name;

public int damage;



public Weapon(string name, int damage)

{

    this.name = name;

    this.damage = damage;

}

}

// LearningCurve.cs

using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

// Start is called before the first frame update

void Start()

{

    Character hero = new Character();

    Character heroine = new Character("Agatha");

    

    hero.PrintStatsInfo();

    heroine.PrintStatsInfo();



    Weapon huntingBow = new Weapon("Hunting Bow", 105);

}





// Update is called once per frame

void Update()

{

    

}

}

即使Weapon结构体是在Character脚本中创建的,但这个结构体位于Character类的实际声明(花括号)之外,因此不是Character类的一部分。

将脚本限制为类是个好主意。把只有某个类使用的结构体包含在同一脚本中,但处于这个类之外是很常见的方式,Character脚本和Weapon结构体就是典型的实例。

类与结构体

目前,除了关键字以及初始化字段之外,类与结构体没有什么不同.类适合于将复杂的行为以及整个程序中可能发生变化的数据组合在一起;对于大多数简单的对象与保持不变的数据来说,结构体是更好的选择.除使用方法外,最根本的区别在于它们如何在变量之间传递与赋值:类是引用类型,这意味着它们是按引用传递的;结构体是值类型,他们是按值传递的.

引用类型

当Character类的实例完成初始化之后,hero和heroine变量并没有直接保存类信息.相反,它们保存了位于内存中的对象的引用.如果将hero或heroine分配给另一个变量,那么实际上相当于分配对内存的引用而不是角色数据.最重要的是,如果有多个变量保存同一个引用,那么改变任何一个变量都会影响到其他所有变量.

创建新英雄

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

// Start is called before the first frame update

void Start()

{

    Character hero = new Character();

    //Character heroine = new Character("Agatha");

    

    Weapon huntingBow = new Weapon("Hunting Bow", 105);



    Character hero2 = hero;

    hero.PrintStatsInfo();

    hero2.PrintStatsInfo();

}





// Update is called once per frame

void Update()

{

    

}

}

这两条调试日志都相同,因为在创建hero2时将hero赋给了hero2,此时,hero2和hero都指向内存中hero对象的位置。

现在,修改hero2的name字段,再次点击play按钮

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

// Start is called before the first frame update

void Start()

{

    Character hero = new Character();

    //Character heroine = new Character("Agatha");

    

    

    //heroine.PrintStatsInfo();



    Weapon huntingBow = new Weapon("Hunting Bow", 105);



    Character hero2 = hero;

    hero2.name = "Sir Krane the Brave";

    hero.PrintStatsInfo();

    hero2.PrintStatsInfo();

}





// Update is called once per frame

void Update()

{

    

}

}

hero和hero2现在拥有相同的name信息,即使我们认为只有一个角色的name信息会改变。这里的要点是,需要谨慎对待引用类型,给新变量赋值时,它们并不会被复制,对一个引用所做的任何更改都会使包含相同引用的所有其他变量发生相应的变化。

如果想尝试复制类,那么要么创建新的独立实例,要么考虑使用结构体作为对象的蓝图。

值类型

创建结构体对象时,所有数据都存储在相应的变量中,不存在指向内存位置的引用或链接。当需要创建能快速、高效复制且保持独立性的对象时,结构体最适合不过。

复制武器

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

// Start is called before the first frame update

void Start()

{

    Character hero = new Character();

    //Character heroine = new Character("Agatha");

    

    

    //heroine.PrintStatsInfo();



    Weapon huntingBow = new Weapon("Hunting Bow", 105);

    Weapon warBow = huntingBow;

    

    huntingBow.PrintWeaponStats();

    warBow.PrintWeaponStats();



    warBow.name = "War Bow";

    warBow.damage = 155;

    

    huntingBow.PrintWeaponStats();

    warBow.PrintWeaponStats();



    //Character hero2 = hero;

    //hero2.name = "Sir Krane the Brave";

    //hero.PrintStatsInfo();

    //hero2.PrintStatsInfo();

}





// Update is called once per frame

void Update()

{

    

}

}

控制台显示只有warBow的数据变了,huntingBow则保留了原始数据。这个例子说明,结构体作为独立对象能够方便地进行复制和修改,而不像类那样保留对原始对象的引用。

面向对象思想

如果类和结构体是对象的蓝图,那么OOP就是将所有内容结合在一起的框架。之所以将OOP称为编程范性,是因为OOP按某种原则规范类整个程序的工作和通信方式。从本质上讲,OOP专注于对象及其保存的数据、驱动行为的方式以及对象之间相互通信的方式,而非专注于纯粹的顺序逻辑。

现实世界中的事物是以类似的方式运作的。当你从自动售货机购买饮料时,你会取下一瓶汽水而不是汽水本身。汽水瓶就是对象,人们将相关信息和操作组合在类独立的包装中。不论是编程还是自动售货机,处理对象时都有需要遵守的规则。例如,谁能获取它们,各种对象有哪些不同之处,以及可以适用于周围各种对象的常见行为。用编程术语讲,这些规则是OOP的重要组成部分:封装、继承和多态。

封装

OOP支持的最有用处的一点是封装——定义外部代码(有时候称为调用方代码)对某个对象的变量和方法的访问能力。以汽水瓶为例,在自动售货机中,一些交互方式受到了限制。机器是锁着的,你无法直接从里面拿走一瓶汽水。如果恰好有合适的零钱,那就可以购买,但能购买多少是由机器决定的。如果机器本身被锁在房间内,那么只有持有房门钥匙的人才知道汽水放在这里。

在程序中如何设置这些限制呢?答案很简单,我们之前一直在通过指定对象的变量以及方法的可访问性来使用封装。

添加Reset方法

C#
// Character.cs

public class Character

{

// Character类现在已注册为公共类,这意味着项目中的任何类都能用它来创建角色。

public string name;

public int exp;



public Character()

{

    name = "Not assigned";

}



public Character(string name)

{

    this.name = name;

}



public void PrintStatsInfo()

{

    Debug.LogFormat("Hero:{0} - {1} EXP",name,exp);

}



private void Reset()

{

    this.name = "Not assigned";

    this.exp = 0;

}

}

//LearningCurve.cs

hero2.Reset();

将变量或方法标记为私有的之后,会使它们无法在其他类中通过点符号进行访问。

继承

一个类可以在另一个类的基础上进行创建,共享后者的变量和方法,并且可以定义自身独有的数据。在OOP中,这被称为继承。有了这种机制,无需重复代码就可以创建相关的类。以汽水为例。市场上销售的大部分汽水都有一些相同的基本特点:此外还有一些特殊的汽水,它们也具有这些基本特点,但可以通过不同的商标或包装区分开。虽然都是汽水,但它们还是有明显的不同。

原始类通常被称为基类或父类,继承得到的类被称为派生类或子类。使用public、protectd或internal访问修饰符标记的任何修饰符标记的任何基类成员都会自动成为派生类的一部分,但构造函数除外。类的构造函数始终属于包含它们的类,但是派生类也可以使用以减少重复代码。

大多数游戏都有多种角色,可以创建一个继承自Character类的名为Paladin的类。可以将这个类添加到Character脚本中,也可以创建新脚本,使用你喜欢的方式即可。

C#
public class Paladin : Character

{

}

正如LearningCurve继承自MonoBehaviour一样,你需要做的就是添加冒号和想要继承的基类,C#会处理剩下的事情。现在,然和Paladin实例都可以访问name和exp属性。还可以访问PrintStatsInfo方法。

基类构造函数

当一个类从另一个类继承时,它们就会形成一种金字塔结构,成员变量可从父类流到任何派生级。父类不知道子类,但子类知道父类。可以通过使用一些简单的语法,在子类构造函数中直接调用父类构造函数:

C#
public class ChildClass : ParentClass

{

public ChildClass() : base()

{



}

}

关键字base代表父类构造函数,在这里就是父类的默认构造函数。由于构造函数是方法,因此子类可以将参数向上传递给父类构造函数。

调用基类构造函数

C#
public class Paladin : Character

{

public Paladin(string name) : base(name)

{

    

}

}

C#
// 在LearningCurve脚本中创建一个名为knight的Paladin实例,并使用构造函数为name赋值

// 调用Knight.PrintStatsInfo方法,观察控制台的输出

void Start()

{

Paladin Knight = new Paladin("Sir Arthur");

Knight.PrintStatsInfo();

}

除了在Paladin构造函数中为name赋值之外,输出的调试日志与其他角色相同。

当Paladin构造函数被调用时,就将name参数传递给Character构造函数,从而设置角色名称。这在本质上相当于使用Character构造函数对Paladin类进行初始化,使Plaldin构造函数仅负责初始化自己独有的属性,但目前我们还没有这些属性。

组合

除了继承,类还可以由其他类组合而成。以Weapon结构体为例,Paladin类可以轻松地在自身内部包含一个Weapon变量,并且可以访问里面的所有属性和方法。

C#
public class Paladin : Character

{

public Weapon weapon;

public Paladin(string name, Weapon weapon) : base(name)

{

    this.weapon = weapon;

}

}

// ---------

public class LearningCurve : MonoBehaviour

{

// Start is called before the first frame update

void Start()

{



    Weapon huntingBow = new Weapon("Hunting Bow", 105);



    Paladin Knight = new Paladin("Sir Arthur",huntingBow);

    Knight.PrintStatsInfo();

}

如果现在运行游戏,那么由于使用了来自Character类的PrintStatsInfo方法,因此我们并不知晓圣骑士的武器情况,因此看不到任何变化。为了解决这个问题,需要引入多态。

多态

多态可以两种不同的方式应用于OOP:

•派生类对象与父类对象会被等同看待。例如,Character对象的数组也可以存储Paladin对象,因为它们都派生自Character类。

•父类可以将方法标记为虚拟方法,这意味着派生类可以使用override关键字修改方法中的指令。对于Character类和Paladin类,如果它们各自能使用PrintStatsInfo方法输出不同的调试信息,这将会很有用。

多态允许派生类保留父类的结构,同时可以根据自己的具体需求调整行为。

方法的变体

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class Character

{

// Character类现在已注册为公共类,这意味着项目中的任何类都能用它来创建角色。

public string name;

public int exp;



public Character()

{

    name = "Not assigned";

}



public Character(string name)

{

    this.name = name;

}



public virtual void PrintStatsInfo()

{

    Debug.LogFormat("Hero:{0} - {1} EXP",name,exp);

}



private void Reset()

{

    this.name = "Not assigned";

    this.exp = 0;

}

}

public struct Weapon

{

public string name;

public int damage;



public Weapon(string name, int damage)

{

    this.name = name;

    this.damage = damage;

}



public void PrintWeaponStats()

{

    Debug.LogFormat("Weapon:{0} - {1}DMG",name,damage);

}

}

public class Paladin : Character

{

public Weapon weapon;

public Paladin(string name, Weapon weapon) : base(name)

{

    this.weapon = weapon;

}



public override void PrintStatsInfo()

{

    Debug.LogFormat("Hail {0} - take up your {1}!",name,weapon.name);

}

}

当在Paladin类中声明重载版本的PrintSatasInfo方法时,相当于添加针对Paladin类的自定义行为。由于多态机制的存在,我们不需要从Character类或Paladin类中选择调用哪个版本的PrintStatsInfo方法,因为编辑器已经知道应该调用哪个版本。

OOP总结

对于OOP,初学者有很多概念需要了解,下面总结了一些要点:

•OOP在本质上就是将相关的数据与行为组合到对象中,这些对象之间既可以互相联系,也可以独立运作。

•类似于变量,访问任何类成员时都可以使用访问修饰符。

•类也能继承其他类,从而构成自顶向下的父子关系层级。

•类可以将其他类或结构体作为成员。

•类可以重载标记为virtual的父类方法,从而在保持结构统一的同时还能执行自定义行为。

OOP不是C#唯一可用的编程范例,可以在 http://cs.lmu.edu/~ray/notes/paradigms 找到一些其他的编程范型。

在Unity中使用OOP

根据OOP原则,程序中的任何内容都应该是对象,Unitu中的GameObject可以代表类与结构体。但这并不是说Unity中的所有对象都必须出现在实际场景中,因此我们仍然可以不在场景中使用新创建的类。

对象是集合起来的行为

所有组件在Unitu中都是类。当单击Play按钮时,这些组件都会变成内存中的对象,包含它们自身的成员变量和方法。Unity中的组件都是引用类型。

如果将LearningCurve脚本(以及其他任何脚本或组件)附加到1000个GameObject上并单击Play按钮,就会在内存中创建并存储1000个不同的LearningCurve实例。

获取组件

Unity中的游戏游戏对象都继承自gameObject类,因而可以通过GaneObject类的成员方法来查找场景中所需的游戏对象。以下两种方法可以用来分配或检索当前场景中激活的游戏对象:

•使用GameObject类的GetComponent 或 Find方法,这种方法对公共或似有变量都适用。

•将Project面板中的游戏对象拖动并放置到Inspector面中对应的变量位置。这种方法只适用于公共变量。因为只有公共变量才会出现在Inspector面板中。

基本语法

GetComponent方法很简单,但是这个方法的签名与之前见到的其他方法相比有所不同:

C#
GameObject.GetComponent();

我们需要的只是组件的类型,如果想要查找的组件存在,GameObject就会返回组件,否则就会返回空值。此外,还有其他形式的GetComponent方法,上面只是最简单的一种,因为不需要知道所需类型的任何细节。这种方法称为泛型方法,相见11章。

获取当前的Transform组件

由于LearningCurve脚本已经被附加到Main Camera上,因此我们可以从Main Camera获取Transform组件并存储至一个公共变量中。

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

private Transform camTransForm;

// Start is called before the first frame update

void Start()

{

    camTransForm = this.GetComponent<Transform>();

    Debug.Log(camTransForm.localPosition);

}

}

我们在LearningCurve脚本的顶部添加了一个公共的且未初始化的Transform变量,然后在Start方法中通过GetComponent方法对这个变量进行了初始化,GetComponent方法找到了GameObject上的Transform组件并返回给camTransform。camTransform现在存储了一个Transform组件,你可以访问其中的属性和方法。

查找游戏对象

GetComponent 方法可以用来快速检索组件,但只能访问到调用脚本所在 GameObject 上附加的组件。 举例来说,如果使用附加到 Main Camera上的 LearningCurve 脚本中的 GetComponent方法来获取组件,就只能获取到 Transform、Camera和Audio Listener三个组件。如果想要引用其他对象(例如Direction Light)上的组件,那么需要首先通过Find方法获取对象。Find方法需要传入的参数只有游戏对象的名称,Unitu会返回合适的GameObject会想来进行存储或操作。

选中对象后,在Inspector面板的顶部即可看到游戏对象的名称。

查找不同游戏对象上的组件

下面在LearningCurve脚本中使用Find方法来查找Direction Light对象

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

private Transform camTransForm;



public GameObject directionLight;



private Transform lightTransform;

// Start is called before the first frame update

void Start()

{

    camTransForm = this.GetComponent<Transform>();

    Debug.Log(camTransForm.localPosition);



    directionLight = GameObject.Find("Directional Light");



    lightTransform = directionLight.GetComponent<Transform>();

    Debug.Log(lightTransform.localPosition);

}

}

可通过链式调用方法来减少代码。通过组合Find和GetComponent方法,我们可以在不使用中间变量directionLight的情况下,用一行代码完成lightTransform的初始化。

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

private Transform camTransForm;



private Transform lightTransform;

// Start is called before the first frame update

void Start()

{

    camTransForm = this.GetComponent<Transform>();

    Debug.Log(camTransForm.localPosition);



    lightTransform = GameObject.Find("Direction Light").GetComponent<Transform>();

    Debug.Log(lightTransform.localPosition);

}

}

在复杂的项目中,太长的链式代码会导致程序的可读性变差并造成困惑。总的来说,最好避免代码太长。

拖放对象

直接拖放要比在代码中使用GameObject类快得多,但是当保存或导出项目时,Unity可能丢失这种方法下对象与变量的链接关系。当需要快速赋值时,一定要利用好Unity提供的这种拖放功能。大部分情况下,建议统一使用代码赋值方式。

在Unity中为变量赋值

C#
using System.Collections;

using System.Collections.Generic;

using UnityEngine;

public class LearningCurve : MonoBehaviour

{

private Transform camTransForm;



public GameObject directionLight;



private Transform lightTransform;

// Start is called before the first frame update

void Start()

{

    camTransForm = this.GetComponent<Transform>();

    Debug.Log(camTransForm.localPosition);



    //directionLight = GameObject.Find("Directional Light");



    lightTransform = directionLight.GetComponent<Transform>();

    Debug.Log(lightTransform.localPosition);

}

}

Directional Light对象现在已经被赋值给direactionLight变量。因为Unity在内部进行了赋值,不涉及任何代码,所以无需修改LearningCurve类。

在决定是使用拖放功能还是使用GameObject.Find方法来给变量赋值时,以下两点可供参考:首先Find方法稍微慢一点,如果在多个脚本中多次调用Find方法,游戏有可能产生性能问题。其次,需要保证场景中的所有GameObject都有唯一的名称,否则,当一些对象拥有相同的名称时,就会产生一些奇怪的bug。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Unity3D是一款流行的跨平台游戏开发引擎,支持多种移动平台和操作系统。最近,苹果推出了“Sign in with Apple”功能,该功能允许用户使用其Apple ID来登录第三方应用程序。 要在Unity3D接入“Sign in with Apple”功能,需要遵循以下步骤: 1. 首先,确保你的Unity3D版本是最新的,以便能够支持最新的API和功能。 2. 在苹果开发者平台上创建一个新的App ID,并将其与你的Unity3D项目关联。确保在App ID设置启用“Sign in with Apple”功能。 3. 在Unity3D,编写代码以实现与苹果登录服务通信的逻辑。你需要使用Unity内置的网络API,通过发送HTTP请求和接收回复来实现与苹果服务器的通信。 4. 在Unity3D项目创建一个用户界面,允许用户点击“Sign in with Apple”按钮。当用户点击这个按钮时,你的代码将向苹果服务器发送请求,获取用户的验证凭证。 5. 将从苹果服务器接收到的验证凭证与你的后端服务器通信。你的后端服务器需要验证这个凭证的有效性,并通过向苹果服务器发送请求获得用户的基本信息。 6. 在Unity3D使用这些用户信息,例如显示用户的用户名、头像等。 需要注意的是,为了保护用户的隐私,苹果有一些要求和限制,开发人员需要遵守这些规定,例如要求提供“其他登录选项”以及对于用户与苹果登录服务的数据处理等。 总结:要在Unity3D接入“Sign in with Apple”功能,你需要使用最新版本的Unity3D、遵循苹果开发者平台的规定、实现与苹果服务器的通信逻辑、创建用户界面、验证凭证、获取用户信息,并遵守苹果的隐私规定。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值