尧图精选

Unity躲避障碍小游戏全流程开发指南:从对象池到WebGL发布

🕒 发布时间:2026/9/14 4:51:47 📁 来源:尧图网络
简介这是一个基于Unity开发的期末课程设计项目——简单躲避障碍小游戏。玩家操控一个小人通过左右移动和跳跃躲避从天上掉落的障碍物一旦被砸中就会扣除生命值玩法直观适合Unity初学者作为入门练手或直接参考完成课设作业。压缩包为zip格式共21147个文件大小约552MB。内容以C#脚本、Unity场景与预制体、贴图与材质、动画控制器及官方DLL库为主同时包含可直接运行的exe版本无需安装Unity也能体验游戏效果。文件结构完整覆盖从场景搭建、角色控制到游戏循环的各个环节。目前已有961人学习下载资源中包含完整源码与可执行程序便于对照学习角色移动、跳跃物理、碰撞检测、随机掉落和生命值系统等关键实现也可帮助理解Unity项目的目录组织与资源管理方式。1. 先把躲避障碍小游戏这个课设的骨架搭清楚对大多数 Unity 课设来说躲避障碍是最容易做出效果也最容易翻车的选题之一。场景里只有玩家、障碍物和分数逻辑链路短但把移动手感、碰撞判定、游戏状态切换这三件事连起来就覆盖了 Unity 开发最常考的评分点场景组织、预制体使用、物理触发器、UI 与流程控制。这篇文章按一条能直接复现的路径推进从新建项目开始逐步把玩家控制、对象池与障碍物生成、碰撞与死亡判定、计分与重开流程写成代码再处理 Profiler 和 WebGL 发布这些交付后可能被追问的细节。适合正在做 Unity 期末课设的学生也适合想快速拿一套干净模板改出自己版本的开发者。全程以 3D 模板为基础摄像机设成正交投影游戏元素放在 XY 平面上。2. 玩家移动的最稳实现选输入方案、写脚本、调手感2.1 输入方案先定住Input System 与旧 Input Manager 怎么选要从空场景搭到能跑第一步其实不是写 C#而是在项目设置里把输入方案定住。新建 Unity 项目时Active Input Handling 的选项决定了你写的 Input 类调用是否能被编译。观察现象代码里写using UnityEngine.InputSystem编辑器提示命名空间不存在或者相反调用Input.GetAxisRaw时直接抛InvalidOperationException基本可以定位到这个设置上。打开 Project Settings → Player → Other Settings → Active Input Handling下拉项里有 Input Manager (Old)、Input System Package (New)、Both 三种。Input Manager 是 Unity 沿用多年的旧方案API 是静态方法在 Update 里拿来就用新 Input System 则依赖包和 Action 资产要额外建立输入绑定资产才能跑起来。对躲避类小游戏来说控制输入无非是 WASD、方向键和可选鼠标把整套新输入系统引进来收益很低。常见做法是沿用 Input Manager 的键盘轴省去所有配置步骤。如果你的 Unity Hub 模板创建项目时默认选到了 Input System Only先别急着改代码在 Active Input Handling 里切到 Both保存场景后重启编辑器旧 API 就恢复了。注意 Unity 弹出重启提示时千万不要忽略不重启的话设置不会真正生效编辑器会保持旧状态。新 Input System 的优势是支持手柄、触控和自定义绑定以及更低的输入延迟缓冲这些对课设中的躲避玩法收益不大。Unity 2022 LTS 之后的新模板多数默认 Both两种 API 都能用直接在脚本里写Input.GetAxis就能跑。2.2 最小可跑的玩家移动脚本把下面这段脚本挂到玩家物体上用方向键或 WASD 就能控制移动using UnityEngine; public class PlayerMove : MonoBehaviour { public float moveSpeed 8f; public float smoothTime 0.08f; public float boundWidth 8f; public float boundHeight 4.5f; Vector3 velocity; void Update() { float inputX Input.GetAxis(Horizontal); float inputY Input.GetAxis(Vertical); Vector3 targetVelocity new Vector3(inputX, inputY, 0f).normalized * moveSpeed; velocity Vector3.Lerp(velocity, targetVelocity, smoothTime); transform.position velocity * Time.deltaTime; Vector3 pos transform.position; pos.x Mathf.Clamp(pos.x, -boundWidth, boundWidth); pos.y Mathf.Clamp(pos.y, -boundHeight, boundHeight); transform.position pos; } }代码逻辑分四步读取输入值组装目标速度向量并做归一化防止斜向移动时速度超过直线速度用 Lerp 让实际速度向量向目标速度平滑过渡最后用 Mathf.Clamp 把玩家坐标钳制在边界内。参数含义如下moveSpeed移动速度上限单位是 Unity 世界单位/秒smoothTime速度平滑系数数值越小响应越硬调大后惯性更明显boundWidth 与 boundHeight玩家坐标钳制边界取的是世界坐标范围不是屏幕像素。如果项目是 2D 模板玩家物体用的是 SpriteRenderer脚本逻辑基本通用只需要把 Z 轴固定在 0。另一个常见错误是玩家身上同时挂了 Rigidbody2D 和这个移动脚本然后在 FixedUpdate 里没有动刚体导致物理系统每帧试着把物体拉回原处视觉上会出现抖动。规避办法是移动代码和刚体二选一或者把刚体类型设为 Kinematic。2.3 手感调优的参数表和触控板适配移动手感在躲避游戏里直接影响可玩性下面是课设里常用的参数组合moveSpeedsmoothTime适用节奏60.05慢速起步适合新手关卡80.08常规节奏默认推荐120.03高难度速通靠反应操作smoothTime 不是越大越好。调太大时玩家按方向键后角色会慢悠悠滑过去产生输入不跟手的体感调太小时又失去平滑过渡显得生硬。我一般从 0.08 起步在 Game 视图里实际玩两分钟根据感觉微调。笔记本触控板玩家的操作体验也需要照顾。触控板模拟鼠标移动时Input.GetAxis 的 Horizontal 轴可能没有响应因为笔记本触控板不会产生物理按键。常见做法是在 Update 里把鼠标位置换算成世界坐标和键盘输入取较大值Vector3 mouseWorld Camera.main.ScreenToWorldPoint(Input.mousePosition); Vector3 mouseDir (mouseWorld - transform.position).normalized; float inputX Input.GetAxis(Horizontal); float inputY Input.GetAxis(Vertical); if (Mathf.Abs(mouseDir.x) 0.05f || Mathf.Abs(mouseDir.y) 0.05f) { inputX Mathf.Abs(inputX) Mathf.Abs(mouseDir.x) ? inputX : mouseDir.x; inputY Mathf.Abs(inputY) Mathf.Abs(mouseDir.y) ? inputY : mouseDir.y; }逻辑说明ScreenToWorldPoint 把鼠标的屏幕像素坐标转成 3D 世界坐标再用归一化向量表示从玩家指向鼠标的方向。与键盘输入的对比采用绝对值取大保证触控板操作和键盘操作不会互相干扰。注意这个写法要求摄像机是正交投影如果是透视相机ScreenToWorldPoint 需要额外换算深度值。3. 障碍物生成与碰撞检测从 Instantiate 到对象池再到 Trigger 判定3.1 预制体与对象池为什么不能直接 Instantiate障碍物在游戏过程中持续产生离开了屏幕就销毁。最直接的写法是Instantiate和Destroy成对出现几十个物体的创建和销毁在编辑器里看不出问题但发布之后容易在低端设备上出现瞬时卡顿。Instantiate的主要开销在于序列化资源的加载和组件初始化Destroy则延迟到帧末才真正释放内存高频调用会产生 GC 压力。躲避游戏一局可能生成几百个障碍物这个数量级不至于压垮设备但课设答辩时如果老师问“为什么用对象池”你需要能答出区别。对象池的思路是预先创建一批实例需要时从池里取用完了归还而不是销毁。using System.Collections.Generic; using UnityEngine; public class ObstaclePool : MonoBehaviour { public GameObject obstaclePrefab; public int poolSize 15; ListGameObject pool; void Awake() { pool new ListGameObject(); for (int i 0; i poolSize; i) { GameObject obj Instantiate(obstaclePrefab); obj.SetActive(false); pool.Add(obj); } } public GameObject GetFromPool(Vector3 position, Quaternion rotation) { foreach (GameObject obj in pool) { if (!obj.activeInHierarchy) { obj.transform.position position; obj.transform.rotation rotation; obj.SetActive(true); return obj; } } return null; } public void ReturnToPool(GameObject obj) { obj.SetActive(false); } }Awake 阶段创建 15 个实例并全部隐藏。GetFromPool 遍历池子找到第一个未激活的物体设置位置和旋转后启用。ReturnToPool 只是把物体禁用不销毁资源。poolSize 的选取有讲究它应接近正常游戏时间内同时存在的障碍物数量上限而不是越大越好。躲避游戏的障碍物从生成到移出屏幕大约 3 到 5 秒生成间隔从 1.8 秒逐渐缩短到 0.6 秒同时存活的最多也就是 8 到 10 个给 15 已经留足余量。盲目加大会白白占用内存却不会提升任何表现。回收时机的选择同样关键。常见的做法是在障碍物脚本里用 OnBecameInvisible 判断是否离开摄像机视野这个回调在编辑器切窗口或 Game 视图被遮挡时会误触发导致障碍物提前回收到池里下一帧又被取出来视觉上表现为闪烁。更稳妥的方案是给场景底部放一个隐藏的触发器障碍物穿过它时调用 ReturnToPool。3.2 随机生成与难度曲线的联动障碍物的生成逻辑决定了游戏的可玩性。固定间隔固定位置的写法会让玩家十几秒就摸清规律失去挑战性。合理的做法是生成位置随机化生成间隔随分数提高逐步缩短同时障碍物移动速度缓慢增加。using UnityEngine; public class Spawner : MonoBehaviour { public ObstaclePool pool; public float startInterval 1.8f; public float minInterval 0.6f; public float obstacleSpeed 4f; float timer; void Update() { timer - Time.deltaTime; if (timer 0f) { Spawn(); float difficulty Mathf.Clamp01( ScoreManager.Instance.GetScore() / 100f ); timer Mathf.Lerp(startInterval, minInterval, difficulty); } } void Spawn() { float halfWidth Camera.main.orthographicSize * Camera.main.aspect; float randomX Random.Range(-halfWidth 0.5f, halfWidth - 0.5f); Vector3 pos new Vector3( randomX, Camera.main.transform.position.y 8f, 0f ); GameObject obj pool.GetFromPool(pos, Quaternion.identity); if (obj ! null) { Obstacle obstacle obj.GetComponentObstacle(); obstacle.speed obstacleSpeed; obstacle.direction Vector3.down; } } }位置计算的 key 在于Camera.main.orthographicSize * Camera.main.aspect这是正交摄像机可见区域的半宽。减去 0.5 是为了不让障碍物贴着屏幕边缘生成给玩家留出反应余地。摄像机所在位置的 y 值加上 8让障碍物从屏幕外进入视野。难度曲线用了分数与 100 的比值作为 0 到 1 的插值因子再通过 Lerp 在初始间隔和最小间隔之间取值。Mathf.Clamp01 保证因子不会越界防止分数为负或非正常值时把 timer 算成负值导致刷屏。这里obstacleSpeed是固定值想进一步加大难度可以在每一次 Spawn 时把它乘以 1.02但要注意单帧内不要连续生成太快否则低端设备上物理运算和碰撞检测的负担会明显增加。3.3 碰撞检测用 OnTriggerEnter 还是 OnCollisionEnterUnity 的碰撞反馈分为 Collision 和 Trigger 两类。Collision 需要双方碰撞体至少一方带非 Kinematic 的刚体会产生物理作用力Trigger 则只是区域重叠判定不产生物理位移。躲避游戏里玩家碰到障碍物要触发失败但不需要把障碍物弹开所以用 Trigger 是正确的选择。物体上必须满足一个前提玩家和障碍物二者至少一方挂有 Rigidbody 组件否则OnTriggerEnter不会被调用。下面用表格区分两种事件的适用条件事件必要条件典型应用OnCollisionEnter双方都有 Collider至少一方有非 Kinematic 的 Rigidbody平台跳跃落地、物理反弹OnTriggerEnter双方都有 Collider至少一方有任意类型 Rigidbody且碰撞体勾选 Is Trigger区域检测、道具拾取、失败判定我的推荐设置是玩家物体上挂 Rigidbody 并勾选 Is Kinematic同时挂 BoxCollider勾选 Is Trigger障碍物只挂 BoxCollider。Is Kinematic 的意思是刚体不受物理力驱动只随代码移动但依然参与碰撞和触发器检测。失败判定的回调写在玩家脚本里private void OnTriggerEnter(Collider other) { if (other.CompareTag(Obstacle)) { GameManager.Instance.GameOver(); } }逻辑说明回调挂在玩家物体上other 是进入触发区域的那个碰撞体。先用 CompareTag 判断是否是障碍物排除场景里其他 Trigger例如底部回收区域。确认标签后调用 GameManager 的 GameOver 方法。CompareTag 比直接比较字符串更省开销因为它内部用的是哈希比较不会产生字符串分配。3.4 Collider 尺寸的适配和常见误配Collider 是碰撞判定发生的真实区域它和视觉模型不一致是躲避类游戏最常见的隐蔽 bug。如果你的玩家是 Unity 内置的 Cube 或 Sphere碰撞体默认自动生成且贴合模型问题不大。但从 Asset Store 下载的资源模型导入后碰撞体可能缺失或尺寸离谱看起来细长的柱子碰撞范围却大了一圈玩家离着老远就被判负。排查方法在 Scene 视图里选中玩家物体按 F 键聚焦查看线框覆盖范围是否与模型主体匹配。碰撞体旋转之后也会出现边缘覆盖缩小的情况正方形碰撞体旋转 45 度后四角超出原范围或边线内收建议把 BoxCollider 的 Size 微调大 0.05 到 0.1 个单位别让“擦边”变成判定死角。另一个坑是碰撞体层级设置。如果玩家下有子物体带了自己的 Collider父物体的 OnTriggerEnter 接收到的 other 可能是子物体上的其他碰撞体导致重复触发。解决方案是只在玩家根物体上保留一个 Collider子物体统一用碰撞组件的开关来控制可碰撞性而不是叠加多个 Collider。4. 状态管理与 UI别让计分、重开逻辑散落一地4.1 用枚举状态机管住游戏主流程躲避小游戏的流程只有三态准备中、进行中、已结束。很多课设代码里用一堆 bool 变量控制流程isStart、isOver、isPause散落在各脚本里互相覆盖时很难排查。简单可靠的做法是定义枚举状态在 GameManager 里统一管理。public enum GameState { Ready, Playing, GameOver }状态切换过程中的重复保护public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } public GameState State { get; private set; } void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; } public void StartGame() { if (State GameState.Playing) return; State GameState.Playing; ScoreManager.Instance.ResetScore(); Spawner.Instance.ResetSpawner(); } public void GameOver() { if (State GameState.GameOver) return; State GameState.GameOver; Time.timeScale 0f; UIManager.Instance.ShowGameOver(); } public void Restart() { Time.timeScale 1f; ScoreManager.Instance.ResetScore(); Spawner.Instance.ResetSpawner(); RespawnPlayer(); State GameState.Playing; } }代码里最关键的是两处if保护。GameOver 里重复调用会被挡掉避免同一帧碰撞两个障碍物时重复触发 UIRestart 里的 timeScale 恢复则保证暂停状态能被正确解除。Time.timeScale 0f是全局暂停的常用做法。所有依赖 deltaTime 的 Update 逻辑都会停下来但 Update 本身仍然执行Input 读取也仍然有效。如果你有动画依赖 unscaledDeltaTime或 UI 里用了 DoTween 且设置为忽略时间轴这些地方还需要额外处理。4.2 用 TMP 显示得分与 Canvas 层级问题UI 部分至少要包含得分显示、开始按钮和结束面板。Canvas 的渲染模式有三种Overlay 模式把 UI 直接铺在屏幕上不依赖摄像机参数课设场景最省心。Screen Space - Camera 模式需要指定渲染相机适合 UI 要跟随视角或伪装成世界内物体的场景普通躲避玩法用不到。新版 Unity 里文本组件推荐用 TextMeshPro也就是 TMP。创建方式在 Hierarchy 里右键 UI → Text - TextMeshPro。它的字体资源管理和渲染效果都比旧版 Text 好。得分更新脚本挂到 Canvas 上using TMPro; using UnityEngine; public class ScoreManager : MonoBehaviour { public static ScoreManager Instance { get; private set; } public TMP_Text scoreText; int score; void Awake() { Instance this; } public void AddScore(int points) { score points; scoreText.text 得分: score.ToString(); } public void ResetScore() { score 0; scoreText.text 得分: 0; } public int GetScore() { return score; } }TMP 的 text 属性在每次赋值时会产生一小段字符串内存。得分只在障碍物通过时刷新频率远低于每帧一次字符串分配压力可以忽略。如果换成持续增长的得分条那就值得用 StringBuilder 或预先分配缓冲区。Canvas 的层级问题得分文本在 Canvas 里的顺序决定了它是否被其他 UI 元素遮挡。同级元素的先后顺序由 Hierarchy 里的上下位置决定想置顶就把 RectTransform 的 SetAsLastSibling 调用到需要弹出的面板上。4.3 重开按钮的两种写法结束面板的重开按钮实现方式有重载场景和手动重置两种各有利弊方案优点缺点SceneManager.LoadScene 重载当前场景所有 Awake/OnEnable 重新执行状态天然干净场景资源重新加载规模稍大时有短暂黑屏GameManager.Restart 手动重置无加载等待过渡顺滑每个脚本都要写 Reset遗漏一处就会出现旧状态残留我一般建议课设作业使用场景重载。它不是技术水平低而是出错概率更小。场景重载要求入口场景和结算场景处于同一场景的同一 buildSettings 索引否则 LoadScene 的参数传错就直接白屏。重载场景不会自动销毁 DontDestroyOnLoad 的物体如果 GameManager 做了跨场景单例重载时要注意Destroy(gameObject)清理多余实例否则 Awake 里的单例保护会删掉新创建的物体但旧实例的位置和状态可能已经不对了。重载场景前要恢复 timeScaleTime.timeScale 1f; SceneManager.LoadScene(SceneManager.GetActiveScene().buildIndex);放在按钮点击事件里执行时记得先把 timeScale 恢复因为 GameOver 时它被设成了 0。如果 LoadScene 执行前 timeScale 还是 0新场景加载后所有依赖 deltaTime 的脚本仍然不会执行看起来就是“黑屏卡住”的状态。4.4 扩大按钮点击区域的实现细节UI 按钮的点击检测依赖 Image 组件参与射线检测。经常遇到的问题是美术素材只有一小块按钮视觉上就一小块玩家很难点中。Unity 中扩大点击范围的标准做法是给按钮物体加一个全透明的 Image把透明度设成 0然后拉伸 RectTransform 到期望范围。具体步骤选中按钮物体在 Inspector 里确认 Image 组件的 Color 的 Alpha 为 0同时把 Raycast Target 保持勾选状态。然后按住 Shift 调整 RectTransform 的 Width 和 Height让透明区域覆盖到视觉元素之外。透明区域越界不会影响性能因为 Canvas 的 Overlay 渲染会把全透明色块合并不产生实际画面。还有另一种场景父物体负责接收点击子物体是视觉表现。这时要确保父物体上的 CanvasGroup 的 Blocks Raycasts 为 true否则子物体上的视觉元素即使遮挡了按钮点击仍会穿透到下层。5. 游戏写完后用验证流程和 Profiler 收尾再发布5.1 打开 Profiler 前的 5 分钟人工验证代码写全之后不要急着打包先在编辑器和打包后用同一套检查清单跑一遍。第一个检查点障碍物是否持续生成离开屏幕后是否回收到池中而不是堆积在场景中。第二个检查点玩家触碰障碍物后是否只触发一次 GameOverUI 有没有重复弹出。第三个检查点重开按钮是否完全重置了分数和障碍物不会保留上一局的旧障碍物。第四个检查点发布到 WebGL 之后UI 布局在不同分辨率下有没有错位得分是否随游戏正常刷新。5.2 用 Profiler 定位真正的性能瓶颈打开 Window → Analysis → Profiler勾选 CPU Usage播放前点 Record跑 3 到 5 分钟后再停。看 PlayerLoop 里 Update、LateUpdate、Physics 各自的时间占比。如果 Physics 耗时超过 10ms而场景里只有几个碰撞体优先检查刚体数量和非 Kinematic 刚体是否比预期多。如果 Update 时间集中在某个脚本用右侧的 Hierarchy 搜索线程里定位具体方法。5.3 WebGL 发布时的 PlayerPrefs 写入问题免积分下载的项目经常直接发 WebGL 链接并提到“unity 发布 webgl 使用 idbfs 写入失败”这样一个常见报错。WebGL 构建环境没有本地文件系统PlayerPrefs 实际写入浏览器 IndexedDB首次写入或配额不足时容易失败。躲过这个坑的办法不要每帧写入把成绩保存放到 GameOver 或离开场景时一次性调用。5.4 交付目录与免积分包的整理既然打着“免积分下载”的标签交付物的目录和说明就得对得起下载者。Assets 下至少分 Scripts、Prefabs、Scenes、UI 四类入口场景单独放在 Scenes 下并用文件名说明。脚本类名和文件名保持完全一致否则重新打开工程后 Inspect本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →