1. 先搞清楚这个系列能帮你解决什么问题
如果你刚开始接触 C# 和 Unity,或者已经学了一些语法但不知道如何把它们串起来做成一个真正的 3D 游戏,那么这个“从零构建五款完整 3D 游戏”的系列,就是为你准备的。它最核心的价值,不是教你零散的语法,而是让你通过五个不同类型的游戏项目,把 C# 基础逻辑、Unity 组件使用、物理系统、脚本协作这些知识点,在一个个可运行、可扩展的工程里跑通。
很多人学 C# 和 Unity 容易陷入两个误区:要么只啃书本上的语法,写一堆控制台程序,对游戏开发没概念;要么直接去抄复杂的项目,代码看不懂,出了问题也不知道从哪改起。这个系列的设计就是来打破这个僵局的——通过做游戏来学编程。五款游戏,意味着你会反复练习变量、循环、条件判断、函数、类这些基础,但每次练习的上下文都不一样,理解会更深。同时,你也能掌握 Unity 编辑器操作、预制体、刚体、碰撞器、输入控制这些必备技能。
所以,无论你是想转行游戏开发的学生,还是想拓展技能栈的开发者,这个系列都值得跟下来。它不是理论课,是实打实的项目课。下面,我就带你拆解一下上半部分的核心内容和上手前必须准备好的环境。
1.1 五款游戏分别练什么?
虽然项目标题没具体说,但根据“3D游戏逻辑 / 物理碰撞 / 脚本开发”这些关键词,我们可以推断这五款游戏大概率是经典且教学意义明确的类型。上半部分可能包含以下两到三种:
- 3D 滚球或跑酷游戏:这是 Unity 的经典入门项目。核心练的是刚体物理(Rigidbody)和力/速度的控制。你会学到如何用
AddForce或直接修改velocity来控制一个球或角色移动,如何处理跳跃,以及如何通过碰撞检测来收集物品或触发死亡。 - 第一人称或第三人称射击/探索游戏:这类项目重点练摄像机控制和角色控制器。你会接触到
CharacterController组件或者自己用刚体模拟移动,学会处理鼠标视角旋转(Input.GetAxis(“Mouse X/Y”)),并实现简单的交互,比如开门、拾取。 - 物理解谜或破坏类游戏:比如推箱子、打砖块或者“愤怒的小鸟”风格的弹射游戏。这类项目深度练习碰撞检测和关节(Joint)的使用。你会明白
OnCollisionEnter和OnTriggerEnter的区别,并学会用代码控制物体的连接与分离。
每一款游戏都是一个独立的工程,但知识点是递进和互补的。做完一个,你对 C# 在 Unity 里该怎么写,会有一个非常具体的画面。
1.2 你需要提前准备什么?
在开始写第一行代码之前,先把环境搭好。别小看这一步,很多新手卡住就是因为环境没配对。
- Unity Hub & Unity Editor:这是必须的。去 Unity 官网下载 Unity Hub,然后通过 Hub 安装一个 Long-Term Support (LTS) 版本的 Unity Editor。对于初学者,2022.3 LTS或2023.3 LTS都是稳定且社区资源丰富的选择。安装时,至少勾选“Windows Build Support”(如果你是 Windows 用户)或“macOS Build Support”,以及“WebGL Build Support”(如果你想发布到网页)。至于热议的“Unity WebGL 背景透明”等问题,那是后话,初期不用管。
- 代码编辑器:Visual Studio 是官方默认且集成最好的,安装 Unity 时通常会一起装上。如果你更喜欢轻量级的,Visual Studio Code (VSCode)也是绝佳选择,但需要额外配置。你需要安装 VSCode,然后从扩展商店安装“C#”扩展和“Unity”扩展。之后在 Unity Editor 的
Edit -> Preferences -> External Tools中,将外部脚本编辑器设置为 VSCode。这样在 Unity 里双击 C# 脚本就能用 VSCode 打开了。 - 基础概念:确保你理解一些最基础的术语,比如“场景 (Scene)”、“游戏对象 (GameObject)”、“组件 (Component)”、“预制体 (Prefab)”、“变换 (Transform)”。如果不懂,打开 Unity 新建一个空项目,随便拖几个立方体,看看 Inspector 面板,半小时就能有个直观感受。
环境准备好后,我建议先新建一个空项目,不跟教程,自己试着创建一个立方体,挂个脚本让它旋转。这个“Hello World”能帮你验证环境是否真正可用。
2. 第一个项目:搭建场景与理解游戏对象
我们假设第一个项目是一个简单的 3D 滚球收集硬币游戏。这是理解 Unity 工作流最直接的起点。
2.1 创建基础场景:地板、玩家和硬币
打开 Unity,新建一个 3D 项目(Core 或 URP 模板都可以,初学者用 Core 更纯粹)。
- 创建地板:在 Hierarchy 窗口右键 -> 3D Object -> Plane。这将是我们的地面。在 Inspector 里,你可以修改它的 Scale(比如放大到 10倍),或者给它一个绿色的材质来区分。
- 创建玩家(球):同样右键 -> 3D Object -> Sphere。重命名为 “Player”。调整它的位置,让它悬在地板之上。
- 添加物理属性:选中 Player 球体,在 Inspector 中点击 “Add Component”,搜索并添加Rigidbody组件。这样球体就会受到重力影响掉下去。你可能会发现球直接穿过了地板,这是因为 Plane 默认没有碰撞器。选中 Plane,确保它有Mesh Collider组件(默认就有)。现在运行游戏,球应该会掉落在平面上。
- 创建可收集物:右键 -> 3D Object -> Cube。重命名为 “Coin”。缩小它(Scale 设为 0.5, 0.5, 0.5)。为了美观,可以给它加个金色的材质。关键一步:将这个 Cube 从 Hierarchy 拖到 Project 窗口的 Assets 文件夹里,这会创建一个Prefab(预制体)。预制体是你的蓝图,之后可以在场景里复制很多个实例,修改预制体,所有实例都会更新。创建完预制体后,可以删除 Hierarchy 里那个原始的 Coin 立方体。
现在,你的场景里有一个带刚体的球(玩家)和一个地板。游戏对象(GameObject)就是场景中的一切,而组件(如 Transform, Rigidbody, Mesh Collider)则定义了它们的行为和属性。
2.2 编写第一个 C# 脚本:让玩家动起来
在 Project 窗口的 Assets 文件夹右键 -> Create -> C# Script,命名为PlayerController。
双击用 VSCode 或 Visual Studio 打开,你会看到一个默认的类。我们修改它,实现用键盘控制球体移动。
using UnityEngine; public class PlayerController : MonoBehaviour { // 声明一个公共变量,用于控制移动速度 // 在Inspector中可以直接修改这个值,无需改代码 public float moveSpeed = 10f; // 声明一个私有变量,引用刚体组件 private Rigidbody rb; // Start 在游戏开始时调用一次,用于初始化 void Start() { // 获取挂载在同一游戏对象上的Rigidbody组件 rb = GetComponent<Rigidbody>(); } // FixedUpdate 在固定的物理时间步长调用,适合处理物理运动 void FixedUpdate() { // 获取键盘的水平(A/D或左右箭头)和垂直(W/S或上下箭头)输入 float moveHorizontal = Input.GetAxis("Horizontal"); float moveVertical = Input.GetAxis("Vertical"); // 创建一个三维向量来表示移动方向 Vector3 movement = new Vector3(moveHorizontal, 0.0f, moveVertical); // 给刚体施加一个力,实现移动 rb.AddForce(movement * moveSpeed); } }保存脚本,回到 Unity。将PlayerController脚本从 Project 窗口拖拽到 Hierarchy 中的 Player 球体上。运行游戏,按下 WASD 或方向键,球体应该会受力移动。
为什么用FixedUpdate和AddForce?
Update每帧调用,帧率不稳定会导致物理运动不平滑。FixedUpdate调用间隔固定,与物理引擎同步,是做物理运动的首选。AddForce是给刚体施加力,模拟真实的物理运动(有惯性)。你也可以直接修改rb.velocity,移动会更直接、更“飘”,取决于你想要的手感。
2.3 完善硬币:碰撞检测与销毁
现在来处理硬币。我们需要做两件事:1. 让硬币能被收集(碰撞后消失);2. 给玩家一个分数。
为硬币预制体添加碰撞和脚本:
- 在 Project 窗口选中 Coin 预制体,确保它有Box Collider组件。
- 在 Box Collider 组件上,勾选Is Trigger。这意味着这个碰撞器将作为一个“触发器”,物理引擎不会计算它的物理碰撞(比如阻挡玩家),但会检测到有其他碰撞器进入它。
- 为 Coin 预制体创建一个新脚本
Coin,并挂载上去。
using UnityEngine; public class Coin : MonoBehaviour { // 当有其他碰撞器进入这个触发器时调用 private void OnTriggerEnter(Collider other) { // 检查进入触发器的对象是否是玩家(通过Tag) if (other.CompareTag("Player")) { // 通知游戏管理器(或其他脚本)增加分数 // 这里我们先简单调用一个静态方法,后续优化 GameManager.Instance.AddScore(10); // 销毁这个硬币实例 Destroy(gameObject); } } }设置玩家标签:在 Hierarchy 中选中 Player 球体,在 Inspector 顶部的 Tag 下拉菜单中,选择 “Add Tag…”,创建一个新标签 “Player”。然后再次选中 Player,将其 Tag 设置为 “Player”。
创建简单的游戏管理器:在场景中创建一个空的 GameObject,重命名为 “GameManager”。为其创建脚本
GameManager。using UnityEngine; using UnityEngine.UI; // 如果需要UI public class GameManager : MonoBehaviour { // 单例模式,方便其他脚本访问 public static GameManager Instance; public int score = 0; public Text scoreText; // 在Inspector中关联一个UI Text组件 void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); } public void AddScore(int points) { score += points; UpdateScoreUI(); } void UpdateScoreUI() { if (scoreText != null) scoreText.text = "Score: " + score; } }创建 UI 显示分数:在 Hierarchy 右键 -> UI -> Text – TextMeshPro(如果第一次使用会导入 TMP 资源)。将 Canvas 下的 Text 对象重命名为 “ScoreText”,调整位置和大小。将 GameManager 脚本中的
scoreText变量拖拽绑定到这个 Text 对象。在场景中放置硬币:从 Project 窗口将 Coin 预制体拖入 Scene 视图,多复制几个,摆放在地板的不同位置。
现在运行游戏,控制球体去触碰硬币,硬币会消失,并且屏幕上的分数会增加。至此,你完成了第一个迷你游戏的核心循环:控制 -> 移动 -> 碰撞检测 -> 状态更新(分数) -> 物体管理(销毁)。
3. 深入物理碰撞与输入处理
第一个项目跑通后,你会发现很多细节需要打磨。比如球太滑、碰撞不精确、输入不够灵活。这部分我们来深入。
3.1 刚体参数调优:让手感更好
选中 Player 球体,查看它的 Rigidbody 组件,有几个关键参数影响手感:
- Mass(质量):影响惯性。质量越大,启动和停止越慢。对于玩家角色,一般不用太大。
- Drag(阻力):空气阻力。增大它可以让物体更快停下,防止“溜冰”感。可以设为 1-5 试试。
- Angular Drag(角阻力):旋转阻力。对于球体,可以适当增加防止它乱转。
- Constraints(约束):可以冻结某个轴的位置或旋转。比如在一个 2.5D 游戏中,你可能想冻结 Y 轴位置和 X、Z 轴的旋转。
一个常见的手感优化是使用AddForce时,采用ForceMode.VelocityChange或ForceMode.Impulse,它们忽略质量,提供更直接的响应。你可以修改PlayerController的AddForce行:
rb.AddForce(movement * moveSpeed, ForceMode.VelocityChange);多试试不同的力和模式组合,找到最适合你游戏的手感。
3.2 碰撞检测的两种方式:Collider 与 Trigger
你已经用到了OnTriggerEnter。这里系统梳理一下:
- 普通碰撞器(Is Trigger = false):
- 物理引擎会计算碰撞反应(反弹、阻挡)。
- 使用
OnCollisionEnter(Collision collisionInfo)函数来响应。 - 可以通过
collisionInfo.rigidbody获取对方刚体,collisionInfo.contacts获取碰撞点。 - 适用于墙壁、地面、需要物理交互的物体。
- 触发器(Is Trigger = true):
- 物理引擎忽略其碰撞体积,物体可以穿透。
- 使用
OnTriggerEnter(Collider other)函数来响应。 - 适用于收集品、陷阱区域、检测区域等。
一个关键点:触发器检测要求至少一方有刚体。通常是把刚体放在运动的物体上(如玩家),触发器放在静态物体上(如硬币)。
3.3 更灵活的输入管理
目前我们直接用Input.GetAxis。Unity 新的输入系统(Input System Package)更强大,支持手柄、重映射等,但对于初学者,旧系统足够。你可以创建自定义输入轴: 打开Edit -> Project Settings -> Input Manager,你可以看到 Horizontal 和 Vertical 的定义。你可以复制它们,创建新的轴,比如 “Jump”,绑定空格键。然后在脚本中用Input.GetButtonDown(“Jump”)来检测跳跃。
跳跃的实现示例(在PlayerController的FixedUpdate外添加):
void Update() // 检测按键用Update更及时 { if (Input.GetButtonDown("Jump")) { // 检查是否在地面上(简单版:射线检测) if (Physics.Raycast(transform.position, Vector3.down, 1.1f)) { rb.AddForce(Vector3.up * 10, ForceMode.Impulse); } } }这里用到了Physics.Raycast进行射线检测,判断玩家是否在地面上,这是实现可靠跳跃的常用方法。
4. 项目架构与脚本通信优化
当游戏功能变多,把所有代码写在一个脚本里会变得混乱。我们需要更好的架构。
4.1 使用单例模式与事件中心
之前的GameManager用了简单的单例模式。对于小型项目够用。但更好的方式是使用事件(Event)来解耦脚本。例如,硬币被收集时,它不需要知道谁在管理分数,它只需要广播一个“硬币被收集”的事件。GameManager或其他需要响应的脚本(如音效管理器、UI管理器)来订阅这个事件。
C# 的Action或UnityEvent可以实现:
// 在某个全局可访问的类中(如EventManager) public static class GameEvents { public static Action<int> OnCoinCollected; // 参数可以是分数值 } // 在Coin脚本中触发事件 private void OnTriggerEnter(Collider other) { if (other.CompareTag("Player")) { GameEvents.OnCoinCollected?.Invoke(10); // 触发事件 Destroy(gameObject); } } // 在GameManager或其他脚本中订阅事件 void OnEnable() { GameEvents.OnCoinCollected += AddScore; } void OnDisable() { GameEvents.OnCoinCollected -= AddScore; } void AddScore(int points) { /* ... */ }这样,硬币脚本和分数管理脚本就完全解耦了,新增一个播放收集音效的脚本也变得很容易。
4.2 对象池管理:优化硬币的生成与回收
当前,硬币被收集后直接Destroy。如果游戏需要大量、频繁生成和销毁硬币(比如射击游戏的子弹),频繁的Instantiate和Destroy会产生垃圾回收(GC),可能导致卡顿。对象池(Object Pooling)是解决方案。
思路是:游戏开始时预先创建一堆硬币(或任何可重用对象)并禁用它们,放在一个“池子”里。需要时从池中取一个激活它,用完后不是销毁,而是禁用它并放回池中。
这是一个简化的硬币对象池示例:
public class CoinPool : MonoBehaviour { public GameObject coinPrefab; public int poolSize = 20; private Queue<GameObject> coinPool = new Queue<GameObject>(); void Start() { for (int i = 0; i < poolSize; i++) { GameObject coin = Instantiate(coinPrefab); coin.SetActive(false); coinPool.Enqueue(coin); } } public GameObject GetCoin() { if (coinPool.Count > 0) { GameObject coin = coinPool.Dequeue(); coin.SetActive(true); return coin; } else { // 池子空了,可以动态扩容 GameObject coin = Instantiate(coinPrefab); return coin; } } public void ReturnCoin(GameObject coin) { coin.SetActive(false); coinPool.Enqueue(coin); } }然后在Coin脚本中,被收集时调用CoinPool.Instance.ReturnCoin(this.gameObject)而不是Destroy。生成新硬币时也通过对象池获取。对于第一个小游戏这可能过度设计,但这是非常重要的性能优化思想,在后续更复杂的项目中会用到。
4.3 配置化与数据驱动
不要把所有数值都硬编码在脚本里。比如硬币的分数值、玩家的移动速度、跳跃力度。你可以创建 ScriptableObject 来管理游戏数据,或者简单地在 Inspector 中配置公共变量。
在PlayerController中,我们把moveSpeed声明为public,就是为了能在 Inspector 中随时调整,而无需重新编译代码。对于更复杂的数据,如关卡列表、敌人属性表,ScriptableObject 是更好的选择。它本质上是一个存储在项目中的资源文件,可以在多个对象间共享和复用。
上半部分的核心,就是通过这样一个完整的迷你项目闭环,让你把 C# 语法(变量、函数、类、条件判断、循环)和 Unity 的核心概念(GameObject, Component, Prefab, Rigidbody, Collider, Trigger)以及基本工作流(创建、脚本、调试、优化)串联起来。当你成功让球滚动起来、收集硬币、看到分数变化时,你已经跨过了从“学语法”到“做东西”最关键的一步。
接下来的项目,会在这些基础上,引入更复杂的控制(如摄像机跟随)、更多的物理交互(如使用关节)、状态管理(如玩家生命值、游戏关卡)以及基本的 UI 交互。每一步都会巩固已有知识,并引入一两个新概念,像搭积木一样,最终让你有能力独立设计和实现自己的 3D 游戏原型。记住,遇到问题先自己查(Unity 手册、API 文档是最好老师),再参考社区,这样成长最快。