24.Unity核心实践项目总结

24.总结


24.1 知识点

记住一句话

商业游戏中的配置表

商业游戏制作套路

如何和策划配合设计配置表

学习的主要内容

总结目的


24.2 核心要点速览

项目概述

项目类型:塔防游戏(Tower Defense)

核心玩法:玩家控制角色移动、攻击怪物,保护中央主塔不被摧毁;可在造塔点建造/升级防御塔协助防守

场景结构:开始场景(选角、选关) → 游戏场景(战斗) → 结算界面

UI框架

BasePanel 面板基类

  • 所有 UI 面板的父类,继承 MonoBehaviour
  • 通过 CanvasGroup 控制淡入淡出效果
  • 核心方法:Init() 抽象方法(子类实现事件注册)、ShowMe() / HideMe() 控制显隐
public abstract class BasePanel : MonoBehaviour
{
    private CanvasGroup canvasGroup;
    public bool isShow = false;
    private UnityAction hideCallBack = null;
    
    public abstract void Init();  // 子类必须实现
    public virtual void ShowMe() { canvasGroup.alpha = 0; isShow = true; }
    public virtual void HideMe(UnityAction callBack) { canvasGroup.alpha = 1; isShow = false; hideCallBack = callBack; }
}

UIManager 单例

方法 作用 说明
ShowPanel<T>() 显示面板 泛型 T 名须与预制体名一致,自动实例化并调用 ShowMe
HidePanel<T>(bool isFade) 隐藏面板 isFade=true 时淡出后销毁,false 直接销毁
GetPanel<T>() 获取面板实例 从字典中获取已显示的面板

关键设计:面板名 = 脚本类名 = 预制体名,通过 typeof(T).Name 统一管理

数据管理

GameDataMgr 数据管理器单例

统一管理所有游戏数据,启动时从 Json 文件加载:

数据类 内容 文件名
MusicData 音乐/音效开关、音量 MusicData.json
PlayerData 玩家金币、已解锁角色 PlayerData.json
RoleInfo 角色属性(攻击力、解锁价格等) RoleInfo.json
SceneInfo 场景属性(初始金币、主塔血量) SceneInfo.json
MonsterInfo 怪物属性(血量、速度、攻击力) MonsterInfo.json
TowerInfo 防御塔属性(攻击力、范围、升级ID) TowerInfo.json

Json 数据读写:通过 JsonMgr 单例,文件存放在 StreamingAssets 文件夹

开始场景流程

场景切换流程

  1. Main 脚本启动 → 显示 BeginPanel 开始界面
  2. 点击开始 → 摄像机左转动画 → 显示 ChooseHeroPanel 选角界面
  3. 选角完成 → 显示 ChooseScenePanel 场景选择界面
  4. 点击开始 → 异步加载游戏场景 → GameLevelMgr 初始化

摄像机动画控制 CameraAnimator

public void TurnLeft(UnityAction action) { animator.SetTrigger("Left"); overAction = action; }
public void TurnRight(UnityAction action) { animator.SetTrigger("Right"); overAction = action; }
public void PlayerOver() { overAction?.Invoke(); overAction = null; }  // 动画帧事件调用

选角界面 ChooseHeroPanel

  • 左右切换角色,动态实例化模型显示
  • 解锁逻辑:判断金币是否足够 → 扣钱 → 记录已购买 ID → 保存数据
  • 隐藏时销毁显示的模型对象

游戏场景核心逻辑

GameLevelMgr 关卡管理器单例

统筹游戏场景中所有对象:

职责 方法
创建玩家 InitInfo(SceneInfo) 中根据选中角色数据实例化
管理出怪点 AddMonsterPoint() 注册、CheckOver() 检测胜利
管理怪物列表 AddMonster() / RemoveMonster() / FindMonster()
波数显示 UpdateMaxNum() / ChangeNowWaveNum()

玩家逻辑 PlayerObject

  • 移动:通过 Animator 的 VSpeed/HSpeed 参数控制混合树
  • 旋转:Input.GetAxis("Mouse X") 控制角色转向
  • 攻击:刀武器用 Physics.OverlapSphere 范围检测,枪武器用 Physics.RaycastAll 射线检测
  • 动画层:按住 Shift 切换第二层动画,R 键翻滚,左键开火

