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 文件夹
开始场景流程
场景切换流程
- Main 脚本启动 → 显示 BeginPanel 开始界面
- 点击开始 → 摄像机左转动画 → 显示 ChooseHeroPanel 选角界面
- 选角完成 → 显示 ChooseScenePanel 场景选择界面
- 点击开始 → 异步加载游戏场景 → 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秒后销毁
}
制作流程建议
- 前期准备:导入资源、创建工程结构(Resources、StreamingAssets)
- UI 框架搭建:BasePanel → UIManager → 各面板脚本
- 数据层:配置 Excel 转 Json、创建数据类、GameDataMgr 加载
- 开始场景:界面制作 → 摄像机动画 → 选角选关逻辑
- 游戏场景:场景烘焙寻路 → 玩家/怪物/塔逻辑 → 出怪点/造塔点
- 关卡管理:GameLevelMgr 统筹对象、胜利失败判定
- 细节完善:音效特效、参数调优、多场景制作
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); // 通知管理器
}
扩展方向:
- 配置表驱动:将波次配置(出怪点ID、怪物组合、时间间隔)写入 Json,支持关卡编辑器
- 出怪规则:支持条件触发(如”第3波后出Boss”)、怪物编队、巡逻路线
- 动态难度:根据玩家表现实时调整怪物数量和属性
答题示例
三层架构各司其职: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 回调时机:
- 场景文件加载完成
- 场景中所有 GameObject 实例化完成
- Awake、OnEnable、Start 执行完毕
- 首帧渲染完成
- 触发 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