ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unity3d常用实例源码剖析:角色控制、对象池、UI流程与存档

Unity3d常用实例源码剖析:角色控制、对象池、UI流程与存档 简介面向刚入门Unity游戏开发或希望进行二次开发的初学者这份压缩包内含四个可运行的Unity 3D常用实例Demo源码。四个实例分别围绕基础移动与碰撞检测、UI系统与交互、粒子特效、物理模拟与刚体动力学展开并延伸至动画控制器、光照阴影、C#脚本、场景管理、资源加载等常见开发环节覆盖了CharacterController、Rigidbody、Animator、Canvas、SceneManager等高频组件的真实配合方式。通过学习源码既能理解角色移动、碰撞响应、按钮点击触发、粒子发射等功能的实现思路也能看清完整项目的模块划分与脚本组织方式方便后续直接改造或迁移到自己的游戏Demo中。压缩包约439KB整体小巧精炼便于下载后立即打开练习。目前已有2348人学习适合想通过小案例快速上手Unity开发或准备做二次开发的初学者认真拆解与参考。1. 为什么Unity3d常用实例源码里总少不了这4个Demo实际开发里资源站最常见的那类 Unity3d 实例源码包标题写着“4个常用实例源码-Demo”解压开多半是一两个场景加一堆脚本。先给结论这类包里的4个 Demo 通常是角色控制、对象池、UI流程和存读取覆盖了从手感到进度的完整闭环。把它们真正装进自己的工程比下载后只开场景点两下有用得多——很多人下回来跑两圈就关了根本原因是没搞清每个模块的适用边界和参数出处。拿到这类源码先别急着跑。打开项目第一件事看目录结构脚本是否按模块分目录场景里是否每个系统都有独立入口。一个可当作判断标准的经验是任何一个模块单独拖进新工程、连线少于15分钟才叫“常用实例”质量。下面按这个标准逐个过适合手头正要拼一个小游戏项目、需要一套拿来即改的 Unity3d 入门骨架的开发者。2. 实例源码1Unity3d角色控制与相机防遮挡Demo2.1 为什么角色控制要选 CharacterController 而不是 RigidbodyCharacterController 是一个依靠 Move 和 SimpleMove 驱动、自带碰撞与爬坡约束的组件不参与物理受力计算。对第三人称主角这类“要稳定手感、不要随机物理扰动”的场景它是首选。Rigidbody 更适合需要受力反馈的物体被击飞、载具、布娃娃。Demo 中如果角色用 Rigidbody 写你会发现角色踩在台阶或斜坡上时质心抖动会直接传导给相机最终表现出来的就是镜头晃动、难以调平。另一个选型理由是移动方式上的差异。CharacterController 的 Move 会把碰撞检测和滑动处理都做了而 Rigidbody 需要你自己处理速度方向和碰撞反应的叠加。在“实例源码”这个定位下被复制到不同项目里改动的频率很高CharacterController 写法更接近“填参数就能跑”。理解了这一点再看下面这份脚本时就不会觉得代码繁琐它只是把输入、重力、旋转三者按固定顺序塞进了 Move。2.2 一套可直接挂上的角色移动和相机跟随脚本先看角色移动这是整个 Demo 里最容易被抄错的部分。下面这段可以直接挂到带 CharacterController 的空物体上主相机如果没有跟随脚本它也会自动找一下using UnityEngine; [RequireComponent(typeof(CharacterController))] public class ThirdPersonMotor : MonoBehaviour { [Header(移动参数)] public float moveSpeed 5f; // 米/秒跑步速度 public float rotateSpeed 10f; // 旋转插值速度 public float gravity -9.81f; // 重力加速度 private CharacterController controller; private Transform cameraTransform; private Vector3 velocity; void Start() { controller GetComponentCharacterController(); cameraTransform Camera.main ! null ? Camera.main.transform : null; } void Update() { float h Input.GetAxis(Horizontal); float v Input.GetAxis(Vertical); // 以相机朝向为基准计算输入方向 Vector3 forward cameraTransform ! null ? cameraTransform.forward : transform.forward; forward.y 0f; forward.Normalize(); Vector3 right Quaternion.Euler(0f, 90f, 0f) * forward; Vector3 inputDir right * h forward * v; if (inputDir.sqrMagnitude 1f) inputDir.Normalize(); // 重力与地面判定 if (controller.isGrounded velocity.y 0f) velocity.y -2f; velocity.y gravity * Time.deltaTime; // 位移水平方向 垂直重力 controller.Move(inputDir * moveSpeed * Time.deltaTime velocity * Time.deltaTime); // 面朝输入方向用 Slerp 做平滑转向 if (inputDir.sqrMagnitude 0.01f) { Quaternion targetRot Quaternion.LookRotation(inputDir); transform.rotation Quaternion.Slerp(transform.rotation, targetRot, rotateSpeed * Time.deltaTime); } } }这里的关键逻辑是把输入方向从“世界的 X/Z 轴”换成“相机的右方和前上方”。否则玩家按 W角色只会冲向世界坐标的 Z 正方向镜头一转就乱套。Quaternion.LookRotation(inputDir)会把角色模型的正面朝向输入方向Slerp的作用是让转向不是瞬切而是带阻尼的转动rotateSpeed越大转向响应越快。相机跟随这段建议在 LateUpdate 里写。因为 Update 里角色可能刚从旧位置移动到新位置LateUpdate 能保证所有物理和角色逻辑完成后相机再取最终位置绝大多数相机抖动都源于这一步写错using UnityEngine; public class FollowCamera : MonoBehaviour { public Transform target; public float distance 5f; public float height 2f; public float smooth 8f; public float collisionRadius 0.2f; public LayerMask obstacleMask ~0; // 会被当遮挡物的层 private Vector3 smoothVelocity; void LateUpdate() { if (target null) return; Vector3 targetPos target.position Vector3.up * height; Vector3 back -target.forward; float currentDistance distance; // 用球体射线从角色头顶向镜头方向探测命中则把距离压缩到碰撞点 if (Physics.SphereCast(targetPos, collisionRadius, back, out RaycastHit hit, distance, obstacleMask, QueryTriggerInteraction.Ignore)) { currentDistance hit.distance - 0.1f; } Vector3 nextPos targetPos back * currentDistance; // SmoothDamp 比 Lerp 更顺滑smooth 越小跟随越“重” transform.position Vector3.SmoothDamp(transform.position, nextPos, ref smoothVelocity, 1f / smooth); transform.LookAt(targetPos); } }SphereCast的起点在角色头顶方向朝镜头后方。命中遮挡物时hit.distance表示从起点到墙面的距离用它减去 0.1 米防止相机与墙面穿模。collisionRadius值越大相机越容易在门缝、窄走廊里提前拉近通常 0.2 到 0.4 之间比较合适。2.3 让遮挡物材质透视相机被墙挡住时自动半透明球体探测只能把相机拉近解决不了“角色在墙后面看不见”的问题。常见做法是命中墙面时把遮挡物材质切到透明通道术语上常叫作“材质透视”或“上层穿透看见下层”。下面这段做了一件看起来很聪明、但实际操作必须谨慎的事private DictionaryRenderer, Material[] backup new DictionaryRenderer, Material[](); public void FadeOut(Renderer renderer) { if (backup.ContainsKey(renderer)) return; // 注意materials 会为当前 Renderer 生成材质实例不会污染项目里的共享材质 backup[renderer] renderer.materials; foreach (Material mat in renderer.materials) { // URP Lit 材质切到透明表面类型再调 renderQueue 和 Alpha mat.SetFloat(_Surface, 1f); mat.SetOverrideTag(RenderType, Transparent); mat.renderQueue 3000; mat.EnableKeyword(_SURFACE_TYPE_TRANSPARENT); Color c mat.color; c.a 0.35f; mat.color c; } }备份字典是必要的。只要相机离开遮挡区域就应该把Renderer.materials恢复成原状态如果直接改sharedMaterial场景里所有用同一颗材质球的物体都会跟着变透明。角色身上的布料、半透明特效还需要在obstacleMask里手动排除不然 FadeOut 会把敌人、NPC 全变透明。2.4 角色控制实例的参数对照表与两个典型坑参数含义建议值调节方向moveSpeed角色最大移动速度米/秒4.0 - 6.0游戏节奏偏快取上限解谜类取下限rotateSpeed转向插值速度8.0 - 12.0手感“发飘”就加大转向过生硬就减小height相机相对角色头顶的高度1.6 - 2.2角色越高取值越大视野越俯视distance相机拉开的水平距离4.0 - 6.0场景狭窄应缩短否则镜头频繁撞墙collisionRadius球体探测半径0.2 - 0.4值越大门缝处相机越早拉近典型坑一obstacleMask ~0会把角色自己也当成遮挡物。角色身上的碰撞体和裙摆、武器碰撞都会触发 SphereCast导致镜头距离被错误压缩。一般给角色单独建一个Player层然后在 Inspector 里把obstacleMask勾选为Everything再手动去掉Player层。典型坑二角色在斜坡上时controller.isGrounded返回 true 但下滑严重。这是因为 character controller 的slopeLimit默认为 45 度超过会判定为斜坡。拿到的实例包如果处理不好这块可以在控制器上直接把slopeLimit降到 30把stepOffset保持在 0.3 米左右。3. 实例源码2Unity3d对象池Demo——高频生成与回收的标准写法3.1 什么时候该用看 Instantiate 频次而不是场景复杂度很多初学者觉得“我场景里总共才50个敌人用不上对象池”。真正决定要不要池化的指标是单位时间内的 Instantiate/Destroy 次数。每生成一个 GameObjectUnity 要分配托管对象、触发 OnEnable、初始化 Transform 层级每销毁一个又要触发 OnDisable 和 OnDestroy还产生 GC 压力。子弹飞行类 Demo 里一秒打出 15 发、每发存活 2 秒峰值就是 30 个活跃物体加 30 次待回收的克隆体频繁创建销毁时主线程就会周期性卡顿。对象池的处理思路是把“创建/销毁”换成“从池里取/放回池里”。物体反激活时不真正释放内存只是临时隐藏。这样免去了构造函数、OnEnable/OnDisable 反复执行的消耗也避免了创建时触发阴影、烘焙等引擎内部逻辑的开销。3.2 一个不依赖第三方插件的通用 GameObjectPool常见做法是写一个最朴素的队列池不用 Addressables 也能跑。把它挂到场景里任意空物体上prefab 拖到 Inspector 上就能用using System.Collections.Generic; using UnityEngine; public class GameObjectPool : MonoBehaviour { [Header(池参数)] public GameObject prefab; public int prewarmCount 10; // 启动时预热数量 public int maxCount 50; // 允许同时活跃的最大数量 private QueueGameObject pool new QueueGameObject(); void Start() { for (int i 0; i prewarmCount; i) { GameObject go Create(); go.SetActive(false); pool.Enqueue(go); } } public GameObject Get(Vector3 position, Quaternion rotation) { if (pool.Count 0 transform.childCount maxCount) pool.Enqueue(Create()); if (pool.Count 0) return null; // 已达上限宁可不出也不卡顿 GameObject go pool.Dequeue(); go.transform.SetPositionAndRotation(position, rotation); go.SetActive(true); return go; } public void Release(GameObject go) { if (go null || !go.activeSelf) return; // 防止重复回收 go.SetActive(false); pool.Enqueue(go); } private GameObject Create() { GameObject go Instantiate(prefab, transform); return go; } }释放时先判断!go.activeSelf这个防御能挡住大部分“打了一半报错说对象已销毁”的问题。transform.childCount maxCount是简单的容量上限判断超过后Get直接返回 null调用方要自己处理空引用这是刻意为之把“子弹超出上限该往哪打”留给业务层决定而不是池子默默吞掉。3.3 对象池参数怎么调预热数量、上限、回收时序参数含义建议值说明prewarmCount场景启动时预创建的对象数与单波次峰值接近预热太少第一波生成仍会瞬间卡顿maxCount池内 活跃对象的总上限单波峰值的 1.2 - 1.5 倍过高会浪费显存和内存回收时机对象何时调用 Release特效播放完、子弹命中或超出射程过早回收会让特效视觉断层预热数量不要拍脑袋填。先在游戏里跑一场完整的战斗观察 Profiler 中某个瞬间活跃物体数量的峰值然后以峰值作为 prewarmCount。如果写的是敌人刷新逻辑还需要把释放时机和敌人死亡动画绑定死亡动画播完再回收否则会出现敌人刚倒地就消失的穿帮。3.4 把对象池接进 Demo 的推荐挂法与热更新注意对象池直接挂在一个名为_PoolRoot的空物体上池内所有对象都会作为它的子节点。游戏运行时打开 Hierarchy 面板把_PoolRoot展开就能看到哪些对象是活跃的、哪些是隐藏的比断点调试直观得多。这一步对排查“为什么对象没出现”“为什么对象不消失”非常有效。还要提一个容易踩的场景如果是从网上拿到的 minecraft 风格的 demo高频方块生成改造时很多人会把Instantiate直接替换成pool.Get却忘了方块被破坏时仍然走的Destroy。这会导致池里永远空着内存还是持续泄漏。正确做法是把方块的破坏逻辑里Destroy(gameObject)改成pool.Release(gameObject)发射点和回收点必须成对出现。4. 实例源码3Unity3d UI 与流程管理的 Demo 状态机4.1 UI 面板直接 enable/disable 的问题小游戏项目里最容易失控的就是 UI 面板开始界面、暂停界面、结算界面各挂一个脚本互相用public GameObject引用。前两个面板这样写没问题一旦加了暂停功能就出事。问题集中在三处。一是Time.timeScale 0后如果玩家直接关闭暂停面板忘了把 timeScale 恢复回去角色会永远静止而这类 bug 极难在第一次运行时就发现。二是多个面板同时引用同一个按钮事件比如菜单里的开始按钮和暂停里的继续按钮都要切场景逻辑分散后容易点错。三是 UI 界面之间互相等待菜单要等 SettingPanel 的返回值SettingPanel 又要等菜单关闭一旦顺序不对就死锁。4.2 GameFlow 单例与状态切换常见做法是单独写一个 GameFlow 单例把游戏状态收敛到一组枚举上所有 UI 都只响应状态变化不直接互相调用。下面这个是经过简化但结构完整的版本using UnityEngine; public enum GameState { Menu, Playing, Paused, GameOver } public class GameFlow : MonoBehaviour { public static GameFlow Instance; public GameState State { get; private set; } public int Score { get; private set; } public float PlayTime { get; private set; } public event System.ActionGameState OnStateChanged; void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); SetState(GameState.Menu); } void Update() { if (State GameState.Playing) PlayTime Time.deltaTime; } public void SetState(GameState next) { if (State next) return; // 退出前把 timeScale 恢复避免从暂停直接回主菜单卡死 if (State GameState.Paused) Time.timeScale 1f; State next; if (next GameState.Playing) Time.timeScale 1f; else if (next GameState.Paused) Time.timeScale 0f; OnStateChanged?.Invoke(State); } public void AddScore(int points) { Score points; // 分数变化可以走单独的事件UI 按需订阅 } }DontDestroyOnLoad在这里是因为从主菜单切到游戏场景时GameFlow 不能被卸载。注意Awake中已经做了单例去重场景切回来时旧的 GameFlow 会被销毁新场景里再放一个即可。Time.timeScale统一在 SetState 内部处理外部任何脚本都不应该直接修改 timeScale这是整个 UI 状态机能稳定运行的前提。4.3 事件总线让 UI 与玩法解耦把 GameFlow 与 UI 解耦的常见做法是加一个轻量的事件中心。它不做复杂的消息路由只保存两个 C# eventpublic static class GameEvents { public static System.ActionGameState OnStateChanged; public static System.Actionint OnScoreChanged; public static void RaiseStateChanged(GameState state) { OnStateChanged?.Invoke(state); } public static void RaiseScoreChanged(int score) { OnScoreChanged?.Invoke(score); } }然后在 GameFlow 的SetState里调用GameEvents.RaiseStateChanged(State)而不是直接在 GameFlow 内去操作 UI。UIManager 在 OnEnable 时订阅事件在 OnDisable 时退订这一步必须成对否则场景销毁后 UI 依然在响应事件会报 MissingReferenceExceptionpublic class UIManager : MonoBehaviour { public GameObject menuPanel; public GameObject gamePanel; public GameObject pausePanel; void OnEnable() { GameEvents.OnStateChanged HandleStateChanged; } void OnDisable() { GameEvents.OnStateChanged - HandleStateChanged; } private void HandleStateChanged(GameState state) { menuPanel.SetActive(state GameState.Menu); gamePanel.SetActive(state GameState.Playing || state GameState.Paused); pausePanel.SetActive(state GameState.Paused); } }把三个面板的显隐整合到一个方法里优势是状态切换时只需要关注这一个方法的状态判定逻辑。以后新增一个设置面板只需要加一行settingsPanel.SetActive(state GameState.Menu)不会影响其他界面。4.4 状态转换表与验证用的日志当前状态触发动作下一状态关键行为Menu点击开始PlayingtimeScale1分数清零Playing按 ESCPausedtimeScale0弹出暂停面板Paused点击继续PlayingtimeScale1关闭暂停面板Playing角色死亡GameOver记录最高分显示结算 UI写进调试期日志很方便可以直接在 SetState 里临时扔一条Debug.Log($[GameFlow] {State} - {next});观察日志里状态是否出现跳跃比如Menu - GameOver那就说明有脚本绕过开始按钮直接改了状态通常是新手直接在 Button 的 OnClick 里写FindObjectOfTypeGameFlow().State GameState.GameOver造成的。只要所有切换都走 SetState这种问题从一开始就不会存在。5. 实例源码4Unity3d 存读取 Demo——从 PlayerPrefs 到 JSON 存档5.1 PlayerPrefs 能用多久什么时候必须换PlayerPrefs 是 Unity 自带的键值对存储写法和读取都很简单但它有三个硬限制只支持 int、float、string 三类基本值不适合存复杂结构硬要存会把对象拼成字符串再解析没有版本概念游戏更新后旧存档缺字段会直接读崩。所以需要把“配置写入”和“进度写入”分开。音量、画质等级、语言这些单项配置继续用 PlayerPrefs角色等级、通关进度、背包物品这类复杂数据必须换成文件存档。文件存档的另一个好处是方便调试打开电脑上对应目录就能看到 JSON 明文内容手动改一档数据再启动游戏也比在 Unity 编辑器里反复操作 UI 快得多。5.2 一份 JSON 存档读写可读、可改、可回滚JsonUtility 是 Unity 内置的 JSON 序列化类性能不如第三方库 Newtonsoft.Json但零依赖合适做 Demo 存档。下面这份结构把 SaveData 和 SaveManager 拆开using System; using System.IO; using UnityEngine; [Serializable] public class SaveData { public int version 1; public string playerName Player; public int highestScore 0; public int level 1; public float playTime 0f; public string checkPoint Level1_Start; }再写管理类public class SaveManager : MonoBehaviour { private static string SavePath Path.Combine(Application.persistentDataPath, save.json); public static void Save(SaveData data) { string json JsonUtility.ToJson(data, true); File.WriteAllText(SavePath, json); } public static SaveData Load() { if (!File.Exists(SavePath)) return new SaveData(); string json File.ReadAllText(SavePath); SaveData data new SaveData(); JsonUtility.FromJsonOverwrite(json, data); // 简单兼容旧档缺字段时补默认值 if (data.version 1) { data.checkPoint Level1_Start; data.version 1; } return data; } }Application.persistentDataPath在不同平台上指向不同目录Windows 上一般是C:\Users\用户名\AppData\LocalLow\公司名\产品名所以保存/读取都不要拼接绝对路径。JSON 文件的好处是可直接用记事本打开改highestScore后重进游戏就能省掉“反复打完整一关看结算”的验证时间。5.3 版本号字段与 Editor 调试入口version字段看似多余实际上是存档第一道防线。数据表加列时旧存档读取后走if (data.version 2)补默认值用户进度不丢。如果结构变化太大还能走“旧档备份、新档重建”的兜底路线[ContextMenu(清空存档)] private void ResetSave() { File.Delete(SavePath); PlayerPrefs.DeleteAll(); Debug.Log(存档已清空); }[ContextMenu]会把“清空存档”直接加到 Inspector 组件的右键菜单里在编辑器里一键重置不需要每次手动去目录里删文件。这个方法不应该出现在正式包里正式包可以用一个隐藏的“长按标题10次”彩蛋当作重置入口。5.4 存档内容别放资源材质球、视频流与配置分离存档里只保存“值”不保存“资源引用”。对应到实际做法PBR 材质球、贴图、视频流这类资产不能把Material对象或文件路径硬编码进存档。存档里存一个标识比如environment: desert运行时再去资源系统里加载对应的材质球资产包或视频流地址。网上常见的 unity3d 常用材质球资产包、PBR 材质下载下载下来会有几十颗材质球。正确姿势是给每种场景一个字符串 tag加载时用 Addressables 或 Resources.Load 按 tag 找资源。这样用户换了材质球、更新了视频文件存档完全不用动。UI 设置里的音量、语言这类偏好继续留在 PlayerPrefs存档文件只负责游戏进度。6. 四个 Unity3d Demo 合体成一套小游戏脚手架验证与收尾技巧6.1 最小合体顺序和验证清单四个模块合进新工程时建议按“对象池 → 存档 → GameFlow → 角色控制”的顺序。先把对象池挂进场景确认子弹生成和回收正常再挂 SaveManager让角色死亡后能够回档接着放 GameFlow 和 UIManager接好开始、暂停、结算三个界面的按钮最后才把角色控制和相机脚本挂到玩家身上。每接一个模块跑一次出问题的时间能缩到最短。验证清单只需要三行能跑、能存、能恢复。进入游戏后走完“开始-暂停-继续-死亡”然后File.Delete存档路径下的 save.json 再重进确认能回到初始状态这套脚手架就算立住了。6.2 用 Profiler 和 Frame Debugger 做快速体检合体后先打开主菜单 Window 下的 Profiler切到 CPU Usage 面板跑 10 秒战斗流程重点看 GC Alloc。如果检测到频繁的 Instantiate 和 Destroy说明还有代码绕过了对象池在堆栈里找到调用点改掉。逐帧检查时可以配合 Frame Debugger它能按绘制顺序列出所有 Draw Call能直接看出相机 FadeOut 的透明材质是否触发了额外的渲染批次。给四个模块的关键函数加上Profiler.BeginSample和EndSample性能数据就能直接定位到具体模块Profiler.BeginSample(GameObjectPool.Get); GameObject go pool.Get(pos, rot); Profiler.EndSample();6.3 交付前清缓存与跨端导出交付测试包之前处理三件事在构建设置里关掉 Auto Graphics API避免部分设备上的图形特性差距把开发构建的UNITY_EDITOR预编译开关保留下来的调试日志关掉清空persistentDataPath和 PlayerPrefs确保测试人员拿到的是全新状态。如果目标平台是鸿蒙或手机端Unity 工程导出时可以按目标平台切 Build Target常见做法是先用当前编辑器导出一个可运行的 Windows 包验证核心逻辑再切 Android/HarmonyOS 导出对应产物。HarmonyOS NEXT 的 Unity 适配链路已经能直接产出 hap 包团队内部习惯把公共代码拆成 hsp 动态共享包和 har 静态库Unity 侧的 C# 业务脚本不需要为这三种后缀改逻辑。导出后用测试机跑一遍暂停、存读取、对象池大量生成那几步确认没有平台相关的 API 差异四个实例源码到这一步才算真正落地成了你自己的脚手架。本文还有配套的精品资源点击获取
返回列表