跳转到内容

11. 刚好够用的 C#

约 40 分钟 难度:新手 动手章

第 10 章把变量、判断、循环、函数、类用做菜的方式过了一遍。这一章把那些朴素思路落到 C# 的真实写法上。

C# 整套语言能写到非常深,但 VRChat 世界用得上的语法只是其中一小块。这一章只讲那一小块。学得太多反而绕路:第 12 章会指出哪些 C# 特性 UdonSharp 不支持,提前学了也用不上。

VRChat 世界里的脚本几乎都是这个骨架:

LampToggle.cs
using UdonSharp;
using UnityEngine;
public class LampToggle : UdonSharpBehaviour
{
public Light targetLight;
public override void Interact()
{
targetLight.enabled = !targetLight.enabled;
}
}

这段不到十行的脚本就能让按钮控制一盏灯。先不深究每一行,整体认识一下:

  • using ... 在文件顶上引一些工具,类似菜谱开头写「需要烤箱、电饭锅」。
  • public class LampToggle : UdonSharpBehaviour 定义了一个类,名字叫 LampToggle。冒号后面的 UdonSharpBehaviour 表示这个类要装到 GameObject 上当行为脚本。
  • public Light targetLight; 声明了一个变量。Light 是类型,targetLight 是名字。public 让这个变量在 Unity Inspector 里可见,可以用拖拽的方式指过去。
  • Interact() 是一个方法,名字是 VRChat 规定好的。玩家走过来按 Use 键时,VRChat 会替作者调用这个方法。
  • 方法体里那行 targetLight.enabled = !targetLight.enabled; 把灯的开关状态翻转。

短短一段 C#,已经把第 10 章谈到的东西全用上了:类、变量、方法、判断(! 是「取反」)。下面把每一块拆开。

在拆变量、控制流之前,先把三件最朴素的语法物理过一遍。如果这三件没扫过,一些莫名其妙的红字会把人卡住。

分号 ; 结束一条语句int score = 0; 末尾那个分号是必须的,少一个就报错。同一段代码里换行不重要,分号才决定一条语句到哪里结束。

大括号 { ... } 圈出一组语句。类的内容、方法的内容、if 块的内容、for 块的内容,都用一对大括号括起来。括号必须成对,少一个右括号编译器会一路报错到文件末尾。Unity 里写代码时编辑器会自动配对,大概率不会漏。

注释 ///* ... */// 后面到行尾的内容是给人看的、编译器忽略:

int score = 0; // 玩家分数,每分一格
// 整行只是说明文字,不会被执行

/* ... */ 用来注释多行:

/*
这一段先放着不跑,调试时再开
targetLight.enabled = false;
*/

调试时常用注释临时屏蔽一两行代码,比删掉再写回去稳。

C# 每个变量都得有个类型。这跟 Python、JavaScript 这种「动态类型」不一样,在 C# 里写变量时必须告诉编译器它是什么。

VRChat 世界里高频出现的几种类型:

类型装什么例子
int整数int score = 0;
float小数(带小数点)float speed = 1.5f;
bool真或假bool isOpen = false;
string一段文字string playerName = "alice";
Vector3三维坐标Vector3 spawnPoint = new Vector3(0, 0.2f, 0);
GameObject一个 Unity 物体public GameObject door;
Light一盏灯组件public Light mainLight;
Transform物体的位置朝向public Transform target;
Material一份材质public Material redMat;

float 数值时末尾要加 f,比如 1.5f。不加的话编译器会按 double(更精确的小数类型)解读,赋值给 float 变量时会报错。

声明变量的语法非常稳定:

类型 名字 = 初始值;

初始值可以省略,省略后基本类型会拿到默认值(数字是 0,bool 是 false,引用类型是 null)。null 这个值后面会反复碰到,它表示「这个引用还没指向任何东西」。

C# 项目通行的命名约定:类名、方法名、public 字段用大驼峰(PlayerHealthOpenDoor),private 字段和局部变量用小驼峰(isOpencurrentScore)。本书里的代码统一这套写法。约定不是硬性规则,但跟主流不一致的命名会让其他读者迷惑,也容易跟 Unity API 的命名风格打架。