怪物逻辑 MonsterObject

  • 出生后通过 NavMeshAgent 寻路到主塔位置
  • 到达攻击范围后播放攻击动画,动画帧事件中检测伤害
  • 死亡时通知 GameLevelMgr 移除、检测胜利条件

防御塔逻辑 TowerObject

攻击类型 逻辑
单体攻击(atkType=1) 从 GameLevelMgr 获取最近怪物,炮塔头部旋转瞄准后开火
群体攻击(atkType=2) 获取范围内所有怪物,同时造成伤害

造塔点 TowerPoint

  • 玩家进入触发器 → GamePanel 显示造塔按钮
  • 按数字键建造对应塔 → 扣钱 → 实例化塔对象
  • 已有塔时按空格升级(nextLev 不为 0 时可升级)

摄像机跟随

CameraMove 脚本

通过向量计算摄像机位置,实现平滑跟随:

void Update()
{
    // 位置:目标点 + 前后偏移 + 上下偏移 + 左右偏移
    targetPos = target.position + target.forward * offsetPos.z;
    targetPos += Vector3.up * offsetPos.y;
    targetPos += target.right * offsetPos.x;
    transform.position = Vector3.Lerp(transform.position, targetPos, moveSpeed * Time.deltaTime);
    
    // 旋转:看向目标点(带身体高度偏移)
    targetRotation = Quaternion.LookRotation(target.position + Vector3.up * bodyHeight - transform.position);
    transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
}

游戏结束判定

条件 结果
主塔血量归零 游戏失败,奖励当前金币的 50%
所有波数怪物清空 + 场景无存活怪物 游戏胜利,奖励全部金币

GameOverPanel:显示胜负结果和奖励金额,点击确定返回开始场景

音效管理

BKMusic 背景音乐

  • 单例,挂载在场景中的音乐对象上
  • 根据 GameDataMgr.musicData 控制开关和音量

GameDataMgr.PlaySound(string resName)

public void PlaySound(string resName)
{
    GameObject obj = new GameObject();
    AudioSource a = obj.AddComponent<AudioSource>();
    a.clip = Resources.Load<AudioClip>(resName);
    a.volume = musicData.soundValue;
    a.mute = !musicData.soundOpen;
    a.Play();
    Destroy(obj, 1);  // 1秒后销毁
}

制作流程建议

  1. 前期准备:导入资源、创建工程结构(Resources、StreamingAssets)
  2. UI 框架搭建:BasePanel → UIManager → 各面板脚本
  3. 数据层:配置 Excel 转 Json、创建数据类、GameDataMgr 加载
  4. 开始场景:界面制作 → 摄像机动画 → 选角选关逻辑
  5. 游戏场景:场景烘焙寻路 → 玩家/怪物/塔逻辑 → 出怪点/造塔点
  6. 关卡管理:GameLevelMgr 统筹对象、胜利失败判定
  7. 细节完善:音效特效、参数调优、多场景制作

24.3 面试题精选

进阶题

1. UI 面板的淡入淡出如何实现?为什么用 CanvasGroup 而不是直接控制 Active?

题目

在 Unity UI 开发中,面板的显示和隐藏有多种方式。请说明使用 CanvasGroup 实现淡入淡出的原理,以及相比直接 SetActive 的优势。

深入解析

CanvasGroup 方案

  • 通过控制 alpha 属性(0~1)实现透明度渐变
  • blocksRaycasts 可控制是否响应点击事件
  • Update 中根据 isShow 状态逐帧增减 alpha

对比 SetActive

方面 CanvasGroup SetActive
视觉效果 平滑过渡 突然出现/消失
性能 对象始终存在,持续渲染 隐藏时完全不渲染
状态保持 变量、动画状态保持 重新激活会重新初始化
适用场景 频繁切换的面板 一次性或长时间不用的对象

关键代码

