做 Unity 求职 Demo 的同学,十有八九会卡在同一个地方:功能写了不少,但面试官打开工程三分钟就关掉了,因为看不出你到底会什么。这篇文章围绕一套面向 2027 届校招的 Unity 求职 Demo 展开,从选题定位、工程结构、核心代码到打包展示,拆成可以直接照着做的步骤。无论你是刚学完 Unity 基础、准备做第一个作品集项目,还是已经有一个能跑但"讲不出亮点"的 Demo,都可以从下面这套流程里拿到可复用的部分:一套分层清晰的移动与相机控制代码、一个可扩展的状态机与事件总线、数据驱动的 ScriptableObject 配置、对象池与存档模块,以及面试现场演示时会用到的检查清单。
1. 求职 Demo 的定位:先想清楚"给谁看"
很多同学做求职 Demo 的第一反应是"做一个好玩的游戏",这个思路在课程作业里没问题,但放在求职场景里就偏了。求职 Demo 的本质不是游戏产品,而是一份可以交互的技术简历:招聘方打开它,是为了判断你的工程能力、代码习惯和表达逻辑,而不是为了通关体验。所以选题、规模、功能取舍都要围绕"能否被快速验证"来设计,而不是围绕"内容量够不够大"。
1.1 面试官打开 Demo 时在看什么
从一般校招流程来看,技术面关注点大致可以分成四层:第一层是能不能跑起来,工程是否完整、有没有缺资源、有没有编译错误;第二层是代码结构,脚本是不是全堆在一个文件夹里、有没有上帝类、命名是否规范;第三层是技术点密度,有没有用上对象池、事件解耦、数据配置这些体现工程思维的方案;第四层是表达能力,你能不能在三五分钟内讲清楚"我为什么这么设计"。这四层里,前两层是门槛,后两层才是加分项。
这意味着一个只有移动跳跃的 Demo 并不一定差,只要它的代码干净、结构清楚、你能把设计取舍讲明白,就足以支撑一次技术面。反过来,一个内容量大但脚本互相引用、改一处崩三处的工程,反而会暴露负面信息。所以本文后续的所有代码和结构,都优先服务于"可讲解"和"可维护",而不是功能堆叠。
1.2 选题的三条边界
第一条边界是工作量可控。校招周期里你要同时准备笔试、八股和项目,一个 Demo 的核心玩法最好能在一到两周内成型,剩下的时间留给打磨和整理文档。第二条边界是技术点可展示。你选择的玩法要能自然带出你想展示的技术,比如做俯视角射击可以带出对象池与子弹管理,做平台跳跃可以带出状态机与物理参数调优。第三条边界是演示时长可控。面试现场通常不会给你十分钟,Demo 的核心体验最好能在 90 秒内被感知到。
把这三条边界套一遍,你会发现"开放世界""联机对战"这类选题基本可以排除,不是不能做,而是投入产出比太低。对大多数同学来说,一个带完整生命周期的小型单机 Demo——有移动、有交互、有数据、有存档、有 UI——就已经足够撑起一次完整的技术面。
1.3 项目规模与目录规划建议
我一般建议把求职 Demo 控制在 30 到 60 个脚本、3 到 5 个场景的规模。少于这个量级,能讲的技术点不够;多于这个量级,维护成本会吃掉打磨时间。场景上可以拆成启动场景、主菜单场景、游戏场景和测试场景,测试场景专门用来验证单个模块,面试时也可以展示"我是如何做隔离验证的",这本身就是一个加分的工程习惯。
2. 环境准备与工程规范
工程能不能被顺利打开,是求职 Demo 的第一道生死线。很多同学在自己电脑上跑得好好的,压缩包发过去对方解压后一片报错,原因是版本不一致、资源没提交、或者依赖了本地的第三方插件。这一节把环境和规范先定下来,后面写代码才不会返工。
2.1 引擎与工具链版本说明
本文示例使用 C# 脚本,基于 Unity 的 LTS 长期支持版本编写,具体使用哪个 LTS 小版本需要根据你本机安装的版本和招聘方常见环境来定,重点在于配置思路和代码结构,而不是某个具体版本号。如果你打算投递的公司对版本有要求,可以在简历或 README 里明确标注工程使用的版本,这本身就是专业度的体现。
选择 LTS 版本的原因很实际:LTS 版本在编辑器稳定性、文档完整度和第三方插件兼容性上都更友好,出问题也更容易搜到现成答案。不建议为了用某个新特性去选最新的大版本,求职 Demo 的稳定性远比新特性重要。
除了编辑器本身,还需要准备几样工具:一个趁手的代码编辑器并配置好 C# 智能提示;Git 加一个远程仓库用于版本管理和作品展示;一个压缩工具用于打包交付。如果要做演示视频,再准备一个录屏工具即可。
2.2 目录结构设计与命名约定
Unity 工程有个硬性规则:只有放在Assets目录下的资源才会被编辑器识别。所以所有自定义内容都要在Assets下规划好子目录。下面这套结构是经过多次项目验证后比较稳的划分方式,可以直接照搬:
Assets/ ├── Art/ # 美术资源 │ ├── Materials/ │ ├── Models/ │ └── Textures/ ├── Audio/ # 音效与音乐 ├── Prefabs/ # 预制体 ├── Scenes/ # 场景文件 ├── Scripts/ # 全部脚本 │ ├── Core/ # 事件总线、对象池、通用工具 │ ├── Data/ # ScriptableObject 配置类 │ ├── Player/ # 玩家相关逻辑 │ ├── Camera/ # 相机逻辑 │ ├── UI/ # 界面逻辑 │ └── Save/ # 存档模块 ├── Settings/ # 渲染、输入等配置文件 └── ThirdParty/ # 第三方插件(单独隔离)命名上建议统一采用大驼峰给类名、脚本文件名与类名保持一致,私有字段用camelCase,序列化字段加[SerializeField]而不是直接public。这些约定看起来琐碎,但面试官浏览代码时第一眼看到的就是这些细节,规范本身就是能力的信号。
2.3 版本管理与交付前检查
Unity 工程提交 Git 时必须配置.gitignore,否则Library、Temp、Logs、obj这些自动生成目录会把仓库撑到几个 G,而且完全没有必要提交。下面是一份精简可用的配置:
# Unity 自动生成目录 /[Ll]ibrary/ /[Tt]emp/ /[Oo]bj/ /[Bb]uild/ /[Bb]uilds/ /[Ll]ogs/ /[Uu]ser[Ss]ettings/ # IDE 与系统文件 .vs/ .idea/ *.csproj *.sln .DS_Store # 打包产物 *.apk *.unitypackage交付前建议走一遍固定检查:新建一个空目录,从仓库重新克隆一份工程,用目标版本打开,确认没有编译错误、场景能正常加载、打包能成功。这一步能拦住绝大多数"在我电脑上能跑"的翻车情况。
3. 核心架构设计:让代码能被讲清楚
这一节是整个 Demo 的地基。很多同学的工程之所以"讲不出亮点",根本原因是所有逻辑都写在一个PlayerController里,移动、输入、动画、UI、音效全混在一起。只要把职责拆开,代码立刻就有了可讲解的结构。下面这套分层方案不追求复杂的框架,目标是用最少的类把关注点分离做出来。
3.1 三层结构:输入层、逻辑层、表现层
把玩家相关逻辑拆成三层是最容易落地的方案。输入层只负责"读到玩家想干什么",输出的是抽象指令;逻辑层负责"根据指令改变游戏状态",比如位置、速度、血量;表现层负责"把状态变化展示出来",包括动画、粒子、音效和 UI。
这样拆分最直接的好处是测试方便。你可以在没有任何输入设备的情况下,用一段假数据驱动逻辑层,验证移动和跳跃是否符合预期。面试时如果被问到"你怎么保证逻辑正确",你可以直接说"输入被抽象成了接口,我可以注入一个模拟输入来单独验证运动逻辑",这个回答的份量比"我手动点了几次没问题"要高得多。
3.2 用接口隔离输入源
Unity 目前有两套输入方案:旧版Input类和新版 Input System 包。两者 API 完全不同,如果业务代码直接调用其中任意一个,将来切换就要改动所有涉及输入的脚本。解决办法是定义一个极简的接口,业务代码只依赖接口,具体实现随你替换。
// 文件路径:Assets/Scripts/Player/IInputProvider.cs namespace Game.Player { /// <summary> /// 输入抽象:逻辑层只依赖这个接口,不关心数据来自键盘、手柄还是模拟测试。 /// </summary> public interface IInputProvider { /// <summary>水平输入,x 为左右,y 为前后,范围 [-1, 1]</summary> UnityEngine.Vector2 Move { get; } /// <summary>本帧是否按下跳跃</summary> bool JumpPressed { get; } /// <summary>是否按住加速键</summary> bool SprintHeld { get; } } }接口只暴露三个成员,是因为求职 Demo 里这三个已经足够支撑移动、跳跃和冲刺三种行为。接口越窄,实现成本越低,将来扩展也越容易。这里刻意没有把"攻击""交互"放进来,因为那些属于不同模块的输入,混在一个接口里会让职责变模糊。
3.3 事件总线:让模块之间不再硬引用
UI 需要知道血量变化,音效需要知道角色受伤,成就系统可能还需要知道击杀数。如果每个系统都直接持有对方的引用,脚本之间的依赖会迅速变成一张网,改一处就要动十处。事件总线的作用是把"通知"这件事抽出来,发布者只管发,订阅者只管收,双方互不认识。
// 文件路径:Assets/Scripts/Core/EventBus.cs using System; using System.Collections.Generic; namespace Game.Core { /// <summary> /// 轻量事件总线:以事件类型为 key,支持订阅、取消订阅和发布。 /// 约束 T 为 struct,避免装箱并强制使用值类型事件,减少误用。 /// </summary> public static class EventBus { private static readonly Dictionary<Type, Delegate> Handlers = new Dictionary<Type, Delegate>(); public static void Subscribe<T>(Action<T> handler) where T : struct { if (handler == null) return; Type type = typeof(T); Handlers[type] = Handlers.TryGetValue(type, out Delegate existing) ? Delegate.Combine(existing, handler) : handler; } public static void Unsubscribe<T>(Action<T> handler) where T : struct { if (handler == null) return; Type type = typeof(T); if (!Handlers.TryGetValue(type, out Delegate existing)) return; Delegate current = Delegate.Remove(existing, handler); if (current == null) { Handlers.Remove(type); } else { Handlers[type] = current; } } public static void Publish<T>(T evt) where T : struct { if (Handlers.TryGetValue(typeof(T), out Delegate existing) && existing is Action<T> action) { action.Invoke(evt); } } /// <summary> /// 清空所有订阅。在场景切换或编辑器关闭域重载时调用,避免残留引用。 /// </summary> public static void Clear() { Handlers.Clear(); } } }这里有三个细节值得注意。第一,泛型约束用struct是为了让事件写成小的值类型结构体,避免每次发布都产生堆分配,同时也阻止了把MonoBehaviour之类引用类型当事件传出去。第二,Unsubscribe里判断current == null后要把 key 从字典移除,否则字典会随着订阅类型增多而无限增长。第三,如果项目在编辑器设置里关闭了域重载(Enter Play Mode Options),静态字段在多次进入播放模式之间不会被重置,一定要在合适的时机调用Clear(),否则会出现"订阅了两遍、回调触发两次"的诡异现象。
3.4 状态机:把 if-else 换成可扩展的结构
角色状态一旦超过三种,if-else就会开始失控。比如"待机、移动、跳跃、受击、死亡"这五个状态,如果用条件判断写,光是组合分支就有十几条,而且新增一个状态就要回头改所有判断。状态机的作用是让每个状态只管自己的进入、更新和退出逻辑,状态切换由统一的入口控制。
// 文件路径:Assets/Scripts/Core/IState.cs namespace Game.Core { public interface IState { void Enter(); void Tick(float deltaTime); void Exit(); } }// 文件路径:Assets/Scripts/Core/StateMachine.cs using System; using System.Collections.Generic; namespace Game.Core { /// <summary> /// 通用状态机:T 为状态枚举,状态实例通过字典注册,按需创建。 /// </summary> public class StateMachine<TKey> where TKey : struct, Enum { private readonly Dictionary<TKey, IState> states = new Dictionary<TKey, IState>(); private IState current; public TKey CurrentKey { get; private set; } public bool HasState => current != null; public void Register(TKey key, IState state) { if (state == null) throw new ArgumentNullException(nameof(state)); states[key] = state; } public void ChangeState(TKey key) { if (!states.TryGetValue(key, out IState next)) { throw new KeyNotFoundException($"状态 {key} 未注册"); } if (ReferenceEquals(next, current)) return; current?.Exit(); current = next; CurrentKey = key; current.Enter(); } public void Tick(float deltaTime) { current?.Tick(deltaTime); } } }ChangeState里用ReferenceEquals判断同一个状态实例时直接返回,是为了避免重复执行Enter造成动画重播或者计时器重置。这个坑在实际项目里很常见,尤其是当状态切换由输入驱动时,同一帧内可能被调用多次。注册时抛KeyNotFoundException而不是静默失败,是因为状态名写错属于开发期错误,越早暴露越好。
4. 完整实战:搭建可演示的角色控制系统
理论讲完,下面进入可运行的部分。这一节会从新建工程开始,一步步把移动、跳跃、相机跟随做出来,所有代码都可以直接复制使用。为了便于理解,我们先把整体流程拆成几步:建工程与目录、写输入实现、写移动核心、写相机跟随、最后运行验证。
4.1 创建工程与目录骨架
打开 Unity Hub,新建一个 3D 工程,工程名建议带上你的名字缩写和用途,比如JobDemo2027,方便面试官在多个压缩包里快速定位。创建完成后,在Assets下按照 2.2 节的目录结构建好文件夹,然后把Create > Scene新建两个场景:Boot和Gameplay,都保存到Scenes目录。
接着在Gameplay场景里创建一个地面:GameObject > 3D Object > Plane,缩放到 10 倍左右,方便观察移动距离。再创建一个胶囊体作为玩家,命名为Player,位置放在地面上方一点,避免初始穿地。
4.2 实现输入层
新建脚本LegacyInputProvider,实现第 3.2 节定义的接口。这里使用旧版Input类,原因是它在默认工程里开箱即用,不需要额外安装包。如果你的工程启用了新版 Input System,把下面这个类换成对应的实现即可,业务代码一行都不用改。
// 文件路径:Assets/Scripts/Player/LegacyInputProvider.cs using UnityEngine; namespace Game.Player { /// <summary> /// 基于旧版 Input 类的输入实现。 /// 注意:如果工程启用了新版 Input System 包,需要在 Player Settings 中确认输入处理方式。 /// </summary> public class LegacyInputProvider : MonoBehaviour, IInputProvider { public Vector2 Move { get; private set; } public bool JumpPressed { get; private set; } public bool SprintHeld { get; private set; } private void Update() { Move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); JumpPressed = Input.GetButtonDown("Jump"); SprintHeld = Input.GetKey(KeyCode.LeftShift); } } }GetAxisRaw和GetAxis的区别在于前者不做平滑,返回 -1、0、1 三档值,后者会有一个渐变过程。做角色移动时通常用GetAxisRaw,把平滑交给运动逻辑自己处理,这样手感更可控,也方便在代码里调整。
4.3 实现移动核心
移动脚本是整个 Demo 的核心,这里使用CharacterController而不是Rigidbody。原因很直接:CharacterController提供了Move方法,位移完全由代码控制,不受物理引擎的力和摩擦影响,调参更直观,也不容易出现角色被碰撞体弹飞的怪现象。代价是要自己处理重力,下面代码里会补上。
// 文件路径:Assets/Scripts/Player/PlayerMotor.cs using UnityEngine; namespace Game.Player { /// <summary> /// 玩家移动核心:读取输入 -> 计算位移 -> 应用重力与跳跃。 /// 只负责运动,不处理动画、音效和 UI。 /// </summary> [RequireComponent(typeof(CharacterController))] public class PlayerMotor : MonoBehaviour { [Header("速度参数")] [SerializeField] private float walkSpeed = 4f; [SerializeField] private float sprintSpeed = 7f; [Header("跳跃与重力")] [SerializeField] private float jumpHeight = 1.2f; [SerializeField] private float gravity = -25f; [Header("转向")] [SerializeField] private float rotationSpeed = 12f; private CharacterController controller; private IInputProvider input; private Transform cameraTransform; private Vector3 verticalVelocity; public bool IsGrounded => controller != null && controller.isGrounded; public float CurrentSpeed => new Vector2(controller.velocity.x, controller.velocity.z).magnitude; private void Awake() { controller = GetComponent<CharacterController>(); input = GetComponent<IInputProvider>(); if (input == null) { Debug.LogError($"{name} 缺少 IInputProvider 实现,请在同一物体上挂载输入组件。", this); } if (Camera.main != null) { cameraTransform = Camera.main.transform; } } private void Update() { if (input == null || controller == null) return; Vector3 moveDirection = GetCameraRelativeDirection(input.Move); float targetSpeed = input.SprintHeld ? sprintSpeed : walkSpeed; controller.Move(moveDirection * (targetSpeed * Time.deltaTime)); if (moveDirection.sqrMagnitude > 0.001f) { Quaternion targetRotation = Quaternion.LookRotation(moveDirection, Vector3.up); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); } ApplyGravityAndJump(); } /// <summary> /// 把输入向量转换成相对相机的世界方向,这样按 W 永远是"朝屏幕前方"。 /// </summary> private Vector3 GetCameraRelativeDirection(Vector2 rawInput) { Vector3 forward = cameraTransform != null ? cameraTransform.forward : Vector3.forward; Vector3 right = cameraTransform != null ? cameraTransform.right : Vector3.right; forward.y = 0f; right.y = 0f; forward.Normalize(); right.Normalize(); Vector3 direction = forward * rawInput.y + right * rawInput.x; return Vector3.ClampMagnitude(direction, 1f); } private void ApplyGravityAndJump() { if (controller.isGrounded && verticalVelocity.y < 0f) { // 保留一点向下的速度,避免在斜面或台阶上 isGrounded 反复抖动 verticalVelocity.y = -2f; } if (controller.isGrounded && input.JumpPressed) { // 由目标高度反推初速度:v = sqrt(2 * h * |g|) verticalVelocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity); } verticalVelocity.y += gravity * Time.deltaTime; controller.Move(verticalVelocity * Time.deltaTime); } } }代码里有几个容易被忽略但很关键的点。第一,GetCameraRelativeDirection里把相机的forward和right的 y 分量清零后重新归一化,否则相机一旦俯视,输入就会被投影到地面上导致速度变慢。第二,Vector3.ClampMagnitude保证斜向移动不会比直线移动更快,这是新手最常犯的错误之一。第三,重力部分在落地时把 y 速度设成 -2 而不是 0,是为了让isGrounded保持稳定,避免在台阶边缘反复触发跳跃。
4.4 实现相机跟随
第三人称 Demo 的相机建议放在LateUpdate里更新,保证在角色移动完成之后再调整,否则会出现画面轻微抖动。
// 文件路径:Assets/Scripts/Camera/CameraFollow.cs using UnityEngine; namespace Game.CameraControl { /// <summary> /// 第三人称跟随相机:固定偏移 + 平滑插值 + 看向角色上半身。 /// </summary> public class CameraFollow : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private Vector3 offset = new Vector3(0f, 5f, -7f); [SerializeField] private float smoothTime = 0.12f; [SerializeField] private float lookHeight = 1.5f; private Vector3 velocity; private void LateUpdate() { if (target == null) return; Vector3 desiredPosition = target.position + offset; transform.position = Vector3.SmoothDamp(transform.position, desiredPosition, ref velocity, smoothTime); transform.LookAt(target.position + Vector3.up * lookHeight); } } }Vector3.SmoothDamp的第三个参数是一个引用变量,用于在多次调用之间保存当前速度,所以必须声明成字段而不是局部变量,否则每帧都从零开始,平滑效果会完全失效。这个细节在面试里也经常被问到。
4.5 组装与运行验证
回到编辑器,完成下面的挂载步骤:在Player上添加CharacterController组件,把Center调整到胶囊体中部,Height设为 2 左右;然后添加LegacyInputProvider和PlayerMotor两个脚本。接着选中Main Camera,添加CameraFollow脚本,把Player拖到Target字段上。
点击运行,预期表现是:按 WASD 角色朝相机相对方向移动并平滑转向,按住左 Shift 加速,按空格跳跃并能正常落回地面。如果角色在空中可以连续起跳,检查controller.isGrounded的判断位置是否正确;如果角色一直贴着地面抖动,检查CharacterController的Min Move Distance是否被设得过大。
5. 数据驱动与对象池:让 Demo 有"工程味"
移动能跑只是及格线,真正让面试官记住你的是"有没有工程化的意识"。这一节我们用 ScriptableObject 做配置、用对象池管理运行时对象,这两个技术点在 Unity 项目里出现频率极高,讲解起来也有足够的深度。
5.1 用 ScriptableObject 管理配置
把数值写在MonoBehaviour的字段里有个明显问题:策划想调数值就得改预制体,多个预制体共用同一套数值时会重复配置,改一处漏一处。ScriptableObject把配置从场景对象里抽出来,变成独立的资产文件,可以复用、可以版本管理、可以在运行时共享。
// 文件路径:Assets/Scripts/Data/WeaponConfig.cs using UnityEngine; namespace Game.Data { /// <summary> /// 武器配置:策划可通过右键 Create/Game/Weapon Config 创建资产。 /// </summary> [CreateAssetMenu(fileName = "WeaponConfig", menuName = "Game/Weapon Config")] public class WeaponConfig : ScriptableObject { [Header("标识")] public string weaponId = "weapon_default"; public string displayName = "训练用脉冲枪"; [Header("战斗参数")] [Min(0.05f)] public float fireInterval = 0.25f; [Min(1)] public int damage = 10; [Header("表现资源")] public GameObject projectilePrefab; public AudioClip fireSfx; } }[Min]特性会在 Inspector 里限制输入下限,比运行时再校验更早发现问题。需要注意的是,ScriptableObject在编辑器里的修改会在停止播放后保留,如果某些值需要每次进入播放模式都重置,要么在运行时拷贝一份,要么在OnEnable里做初始化,直接改资产字段会导致"数据被改坏了"的困惑。
5.2 实现一个通用对象池
子弹、特效、飘字这类对象的特点是生成频繁、生命周期短,每次都Instantiate和Destroy会造成明显的 GC 压力。对象池的思路是预先生成一批对象,用完不销毁只隐藏,下次需要时直接取出来复用。
// 文件路径:Assets/Scripts/Core/SimplePool.cs using System.Collections.Generic; using UnityEngine; namespace Game.Core { /// <summary> /// 极简对象池:适用于子弹、特效等高频短生命周期对象。 /// 使用栈存储空闲实例,Get 时优先复用。 /// </summary> public class SimplePool<T> where T : Component { private readonly T prefab; private readonly Transform parent; private readonly Stack<T> pool = new Stack<T>(); public int CountInactive => pool.Count; public SimplePool(T prefab, Transform parent, int prewarmCount = 8) { this.prefab = prefab; this.parent = parent; for (int i = 0; i < prewarmCount; i++) { T instance = CreateInstance(); pool.Push(instance); } } private T CreateInstance() { T instance = Object.Instantiate(prefab, parent); instance.gameObject.SetActive(false); return instance; } public T Get(Vector3 position, Quaternion rotation) { T instance = pool.Count > 0 ? pool.Pop() : CreateInstance(); instance.transform.SetPositionAndRotation(position, rotation); instance.gameObject.SetActive(true); return instance; } public void Release(T instance) { if (instance == null) return; instance.gameObject.SetActive(false); instance.transform.SetParent(parent, false); pool.Push(instance); } } }使用时有两条纪律必须遵守。第一,Release之后不要再持有该实例的引用去改它的状态,因为它可能已经被别人取走。第二,如果对象内部有协程、计时器或事件订阅,必须在Release之前清理干净,否则复用时会出现"子弹一生成就爆炸"或者"回调执行了两次"的问题。这两点也是面试中经常追问的地方。
5.3 UI 与游戏逻辑的通信
UI 最容易写成"到处FindObjectOfType",一旦场景结构调整就会全部失效。推荐做法是让 UI 通过事件总线订阅数据变化,游戏逻辑只负责发布。
// 文件路径:Assets/Scripts/Core/GameEvents.cs using UnityEngine; namespace Game.Core { public struct PlayerHealthChangedEvent { public int Current; public int Max; public PlayerHealthChangedEvent(int current, int max) { Current = current; Max = max; } public float Percent => Max > 0 ? (float)Current / Max : 0f; } }// 文件路径:Assets/Scripts/UI/HealthBarView.cs using Game.Core; using UnityEngine; using UnityEngine.UI; namespace Game.UI { /// <summary> /// 血条视图:只负责显示,不参与任何战斗计算。 /// </summary> public class HealthBarView : MonoBehaviour { [SerializeField] private Image fillImage; [SerializeField] private Text valueText; private void OnEnable() { EventBus.Subscribe<PlayerHealthChangedEvent>(OnHealthChanged); } private void OnDisable() { // 必须成对取消订阅,否则场景切换后回调仍会指向已销毁对象 EventBus.Unsubscribe<PlayerHealthChangedEvent>(OnHealthChanged); } private void OnHealthChanged(PlayerHealthChangedEvent evt) { if (fillImage != null) { fillImage.fillAmount = Mathf.Clamp01(evt.Percent); } if (valueText != null) { valueText.text = $"{evt.Current}/{evt.Max}"; } } } }订阅写在OnEnable、取消订阅写在OnDisable,是为了保证组件在被禁用期间不会收到更新,同时避免持有已销毁对象的引用。这个成对出现的习惯值得在简历里拿出来讲,因为它体现的是对生命周期的理解。
6. 存档、性能与演示准备
Demo 做到这里,功能层已经完整了。但对于求职来说,还差三块内容:存档能体现数据持久化的思路,性能优化能体现工程深度,演示准备则直接决定面试现场的表现。
6.1 用 JsonUtility 实现轻量存档
Application.persistentDataPath是 Unity 提供的跨平台可写目录,在不同操作系统和平台上路径不同,但保证可写。用它配合JsonUtility,可以做出一个不依赖第三方库的存档模块。
// 文件路径:Assets/Scripts/Save/SaveSystem.cs using System; using System.IO; using UnityEngine; namespace Game.Save { [Serializable] public class SaveData { public string version = "1.0"; public int level = 1; public float playTime; public Vector3 lastPosition; } public static class SaveSystem { private static string FilePath => Path.Combine(Application.persistentDataPath, "slot_0.json"); public static bool HasSave => File.Exists(FilePath); public static void Save(SaveData data) { if (data == null) throw new ArgumentNullException(nameof(data)); string directory = Path.GetDirectoryName(FilePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) { Directory.CreateDirectory(directory); } string json = JsonUtility.ToJson(data, true); File.WriteAllText(FilePath, json); Debug.Log($"存档已写入:{FilePath}"); } public static SaveData Load() { if (!File.Exists(FilePath)) { return new SaveData(); } try { string json = File.ReadAllText(FilePath); return JsonUtility.FromJson<SaveData>(json) ?? new SaveData(); } catch (Exception e) { Debug.LogWarning($"存档读取失败,已回退到默认数据:{e.Message}"); return new SaveData(); } } public static void Delete() { if (File.Exists(FilePath)) { File.Delete(FilePath); } } } }SaveData里带一个version字段是刻意设计的,将来字段结构变化时可以据此做迁移逻辑,而不是直接让旧存档解析失败。JsonUtility的局限也要心里有数:它不支持字典、不支持多态、只序列化带[Serializable]的类和公开字段。如果你的存档需要这些能力,就得换用其他序列化库,这个取舍可以在面试时讲出来,正好展示你对工具边界的理解。
6.2 性能优化的关注点
求职 Demo 不需要做到极致优化,但必须有"知道该看什么"的意识。建议至少掌握三个观察点:第一是 Frame Debugger,用来看渲染批次,确认材质是否被合批、有没有因为贴图不同导致批次暴涨;第二是 Profiler 的 CPU 模块,看脚本更新耗时和 GC Alloc,重点排查Update里的字符串拼接和每帧new;第三是 Stats 面板,看 Draw Call 和 Batch 数量,判断场景复杂度是否合理。
常见的低成本优化手段有几种:把频繁创建的对象改成池化;把每帧GetComponent的结果缓存到字段;把字符串拼接改成StringBuilder或者在文本没变时不赋值;把大量相同材质的小物件做静态合批。每一条都能在面试里说清楚"为什么要这么做",比单纯背优化名词有用得多。
6.3 打包与作品集呈现
打包时建议至少提供两个版本:一个 Windows 可执行包,方便面试官直接运行;一个 WebGL 版本,可以直接挂在个人主页上点开就玩。WebGL 构建要注意资源体积,压缩格式和纹理尺寸都要控制,否则加载时间会劝退人。
除了可执行包,强烈建议补一份 README,放在仓库根目录,内容包含:项目简介、技术栈与版本、核心功能列表、操作说明、目录结构说明、以及一段"技术点说明"——把你想让面试官看到的设计点逐条列出来,比如"输入通过接口抽象,可替换为新版 Input System""子弹使用对象池管理,避免频繁实例化"。这份文档的作用是替你在面试官没耐心翻代码时,先把重点送到他眼前。
演示流程也值得提前排练一遍:开机进主菜单、展示一局完整的核心玩法、打开一个配置文件演示数据驱动、切到编辑器展示代码结构、最后讲一个自己踩过的坑和解决方案。整个流程控制在五到八分钟,反复练到不卡壳。
7. 常见问题与排查清单
实操过程中遇到的问题大多集中在这几类,下面整理成表格,方便遇到相似现象时快速定位。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 脚本编译报错,提示找不到命名空间 | 命名空间拼写不一致,或文件未放在 Assets 目录下 | 检查using与文件namespace是否一致;确认脚本在Assets/Scripts内 |
| 角色移动速度比预期慢 | 相机俯视导致输入向量被投影,未重新归一化 | 在方向计算中把 forward/right 的 y 清零后重新Normalize |
| 斜向移动比直线更快 | 输入向量长度为根号 2 | 用Vector3.ClampMagnitude限制输入长度 |
| 角色在空中可以连续跳跃 | 跳跃判断未限定在地面状态 | 在跳跃条件中加入controller.isGrounded |
| 角色站在地面上持续抖动 | isGrounded状态不稳定 | 落地时将 y 速度设为 -2;检查Min Move Distance |
| 相机跟随有延迟或抖动 | 更新时机不对或SmoothDamp速度变量为局部变量 | 移到LateUpdate;把 velocity 声明为字段 |
| 事件回调触发两次 | 订阅了两次未取消,或静态字段未清理 | 在OnDisable成对取消;必要时调用EventBus.Clear() |
| 对象池取出的对象状态异常 | 回收前未清理协程、计时器、事件订阅 | 在对象内实现OnSpawn/OnDespawn做状态重置 |
| 存档读出来是空数据 | 字段非公开且无[SerializeField],或类未标[Serializable] | 检查序列化标记;确认路径正确 |
| 打包后运行黑屏或资源丢失 | 场景未加入 Build Settings,或资源路径使用了编辑器专用 API | 检查构建设置;避免在运行时调用仅编辑器可用的接口 |
| WebGL 加载时间过长 | 首包资源过大 | 压缩纹理、剔除未使用资源、按需加载 |
排查时有个通用顺序值得养成:先看 Console 有没有报错和警告,再看 Inspector 里组件是否挂全、字段是否赋值,然后用Debug.Log打关键变量确认数据流,最后才怀疑引擎本身。绝大多数问题在前两步就能定位。
8. 最佳实践与学习路线
代码能跑只是起点,能不能被维护、被讲解、被扩展,才是求职 Demo 和课程作业的区别。这一节把前面散落的经验收拢成几条可以直接执行的建议。
命名上,类名用大驼峰、方法与属性同样大驼峰、私有字段用小驼峰,事件结构体用"名词 + Event"结尾。别小看这一条,面试官翻代码时如果看到aaa、test1、NewBehaviourScript这类命名,第一印象会直接掉档。配置上,把所有可调参数集中到ScriptableObject或者统一的配置类,避免散落在几十个预制体里。异常处理上,对文件读写、网络请求、资源加载这类可能失败的操作必须有兜底,try-catch要配合有意义的日志,而不是吞掉异常。
安全边界同样值得强调:涉及到本地文件读写时,路径要统一从Application.persistentDataPath获取,不要硬编码系统目录;涉及到用户数据时,不要假设数据一定合法,加载后要做范围校验;如果 Demo 有任何联网能力,务必只连接自己可控的测试环境,不要在公开演示版本里暴露任何密钥或令牌。生产环境的那套习惯,从练习项目开始养成就不会觉得别扭。
性能上优先关注两件事:减少每帧的堆分配,减少不必要的Update。前者靠缓存、池化和避免字符串拼接,后者靠事件驱动替代轮询,比如血量变化用事件通知而不是每帧比较。可维护性上,每个脚本只做一件事,超过 300 行的MonoBehaviour就该考虑拆分了。
学习路线上,接下来可以按这个顺序往下走:先把本文这套结构完整落地,跑通移动、战斗、UI、存档四条链路;然后补上动画状态机与Animator的参数驱动,让表现层完整;再深入一点可以研究 Unity 的 Addressables 资源管理、URP 渲染管线的基础配置、以及简单的 AI 行为树;如果目标是客户端开发岗,还要补上网络同步的基本概念和帧同步/状态同步的区别。每一步都不用做得很深,但至少要有能讲五分钟的内容。
最后提醒一句:Demo 做完之后不要急着投,先找一个同学当"假面试官",让他打开工程随便点、随便问,你会发现很多自己习以为常、但其实讲不清楚的地方。把这些地方补上,比再加一个新功能的价值大得多。