类里写在大括号最外层的变量叫字段(field),方法里写在大括号内层的变量叫局部变量(local variable)。

public class Counter : UdonSharpBehaviour
{
public int score; // 字段:整个类都能访问
private int internalState; // 字段:但外部访问不到
public override void Interact()
{
int delta = 1; // 局部变量:只在这个方法里有效
score = score + delta;
}
}

字段的访问修饰符常见三种:

  • public:外部类可以访问,Unity Inspector 也会显示。
  • private:只有自己类内部能访问,Inspector 默认看不到。
  • [SerializeField] private:外部类访问不到,但 Inspector 能看见。VRChat 项目里推荐这种写法,既不污染外部接口,又能在 Inspector 里调参数。
[SerializeField] private float openSpeed = 2f;

C# 还有一种叫「属性」(property)的东西,长得像方法又像字段。VRChat 项目里很少自己写属性,用到的多是 Unity API 提供的,比如 transform.position。这一章先不展开。

// if 单分支
if (score >= 10)
{
door.SetActive(false); // 隐藏门
}
// if / else
if (isDay)
{
sun.intensity = 1f;
}
else
{
sun.intensity = 0.1f;
}
// if / else if / else
if (score < 0)
{
label.text = "异常";
}
else if (score < 10)
{
label.text = "继续努力";
}
else
{
label.text = "通关";
}

条件表达式里常用的比较符号:

写法含义
==相等
!=不相等
< <= > >=小于、小于等于、大于、大于等于
&&
||
!取反

注意「相等」是两个等号 ==,单个等号 = 是赋值。这是最常踩的笔误之一。

循环最常用的是 for

for (int i = 0; i < 10; i++)
{
Debug.Log("打印第 " + i + "");
}

for 三段分别是:起始(int i = 0)、继续条件(i < 10)、每轮结束做的事(i++,即 i 加 1)。

while 用在「直到某个条件满足为止」:

while (door.transform.position.y < 3f)
{
door.transform.Translate(Vector3.up * 0.01f);
}

VRChat 世界写脚本时 while 用得比 for 少很多,多数情况下 for 配合数组就够。

方法的完整写法是这样:

返回类型 方法名(参数列表)
{
方法体;
return 返回值;
}

具体例子:

// 没有返回值,没有参数
public void OpenDoor()
{
door.SetActive(false);
}
// 有参数,没有返回值
public void SetScore(int newScore)
{
score = newScore;
UpdateLabel();
}
// 有参数,有返回值
public int Add(int a, int b)
{
return a + b;
}

void 表示「没有返回值」。return 用来把结果交还给调用方,调用方可以接着用:

int total = Add(3, 5); // total 现在是 8

VRChat 脚本里有几个特殊方法名是 Unity 和 VRChat 帮调的:

方法名调用时机
Start()物体出现在场景里之后调用一次
Update()每一帧调用一次(桌面端约 60 次/秒,VR 模式约 90 次/秒)
Interact()玩家按 Use 键交互时调用
OnPlayerJoined(VRCPlayerApi player)有玩家进入这个房间实例
OnPlayerLeft(VRCPlayerApi player)有玩家离开

第四部会反复用到这几个。这一章先认识到:作者不主动调用它们,是 VRChat 在合适的时机替作者调用。作者的工作是定义「被调用时该做什么」。

写法上有一个细分要点先打个伏笔:上面这五个里,StartUpdate 是 Unity 标准生命周期方法,写定义时不写 overrideInteract / OnPlayerJoined / OnPlayerLeft 是 UdonSharpBehaviour 自己定义的虚方法,必须写 public override。第 12 章会专门讲这条规则的来历。

写脚本时最常用的「工具方法」是 Debug.Log。它把一段文字打到 Unity 编辑器的 Console 面板里:

public override void Interact()
{
Debug.Log("按钮被按了");
score = score + 1;
Debug.Log("现在分数是 " + score);
}

