Skip to content

Unity 作业

做一个 2D 角色移动

unity-2d-character-movement

标准答案

做 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 结束
} // 类结束

工程坑点

角色对象要挂 Rigidbody2DCollider2D。障碍物要有 Collider2D。如果移动速度很快,可以把 Collision Detection 调成 Continuous。如果相机跟随角色,通常放在 LateUpdate,减少抖动。

做一个攻击碰撞检测

unity-2d-attack-collision-detection

标准答案

做攻击碰撞检测,核心不是“按攻击键就立刻扣血”,而是:攻击进入状态后,在命中帧打开攻击判定盒 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。 不要忘记命中去重,否则敌人在命中窗口里会被连续扣血。 不要只靠动画事件做强业务逻辑,复杂项目更推荐技能时间轴或配置驱动。 联机游戏里客户端可以做表现,最终命中和伤害最好由服务端裁决。

做一个血条跟随

unity-world-healthbar-follow

标准答案

血条跟随的核心是:怪物在世界空间里移动,血条在 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,这样视觉更稳。

做一个背包拖拽

unity-inventory-drag-drop

标准答案

背包拖拽的核心不是“把图标拖过去”,而是:拖拽时只移动临时 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。 如果拖到空白处,应该清理拖拽图标,并让物品回到原格子。 同类物品还要考虑合并堆叠、数量上限、拆分、不可放入装备栏等规则。

做一个窗口管理器

unity-ui-window-manager

标准答案

窗口管理器的核心是:业务不要到处 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 通信。

做一个虚拟列表

unity-ui-virtual-list

标准答案

虚拟列表就是:数据有很多条,但 UI 不创建很多个 Item,只创建“可见区域 + 缓冲区”的少量 Item。滚动时根据 content.anchoredPosition.y 算出当前可见索引,把旧 Item 回收,把新索引绑定到复用 Item 上。

核心公式

固定高度列表最简单:

c
firstIndex = scrollY / itemHeight
contentHeight = dataCount * itemHeight

Item 的位置:

c
anchoredPosition.y = -index * itemHeight

C# 示例:固定高度虚拟列表

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 再挂 VerticalLayoutGroupContentSizeFitter,否则滚动时会频繁触发布局重建。虚拟列表一般手动设置 anchoredPosition

固定高度最好做;可变高度要维护每个 Item 的高度缓存和前缀和,用二分查找滚动位置对应的索引。

常见坑

Item 复用时要重新绑定全部显示状态,比如图标、文字、红点、选中态、按钮事件。 复用 Item 时事件监听要清理,否则会点到旧数据。 大量数据刷新时,不要全量重建,只刷新受影响的索引。

做一个对象池子弹

unity-bullet-object-pool

标准答案

对象池子弹的核心是:子弹不反复 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 结束
} // 类结束

工程重点

子弹预制体上要有 Rigidbody2DCollider2D,Collider 勾选 IsTrigger。 命中后不要 Destroy(gameObject),要调用 Despawn()。 回收时一定要清速度、清伤害、清发射者、清生命周期,否则下一颗子弹会带着旧状态飞出去。

做一个怪物状态机

unity-monster-state-machine

标准答案

怪物状态机就是把怪物行为拆成几个明确状态,比如 IdlePatrolChaseAttackDead。每个状态只负责自己的行为,状态机统一判断什么时候切换状态。

最常见流程是:待机 → 巡逻 → 发现玩家 → 追击 → 进入攻击距离 → 攻击 → 死亡。

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、是否减速都做成状态,会状态爆炸。 切换状态时要清理旧状态,比如攻击判定盒、移动目标、计时器。

做一个场景异步加载

unity-async-scene-loading

标准答案

场景异步加载就是:先显示 Loading 界面,再调用 SceneManager.LoadSceneAsync 后台加载场景;当 progress0.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

unity-asset-check-editor-window

标准答案

资源检查 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 自动构建。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0