void Update()
{
    if (isShow && canvasGroup.alpha != 1)
    {
        canvasGroup.alpha += alphaSpeed * Time.deltaTime;
        if (canvasGroup.alpha >= 1) canvasGroup.alpha = 1;
    }
    else if (!isShow && canvasGroup.alpha != 0)
    {
        canvasGroup.alpha -= alphaSpeed * Time.deltaTime;
        if (canvasGroup.alpha <= 0)
        {
            canvasGroup.alpha = 0;
            hideCallBack?.Invoke();  // 淡出完成才执行回调
        }
    }
}
答题示例

CanvasGroup 通过控制 alpha 属性实现透明度渐变,配合 Update 逐帧修改实现淡入淡出。相比 SetActive,优势有三:一是视觉效果更平滑,用户体验更好;二是隐藏过程中可以执行回调,比如淡出完成后再销毁对象;三是对象状态保持,不会重新触发 Awake/Start。但 CanvasGroup 方案对象始终渲染,性能上不如 SetActive 彻底,所以对于长时间不用的面板,淡出完成后应该销毁而非仅隐藏。

参考文章
  • 3.面板基类
  • 4.UI管理器

2. 泛型方法 ShowPanel() 如何实现面板名与预制体名的自动匹配?

题目

UIManager 中的 ShowPanel<T>() 方法通过泛型自动加载对应预制体,请说明其实现原理和使用时的约束。

深入解析

核心原理:利用 typeof(T).Name 获取泛型类型的类名,将其作为预制体名称和字典键

public T ShowPanel<T>() where T : BasePanel
{
    string panelName = typeof(T).Name;  // 关键:类名即资源名
    
    if (panelDic.ContainsKey(panelName))
        return panelDic[panelName] as T;  // 已存在直接返回
    
    // 动态加载:Resources/UI/面板名
    GameObject panelObj = Instantiate(Resources.Load<GameObject>("UI/" + panelName));
    panelObj.transform.SetParent(canvasTrans, false);
    
    T panel = panelObj.GetComponent<T>();
    panelDic.Add(panelName, panel);
    panel.ShowMe();
    
    return panel;
}

约束条件

约束 原因
脚本类名 = 预制体名 typeof(T).Name 用于拼接资源路径
预制体放在 Resources/UI 下 Resources.Load 的路径要求
预制体挂载对应脚本 GetComponent<T>() 获取面板组件
泛型约束 where T : BasePanel 确保有 ShowMe/HideMe 等方法

优势:调用方无需关心字符串名称,IDE 自动补全、编译时检查类型

答题示例

通过 typeof(T).Name 获取泛型类型的类名字符串,用它拼接 Resources 加载路径和作为字典键。使用时有三个约束:一是脚本类名必须和预制体名完全一致;二是预制体要放在 Resources/UI 目录下;三是预制体上必须挂载对应的脚本组件。这种设计的好处是调用时完全不用写字符串,IDE 能自动补全,编译期就能发现类型错误。

参考文章
  • 4.UI管理器

3. 怪物寻路为什么用 NavMeshAgent 而不是直接 Transform.Translate?

题目

怪物移动到主塔位置,为什么选择导航网格寻路而不是简单的直线移动?请从游戏性和技术两个角度分析。

深入解析

NavMeshAgent 优势

方面 NavMeshAgent Transform.Translate
路径规划 自动绕开障碍物 直线穿墙
地形适配 自动贴合地面高度 需手动处理 Y 轴
角色控制 内置加速/减速/停止 需手动实现
性能优化 内置避让、LOD 需自行实现

本项目应用

// 初始化:速度和加速度设相同值实现匀速
agent.speed = agent.acceleration = info.moveSpeed;

// 出生后设置目标点
public void BornOver()
{
    agent.SetDestination(MainTowerObject.Instance.transform.position);
    animator.SetBool("Run", true);
}

// 死亡时禁用寻路
public void Dead()
{
    agent.enabled = false;  // 注意:用 enabled 而非 isStopped
    animator.SetBool("Dead", true);
}

场景烘焙要点

  • 场景物体标记为 Navigation Static
  • 调整 Agent Radius 让可行走区域更合理
  • 植物等装饰物取消寻路静态避免烘焙出错误区域
答题示例