按下 Use 键之后,Unity 顶上的菜单 Window → General → Console 打开的那个面板里会看到刚才打出来的字。这是「程序到底跑没跑、跑到哪一步、变量是什么值」最有效的检查手段。

不知道脚本有没有挂上 → Start 里写一句 Debug.Log("我活了")
不知道按钮按了没 → Interact 里写一句 Debug.Log("被按了")
不知道变量是什么值 → 在那一行前后写 Debug.Log("score = " + score)

这一招在做完第四部里几个练习之前不会显眼,撞上第一个「按钮按下去没反应」的问题时立刻有用。

Unity 在每次保存 .cs 文件时都会重新编译一次,编译错误第一时间出现在 Console 里。改几行保存一次和写完一大段再保存,错误暴露的时机会差很多。

类是 C# 组织代码的基本单位。VRChat 世界里每个 .cs 文件通常装一个类,类继承自 UdonSharpBehaviour

using UdonSharp;
using UnityEngine;
public class DoorController : UdonSharpBehaviour
{
[SerializeField] private float openHeight = 3f;
[SerializeField] private float closeHeight = 0f;
[SerializeField] private float speed = 2f;
private bool isOpening;
public override void Interact()
{
isOpening = !isOpening;
}
private void Update()
{
float targetY = isOpening ? openHeight : closeHeight;
Vector3 pos = transform.position;
pos.y = Mathf.MoveTowards(pos.y, targetY, speed * Time.deltaTime);
transform.position = pos;
}
}

这段脚本能做出一扇按 Use 键开关、开合带动画的门。它把上面讲过的东西全用了一遍:

  • 字段(带 SerializeField):让作者在 Inspector 里调参数。
  • Interact() 方法:响应玩家按 Use 键。
  • Update() 方法:每帧推动门朝目标高度移动。
  • 三元表达式 isOpening ? openHeight : closeHeight:等同于 if (isOpening) ... else ... 的简写。
  • 调用 Unity 提供的 API:Mathf.MoveTowardsTime.deltaTimetransform.position

读不懂每一行没关系,第四部第 17 章会专门做一扇能开的门。这里先看到「类长这样、字段长这样、方法长这样」就够了。

VRChat 脚本里高频要做一件事:让脚本控制别的物体。比如「按这个按钮,开那个门」,按钮和门是两个不同的 GameObject。

最稳的做法是定义一个 public 字段或 [SerializeField] private 字段,然后在 Unity Inspector 里把目标物体拖进去:

public class ButtonOpensDoor : UdonSharpBehaviour
{
[SerializeField] private GameObject targetDoor;
public override void Interact()
{
targetDoor.SetActive(false);
}
}

挂上脚本后 Inspector 会出现一个 Target Door 槽位,把场景里的门拖进去就行。

按钮(挂 ButtonOpensDoor 脚本)
└─ Target Door 槽 ────► 门 GameObject

这种「在 Inspector 里连线」的工作流是 Unity 风格脚本最舒服的部分。它免去在代码里写一长串 GameObject.Find("..."),也不会因为名字改了就找不到。

只列学过的,方便回忆:

语法物理 ; 结束语句, { } 圈块, // 注释
变量声明 类型 名字 = 初始值;
访问修饰符 public / private / [SerializeField] private
基本类型 int / float / bool / string
Unity 类型 Vector3 / GameObject / Transform / Light / Material
控制流 if / else if / else / for / while
方法 public void 方法名() { ... }
特殊方法 Start / Update / Interact / OnPlayerJoined / OnPlayerLeft
类 public class XXX : UdonSharpBehaviour { ... }
引用别的物体 在字段里声明,Inspector 里拖
调试 Debug.Log("..."),看 Unity Console

这些就是 VRChat 世界写脚本的工具盘。第四部用到的所有交互,几乎都是把这些拼一拼。

C# 这门语言比这要大得多,还有泛型、异步、反射、属性、委托、事件、LINQ 这些。它们大多在 UdonSharp 里要么不能用、要么有奇怪的限制。这是写 VRChat 脚本和写普通 C# 项目最明显的分叉。理解到这里,C# 这套语法的最小可用集就齐了。