1. 项目速览:Roll A Ball 到底在教你什么
先别急着把 Roll A Ball 当成一个“幼儿园项目”就划走。我可以很负责任地说,这个 Unity 官方示例摆在新手关卡列表里的地位,几乎相当于编程世界的 Hello World,但它比 Hello World 强的地方在于——它不只是一句打印语句,而是用一套完整闭环串起了游戏开发最核心的几条链路:物理系统的运动驱动、碰撞器与触发器的差异、组件式开发思想、UI 数据映射、场景状态管理,以及最基本的相机跟随逻辑。
我第一次带着某工作室的新人做这个 Demo 的时候,就让他在不查任何资料的前提下,只靠 Unity 编辑器拖拽完成整个场景搭建。说不难是假的,但做完之后再回到项目里,他对自己看到的每个组件都有了直觉式的理解。这个项目本质上就是一个带计分和胜利条件的小游戏:玩家控制小球在地面滚动,吃到若干个方块之后就达成通关条件。它看起来平淡无奇,但里面藏的东西特别多。
如果你是一个刚接触 Unity 的开发者,或者经历过“照着视频敲代码但完全不知道自己在写什么”的阶段,这个项目就是你建立心智模型的最佳起点。它能帮你搞清楚的第一个终极问题就是:Unity 里的对象,到底是怎么被组织、被驱动、被交互的。我接下来会把整个项目的拆解过程、完整实操流程和经验教训全部写出来,各位可以直接照着动手,也可以先读完再上手,避免踩我当年踩过的坑。
2. 场景搭建设计:先想明白再动手拖拽
很多新手一上来就直奔“创建地面、创建小球”这类操作,但忽略了一个致命问题——Unity 编辑器里的场景结构,直接决定了后续脚本设计的复杂度。我在做 Roll A Ball 的前期规划时,一般会先在纸面上列出场景里需要的所有物体:
- 地面:作为物理碰撞的基准平面
- 小球:玩家控制的主角,携带刚体和球体碰撞器
- 收集物:若干个可被吃掉的小方块
- 墙体:可选,用来限制活动范围
- UI 画布:用来显示得分信息和胜利提示
- 主相机:需要保持对小球的位置跟随
这里的核心设计逻辑是:让每一个物体只干一件事。地面只管碰撞,小球只管接收输入和移动,收集物只管被检测并消失,UI 只管显示数据。职责分离听起来很像软件工程里的概念,但 Unity 的组件化开发天然就是这么组织的。你给一个物体挂上 Transform、Mesh Filter、Collider、Rigidbody,它就成了一个“有形体、有重量、会碰撞”的游戏对象。理解了这个,后面写脚本才不会东一块西一块。
2.1 地面与边界的搭建细节
关于地面,最常见的做法是创建一个 Plane 还是 Cube?这两个我实际试下来,会给新手完全不同的反馈体验。默认 Plane 的尺寸是 10x10,碰撞面在 y=0 的位置,渲染面在 y=0 的位置,小球放在上面会出现一半贴地的视觉问题;而 Cube 是一个实体,长宽高都可控,改成 (10, 1, 10) 之后,需要适配小球的半径来调整初始 Y 坐标。
我自己更喜欢用 Cube 做地面,因为它的轴心在中心,后续如果需要给地面添加缩放动画或者旋转效果,不会像 Plane 那样出现“翻面”问题。但要注意 Cube 是实心的,小球贴地滚动时会和它产生持续的碰撞接触,不会掉下去,也不会有穿透感。为了美观,可以在地面表面加一个纯色材质,或者直接用默认的 Standard 材质改颜色就行。
你可能会问:为什么不直接把地面放大?因为放大 Plane 会导致材质纹理拉伸,尤其在某些渲染管线里会让光照表现异常。Cube 就简单干净,改尺寸不涉及 UV 问题。场景四周可以再加四个 Cube 墙,但也可以暂时不加——小球一旦跑出边界,只要还没有触发 OnTriggerExit 之类的逻辑,一般还能拉回来。实际项目中我会加上墙壁,原因后面在“常见问题”里详细讲。
2.2 小球的刚体与碰撞器配比
小球这个角色是理解 Unity 物理系统的最佳入口。它的结构是:球体模型(Sphere Mesh)+ 球体碰撞器(Sphere Collider)+ 刚体(Rigidbody)。这里有一个很多新手踩过的坑:不创建刚体就不发生物理碰撞。Mesh 只是视觉模型,Collider 只是碰撞检测区域,Rigidbody 才是真正驱动物理模拟的组件。你把三者拆开看就不难理解了——Collider 告诉物理引擎“这是我的形状”,Rigidbody 告诉物理引擎“我这个东西会受力、会动、会撞”。
小球半径默认是 0.5 米,这个数值在 Unity 的单位体系里算挺小的。为了让视觉和碰撞体积匹配,碰撞器半径也和模型保一致。如果在调试时发现小球和地面之间悬浮感特别强,或者嵌进去一半,多半就是半径和初始位置没算好。小球的 transform.position 的 y 值应该等于地面高度加上小球半径再加一点点余量,比如地面是 y=0 的 Cube 顶面,那小球中心在 y=1.1 左右就差不多了,这样它会在重力作用下轻微下落到贴合地面的位置。
刚体组件里有一个参数值得单独拿出来说——Drag(阻力)。阻力影响的是物体在不受力时减速度的快慢。阻力设得太大,小球会显得像在糖浆里挪动;设得太小,小球又会滑得像溜冰场里的冰壶。Roll A Ball 这种休闲控制风格,我建议阻力设在 0.5 到 1 之间,配合后面的移动力参数一起调。调参的时候别只盯着屏幕看,把 Inspector 窗口的数值改动和场景里小球的运动直观感受结合起来,每次只改一个变量。
3. 核心细节解析:物理移动为什么不能用 Transform
这个项目的第一个“分水岭”出现在写移动脚本时。绝大多数新手照着记忆写出来的是这种代码:
void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); transform.Translate(h * speed * Time.deltaTime, 0, v * speed * Time.deltaTime); }这段代码在“非物理”项目里是能跑的,但放在 Roll A Ball 里就是灾难现场。为什么?因为直接修改 transform 的坐标,绕过了物理引擎的模拟。小球虽然有刚体,但刚体并不知道自己的位置已经被强行改掉了,体现在结果上就是:一方面无法正常和墙面产生反弹效果,另一方面穿透地面和墙体的概率大增,而且会破坏物理引擎对速度的计算——因为速度是通过位移差推算的,你每帧手动平移,刚体内部记录的速率却是独立的,等你想用 AddForce 发力时会发现力量被叠加得莫名其妙。
正确做法是利用刚体的物理接口。有两个选择:AddForce 直接施加力,或者直接修改 velocity。Roll A Ball 官方惯用的是前者,因为它更接近真实世界的物理规则:力的作用导致速度改变,摩擦力和阻力让运动逐渐衰减。用生活里的例子解释,就是你推一个箱子,你持续施力箱子才持续移动,手一停,箱子慢慢停,而不是“瞬移”。
3.1 FixedUpdate 和 Update 的差异到底在哪
这里必须要展开说清楚。Update 每帧调用,帧率越高调用越频繁;FixedUpdate 按固定时间步长调用,默认是每秒 50 次。物理引擎的模拟是在 FixedUpdate 的节奏里跑的,所以你在 Update 里给刚体施加力,时机是紊乱的——可能上一帧和下一帧之间的间隔时间不同,导致力的累积效果随机波动。而在 FixedUpdate 里施加力,物理引擎的正常工作节奏不会被打破。
实际调试里我见过不少人把输入代码放进 Update,把物理施加放进 FixedUpdate,结果发现小球操控手感非常“肉”,方向都转不过来。其实输入读取放哪里不是最关键的问题,关键是物理施力代码的宿主函数。为了简单稳妥,新手阶段建议移动检测和施力都放在 FixedUpdate 里,最多用 Input 读取的部分留在 Update 再用标志位传递,但这些细节等到后面写复杂角色控制器时再说。对 Roll A Ball 而言,全放 FixedUpdate 一点问题都没有。
再说 Time.deltaTime。Update 里的 deltaTime 是每帧的时间间隔,而 FixedUpdate 里的 deltaTime 是固定时间步长,两者数值不同。如果你在 FixedUpdate 里写力的时候乘上了 Time.deltaTime,不会报错,但力的大小会和物理步长绑定,看起来就像加了“帧率相关”的误差。官方写法一般是不乘,直接给一个力度常数。原因在于物理引擎本来就按固定步长累积,AddForce 叠加的就是一次瞬间冲击效果。
3.2 坐标系与输入映射的关系
Input.GetAxis("Horizontal") 返回的是 -1 到 1 的连续值,对应键盘 A/D 或者左右方向键;Vertical 对应 W/S 或者上下方向键。问题在于,这些值在屏幕的“左右”“上下”概念里,映射到世界坐标时需要找到合适的轴向。Roll A Ball 的世界坐标里,地面是 XZ 平面,Y 是垂直方向。所以 Horizontal 应该驱动 X 轴,Vertical 应该驱动 Z 轴。
有一个很隐蔽的坑:如果你把相机旋转过角度,再按直觉去映射方向,就会发生“按上键,小球往屏幕的右边跑”这种诡异现象。因为输入轴是“世界坐标轴”,不是相机的“屏幕坐标轴”。Roll A Ball 的相机默认从斜上方看向小球,没有旋转世界坐标,随便用是没问题的。但如果你做过相机跟随,就会发现在某些角度下确实会出现方向的“偏移感”。要想解决,需要用相机的 forward 和 right 向量来重新映射输入方向,这段代码我给你放在后面的优化部分。
4. 实操过程与核心环节实现:完整搭建一版可运行的 Roll A Ball
我先把整套流程整理成可以直接照做的步骤,然后对应每一环节补充“我在做的时候的取舍”,这样你复现时可以少走弯路。整个流程大概 30 分钟能跑通逻辑,如果美术上再做些处理,也不会超过 1 小时。
4.1 场景搭建与材质处理
- 新建一个 3D 项目(URP 模板也可以,但物理逻辑不影响,就是材质和光照参数略不同)。
- 在 Hierarchy 里右键新建 3D Object > Cube,命名为 Ground,设置 Transform 的 Scale 为 (10, 1, 10),Position 默认为 (0, 0, 0)。
- 新建 3D Object > Sphere,命名为 Player,设置 Position 为 (0, 1.1, 0),Scale 为 (1, 1, 1)。
- 给 Player 添加 Rigidbody,Mass 保持默认的 1,阻力设 0.5,角度阻力先不管。
- 创建材质:在 Project 窗口右键 Create > Material,颜色改成你要的颜色,地面用偏灰色,小球用亮色,否则混在一起看不清边界。
- 创建收集物:新建 3D Object > Cube,缩放设为 (0.8, 0.8, 0.8),为了和地面区分,材质改一个抢眼的颜色。做好一个之后,把它拖成 Prefab,然后往场景里复制若干个并摆好位置。
这里有个容易被忽略的点:Prefab 的作用不只是“批量复制”,更重要的是后续你改一个,全部跟着变。比如你想把收集物从方块改成金币模型,只要改 Prefab 源,场景里所有实例都会同步更新。新手阶段就养成用 Prefab 的习惯,能让后续开发效率至少提升一大截。
4.2 移动脚本的完整写法
创建 C# 脚本,命名为 PlayerController,用下面的代码作为基础版本:
using UnityEngine; public class PlayerController : MonoBehaviour { public float speed = 10f; private Rigidbody rb; void Start() { rb = GetComponent<Rigidbody>(); } void FixedUpdate() { float moveHorizontal = Input.GetAxis("Horizontal"); float moveVertical = Input.GetAxis("Vertical"); Vector3 movement = new Vector3(moveHorizontal, 0.0f, moveVertical); rb.AddForce(movement * speed); } }关于这个脚本里的每一个关键词,我都解释一下,因为面试或者做项目的实际需求里,这些都会反复遇到。
public float speed:把力度暴露在 Inspector 里,是为了不用每次改代码就能调参数。这是 Unity 开发最核心的工作流之一——让策划/美术也能参与调参,不需要碰代码。GetComponent<Rigidbody>():获取组件引用。为什么不直接在 Inspector 里拖?因为代码里自己获取更可靠,尤其在 Prefab 被动态实例化的时候,拖拽引用很可能失效。Input.GetAxis:不再像之前的代码那样直接读 KeyCode,因为 GetAxis 自带平滑过渡,按下去和松开的过程不是瞬间切换的,手感更柔和。AddForce:给刚体施加一个持续的力。注意这里没有Time.deltaTime,因为物理引擎在 FixedUpdate 内部以固定步长模拟力和运动,不需要手动乘以帧时间。
把脚本挂到 Player 上后运行游戏,你会看到小球可以被控制着滚动了。但这时候速度手感可能不理想。我的经验是先用 speed = 10 作为起始值,然后运行游戏,边跑边在 Inspector 里拖数值。如果按一下方向键小球直接弹射出去,说明力度值太大;如果推半天没反应,就调大。最终参数取决于个人的操控习惯,但对新手来说,能立刻看到游戏运行结果的反馈是最重要的。
4.3 收集物与触发计时的逻辑
接下来要处理的是“吃到方块”这个交互。这涉及到 Unity 碰撞系统里的一个关键分类:碰撞器(Collider)和触发器(Trigger)。
简单来说,碰撞器负责物理阻挡,就是你撞上去会被挡住;触发器负责检测重叠,你走过去不会被挡住,但引擎会告诉你“有一个东西进入了我的范围”。Roll A Ball 里的收集物就应该用触发器,因为小球应该穿过方块,同时触发“吃掉”的逻辑。
操作步骤是:选中所有收集物,确保它们都有 Box Collider,把Is Trigger复选框勾上。然后把挂在小球身上的 PlayerController 脚本扩展一下,加入触发器检测逻辑:
void OnTriggerEnter(Collider other) { if (other.gameObject.CompareTag("PickUp")) { Destroy(other.gameObject); } }这里思路的核心是:小球是带有刚体的物体,它进入方块的触发器范围时,Unity 会调用小球身上所有脚本的OnTriggerEnter方法。所以这段逻辑写在 PlayerController 里是合理的。但更工程化的做法是单独建一个脚本专管“收集反馈”,小球的脚本只负责移动。这就是组件式开发的精髓——你可以在一个物体上挂多个脚本,每个脚本只关心自己能力范围的事。
关于Destroy还是SetActive(false)的问题,我在项目里一般推荐SetActive(false)。原因有两个:第一,Destroy是真正把对象从场景里移除,如果后续还要做“恢复”或者“重新开始”,还得再实例化,麻烦;第二,SetActive(false)只是隐藏,对象还在内存里,逻辑还可以通过OnEnable来重置状态,对性能影响几乎可以忽略。Roll A Ball 的收集物数量本来就不多,两者差异不大,但良好习惯需要从一开始就养起来。
4.4 计分 UI 和胜利判断
UI 部分用 Canvas 下的 Text(或者 TextMeshPro)来显示分数。创建方式:Hierarchy 右键 > UI > Text(如果用的是 UGUI)。这时 Unity 会自动创建一个 Canvas 以及一个 EventSystem。Canvas 的渲染模式默认是 Screen Space Overlay,也就是直接叠在屏幕上方,不受 3D 场景深度影响,适合做 HUD 类显示。
我在项目里一直强调用 TextMeshPro 替代老的 Text 组件。原因是 TextMeshPro 的字体渲染质量更好,支持中文字体,而且处理动态排版时性能更稳。Unity 新版本里已经把它作为默认替代方案了。如果你用的还是旧模板,建议在 Package Manager 里安装 TMP Essentials,避免升级过程出岔子。
脚本方面,新建一个 ScoreManager 或者直接在 PlayerController 里加一个 public Text 引用。把分数计数作为 PlayerController 的成员变量:
public UnityEngine.UI.Text scoreText; // 或者 TMP_Text private int score; void OnTriggerEnter(Collider other) { if (other.gameObject.CompareTag("PickUp")) { score++; SetScoreText(); other.gameObject.SetActive(false); } } void SetScoreText() { scoreText.text = "得分:" + score.ToString(); }这一段有两个细节值得讲。第一个是为什么用score.ToString()而不是直接加字符串。C# 里字符串拼接scoreText.text = "得分:" + score;其实也能隐式转换,但显式调用 ToString 会更明确,而且在一些更严格的代码规范检查里不会产生告警。第二个是为什么把更新 UI 的代码单独抽成一个方法,而不是直接写在 OnTriggerEnter 里。因为后期加音效、加粒子特效、加关卡判定时,代码如果全堆在一个方法里会疯掉。抽分方法的好处是每次得分时只在同一个地方改逻辑。
胜利判断也很简单:得分等于收集物总数时,显示一个包含 “You Win!” 的文本对象。你可以先把这个文本设为不激活,当分数达标时激活它。这里涉及一个新手常说的SetActive(true)和enable = true的区别。SetActive 控制的是整个 GameObject 的激活状态,而 enable 只控制单个组件(比如 Text 组件的渲染开关)。如果 Text 所在的 GameObject 里还挂了其他脚本或者子物体,用 SetActive 会更彻底。
5. 相机跟随实现:从固定视角到顺滑跟手
Roll A Ball 官方教程里做相机跟随的方式极其简单暴力:把相机作为小球的子物体。这种方式在项目初期没什么问题,相机永远和球绑定,角度怎么调都行。但代价是当小球旋转时,相机也会跟着转(如果小球是滚动的球体,子物体相机会出现自旋),而且镜头运动会完全跟随小球的速度变化,没有任何缓冲,这种视觉在长时间游玩时会让玩家产生晕眩感。
我建议直接用脚本实现 LateUpdate 相跟随,这也是后续所有动作游戏的基石。
5.1 LateUpdate 和 Update 的配合
先给关键点定性:相机跟随代码写在 LateUpdate 里。原因是 Update 的执行顺序是所有脚本都先更新逻辑,LateUpdate 在所有 Update 都执行完之后才跑,此时小球的位置已经是最新状态,相机的更新不会出现“慢半拍”的抖动。如果写在 Update 里,可能会和其他脚本的执行顺序冲突,尤其是在多个脚本同时读写位置数据时。
最简单的跟随逻辑是:相机保持和小球一定偏移量,每帧把位置设为“小球当前位置加偏移量”。例如:
public Transform playerTransform; public Vector3 offset; void LateUpdate() { transform.position = playerTransform.position + offset; }将相机初始位置设在某个斜上方角度,然后把这个位置和小球位置的差存为 offset。运行游戏后,小球的移动会带动相机移动,但它不会旋转,视角稳定。这里还有一个进阶技巧:如果直接用坐标相加,相机和小球之间是“硬连接”,一点缓冲都没有。想要顺滑跟随,可以用 Vector3.Lerp 做插值:
transform.position = Vector3.Lerp(transform.position, playerTransform.position + offset, 0.25f);这个 0.25 的插值系数越小,跟随越慢、越柔;越大,跟随越紧、越快。对于休闲类型的小游戏,0.2 到 0.3 之间都能接受。你可以把系数设成 public 变量,在 Inspector 里实时调,效果直观到没朋友。但这套方案如果在高速移动时,相机难免会稍微“落后”,如果你非要做竞技感非常强的游戏,请先把系数调到 0.5 以上,或者直接改用平滑阻尼方法。
6. 代码结构优化:从一个文件到组件式脚本的演进
Roll A Ball 的官方写法经常把移动、计分、胜利判断全部堆在一个 PlayerController 里,代码量不大时没问题,但如果你的学习目标是“学会 Unity 的开发范式”,我强烈建议从第二步开始就做组件拆分。拆成下面这些脚本:
- PlayerController:只管移动
- PickUp:挂在收集物上,可以通过事件通知玩家加分,或者由 PlayerController 通过标签识别
- ScoreManager:负责计分和 UI 更新
- CameraFollow:负责相机跟随
一个物体挂多个脚本,脚本之间通过 GetComponent 或者公共方法通信,这是 Unity 项目规模变大之后必然要走的路。当你只有 3 个脚本时感觉不明显,等你写到 20 个脚本的时候,组件化带来的解耦价值就非常可观了。Roll A Ball 这个小项目虽然小,但它是你练习各组件协同作战的好试验场。
6.1 PlayerController 的工程化改法
我推荐在 PlayerController 里用事件机制而不是直接修改分数的做法。虽然事件和委托对新手有一定理解成本,但一旦掌握,后续开发复杂交互会非常顺手。
public class PlayerController : MonoBehaviour { public float speed = 10f; private Rigidbody rb; void Awake() { rb = GetComponent<Rigidbody>(); } void FixedUpdate() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 dir = new Vector3(h, 0, v).normalized; rb.AddForce(dir * speed); } }这里我多加了一个.normalized。不加上它,对角线方向的移动速度会比单轴方向更快(因为向量长度变长了)。这对 Roll A Ball 这种项目影响不大,但对操作的“公平性”有影响,尤其是做移动端方向摇杆时,这个细节可以说是基本功。
6.2 用 Tag 还是用 GetComponent
官方教程用的是 Tag 判断,因为简单直观,适合入门。但在工程级项目里,Tag 判断至少有两个问题:第一,比较字符串有性能开销(虽然可以忽略不计),第二,如果拼写错误或者标签没设置好,BUG 会隐蔽到你怀疑人生。
我后来更倾向于在收集物上挂一个标记脚本,比如:
public class PickUp : MonoBehaviour { // 可以作为配置数据的容器 }然后在 PlayerController 里用other.GetComponent<PickUp>()判断是否为空。不为空就说明碰到的是收集物。这种写法比 Tag 更类型安全,改脚本比改 Tag 更不容易出错。Roll A Ball 项目规模小,两种都行。但既然学习,就按工程思路来。
为什么“类型安全”这个理念如此重要?因为 Tag 本质上是一个字符串约定,它依赖“所有人约定好同一个字符串”,这个约定一旦被无意破坏(比如某个人在另一个场景里误改 Tag),排查起来极其费劲。而GetComponent<PickUp>()是强类型匹配,编译器就能帮你找出大部分问题,运行时的健壮性也高。
7. 常见问题与排查技巧实录
这部分是我最想写的,因为这些坑我几乎都踩过,而且每条都能在论坛上对应到一堆求助帖。下面按排列顺序给出现象、原因、解决路径,实用性极高。
7.1 小球弹射飞走或运动过于剧烈
现象:按一下方向键,小球直接飞出屏幕,或者毫无规律地乱弹。原因十有八九是速度值开得太大,或刚体阻力太低。另一种可能是你每帧在 Update 里施加了力,导致帧率越高施加的力次数越多,速度失控。
排查流程:先暂停游戏,查看 Rigidbody 的 velocity 向量变化;然后在移动脚本里临时加一行日志输出每次 AddForce 的向量长度;最后把 speed 调小到 2 左右测试基线速度,再慢慢调回合适的值。记住一个原则:大速度靠多次小力累积,不要一次给个大冲击。
7.2 小球穿过地面或墙体
现象:小球在地面上跑得好好的,突然掉下去或卡在墙里。原因往往是速度太快,单帧位移已经超过了碰撞器的厚度,物理引擎来不及检测到碰撞,这就是所谓的 tunneling 穿透问题。解决方式有几个:启用刚体的 Continuous Dynamic 碰撞检测模式,或者限制最大速度。如果是固定墙体的碰撞体厚度比较薄,也可以把墙做厚一点。在 Roll A Ball 这个项目里,按默认速度是不太会穿透的,如果你自己调了很大的速度,就会遇到。
7.3 收集物明明碰到了却没有反应
如果你检查了 Tag 和脚本都没问题,那大概率是角色的刚体没有覆盖到碰撞检测。OnTriggerEnter只有在“至少一方有 Rigidbody”的情况下才会触发。小球身上有刚体,所以收集物一个没有刚体也能触发——也就是说收集物只需碰撞器和 Is Trigger 勾选即可。如果收集物没有勾选 Is Trigger,那就变成物理阻挡了,小球会被弹开而不是穿过。所以这个问题的常见根源就是:一个对象的 Is Trigger 没勾,另一个的 Tag 大小写不对。
7.4 小球被收集物挡住,怎么都推不动
跟上面正好相反,这就是碰撞器没有设 Is Trigger 的表现。方块变成了“挡路的砖块”。解决方式:选中所有收集物,Inspector 里 Collider 的 Is Trigger 勾上,然后确认小球身上的 Collider 是不能勾的,保持实体碰撞。
7.5 计分疯狂闪烁或多次加一
如果你发现吃一个方块分数跳好几下,多半是因为小球在触发器范围内停留了好几帧,触发了多次OnTriggerEnter。解决方式是在得分之后立即SetActive(false)或者Destroy,确保对象已经不在场景里,不会再触发第二次。如果用的是OnTriggerStay,那铁定每次都会一直更新得分,新手要特别注意区分Enter和Stay的语义。
7.6 相机视角偏移或跟随卡顿
相机偏移通常是你把 camera 设成了小球的子物体,但小球带有旋转,相机也跟着自转。解决方式:把相机从子物体层级中拖出来,用独立脚本做位置跟随。如果是卡顿,不是帧率问题就是勾选了物理引擎的 Interpolate 选项(刚体的插值模式)。这个参数值得单独立一个条目解释。
7.7 刚体 Interpolate 和 Collision Detection 的实战配置
Rigidbody 的 Interpolate 选项有三个值:None、Interpolate、Extrapolate。None 表示物理引擎的运算结果直接用于渲染,在低帧率下会出现一卡一卡的感觉。Interpolate 会基于上一帧和当前帧的位置差值做插值,让视觉更平滑,推荐开启。Extrapolate 是预测下一帧的位置,时机不准时会抖动,除非你需要预测性表现,否则不推荐。
Collision Detection 是另一个常被忽略的参数。默认的 Discrete 模式在高速运动时会漏检,Continuous 会加大计算开销但避免穿透。Roll A Ball 这种低速休闲游戏,保持默认即可。但你心里要有个数:当项目里出现高速子弹、高速移动角色时,这两个参数必须重新评估。
8. 从 Roll A Ball 延伸出去的扩展练习
如果这个 Demo 已经完全跑通并且你能不看代码重新写出来,我建议按下面几个方向给它做迭代,每个方向都能强化一项核心技能。
第一个方向是操作手感的打磨。把移动从 AddForce 改成直接控制 velocity,你会发现小球的响应会立即、快速、可控。然后给地面增加摩擦力材质(Physic Material),调整摩擦系数和弹性系数。此时你会开始理解物理材质、刚体阻力和力矩之间的交互逻辑,这套能力在做平台跳跃类游戏时特别重要。
第二个方向是关卡机制扩展。给收集物加不同类型的颜色,每种颜色对应不同分数;加入计时器,限定时间内完成;加入移动平台和弹射机关,在小球滚过触发器时改变它的运动方向。你会发现这些听起来很复杂的功能,本质上都是对触发器、刚体、AddForce 的不同排列组合。从这里开始,你已经具备搭一个简单跑酷游戏的能力了。
第三个方向是移动端适配。把 Input.GetAxis 替换为虚拟摇杆输入,或者利用手机陀螺仪控制方向。这要涉及新输入系统(Input System Package),以及事件回调的写法。Roll A Ball 这个项目是学习新输入系统的绝佳场所——因为它逻辑简单,你可以把 90% 的注意力放在输入事件的处理上,而不是在复杂的游戏机制里分心。
第四个方向是数据驱动与存档。把收集物个数、最高分、音效开关等参数抽到 ScriptableObject 或 JSON 文件里,实现可配置的关卡数据。这是我比较推荐的中级进阶路径,因为数据驱动是现代游戏开发的常态,越早养成“数据不写死”的习惯越有好处。
我个人在实际操作中的体会是:完成一个 Demo 之后,找一个晚上,把它推倒重写一遍,尽量原封不动地去复现熟悉功能;如果你能做到不看旧代码快速完成,说明组件化思维已经刻进本能了。Roll A Ball 从来不是终点,它只是一个可以不断向上旋转的“地基球”。