NavMeshAgent 能自动绕开障碍物、贴合地形高度,而 Translate 只能直线移动会穿墙。从游戏性角度,塔防游戏怪物需要沿着道路行进,直线移动完全不符合玩法需求。从技术角度,NavMeshAgent 内置了路径计算、角色控制、避让逻辑,开发效率高。使用时要注意场景烘焙:把可行走区域标记为 Navigation Static,调整代理半径,装饰物要取消寻路静态避免烘焙出错误区域。

参考文章
  • 12.游戏场景-场景搭建
  • 17.游戏场景-怪物逻辑

深度题

1. 如何设计一个支持多波次、多出怪点的关卡系统?

题目

本项目实现了波次出怪和多个出怪点,请分析 GameLevelMgr、MonsterPoint、MonsterObject 三者如何协作,以及如何扩展支持更复杂的关卡配置。

深入解析

职责划分

职责
GameLevelMgr 总控:管理出怪点列表、怪物列表、波数统计、胜利判定
MonsterPoint 单个出怪点:配置波数、每波数量、怪物ID列表、生成间隔
MonsterObject 单个怪物:寻路、攻击、死亡通知

协作流程

MonsterPoint.Start()
    ↓
注册到 GameLevelMgr.points 列表
更新 GameLevelMgr.maxWaveNum
    ↓
定时调用 CreateWave() → CreateMonster()
    ↓
每创建一只怪物:GameLevelMgr.ChangeMonsterNum(+1)
    ↓
怪物死亡:GameLevelMgr.RemoveMonster() → CheckOver()

关键代码

// GameLevelMgr - 胜利判定
public bool CheckOver()
{
    for (int i = 0; i < points.Count; i++)
        if (!points[i].CheckOver()) return false;  // 出怪点还有怪
    if (monsterList.Count > 0) return false;       // 场景还有怪
    return true;  // 胜利
}

// MonsterPoint - 波次控制
private void CreateWave()
{
    nowID = monsterIDs[Random.Range(0, monsterIDs.Count)];  // 随机怪物类型
    nowNum = monsterNumOneWave;
    CreateMonster();
    --maxWave;
    GameLevelMgr.Instance.ChangeNowWaveNum(1);  // 通知管理器
}

扩展方向

  1. 配置表驱动:将波次配置(出怪点ID、怪物组合、时间间隔)写入 Json,支持关卡编辑器
  2. 出怪规则:支持条件触发(如”第3波后出Boss”)、怪物编队、巡逻路线
  3. 动态难度:根据玩家表现实时调整怪物数量和属性
答题示例

三层架构各司其职:GameLevelMgr 是总控,维护出怪点列表和怪物列表,负责胜利判定;MonsterPoint 是单个出怪点,配置本点的波数和怪物类型,定时生成怪物;MonsterObject 是具体怪物实例,死亡时通知管理器移除。协作流程是:出怪点启动时注册到管理器并更新总波数,生成怪物时通知管理器加计数,怪物死亡时通知移除并检测胜利。扩展方向包括:配置表驱动实现关卡编辑器、支持条件触发和编队出怪、动态难度调整。

参考文章
  • 18.游戏场景-出怪点逻辑
  • 19.游戏场景-关卡管理器逻辑

2. 防御塔的单体攻击和群体攻击如何统一设计?炮塔旋转瞄准如何实现平滑过渡?

题目

TowerObject 支持单体瞄准攻击和群体范围攻击两种模式,请分析其设计思路,并说明炮塔旋转的平滑实现方式。

深入解析

攻击模式设计

void Update()
{
    if (info.atkType == 1)  // 单体攻击
    {
        // 目标丢失时重新寻找
        if (targetObj == null || targetObj.isDead || 
            Vector3.Distance(transform.position, targetObj.transform.position) > info.atkRange)
        {
            targetObj = GameLevelMgr.Instance.FindMonster(transform.position, info.atkRange);
        }
        
        if (targetObj == null) return;
        
        // 炮塔旋转瞄准
        monsterPos = targetObj.transform.position;
        monsterPos.y = head.position.y;  // 固定Y轴,避免上下倾斜
        head.rotation = Quaternion.Slerp(head.rotation, Quaternion.LookRotation(monsterPos - head.position), roundSpeed * Time.deltaTime);
        
        // 夹角足够小且攻击间隔满足时开火
        if (Vector3.Angle(head.forward, monsterPos - head.position) < 5 && Time.time - nowTime >= info.offsetTime)
        {
            targetObj.Wound(info.atk);
            // 播放音效、特效...
            nowTime = Time.time;
        }
    }
    else  // 群体攻击
    {
        targetObjs = GameLevelMgr.Instance.FindMonsters(transform.position, info.atkRange);
        if (targetObjs.Count > 0 && Time.time - nowTime >= info.offsetTime)
        {
            // 直接对所有目标造成伤害,无需旋转
            for (int i = 0; i < targetObjs.Count; i++)
                targetObjs[i].Wound(info.atk);
            nowTime = Time.time;
        }
    }
}

