Appearance
Unity 作业
做一个 2D 角色移动
标准答案
做 2D 角色移动,推荐用 Rigidbody2D + Collider2D。核心原则是:Update 里读输入,FixedUpdate 里移动刚体,动画和朝向根据移动方向同步。
如果是俯视角 RPG、ARPG、割草游戏,用下面这种写法就很常见;如果是横版平台跳跃,Y 轴通常不拿来移动,而是交给跳跃和重力。
底层原理
Update 跟渲染帧走,帧率不固定,适合读输入。 FixedUpdate 跟物理帧走,默认大约 0.02s 一次,适合操作 Rigidbody2D。 如果直接改 transform.position,可能绕过物理系统,导致碰撞不稳定。
C# 脚本
c
using UnityEngine; // 引入 Unity 引擎 API
[RequireComponent(typeof(Rigidbody2D))] // 要求挂这个脚本的对象必须有 Rigidbody2D
public sealed class Player2DMovement : MonoBehaviour // 定义一个 2D 角色移动脚本
{ // 类开始
[SerializeField] private float moveSpeed = 5f; // 角色移动速度,可在 Inspector 调整
[SerializeField] private Animator animator; // 动画控制器引用,可选
[SerializeField] private SpriteRenderer spriteRenderer; // 精灵渲染器引用,用来左右翻转
private Rigidbody2D rb; // 缓存 Rigidbody2D,避免每帧 GetComponent
private Vector2 moveInput; // 保存当前输入方向
private Vector2 lastMoveDir = Vector2.down; // 保存最后一次移动方向,默认朝下
private void Awake() // 组件初始化时调用
{ // Awake 开始
rb = GetComponent<Rigidbody2D>(); // 获取并缓存刚体组件
rb.gravityScale = 0f; // 俯视角 2D 移动通常不需要重力
rb.freezeRotation = true; // 防止角色因为碰撞发生旋转
rb.interpolation = RigidbodyInterpolation2D.Interpolate; // 开启插值,减少移动抖动
} // Awake 结束
private void Update() // 每帧调用,适合读取输入
{ // Update 开始
float x = Input.GetAxisRaw("Horizontal"); // 读取水平输入,A/D 或 左/右
float y = Input.GetAxisRaw("Vertical"); // 读取垂直输入,W/S 或 上/下
moveInput = new Vector2(x, y); // 把输入组合成二维方向
if (moveInput.sqrMagnitude > 1f) // 如果斜向输入长度超过 1
{ // 归一化判断开始
moveInput = moveInput.normalized; // 归一化,避免斜着走比直走更快
} // 归一化判断结束
if (moveInput.sqrMagnitude > 0.0001f) // 如果当前确实有移动输入
{ // 记录朝向开始
lastMoveDir = moveInput; // 保存最后一次移动方向
} // 记录朝向结束
UpdateVisual(); // 更新角色朝向和动画参数
} // Update 结束
private void FixedUpdate() // 固定物理帧调用,适合移动刚体
{ // FixedUpdate 开始
Vector2 delta = moveInput * moveSpeed * Time.fixedDeltaTime; // 计算本物理帧应该移动的距离
Vector2 nextPosition = rb.position + delta; // 计算下一帧刚体位置
rb.MovePosition(nextPosition); // 通过 Rigidbody2D 移动,保留物理碰撞处理
} // FixedUpdate 结束
private void UpdateVisual() // 更新角色表现层
{ // UpdateVisual 开始
if (spriteRenderer != null && Mathf.Abs(moveInput.x) > 0.001f) // 如果有精灵并且存在水平输入
{ // 翻转判断开始
spriteRenderer.flipX = moveInput.x < 0f; // 向左移动时翻转精灵
} // 翻转判断结束
if (animator == null) // 如果没有绑定 Animator
{ // 空动画判断开始
return; // 直接返回,避免空引用
} // 空动画判断结束
animator.SetFloat("MoveX", lastMoveDir.x); // 设置最后朝向的 X 值
animator.SetFloat("MoveY", lastMoveDir.y); // 设置最后朝向的 Y 值
animator.SetFloat("Speed", moveInput.magnitude); // 设置速度参数,用于 Idle 和 Run 切换
} // UpdateVisual 结束
} // 类结束工程坑点
角色对象要挂 Rigidbody2D 和 Collider2D。障碍物要有 Collider2D。如果移动速度很快,可以把 Collision Detection 调成 Continuous。如果相机跟随角色,通常放在 LateUpdate,减少抖动。
做一个攻击碰撞检测
标准答案
做攻击碰撞检测,核心不是“按攻击键就立刻扣血”,而是:攻击进入状态后,在命中帧打开攻击判定盒 HitBox,用 Physics2D.OverlapBoxNonAlloc 查询敌人层,命中的目标用 HashSet 去重,然后把伤害交给战斗系统结算。
推荐流程
前摇:播放攻击动画,不检测。 命中帧:打开检测区域。 检测:用 LayerMask 只筛敌人。 去重:同一次攻击同一个敌人只受一次伤害。 后摇:关闭检测,等待攻击结束。
C# 示例:2D 近战攻击检测
c
using System.Collections.Generic; // 引入 HashSet,用来做同一次攻击的命中去重
using UnityEngine; // 引入 Unity 引擎 API
public interface IDamageable // 定义一个可受伤接口
{ // 接口开始
void TakeDamage(int damage, GameObject attacker); // 受伤方法,传入伤害值和攻击者
} // 接口结束
public sealed class MeleeAttackHitbox2D : MonoBehaviour // 定义一个 2D 近战攻击检测脚本
{ // 类开始
[SerializeField] private Transform hitCenter; // 攻击判定中心点,通常挂在武器或角色前方
[SerializeField] private Vector2 hitSize = new Vector2(1.5f, 0.8f); // 攻击判定盒大小
[SerializeField] private LayerMask targetMask; // 只检测敌人所在 Layer
[SerializeField] private int damage = 10; // 本次攻击造成的基础伤害
private readonly Collider2D[] hitResults = new Collider2D[16]; // 复用数组,避免 OverlapAll 产生 GC
private readonly HashSet<int> hitTargetIds = new HashSet<int>(); // 记录本次攻击已经命中过的目标
private bool hitWindowOpen; // 当前是否处于命中检测窗口
public void BeginAttack() // 攻击开始时调用,通常由状态机或输入系统触发
{ // BeginAttack 开始
hitTargetIds.Clear(); // 清空上一次攻击的命中记录
hitWindowOpen = false; // 攻击刚开始是前摇阶段,先不检测
} // BeginAttack 结束
public void OpenHitWindow() // 命中帧开始时调用,通常由动画事件或技能时间轴触发
{ // OpenHitWindow 开始
hitWindowOpen = true; // 打开攻击检测窗口
CheckHit(); // 立刻检测一次,避免低帧率下错过命中
} // OpenHitWindow 结束
public void CloseHitWindow() // 命中帧结束时调用
{ // CloseHitWindow 开始
hitWindowOpen = false; // 关闭攻击检测窗口
} // CloseHitWindow 结束
private void FixedUpdate() // 固定物理帧调用
{ // FixedUpdate 开始
if (!hitWindowOpen) // 如果当前不是命中帧
{ // 判断开始
return; // 不做攻击检测
} // 判断结束
CheckHit(); // 命中窗口打开时持续检测
} // FixedUpdate 结束
private void CheckHit() // 执行一次攻击范围检测
{ // CheckHit 开始
if (hitCenter == null) // 如果没有配置攻击中心点
{ // 空判断开始
return; // 直接返回,避免空引用
} // 空判断结束
int count = Physics2D.OverlapBoxNonAlloc(hitCenter.position, hitSize, hitCenter.eulerAngles.z, hitResults, targetMask); // 查询攻击盒内的敌人
for (int i = 0; i < count; i++) // 遍历所有检测到的 Collider2D
{ // for 循环开始
Collider2D hit = hitResults[i]; // 取出当前命中的碰撞体
if (hit == null) // 如果结果为空
{ // 空判断开始
continue; // 跳过这个结果
} // 空判断结束
GameObject target = hit.attachedRigidbody != null ? hit.attachedRigidbody.gameObject : hit.gameObject; // 优先用刚体对象作为受击目标
int targetId = target.GetInstanceID(); // 获取目标唯一实例 ID
if (!hitTargetIds.Add(targetId)) // 如果这个目标本次攻击已经命中过
{ // 去重判断开始
continue; // 跳过,避免一刀多次伤害
} // 去重判断结束
IDamageable damageable = target.GetComponentInParent<IDamageable>(); // 查找目标身上的受伤接口
if (damageable == null) // 如果目标不能受伤
{ // 接口判断开始
continue; // 跳过这个目标
} // 接口判断结束
damageable.TakeDamage(damage, gameObject); // 把伤害交给目标或战斗系统处理
} // for 循环结束
} // CheckHit 结束
} // 类结束工程重点
攻击检测层只负责“找谁被打中了”,不要把暴击、护盾、Buff、闪避、格挡全塞进检测脚本里。更好的拆法是:HitBox 找目标,CombatSystem 算伤害,Animator/SkillTimeline 控制命中帧。
常见坑
不要用 OverlapBoxAll 高频检测,容易产生 GC。 不要忘记命中去重,否则敌人在命中窗口里会被连续扣血。 不要只靠动画事件做强业务逻辑,复杂项目更推荐技能时间轴或配置驱动。 联机游戏里客户端可以做表现,最终命中和伤害最好由服务端裁决。
做一个血条跟随
标准答案
血条跟随的核心是:怪物在世界空间里移动,血条在 UI Canvas 里显示,所以要把“怪物头顶的世界坐标”转换成“屏幕/UI 坐标”。
常用流程是:
怪物头顶点 target.position + offset 转屏幕坐标 Camera.WorldToScreenPoint 再转 Canvas 坐标 RectTransformUtility.ScreenPointToLocalPointInRectangle 最后设置血条 RectTransform.anchoredPosition
C# 脚本:血条跟随世界目标
c
using UnityEngine; // 引入 Unity 基础 API
using UnityEngine.UI; // 引入 UI Image 组件
public sealed class WorldHealthBarFollow : MonoBehaviour // 定义一个世界目标血条跟随脚本
{ // 类开始
[SerializeField] private Canvas canvas; // 血条所在的 Canvas
[SerializeField] private RectTransform canvasRect; // Canvas 的 RectTransform
[SerializeField] private RectTransform barRect; // 血条自身的 RectTransform
[SerializeField] private Image fillImage; // 血量填充图片
[SerializeField] private Camera worldCamera; // 渲染角色的世界相机
[SerializeField] private Transform target; // 要跟随的世界目标
[SerializeField] private Vector3 worldOffset = new Vector3(0f, 1.5f, 0f); // 头顶偏移量
private void Awake() // 初始化时调用
{ // Awake 开始
if (barRect == null) // 如果没有手动绑定血条 RectTransform
{ // 判断开始
barRect = transform as RectTransform; // 使用当前对象的 RectTransform
} // 判断结束
if (canvas != null && canvasRect == null) // 如果有 Canvas 但没有绑定 CanvasRect
{ // 判断开始
canvasRect = canvas.transform as RectTransform; // 获取 Canvas 的 RectTransform
} // 判断结束
if (worldCamera == null) // 如果没有手动绑定相机
{ // 判断开始
worldCamera = Camera.main; // 只在初始化时取一次 Camera.main,避免每帧调用
} // 判断结束
} // Awake 结束
private void LateUpdate() // LateUpdate 在角色移动后调用,适合更新跟随 UI
{ // LateUpdate 开始
if (target == null || worldCamera == null || canvas == null || barRect == null) // 如果关键引用缺失
{ // 判断开始
gameObject.SetActive(false); // 隐藏血条
return; // 结束本帧逻辑
} // 判断结束
Vector3 worldPosition = target.position + worldOffset; // 计算目标头顶的世界坐标
Vector3 screenPosition = worldCamera.WorldToScreenPoint(worldPosition); // 把世界坐标转换成屏幕坐标
if (screenPosition.z < 0f) // 如果目标在相机背后
{ // 判断开始
barRect.gameObject.SetActive(false); // 隐藏血条
return; // 不再更新位置
} // 判断结束
if (!barRect.gameObject.activeSelf) // 如果血条之前被隐藏了
{ // 判断开始
barRect.gameObject.SetActive(true); // 重新显示血条
} // 判断结束
if (canvas.renderMode == RenderMode.ScreenSpaceOverlay) // 如果 Canvas 是 Overlay 模式
{ // Overlay 分支开始
barRect.position = screenPosition; // Overlay 模式可以直接使用屏幕坐标
} // Overlay 分支结束
else // 如果 Canvas 是 Camera 或 World 模式
{ // 非 Overlay 分支开始
Camera uiCamera = canvas.worldCamera; // 获取 UI Canvas 使用的相机
Vector2 localPoint; // 准备接收 Canvas 本地坐标
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect, screenPosition, uiCamera, out localPoint); // 把屏幕坐标转成 Canvas 本地坐标
barRect.anchoredPosition = localPoint; // 设置血条在 Canvas 中的位置
} // 非 Overlay 分支结束
} // LateUpdate 结束
public void Bind(Transform followTarget) // 绑定要跟随的目标
{ // Bind 开始
target = followTarget; // 保存目标引用
gameObject.SetActive(true); // 显示血条
} // Bind 结束
public void SetHealth(float current, float max) // 设置血量显示
{ // SetHealth 开始
if (fillImage == null) // 如果没有绑定血量填充图
{ // 判断开始
return; // 直接返回
} // 判断结束
float rate = max <= 0f ? 0f : current / max; // 计算血量比例,避免除以 0
fillImage.fillAmount = Mathf.Clamp01(rate); // 限制在 0 到 1 之间并设置填充
} // SetHealth 结束
} // 类结束工程重点
不要每个怪物都创建一个独立 Canvas,很贵。推荐把所有血条放到一个统一的 UI Canvas 下,用对象池管理血条。
大量怪物时,也不要每个血条都自己 Update,可以做一个 HealthBarManager 集中刷新可见血条。
常见坑
Camera.main 不要每帧调用,初始化时缓存。 目标在相机背后时要隐藏,否则血条会跑到屏幕奇怪位置。 角色移动在 Update/FixedUpdate,血条跟随最好放 LateUpdate,这样视觉更稳。
做一个背包拖拽
标准答案
背包拖拽的核心不是“把图标拖过去”,而是:拖拽时只移动临时 UI 图标,真正 Drop 成功后交换背包数据,然后刷新两个格子的 UI。
流程是:BeginDrag 创建拖拽图标,OnDrag 跟随鼠标,OnDrop 判断目标格子,交换数据,EndDrag 清理拖拽图标。
C# 示例:背包格子拖拽交换
c
using UnityEngine; // 引入 Unity 基础 API
using UnityEngine.UI; // 引入 UGUI 的 Image 和 Text
using UnityEngine.EventSystems; // 引入拖拽事件接口
[System.Serializable] // 允许 ItemStack 显示在 Inspector
public sealed class ItemStack // 定义一个简单物品堆叠数据
{ // ItemStack 类开始
public Sprite icon; // 物品图标
public int count; // 物品数量
public bool IsEmpty => icon == null || count <= 0; // 判断当前格子是否为空
} // ItemStack 类结束
public sealed class InventorySlotDragUI : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler, IDropHandler // 定义背包格子拖拽脚本
{ // InventorySlotDragUI 类开始
[SerializeField] private Image iconImage; // 当前格子的物品图标
[SerializeField] private Text countText; // 当前格子的数量文本
[SerializeField] private Canvas rootCanvas; // 背包所在的根 Canvas
[SerializeField] private CanvasGroup canvasGroup; // 控制当前格子是否阻挡射线
[SerializeField] private ItemStack item = new ItemStack(); // 当前格子的物品数据
private static InventorySlotDragUI draggingSlot; // 当前正在拖拽的源格子
private static Image draggingIcon; // 当前正在跟随鼠标的临时图标
private void Awake() // 初始化时调用
{ // Awake 开始
if (rootCanvas == null) // 如果没有手动绑定 Canvas
{ // 判断开始
rootCanvas = GetComponentInParent<Canvas>(); // 从父节点查找 Canvas
} // 判断结束
if (canvasGroup == null) // 如果没有手动绑定 CanvasGroup
{ // 判断开始
canvasGroup = GetComponent<CanvasGroup>(); // 尝试获取当前对象上的 CanvasGroup
} // 判断结束
if (canvasGroup == null) // 如果当前对象没有 CanvasGroup
{ // 判断开始
canvasGroup = gameObject.AddComponent<CanvasGroup>(); // 运行时补一个 CanvasGroup
} // 判断结束
Refresh(); // 初始化刷新一次 UI
} // Awake 结束
public void OnBeginDrag(PointerEventData eventData) // 开始拖拽时触发
{ // OnBeginDrag 开始
if (item == null || item.IsEmpty) // 如果当前格子没有物品
{ // 判断开始
return; // 空格子不能拖拽
} // 判断结束
draggingSlot = this; // 记录当前拖拽源格子
canvasGroup.blocksRaycasts = false; // 让射线穿过源格子,否则目标格子收不到 Drop
CreateDraggingIcon(); // 创建跟随鼠标的临时图标
UpdateDraggingIconPosition(eventData); // 立刻把拖拽图标移动到鼠标位置
} // OnBeginDrag 结束
public void OnDrag(PointerEventData eventData) // 拖拽过程中每帧触发
{ // OnDrag 开始
if (draggingSlot != this) // 如果当前格子不是拖拽源
{ // 判断开始
return; // 不处理拖拽
} // 判断结束
UpdateDraggingIconPosition(eventData); // 更新临时图标的位置
} // OnDrag 结束
public void OnDrop(PointerEventData eventData) // 有物品拖到当前格子上时触发
{ // OnDrop 开始
if (draggingSlot == null) // 如果没有拖拽源
{ // 判断开始
return; // 直接返回
} // 判断结束
if (draggingSlot == this) // 如果拖回自己身上
{ // 判断开始
return; // 不需要交换
} // 判断结束
ItemStack temp = item; // 临时保存目标格子的数据
item = draggingSlot.item; // 把源格子的物品放到目标格子
draggingSlot.item = temp; // 把目标格子的原物品放回源格子
Refresh(); // 刷新目标格子的 UI
draggingSlot.Refresh(); // 刷新源格子的 UI
} // OnDrop 结束
public void OnEndDrag(PointerEventData eventData) // 拖拽结束时触发
{ // OnEndDrag 开始
if (draggingSlot != this) // 如果当前格子不是拖拽源
{ // 判断开始
return; // 不处理结束逻辑
} // 判断结束
canvasGroup.blocksRaycasts = true; // 恢复源格子的射线阻挡
if (draggingIcon != null) // 如果临时拖拽图标存在
{ // 判断开始
Destroy(draggingIcon.gameObject); // 销毁临时拖拽图标
} // 判断结束
draggingIcon = null; // 清空临时图标引用
draggingSlot = null; // 清空拖拽源格子引用
} // OnEndDrag 结束
private void CreateDraggingIcon() // 创建拖拽图标
{ // CreateDraggingIcon 开始
GameObject go = new GameObject("DraggingIcon"); // 创建一个新的 UI 对象
go.transform.SetParent(rootCanvas.transform, false); // 把拖拽图标放到 Canvas 下
draggingIcon = go.AddComponent<Image>(); // 给对象添加 Image 组件
draggingIcon.sprite = item.icon; // 设置拖拽图标的图片
draggingIcon.raycastTarget = false; // 拖拽图标不能挡住下面的格子
RectTransform rect = draggingIcon.rectTransform; // 获取拖拽图标的 RectTransform
rect.sizeDelta = iconImage.rectTransform.sizeDelta; // 让拖拽图标大小和原图标一致
} // CreateDraggingIcon 结束
private void UpdateDraggingIconPosition(PointerEventData eventData) // 更新拖拽图标位置
{ // UpdateDraggingIconPosition 开始
if (draggingIcon == null || rootCanvas == null) // 如果缺少拖拽图标或 Canvas
{ // 判断开始
return; // 直接返回
} // 判断结束
RectTransform canvasRect = rootCanvas.transform as RectTransform; // 获取 Canvas 的 RectTransform
Camera uiCamera = rootCanvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : rootCanvas.worldCamera; // Overlay 模式不需要 UI 相机
Vector2 localPoint; // 准备接收 Canvas 本地坐标
RectTransformUtility.ScreenPointToLocalPointInRectangle(canvasRect, eventData.position, uiCamera, out localPoint); // 把屏幕坐标转成 Canvas 坐标
draggingIcon.rectTransform.anchoredPosition = localPoint; // 设置拖拽图标的位置
} // UpdateDraggingIconPosition 结束
private void Refresh() // 刷新当前格子的 UI
{ // Refresh 开始
bool hasItem = item != null && !item.IsEmpty; // 判断当前格子是否有物品
iconImage.enabled = hasItem; // 有物品才显示图标
iconImage.sprite = hasItem ? item.icon : null; // 设置图标图片
countText.text = hasItem && item.count > 1 ? item.count.ToString() : string.Empty; // 数量大于 1 才显示文本
} // Refresh 结束
} // InventorySlotDragUI 类结束工程重点
背包拖拽一定要做到“数据和 UI 分离”。UI 拖动只是表现,真正的交换应该发生在背包数据层。否则你会看到图标换了,但存档、排序、出售、装备逻辑全乱。
常见坑
拖拽图标要设置 raycastTarget = false,否则它会挡住目标格子,导致 OnDrop 不触发。 源格子拖拽时要设置 CanvasGroup.blocksRaycasts = false。 如果拖到空白处,应该清理拖拽图标,并让物品回到原格子。 同类物品还要考虑合并堆叠、数量上限、拆分、不可放入装备栏等规则。
做一个窗口管理器
标准答案
窗口管理器的核心是:业务不要到处 Instantiate UI,而是由 UIWindowManager 统一负责窗口的创建、打开、关闭、层级、遮罩、返回栈和缓存。窗口自己只关心“拿到参数后怎么刷新显示”。
推荐结构
窗口基类:统一 OnOpen / OnClose。 窗口管理器:统一 Open / Close / Back。 窗口配置:记录 id、prefabPath、layer、是否缓存、是否入栈、是否遮罩。 层级根节点:比如 NormalRoot、PopupRoot、TipsRoot。 返回栈:手机返回键或 ESC 关闭栈顶窗口。
C# 示例:简化版窗口管理器
c
using System.Collections.Generic; // 引入 Dictionary、List、Stack 集合
using UnityEngine; // 引入 Unity 基础 API
public enum UILayer // 定义 UI 层级枚举
{ // UILayer 开始
Normal, // 普通页面层
Popup, // 弹窗层
Tips // 提示层
} // UILayer 结束
public abstract class UIWindow : MonoBehaviour // 定义所有窗口的基类
{ // UIWindow 类开始
public string Id { get; private set; } // 当前窗口 ID
protected UIWindowManager Manager { get; private set; } // 当前窗口所属的管理器
public void Init(string id, UIWindowManager manager) // 初始化窗口
{ // Init 开始
Id = id; // 保存窗口 ID
Manager = manager; // 保存窗口管理器引用
OnCreate(); // 调用窗口创建生命周期
} // Init 结束
protected virtual void OnCreate() { } // 窗口第一次创建时调用
public virtual void OnOpen(object arg) { } // 窗口每次打开时调用
public virtual void OnClose() { } // 窗口每次关闭时调用
public void CloseSelf() // 窗口内部关闭自己
{ // CloseSelf 开始
Manager.Close(Id); // 调用管理器关闭当前窗口
} // CloseSelf 结束
} // UIWindow 类结束
[System.Serializable] // 让窗口配置可以在 Inspector 中编辑
public sealed class UIWindowConfig // 定义窗口配置数据
{ // UIWindowConfig 类开始
public string id; // 窗口唯一 ID
public string prefabPath; // 窗口预制体路径,示例用 Resources
public UILayer layer; // 窗口所属层级
public bool cache = true; // 关闭后是否缓存窗口对象
public bool pushStack = true; // 打开后是否压入返回栈
public bool modal = false; // 是否需要遮罩
} // UIWindowConfig 类结束
public sealed class UIWindowManager : MonoBehaviour // 定义 UI 窗口管理器
{ // UIWindowManager 类开始
[SerializeField] private Transform normalRoot; // 普通窗口根节点
[SerializeField] private Transform popupRoot; // 弹窗根节点
[SerializeField] private Transform tipsRoot; // 提示窗口根节点
[SerializeField] private GameObject modalMask; // 弹窗遮罩对象
[SerializeField] private List<UIWindowConfig> configs; // 所有窗口配置
private readonly Dictionary<string, UIWindowConfig> configMap = new Dictionary<string, UIWindowConfig>(); // 窗口配置字典
private readonly Dictionary<string, UIWindow> openedMap = new Dictionary<string, UIWindow>(); // 当前已打开窗口字典
private readonly Dictionary<string, UIWindow> cachedMap = new Dictionary<string, UIWindow>(); // 已缓存但未显示窗口字典
private readonly Stack<string> windowStack = new Stack<string>(); // 返回栈
private void Awake() // 初始化时调用
{ // Awake 开始
foreach (UIWindowConfig config in configs) // 遍历所有配置
{ // foreach 开始
configMap[config.id] = config; // 把配置写入字典
} // foreach 结束
if (modalMask != null) // 如果配置了遮罩
{ // 判断开始
modalMask.SetActive(false); // 默认隐藏遮罩
} // 判断结束
} // Awake 结束
public UIWindow Open(string id, object arg = null) // 打开指定窗口
{ // Open 开始
if (!configMap.TryGetValue(id, out UIWindowConfig config)) // 如果找不到窗口配置
{ // 判断开始
Debug.LogError("UI config not found: " + id); // 输出错误日志
return null; // 返回空
} // 判断结束
if (openedMap.TryGetValue(id, out UIWindow openedWindow)) // 如果窗口已经打开
{ // 判断开始
openedWindow.transform.SetAsLastSibling(); // 把窗口放到当前层级最上面
openedWindow.OnOpen(arg); // 重新刷新窗口参数
return openedWindow; // 返回已打开窗口
} // 判断结束
UIWindow window = GetOrCreateWindow(config); // 获取缓存窗口或创建新窗口
openedMap[id] = window; // 记录为已打开窗口
window.gameObject.SetActive(true); // 显示窗口对象
window.transform.SetParent(GetLayerRoot(config.layer), false); // 挂到正确 UI 层级
window.transform.SetAsLastSibling(); // 放到当前层级最上方
if (config.modal) // 如果窗口需要遮罩
{ // 遮罩判断开始
ShowMaskBelow(window.transform); // 把遮罩显示在窗口下面
} // 遮罩判断结束
if (config.pushStack) // 如果窗口需要进入返回栈
{ // 入栈判断开始
windowStack.Push(id); // 把窗口 ID 压入返回栈
} // 入栈判断结束
window.OnOpen(arg); // 调用窗口打开生命周期
return window; // 返回窗口实例
} // Open 结束
public void Close(string id) // 关闭指定窗口
{ // Close 开始
if (!openedMap.TryGetValue(id, out UIWindow window)) // 如果窗口没有打开
{ // 判断开始
return; // 直接返回
} // 判断结束
openedMap.Remove(id); // 从已打开字典移除
window.OnClose(); // 调用窗口关闭生命周期
UIWindowConfig config = configMap[id]; // 获取窗口配置
if (config.cache) // 如果窗口需要缓存
{ // 缓存判断开始
window.gameObject.SetActive(false); // 隐藏窗口
cachedMap[id] = window; // 放入缓存字典
} // 缓存判断结束
else // 如果窗口不缓存
{ // 不缓存分支开始
Destroy(window.gameObject); // 销毁窗口对象
} // 不缓存分支结束
RefreshMask(); // 刷新遮罩显示状态
} // Close 结束
public void Back() // 处理返回键
{ // Back 开始
while (windowStack.Count > 0) // 只要返回栈里还有窗口
{ // while 开始
string topId = windowStack.Pop(); // 取出栈顶窗口 ID
if (openedMap.ContainsKey(topId)) // 如果这个窗口还开着
{ // 判断开始
Close(topId); // 关闭栈顶窗口
return; // 本次返回只关闭一个窗口
} // 判断结束
} // while 结束
} // Back 结束
private UIWindow GetOrCreateWindow(UIWindowConfig config) // 获取或创建窗口
{ // GetOrCreateWindow 开始
if (cachedMap.TryGetValue(config.id, out UIWindow cachedWindow)) // 如果缓存里有窗口
{ // 判断开始
cachedMap.Remove(config.id); // 从缓存字典移除
return cachedWindow; // 返回缓存窗口
} // 判断结束
GameObject prefab = Resources.Load<GameObject>(config.prefabPath); // 示例用 Resources 加载预制体
GameObject go = Instantiate(prefab); // 实例化窗口预制体
UIWindow window = go.GetComponent<UIWindow>(); // 获取窗口基类组件
window.Init(config.id, this); // 初始化窗口
return window; // 返回新窗口
} // GetOrCreateWindow 结束
private Transform GetLayerRoot(UILayer layer) // 根据层级获取根节点
{ // GetLayerRoot 开始
if (layer == UILayer.Popup) return popupRoot; // 弹窗层返回 popupRoot
if (layer == UILayer.Tips) return tipsRoot; // 提示层返回 tipsRoot
return normalRoot; // 默认返回普通层
} // GetLayerRoot 结束
private void ShowMaskBelow(Transform window) // 显示遮罩并放到窗口下面
{ // ShowMaskBelow 开始
if (modalMask == null) return; // 没有遮罩对象就直接返回
modalMask.SetActive(true); // 显示遮罩
modalMask.transform.SetParent(window.parent, false); // 把遮罩放到同一个层级下
modalMask.transform.SetAsLastSibling(); // 先把遮罩放到最上面
window.SetAsLastSibling(); // 再把窗口放到遮罩上面
} // ShowMaskBelow 结束
private void RefreshMask() // 刷新遮罩状态
{ // RefreshMask 开始
if (modalMask == null) return; // 没有遮罩对象就直接返回
modalMask.SetActive(false); // 简化版:关闭窗口后先隐藏遮罩
} // RefreshMask 结束
} // UIWindowManager 类结束工程重点
这个版本为了好懂,用了 Resources.Load,真实项目里更建议换成 Addressables 或资源管理器,并加上异步加载、防重复打开、加载失败降级、引用计数释放。
常见坑
窗口关闭时要解绑事件,否则窗口被缓存后还会收到消息。 弹窗遮罩要放在弹窗下面,否则会挡住自己或穿透到底层。 返回栈要处理“窗口已经被其他逻辑关闭”的情况,所以 Back() 里要跳过无效 ID。 UI 逻辑不要直接改业务数据,最好通过事件、ViewModel 或 Controller 通信。
做一个虚拟列表
标准答案
虚拟列表就是:数据有很多条,但 UI 不创建很多个 Item,只创建“可见区域 + 缓冲区”的少量 Item。滚动时根据 content.anchoredPosition.y 算出当前可见索引,把旧 Item 回收,把新索引绑定到复用 Item 上。
核心公式
固定高度列表最简单:
c
firstIndex = scrollY / itemHeight
contentHeight = dataCount * itemHeightItem 的位置:
c
anchoredPosition.y = -index * itemHeightC# 示例:固定高度虚拟列表
c
using System.Collections.Generic; // 引入 List、Dictionary、Queue 集合
using UnityEngine; // 引入 Unity 基础 API
using UnityEngine.UI; // 引入 ScrollRect 和 Text
public sealed class VirtualListItem : MonoBehaviour // 定义虚拟列表的单个 UI 节点
{ // VirtualListItem 类开始
[SerializeField] private Text label; // 当前 Item 上显示文本的组件
public RectTransform RectTransform { get; private set; } // 缓存当前 Item 的 RectTransform
private void Awake() // 初始化时调用
{ // Awake 开始
RectTransform = transform as RectTransform; // 缓存 RectTransform,避免反复转换
} // Awake 结束
public void Bind(int index, string text) // 绑定数据到当前 Item
{ // Bind 开始
gameObject.name = "Item_" + index; // 修改名字,方便调试当前节点显示哪个索引
label.text = index + " : " + text; // 显示索引和数据内容
} // Bind 结束
} // VirtualListItem 类结束
public sealed class FixedHeightVirtualList : MonoBehaviour // 定义固定高度虚拟列表
{ // FixedHeightVirtualList 类开始
[SerializeField] private ScrollRect scrollRect; // ScrollView 的 ScrollRect
[SerializeField] private RectTransform content; // ScrollRect 下的 Content 节点
[SerializeField] private RectTransform viewport; // ScrollRect 下的 Viewport 节点
[SerializeField] private VirtualListItem itemPrefab; // Item 预制体
[SerializeField] private float itemHeight = 80f; // 每个 Item 的固定高度
[SerializeField] private int bufferRows = 2; // 上下额外保留的缓冲行数
private readonly List<string> data = new List<string>(); // 当前列表的数据源
private readonly Dictionary<int, VirtualListItem> activeItems = new Dictionary<int, VirtualListItem>(); // 正在显示的 Item
private readonly Queue<VirtualListItem> itemPool = new Queue<VirtualListItem>(); // 回收池中的 Item
private readonly List<int> removeKeys = new List<int>(); // 复用的删除索引列表,避免临时 GC
private void Awake() // 初始化时调用
{ // Awake 开始
scrollRect.onValueChanged.AddListener(OnScrollChanged); // 监听滚动变化
} // Awake 结束
private void OnDestroy() // 对象销毁时调用
{ // OnDestroy 开始
scrollRect.onValueChanged.RemoveListener(OnScrollChanged); // 移除滚动监听,避免回调残留
} // OnDestroy 结束
public void SetData(List<string> newData) // 设置新的列表数据
{ // SetData 开始
data.Clear(); // 清空旧数据
data.AddRange(newData); // 写入新数据
content.sizeDelta = new Vector2(content.sizeDelta.x, data.Count * itemHeight); // 设置 Content 总高度
RefreshVisibleItems(); // 立刻刷新可见 Item
} // SetData 结束
private void OnScrollChanged(Vector2 value) // ScrollRect 滚动时触发
{ // OnScrollChanged 开始
RefreshVisibleItems(); // 根据当前滚动位置刷新可见 Item
} // OnScrollChanged 结束
private void RefreshVisibleItems() // 刷新当前可见范围
{ // RefreshVisibleItems 开始
if (data.Count == 0) // 如果没有数据
{ // 判断开始
ReleaseAllItems(); // 回收所有显示中的 Item
return; // 结束刷新
} // 判断结束
float scrollY = Mathf.Max(0f, content.anchoredPosition.y); // 获取当前向下滚动的距离
float viewHeight = viewport.rect.height; // 获取可见区域高度
int firstIndex = Mathf.FloorToInt(scrollY / itemHeight) - bufferRows; // 计算第一个可见索引并加上缓冲
firstIndex = Mathf.Clamp(firstIndex, 0, data.Count - 1); // 限制 firstIndex 不越界
int visibleCount = Mathf.CeilToInt(viewHeight / itemHeight) + bufferRows * 2 + 1; // 计算需要显示的 Item 数量
int lastIndex = Mathf.Min(data.Count - 1, firstIndex + visibleCount - 1); // 计算最后一个可见索引
removeKeys.Clear(); // 清空待移除索引列表
foreach (KeyValuePair<int, VirtualListItem> pair in activeItems) // 遍历当前正在显示的 Item
{ // foreach 开始
if (pair.Key < firstIndex || pair.Key > lastIndex) // 如果这个 Item 已经离开可见范围
{ // 判断开始
removeKeys.Add(pair.Key); // 记录需要回收的索引
} // 判断结束
} // foreach 结束
for (int i = 0; i < removeKeys.Count; i++) // 遍历所有需要回收的索引
{ // for 开始
int index = removeKeys[i]; // 取出要回收的索引
ReleaseItem(activeItems[index]); // 把对应 Item 放回对象池
activeItems.Remove(index); // 从正在显示字典中移除
} // for 结束
for (int i = firstIndex; i <= lastIndex; i++) // 遍历当前需要显示的索引范围
{ // for 开始
if (!activeItems.TryGetValue(i, out VirtualListItem item)) // 如果这个索引还没有 UI 节点
{ // 判断开始
item = GetItem(); // 从对象池取一个 Item
activeItems[i] = item; // 记录这个索引对应的 Item
} // 判断结束
BindItem(i, item); // 设置 Item 位置并绑定数据
} // for 结束
} // RefreshVisibleItems 结束
private VirtualListItem GetItem() // 从对象池获取 Item
{ // GetItem 开始
VirtualListItem item = itemPool.Count > 0 ? itemPool.Dequeue() : Instantiate(itemPrefab, content); // 优先复用,没有就创建
item.gameObject.SetActive(true); // 激活 Item
return item; // 返回 Item
} // GetItem 结束
private void ReleaseItem(VirtualListItem item) // 回收一个 Item
{ // ReleaseItem 开始
item.gameObject.SetActive(false); // 隐藏 Item
itemPool.Enqueue(item); // 放入对象池等待复用
} // ReleaseItem 结束
private void ReleaseAllItems() // 回收所有正在显示的 Item
{ // ReleaseAllItems 开始
foreach (KeyValuePair<int, VirtualListItem> pair in activeItems) // 遍历所有显示中的 Item
{ // foreach 开始
ReleaseItem(pair.Value); // 回收到对象池
} // foreach 结束
activeItems.Clear(); // 清空显示字典
} // ReleaseAllItems 结束
private void BindItem(int index, VirtualListItem item) // 绑定某个索引的数据到 Item
{ // BindItem 开始
RectTransform rect = item.RectTransform; // 获取 Item 的 RectTransform
rect.SetParent(content, false); // 确保 Item 挂在 Content 下
rect.anchorMin = new Vector2(0f, 1f); // 设置左上锚点
rect.anchorMax = new Vector2(1f, 1f); // 设置右上锚点
rect.pivot = new Vector2(0.5f, 1f); // 设置顶部中心为轴心
rect.sizeDelta = new Vector2(0f, itemHeight); // 设置 Item 高度
rect.anchoredPosition = new Vector2(0f, -index * itemHeight); // 根据索引计算位置
item.Bind(index, data[index]); // 绑定显示数据
} // BindItem 结束
} // FixedHeightVirtualList 类结束工程重点
不要给虚拟列表 Item 再挂 VerticalLayoutGroup 和 ContentSizeFitter,否则滚动时会频繁触发布局重建。虚拟列表一般手动设置 anchoredPosition。
固定高度最好做;可变高度要维护每个 Item 的高度缓存和前缀和,用二分查找滚动位置对应的索引。
常见坑
Item 复用时要重新绑定全部显示状态,比如图标、文字、红点、选中态、按钮事件。 复用 Item 时事件监听要清理,否则会点到旧数据。 大量数据刷新时,不要全量重建,只刷新受影响的索引。
做一个对象池子弹
标准答案
对象池子弹的核心是:子弹不反复 Instantiate / Destroy,而是提前创建一批,发射时取出来,命中或超时后重置状态再放回池子。
完整生命周期是:
预热池子 → 发射取出 → 初始化速度/伤害/生命周期 → 飞行检测 → 命中或超时 → 重置 → 回池。
脚本 1:PooledBullet2D.cs
c
using System; // 引入 Action 回调
using UnityEngine; // 引入 Unity API
public interface IDamageable // 定义可受伤接口
{ // 接口开始
void TakeDamage(int damage, GameObject attacker); // 受伤方法
} // 接口结束
public sealed class PooledBullet2D : MonoBehaviour // 定义池化子弹
{ // 类开始
[SerializeField] private Rigidbody2D rb; // 子弹刚体
private Action<PooledBullet2D> releaseCallback; // 回池回调
private float lifeTimer; // 剩余生命周期
private int damage; // 子弹伤害
private GameObject owner; // 发射者
private bool active; // 是否正在飞行
private void Awake() // 初始化
{ // Awake 开始
if (rb == null) rb = GetComponent<Rigidbody2D>(); // 缓存刚体
} // Awake 结束
public void Launch(Vector2 position, Vector2 direction, float speed, int damageValue, float lifeTime, GameObject attacker, Action<PooledBullet2D> onRelease) // 发射子弹
{ // Launch 开始
transform.position = position; // 设置出生位置
damage = damageValue; // 设置伤害
owner = attacker; // 设置发射者
lifeTimer = lifeTime; // 设置生命周期
releaseCallback = onRelease; // 保存回池回调
active = true; // 标记为激活状态
gameObject.SetActive(true); // 显示子弹
rb.velocity = direction.normalized * speed; // 设置飞行速度
} // Launch 结束
private void Update() // 每帧更新生命周期
{ // Update 开始
if (!active) return; // 未激活就不处理
lifeTimer -= Time.deltaTime; // 扣除存活时间
if (lifeTimer <= 0f) Despawn(); // 超时后回收
} // Update 结束
private void OnTriggerEnter2D(Collider2D other) // 触发碰撞时调用
{ // OnTriggerEnter2D 开始
if (!active) return; // 已回收则忽略
if (other.gameObject == owner) return; // 不打到自己
IDamageable target = other.GetComponentInParent<IDamageable>(); // 查找受伤接口
if (target != null) target.TakeDamage(damage, owner); // 如果能受伤就结算伤害
Despawn(); // 命中后回收
} // OnTriggerEnter2D 结束
public void Despawn() // 主动回收子弹
{ // Despawn 开始
if (!active) return; // 防止重复回收
active = false; // 标记为非激活
rb.velocity = Vector2.zero; // 清空速度
releaseCallback?.Invoke(this); // 通知对象池回收自己
} // Despawn 结束
public void ResetForPool() // 回池前重置状态
{ // ResetForPool 开始
active = false; // 重置激活标记
lifeTimer = 0f; // 清空生命周期
damage = 0; // 清空伤害
owner = null; // 清空发射者
releaseCallback = null; // 清空回调
rb.velocity = Vector2.zero; // 清空速度
gameObject.SetActive(false); // 隐藏子弹
} // ResetForPool 结束
} // 类结束脚本 2:BulletPool2D.cs
c
using System.Collections.Generic; // 引入 Queue 和 HashSet
using UnityEngine; // 引入 Unity API
public sealed class BulletPool2D : MonoBehaviour // 定义子弹对象池
{ // 类开始
[SerializeField] private PooledBullet2D bulletPrefab; // 子弹预制体
[SerializeField] private int initialSize = 32; // 初始预热数量
[SerializeField] private int maxSize = 128; // 最大子弹数量
private readonly Queue<PooledBullet2D> pool = new Queue<PooledBullet2D>(); // 空闲子弹队列
private readonly HashSet<PooledBullet2D> inPool = new HashSet<PooledBullet2D>(); // 防重复回收集合
private int totalCount; // 当前创建过的总数量
private void Awake() // 初始化对象池
{ // Awake 开始
for (int i = 0; i < initialSize; i++) // 预热指定数量
{ // for 开始
PooledBullet2D bullet = CreateBulletInstance(); // 创建一个子弹
Release(bullet); // 放入池中
} // for 结束
} // Awake 结束
public void Fire(Vector2 position, Vector2 direction, float speed, int damage, float lifeTime, GameObject owner) // 发射一颗子弹
{ // Fire 开始
PooledBullet2D bullet = GetBullet(); // 从池中取子弹
if (bullet == null) return; // 池满且没有空闲对象就拒绝发射
bullet.Launch(position, direction, speed, damage, lifeTime, owner, Release); // 初始化并发射
} // Fire 结束
private PooledBullet2D GetBullet() // 获取一个可用子弹
{ // GetBullet 开始
if (pool.Count > 0) // 如果池里有空闲子弹
{ // 判断开始
PooledBullet2D bullet = pool.Dequeue(); // 从队列取出
inPool.Remove(bullet); // 从池内集合移除
return bullet; // 返回子弹
} // 判断结束
if (totalCount >= maxSize) return null; // 超过最大数量就不再创建
return CreateBulletInstance(); // 池不够时扩容创建
} // GetBullet 结束
private PooledBullet2D CreateBulletInstance() // 创建子弹实例
{ // CreateBulletInstance 开始
PooledBullet2D bullet = Instantiate(bulletPrefab, transform); // 实例化子弹
totalCount++; // 增加总数量
bullet.ResetForPool(); // 初始化为池中状态
return bullet; // 返回创建出的子弹
} // CreateBulletInstance 结束
private void Release(PooledBullet2D bullet) // 回收子弹
{ // Release 开始
if (bullet == null) return; // 空对象直接忽略
if (inPool.Contains(bullet)) return; // 防止同一个子弹重复入池
bullet.transform.SetParent(transform, false); // 放回池节点下
bullet.ResetForPool(); // 清空旧状态
pool.Enqueue(bullet); // 放回空闲队列
inPool.Add(bullet); // 记录它已经在池中
} // Release 结束
} // 类结束工程重点
子弹预制体上要有 Rigidbody2D 和 Collider2D,Collider 勾选 IsTrigger。 命中后不要 Destroy(gameObject),要调用 Despawn()。 回收时一定要清速度、清伤害、清发射者、清生命周期,否则下一颗子弹会带着旧状态飞出去。
做一个怪物状态机
标准答案
怪物状态机就是把怪物行为拆成几个明确状态,比如 Idle、Patrol、Chase、Attack、Dead。每个状态只负责自己的行为,状态机统一判断什么时候切换状态。
最常见流程是:待机 → 巡逻 → 发现玩家 → 追击 → 进入攻击距离 → 攻击 → 死亡。
C# 示例:怪物状态机
c
using UnityEngine; // 引入 Unity 基础 API
public enum MonsterStateType // 定义怪物状态枚举
{ // 枚举开始
Idle, // 待机状态
Patrol, // 巡逻状态
Chase, // 追击状态
Attack, // 攻击状态
Dead // 死亡状态
} // 枚举结束
public sealed class MonsterStateMachine : MonoBehaviour // 定义怪物状态机脚本
{ // 类开始
[SerializeField] private Transform target; // 玩家目标
[SerializeField] private Animator animator; // 怪物动画控制器
[SerializeField] private float moveSpeed = 2.5f; // 怪物移动速度
[SerializeField] private float viewDistance = 6f; // 怪物发现玩家的距离
[SerializeField] private float attackDistance = 1.4f; // 怪物攻击距离
[SerializeField] private float attackCooldown = 1.2f; // 攻击冷却时间
[SerializeField] private int health = 100; // 怪物当前血量
private MonsterStateType currentState; // 当前状态
private float idleTimer; // 待机计时器
private float attackTimer; // 攻击冷却计时器
private Vector3 patrolDirection = Vector3.right; // 巡逻方向
private void Start() // 第一次启用脚本前调用
{ // Start 开始
ChangeState(MonsterStateType.Idle); // 初始进入待机状态
} // Start 结束
private void Update() // 每帧更新状态机
{ // Update 开始
attackTimer -= Time.deltaTime; // 更新攻击冷却计时器
if (currentState == MonsterStateType.Dead) // 如果已经死亡
{ // 死亡判断开始
return; // 死亡状态不再执行其他逻辑
} // 死亡判断结束
if (health <= 0) // 如果血量小于等于 0
{ // 血量判断开始
ChangeState(MonsterStateType.Dead); // 切换到死亡状态
return; // 本帧停止后续逻辑
} // 血量判断结束
CheckTransitions(); // 先检查状态切换条件
TickState(); // 再执行当前状态逻辑
UpdateAnimator(); // 最后同步动画参数
} // Update 结束
private void CheckTransitions() // 检查状态切换条件
{ // CheckTransitions 开始
float distance = GetTargetDistance(); // 获取怪物和玩家之间的距离
if (currentState != MonsterStateType.Attack && distance <= attackDistance) // 如果不是攻击状态但进入攻击距离
{ // 攻击距离判断开始
ChangeState(MonsterStateType.Attack); // 切换到攻击状态
return; // 切换后直接返回
} // 攻击距离判断结束
if (currentState == MonsterStateType.Attack && distance > attackDistance) // 如果攻击时玩家离开攻击距离
{ // 离开攻击范围判断开始
ChangeState(MonsterStateType.Chase); // 切回追击状态
return; // 切换后直接返回
} // 离开攻击范围判断结束
if (currentState != MonsterStateType.Chase && distance <= viewDistance) // 如果玩家进入视野范围
{ // 发现玩家判断开始
ChangeState(MonsterStateType.Chase); // 切换到追击状态
return; // 切换后直接返回
} // 发现玩家判断结束
if (currentState == MonsterStateType.Chase && distance > viewDistance * 1.5f) // 如果追击时玩家跑太远
{ // 丢失玩家判断开始
ChangeState(MonsterStateType.Patrol); // 回到巡逻状态
} // 丢失玩家判断结束
} // CheckTransitions 结束
private void TickState() // 执行当前状态逻辑
{ // TickState 开始
if (currentState == MonsterStateType.Idle) // 如果当前是待机状态
{ // Idle 判断开始
TickIdle(); // 执行待机逻辑
} // Idle 判断结束
else if (currentState == MonsterStateType.Patrol) // 如果当前是巡逻状态
{ // Patrol 判断开始
TickPatrol(); // 执行巡逻逻辑
} // Patrol 判断结束
else if (currentState == MonsterStateType.Chase) // 如果当前是追击状态
{ // Chase 判断开始
TickChase(); // 执行追击逻辑
} // Chase 判断结束
else if (currentState == MonsterStateType.Attack) // 如果当前是攻击状态
{ // Attack 判断开始
TickAttack(); // 执行攻击逻辑
} // Attack 判断结束
} // TickState 结束
private void TickIdle() // 待机逻辑
{ // TickIdle 开始
idleTimer -= Time.deltaTime; // 扣除待机时间
if (idleTimer <= 0f) // 如果待机时间结束
{ // 判断开始
ChangeState(MonsterStateType.Patrol); // 切换到巡逻状态
} // 判断结束
} // TickIdle 结束
private void TickPatrol() // 巡逻逻辑
{ // TickPatrol 开始
transform.position += patrolDirection * moveSpeed * 0.5f * Time.deltaTime; // 按巡逻方向慢速移动
} // TickPatrol 结束
private void TickChase() // 追击逻辑
{ // TickChase 开始
if (target == null) // 如果没有玩家目标
{ // 空目标判断开始
ChangeState(MonsterStateType.Patrol); // 回到巡逻状态
return; // 结束追击逻辑
} // 空目标判断结束
Vector3 direction = target.position - transform.position; // 计算指向玩家的方向
direction.y = 0f; // 如果是 3D 地面游戏,忽略高度差
transform.position += direction.normalized * moveSpeed * Time.deltaTime; // 朝玩家移动
transform.forward = direction.normalized; // 让怪物朝向玩家
} // TickChase 结束
private void TickAttack() // 攻击逻辑
{ // TickAttack 开始
if (attackTimer > 0f) // 如果攻击还在冷却
{ // 冷却判断开始
return; // 不能攻击
} // 冷却判断结束
attackTimer = attackCooldown; // 重置攻击冷却
Debug.Log("Monster Attack"); // 示例:这里可以触发攻击判定或技能时间轴
} // TickAttack 结束
private void ChangeState(MonsterStateType nextState) // 切换状态
{ // ChangeState 开始
if (currentState == nextState) // 如果要切换到当前状态
{ // 相同状态判断开始
return; // 不重复进入同一个状态
} // 相同状态判断结束
ExitState(currentState); // 先退出旧状态
currentState = nextState; // 设置新状态
EnterState(currentState); // 再进入新状态
} // ChangeState 结束
private void EnterState(MonsterStateType state) // 进入状态时调用
{ // EnterState 开始
if (state == MonsterStateType.Idle) // 如果进入待机状态
{ // Idle 进入判断开始
idleTimer = 1.5f; // 设置待机时间
} // Idle 进入判断结束
if (state == MonsterStateType.Dead) // 如果进入死亡状态
{ // Dead 进入判断开始
Debug.Log("Monster Dead"); // 示例:这里可以播放死亡动画并关闭碰撞
} // Dead 进入判断结束
} // EnterState 结束
private void ExitState(MonsterStateType state) // 退出状态时调用
{ // ExitState 开始
if (state == MonsterStateType.Attack) // 如果退出攻击状态
{ // Attack 退出判断开始
Debug.Log("Exit Attack"); // 示例:这里可以关闭攻击判定盒
} // Attack 退出判断结束
} // ExitState 结束
private float GetTargetDistance() // 获取目标距离
{ // GetTargetDistance 开始
if (target == null) // 如果目标为空
{ // 空目标判断开始
return float.MaxValue; // 返回极大值,表示看不见目标
} // 空目标判断结束
return Vector3.Distance(transform.position, target.position); // 返回怪物和玩家之间的距离
} // GetTargetDistance 结束
private void UpdateAnimator() // 同步动画参数
{ // UpdateAnimator 开始
if (animator == null) // 如果没有动画控制器
{ // 空动画判断开始
return; // 直接返回
} // 空动画判断结束
animator.SetInteger("State", (int)currentState); // 把逻辑状态同步给 Animator
} // UpdateAnimator 结束
} // 类结束工程重点
状态机最重要的不是代码写几个 if,而是状态边界要清楚:Attack 里不要巡逻,Dead 优先级最高,攻击中能不能被打断要明确。
如果怪物很多,不要所有怪物每帧都做复杂 AI,可以让感知、寻路、决策分帧执行。复杂 Boss 可以用“状态机 + 技能时间轴”,普通小怪用 FSM 就够。
常见坑
不要让动画状态反过来控制核心逻辑,否则动画一变,怪物 AI 就乱。 不要把方向、Buff、是否减速都做成状态,会状态爆炸。 切换状态时要清理旧状态,比如攻击判定盒、移动目标、计时器。
做一个场景异步加载
标准答案
场景异步加载就是:先显示 Loading 界面,再调用 SceneManager.LoadSceneAsync 后台加载场景;当 progress 到 0.9 时,说明场景数据基本加载完,但还没真正激活。等 Loading 动画、最短展示时间、关键资源预加载都完成后,再设置 allowSceneActivation = true 切到新场景。
C# 示例:场景异步加载器
c
using System.Collections; // 引入 IEnumerator,用来写协程
using UnityEngine; // 引入 Unity 基础 API
using UnityEngine.SceneManagement; // 引入场景加载 API
using UnityEngine.UI; // 引入 Slider 和 Text UI 组件
public sealed class AsyncSceneLoader : MonoBehaviour // 定义场景异步加载器
{ // 类开始
[SerializeField] private GameObject loadingRoot; // Loading 界面根节点
[SerializeField] private Slider progressSlider; // 进度条组件
[SerializeField] private Text progressText; // 进度文本组件
[SerializeField] private float minLoadingSeconds = 0.8f; // Loading 最少显示时间
private bool isLoading; // 是否正在加载,防止重复点击
private void Awake() // 初始化时调用
{ // Awake 开始
DontDestroyOnLoad(gameObject); // 切场景时保留加载器
SetProgress(0f); // 初始化进度为 0
if (loadingRoot != null) loadingRoot.SetActive(false); // 默认隐藏 Loading
} // Awake 结束
public void LoadScene(string sceneName) // 对外提供加载场景方法
{ // LoadScene 开始
if (isLoading) return; // 如果正在加载,直接忽略重复请求
StartCoroutine(LoadSceneRoutine(sceneName)); // 开启异步加载协程
} // LoadScene 结束
private IEnumerator LoadSceneRoutine(string sceneName) // 异步加载场景协程
{ // 协程开始
isLoading = true; // 标记当前正在加载
float startTime = Time.unscaledTime; // 记录开始加载时间,不受 timeScale 影响
if (loadingRoot != null) loadingRoot.SetActive(true); // 显示 Loading 界面
SetProgress(0f); // 把 UI 进度重置为 0
AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Single); // 开始异步加载目标场景
if (operation == null) // 如果加载操作创建失败
{ // 失败判断开始
isLoading = false; // 取消加载状态
if (loadingRoot != null) loadingRoot.SetActive(false); // 隐藏 Loading 界面
yield break; // 结束协程
} // 失败判断结束
operation.allowSceneActivation = false; // 先不允许自动激活场景
while (operation.progress < 0.9f) // 等待场景加载到 90%
{ // 加载循环开始
float displayProgress = Mathf.Clamp01(operation.progress / 0.9f); // 把 0 到 0.9 映射成 0 到 1
SetProgress(displayProgress); // 刷新 UI 进度
yield return null; // 等待下一帧继续检查
} // 加载循环结束
SetProgress(1f); // 场景数据加载完后,UI 显示 100%
while (Time.unscaledTime - startTime < minLoadingSeconds) // 保证 Loading 至少显示一小段时间
{ // 最短时间等待开始
yield return null; // 等待下一帧
} // 最短时间等待结束
yield return StartCoroutine(PreloadImportantResources()); // 等待关键资源预加载完成
operation.allowSceneActivation = true; // 允许 Unity 激活新场景
while (!operation.isDone) // 等待场景真正切换完成
{ // 激活等待开始
yield return null; // 等待下一帧
} // 激活等待结束
if (loadingRoot != null) loadingRoot.SetActive(false); // 隐藏 Loading 界面
isLoading = false; // 清除加载状态
} // 协程结束
private IEnumerator PreloadImportantResources() // 预加载关键资源的占位协程
{ // 预加载开始
yield return null; // 示例:这里可以等待 Addressables、音频、角色或 UI 预加载
} // 预加载结束
private void SetProgress(float value) // 设置 Loading 进度
{ // SetProgress 开始
float progress = Mathf.Clamp01(value); // 把进度限制在 0 到 1
if (progressSlider != null) progressSlider.value = progress; // 更新进度条
if (progressText != null) progressText.text = Mathf.RoundToInt(progress * 100f) + "%"; // 更新百分比文本
} // SetProgress 结束
} // 类结束工程重点
progress 停在 0.9 不是 bug,而是因为你设置了 allowSceneActivation = false。这时场景资源基本加载完了,但 Unity 还没激活新场景。激活那一刻会触发新场景对象的 Awake / OnEnable / Start,所以仍可能卡一下。
常见坑
场景名要加入 Build Settings,否则加载失败。 切场景时新旧场景可能短暂共存,会有峰值内存。 Loading 按钮要防重复点击。 如果场景很大,要拆小场景、预加载关键资源、对象池预热、分帧初始化。
做一个资源检查 EditorWindow
标准答案
资源检查 EditorWindow 的核心是:用窗口做入口,用 AssetDatabase 扫描资源,用 TextureImporter / SerializedObject 检查规则,最后把问题展示成可点击定位的列表。
代码建议放在:Assets/Editor/AssetCheckWindow.cs。
c
using System.Collections.Generic; // 引入 List 集合
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 基础 API
public sealed class AssetCheckWindow : EditorWindow // 定义资源检查窗口
{ // 类开始
private const int MaxTextureSize = 2048; // 定义贴图最大尺寸规则
private string scanFolder = "Assets"; // 默认扫描 Assets 目录
private Vector2 scroll; // 保存滚动视图位置
private readonly List<Issue> issues = new List<Issue>(); // 保存检查结果
private struct Issue // 定义问题数据
{ // Issue 开始
public string level; // 问题等级
public string path; // 资源路径
public string message; // 问题描述
public Issue(string level, string path, string message) // 构造问题对象
{ // 构造开始
this.level = level; // 保存等级
this.path = path; // 保存路径
this.message = message; // 保存描述
} // 构造结束
} // Issue 结束
[MenuItem("Tools/资源检查窗口")] // 在 Unity 菜单栏添加入口
private static void Open() // 打开窗口方法
{ // Open 开始
GetWindow<AssetCheckWindow>("资源检查"); // 创建或显示资源检查窗口
} // Open 结束
private void OnGUI() // 绘制窗口 UI
{ // OnGUI 开始
EditorGUILayout.LabelField("资源检查工具", EditorStyles.boldLabel); // 显示标题
scanFolder = EditorGUILayout.TextField("扫描路径", scanFolder); // 输入扫描路径
if (GUILayout.Button("开始扫描")) // 点击按钮开始扫描
{ // 按钮判断开始
Scan(); // 执行扫描
} // 按钮判断结束
EditorGUILayout.LabelField("问题数量:" + issues.Count); // 显示问题数量
scroll = EditorGUILayout.BeginScrollView(scroll); // 开始滚动区域
foreach (Issue issue in issues) // 遍历所有问题
{ // foreach 开始
EditorGUILayout.BeginHorizontal("box"); // 开始一行问题显示
EditorGUILayout.LabelField(issue.level, GUILayout.Width(60)); // 显示问题等级
EditorGUILayout.LabelField(issue.message); // 显示问题描述
if (GUILayout.Button("定位", GUILayout.Width(60))) // 点击定位按钮
{ // 定位判断开始
Object asset = AssetDatabase.LoadAssetAtPath<Object>(issue.path); // 加载问题资源
Selection.activeObject = asset; // 在 Project 面板选中资源
EditorGUIUtility.PingObject(asset); // 高亮提示资源位置
} // 定位判断结束
EditorGUILayout.EndHorizontal(); // 结束一行问题显示
} // foreach 结束
EditorGUILayout.EndScrollView(); // 结束滚动区域
} // OnGUI 结束
private void Scan() // 执行所有检查
{ // Scan 开始
issues.Clear(); // 清空旧结果
CheckTextures(); // 检查贴图资源
CheckPrefabs(); // 检查 Prefab 资源
} // Scan 结束
private void CheckTextures() // 检查贴图规则
{ // CheckTextures 开始
string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { scanFolder }); // 查找所有贴图 GUID
foreach (string guid in guids) // 遍历贴图 GUID
{ // foreach 开始
string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转成资源路径
Texture2D tex = AssetDatabase.LoadAssetAtPath<Texture2D>(path); // 加载贴图对象
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; // 获取贴图导入器
if (tex != null && (tex.width > MaxTextureSize || tex.height > MaxTextureSize)) // 判断贴图尺寸是否超标
{ // 尺寸判断开始
issues.Add(new Issue("Warn", path, "贴图尺寸过大:" + path)); // 添加尺寸问题
} // 尺寸判断结束
if (importer != null && importer.isReadable) // 判断是否开启 Read/Write
{ // ReadWrite 判断开始
issues.Add(new Issue("Warn", path, "贴图开启 Read/Write:" + path)); // 添加 Read/Write 问题
} // ReadWrite 判断结束
} // foreach 结束
} // CheckTextures 结束
private void CheckPrefabs() // 检查 Prefab 规则
{ // CheckPrefabs 开始
string[] guids = AssetDatabase.FindAssets("t:Prefab", new[] { scanFolder }); // 查找所有 Prefab GUID
foreach (string guid in guids) // 遍历 Prefab GUID
{ // foreach 开始
string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转成资源路径
GameObject prefab = AssetDatabase.LoadAssetAtPath<GameObject>(path); // 加载 Prefab
if (prefab == null) continue; // Prefab 为空就跳过
foreach (Component component in prefab.GetComponentsInChildren<Component>(true)) // 遍历 Prefab 所有组件
{ // 组件遍历开始
if (component == null) // 如果组件丢失
{ // 丢失组件判断开始
issues.Add(new Issue("Error", path, "Prefab 存在 Missing Script:" + path)); // 添加丢失脚本问题
continue; // 继续检查其他组件
} // 丢失组件判断结束
SerializedObject so = new SerializedObject(component); // 创建序列化对象
SerializedProperty prop = so.GetIterator(); // 获取属性迭代器
while (prop.NextVisible(true)) // 遍历可见序列化字段
{ // while 开始
bool missingRef = prop.propertyType == SerializedPropertyType.ObjectReference && prop.objectReferenceValue == null && prop.objectReferenceInstanceIDValue != 0; // 判断是否 Missing Reference
if (missingRef) // 如果发现丢失引用
{ // 丢失引用判断开始
issues.Add(new Issue("Error", path, "Prefab 存在 Missing Reference:" + path)); // 添加丢失引用问题
break; // 当前组件发现一次即可
} // 丢失引用判断结束
} // while 结束
} // 组件遍历结束
} // foreach 结束
} // CheckPrefabs 结束
} // 类结束工程重点
面试里要强调:工具不是只 Debug.Log,而是要能定位资源、说明原因、给修复建议。后续可以扩展成“规则接口”,比如贴图检查器、Prefab 检查器、音频检查器、Mesh 检查器,再接 CI 自动构建。