旋转平滑过渡

  • Quaternion.Slerp 球形插值,比 Lerp 旋转更自然
  • 固定 Y 轴高度避免炮塔上下倾斜
  • 夹角判断 Vector3.Angle < 5 确保瞄准后再开火

设计要点

要点 说明
目标管理 单体用 targetObj,群体用 targetObjs 列表
旋转需求 单体需要旋转瞄准,群体直接范围攻击
攻击判定 单体用夹角,群体用间隔时间
数据驱动 atkType 字段控制攻击模式,配置表灵活调整
答题示例

通过 atkType 字段区分攻击模式:单体攻击需要先 FindMonster 找目标,然后炮塔头部用 Quaternion.Slerp 平滑旋转瞄准,夹角小于阈值才开火;群体攻击直接 FindMonsters 获取范围内所有目标,无需旋转直接造成伤害。旋转平滑的关键是:用 Slerp 球形插值而非 Lerp,固定目标 Y 轴高度避免炮塔上下倾斜,用 Vector3.Angle 判断是否瞄准完成。这种设计让两种攻击模式共用一个 Update 结构,通过配置表灵活切换。

参考文章
  • 22.游戏场景-防御塔逻辑

3. 异步加载场景时如何确保关卡初始化在场景加载完成后执行?

题目

从选择场景界面切换到游戏场景时,使用了 SceneManager.LoadSceneAsync 的 completed 回调。请分析为什么不能用同步加载,以及异步加载的回调时机。

深入解析

同步加载的问题

// 错误做法
SceneManager.LoadScene("GameScene");
GameLevelMgr.Instance.InitInfo(nowSceneInfo);  // 可能报错!
  • 同步加载不是瞬间完成的,场景中的对象可能还没初始化
  • GameObject.Find("HeroBornPos") 可能找不到对象
  • MainTowerObject.Instance 可能为 null

异步加载的正确用法

btnStart.onClick.AddListener(() =>
{
    UIManager.Instance.HidePanel<ChooseScenePanel>();
    
    AsyncOperation ao = SceneManager.LoadSceneAsync(nowSceneInfo.sceneName);
    
    // completed 回调在场景加载完成、首帧渲染后触发
    ao.completed += (obj) =>
    {
        GameLevelMgr.Instance.InitInfo(nowSceneInfo);
    };
});

AsyncOperation.completed 回调时机

  1. 场景文件加载完成
  2. 场景中所有 GameObject 实例化完成
  3. Awake、OnEnable、Start 执行完毕
  4. 首帧渲染完成
  5. 触发 completed 事件

扩展:加载进度显示

AsyncOperation ao = SceneManager.LoadSceneAsync(sceneName);
ao.allowSceneActivation = false;  // 暂停激活

// 配合协程显示进度
IEnumerator LoadWithProgress(AsyncOperation ao)
{
    while (ao.progress < 0.9f)
    {
        progressBar.value = ao.progress;
        yield return null;
    }
    ao.allowSceneActivation = true;  // 允许切换
}
答题示例

同步加载时,LoadScene 返回后场景中的对象可能还没完成初始化,此时调用 GameObject.Find 或访问单例会报空引用。异步加载的 completed 回调会在场景完全加载、所有 Awake/Start 执行完毕、首帧渲染完成后触发,此时场景对象已就绪。实际项目中还可以配合 allowSceneActivation 实现加载进度条,等进度到 90% 再允许场景切换,提升用户体验。

参考文章
  • 19.游戏场景-关卡管理器逻辑

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