1. 从零起步:为什么我选择用Unity做第一个完整RPG
1.1 一个新手最该问的问题:为什么是RPG,为什么是Unity
很多人第一次打开Unity,面对空荡荡的Scene视图,脑子里只有一个念头:我该从哪儿开始?网上教程一搜一大把,但绝大多数要么是“十小时精通Unity”这种标题党,要么是丢给你一个半成品工程让你自己悟。我当初也是这么过来的,踩了无数坑之后才明白一个道理——学引擎最好的方式,就是从头到尾做一个完整的、能跑起来的、有头有尾的小项目。而RPG,恰恰是最适合新手入门的项目类型。
为什么这么说?因为RPG天然包含了一个游戏最核心的几大系统:角色移动、战斗交互、UI管理、数据存储、场景切换。你把这几个模块跑通了,基本上就摸清了Unity开发的主干脉络。而且RPG的容错率高,你不需要像做FPS那样死磕帧率和网络同步,也不需要像做RTS那样处理海量单位寻路。一个单机回合制或者即时战斗的小型RPG,足够让你把Unity的核心工作流全部走一遍。
至于为什么选Unity而不是别的引擎,理由很直接。第一,C#这门语言对新手足够友好,语法清晰,IDE提示强大,不像C++那样动不动就给你来个内存泄漏。第二,Unity的社区资源极其丰富,你遇到的90%的问题,网上都有人问过并且给出了答案。第三,Unity的跨平台能力意味着你做完一个项目,可以很方便地发布到PC、移动端甚至WebGL,这种成就感对新手来说非常重要。
注意:我建议新手从Unity 2022 LTS或者2023 LTS版本开始,不要追最新的Tech Stream版本。LTS版本经过长时间验证,插件兼容性好,遇到问题也更容易搜到解决方案。
1.2 这个RPG项目到底要做成什么样
在动手之前,你得先想清楚你要做一个什么样的RPG。我的建议是:做一个俯视角或者2.5D视角的、单场景多区域的、回合制或半即时战斗的小型RPG。具体来说,包含以下核心功能:
- 角色在地图上自由移动,有碰撞检测和边界限制
- 至少两种敌人,有简单的AI追击和攻击逻辑
- 一套完整的战斗系统,包括伤害计算、血量管理、技能释放
- 背包和物品系统,能拾取、使用、丢弃物品
- 对话系统,能和NPC交互触发剧情
- 存档和读档功能
- 主菜单、暂停菜单、游戏结束界面
这些功能听起来多,但拆解开来,每个模块的核心代码量其实并不大。关键在于你要理解模块之间的通信方式,以及Unity的组件化设计思想。
我当初做第一个RPG的时候,最大的误区就是想着“一口气把所有系统都写完再测试”。结果写了三千多行代码,一运行全是报错,根本不知道从哪儿排查。后来学乖了,每完成一个独立功能就立刻测试,确保它能单独跑通再集成到主工程里。这个习惯帮我省了无数调试时间。
1.3 新手最容易踩的三个认知坑
在正式开始之前,我必须先把三个最常见的认知坑给你点出来,这些都是我亲身踩过的。
第一个坑:以为C#要学到精通才能做游戏。完全不需要。你只需要掌握C#的基础语法——变量、条件判断、循环、函数、类、继承、列表和字典——就足够完成一个完整的RPG了。剩下的高级特性比如泛型委托、LINQ、异步编程,可以在做的过程中慢慢学。我见过太多人卡在“学C#”这个阶段,学了三个月语法还在写控制台程序,连Unity的界面都没打开过。
第二个坑:以为要自己写所有系统。Unity的生态里有大量免费或低价的优质插件和资源包,合理使用它们能让你把精力集中在游戏逻辑上。比如角色控制器可以用Unity自带的CharacterController组件,UI可以用TextMeshPro,输入系统可以用新版Input System。你不需要从零造轮子。
第三个坑:以为做完就能上架赚钱。第一个项目的目标应该是“完整跑通”,而不是“商业成功”。把预期放低,你的学习效率反而会更高。我第一个RPG做了两个月,最后只有一个简陋的村庄和三个敌人,但那个过程让我彻底理解了Unity的工作流,第二个项目就顺畅多了。
2. 环境搭建与项目初始化:把地基打牢
2.1 Unity Hub和编辑器的安装细节
安装Unity其实没什么难度,但有几个细节不注意就会浪费时间。首先去Unity官网下载Unity Hub,这是管理多个Unity版本的工具。安装完成后,在Hub里选择“安装编辑器”,建议选2022.3 LTS版本。安装时注意勾选以下模块:
- Microsoft Visual Studio Community:这是写C#代码的IDE,必装
- Windows Build Support (IL2CPP):如果你要发布Windows版本
- Android Build Support:如果你想发布到手机
- WebGL Build Support:如果你想发布到网页
提示:如果你硬盘空间紧张,可以只装Windows Build Support。其他平台支持可以后续随时通过Hub补装。
安装完成后,创建一个新的3D项目。项目名称用英文,路径不要有中文和空格,这是血泪教训。我见过有人项目路径里有中文,结果Unity各种莫名其妙的报错,排查了半天才发现是路径问题。
2.2 项目文件夹结构的规划
新手最容易犯的另一个错误,就是所有文件都堆在Assets根目录下。项目稍微大一点,找东西就像大海捞针。我建议从一开始就建立清晰的文件夹结构:
Assets/ ├── Scripts/ │ ├── Player/ │ ├── Enemy/ │ ├── Combat/ │ ├── UI/ │ ├── Inventory/ │ ├── Dialogue/ │ └── SaveSystem/ ├── Prefabs/ ├── Scenes/ ├── Art/ │ ├── Characters/ │ ├── Environment/ │ ├── UI/ │ └── Effects/ ├── Audio/ │ ├── BGM/ │ └── SFX/ ├── Resources/ └── Settings/这个结构不是随便定的。Scripts按功能模块分文件夹,方便你快速定位代码。Prefabs单独放,因为预制体在Unity里是高频使用的资源。Resources文件夹用来存放需要动态加载的资源,比如通过代码加载的图标和配置数据。
2.3 必装的几个基础包和插件
打开Package Manager,我建议安装以下几个包:
| 包名 | 用途 | 是否必装 |
|---|---|---|
| TextMeshPro | 高质量文字渲染 | 必装 |
| Input System | 新版输入管理 | 推荐 |
| Cinemachine | 相机控制 | 推荐 |
| 2D Sprite | 2D精灵支持 | 按需 |
| Post Processing | 后处理效果 | 推荐 |
TextMeshPro是必须装的,Unity自带的Text组件渲染效果太差,而TextMeshPro能让你用很少的性能开销获得清晰的文字显示。Input System虽然学习曲线比旧版Input Manager稍陡,但它的灵活性和可扩展性远超旧版,值得花时间学。
至于第三方插件,我推荐几个免费且实用的:DOTween用于做简单的动画补间,Odin Inspector(付费但值得)用于优化Inspector面板,NaughtyAttributes用于给字段加标签。这些插件能显著提升开发效率。
2.4 版本控制:别等代码丢了才后悔
我不管你是一个人做还是团队协作,从第一天起就用Git。Unity项目用Git有几个注意事项:
首先,在项目根目录创建.gitignore文件,排除以下目录:
Library/ Temp/ Obj/ Build/ Builds/ Logs/ UserSettings/ .vs/这些目录是Unity自动生成的缓存文件,不需要纳入版本控制。如果不排除,你的仓库会迅速膨胀到几个GB。
其次,Unity的Scene和Prefab文件是文本格式的YAML,但合并冲突非常难处理。建议开启Unity的Force Text序列化模式(Edit > Project Settings > Editor > Asset Serialization > Force Text),这样至少能看到差异。但最好的做法是避免多人同时修改同一个Scene或Prefab。
3. 角色系统:从移动、动画到状态管理
3.1 角色控制器的选型与实现
Unity里做角色移动有三种主流方案:CharacterController组件、Rigidbody物理驱动、以及自己写Transform位移。对于RPG来说,我强烈推荐CharacterController。原因很简单:RPG角色需要精确的移动控制,不需要真实的物理模拟。你用Rigidbody做移动,角色会像肥皂一样滑来滑去,还得处理各种物理碰撞的副作用。
CharacterController的核心代码大概长这样:
public class PlayerController : MonoBehaviour { [SerializeField] private float moveSpeed = 5f; [SerializeField] private float rotationSpeed = 10f; [SerializeField] private float gravity = -9.81f; private CharacterController controller; private Vector3 moveDirection; private float verticalVelocity; void Start() { controller = GetComponent<CharacterController>(); } void Update() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 inputDir = new Vector3(h, 0, v).normalized; if (inputDir.magnitude > 0.1f) { moveDirection = inputDir * moveSpeed; Quaternion targetRotation = Quaternion.LookRotation(inputDir); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime); } else { moveDirection = Vector3.zero; } if (controller.isGrounded) { verticalVelocity = -1f; } else { verticalVelocity += gravity * Time.deltaTime; } Vector3 finalMove = moveDirection + Vector3.up * verticalVelocity; controller.Move(finalMove * Time.deltaTime); } }这段代码有几个关键点。inputDir.normalized确保斜向移动不会比直线移动快。Quaternion.Slerp让角色平滑转向而不是瞬间旋转。controller.isGrounded判断角色是否着地,着地时给一个小的向下速度,防止在斜坡上抖动。
3.2 动画状态机的搭建
角色的动画我用Animator Controller来管理。一个典型的RPG角色动画状态机包含以下状态:
- Idle:待机,默认状态
- Walk/Run:移动,根据速度参数切换
- Attack:攻击,触发后自动返回Idle
- Hit:受击,触发后自动返回Idle
- Death:死亡,触发后停留在该状态
状态之间的转换通过参数控制。我通常用两个参数:Speed(float)和IsAttacking(bool)。Speed大于0.1时从Idle转到Walk,大于3时转到Run。IsAttacking为true时从Any State转到Attack。
实操心得:Animator的过渡条件不要设得太复杂,否则调试起来很痛苦。我建议用代码控制状态切换,而不是完全依赖Animator的自动过渡。比如攻击动画播放完毕后,用
Animator.Play("Idle")强制切回待机,比设置Exit Time更可控。
3.3 角色状态管理:用状态模式替代一堆bool
新手写角色状态,最容易写成一堆bool变量:isMoving、isAttacking、isDead、isStunned……然后代码里到处是if (!isDead && !isStunned && !isAttacking)这样的判断。这种写法在状态少的时候还能应付,一旦状态多起来,逻辑就会变成一团乱麻。
我的做法是用状态模式。定义一个接口:
public interface IPlayerState { void Enter(PlayerStateMachine machine); void Update(PlayerStateMachine machine); void Exit(PlayerStateMachine machine); }然后为每个状态实现这个接口:IdleState、MoveState、AttackState、HitState、DeathState。状态机负责管理当前状态,并在合适的时机切换。
这样做的好处是,每个状态的逻辑独立在一个类里,修改一个状态不会影响其他状态。而且新增状态只需要加一个类,不需要改动现有代码。虽然前期写起来麻烦一点,但项目规模上去之后,维护成本会低很多。
3.4 相机跟随:Cinemachine还是自己写
相机跟随看起来简单,但要做好并不容易。你需要处理:平滑跟随、边界限制、鼠标旋转视角、碰撞回避。Unity的Cinemachine插件能帮你省掉大量工作。
用Cinemachine做第三人称跟随相机,步骤很简单:
- 在Package Manager里安装Cinemachine
- 菜单栏选择Cinemachine > Create 3rd Person Camera
- 设置Follow目标为角色,Look At目标为角色头部
- 调整Aim和Body的参数
Cinemachine的Body组件控制相机位置,Aim组件控制相机朝向。我通常把Body的Follow Offset设为(0, 2, -4),这样相机在角色后上方。Damping参数控制跟随的平滑度,设得太低相机会很硬,设得太高会有延迟感。我一般用X=1, Y=1, Z=1。
如果你不想用Cinemachine,自己写也不难:
public class CameraFollow : MonoBehaviour { [SerializeField] private Transform target; [SerializeField] private Vector3 offset = new Vector3(0, 2, -4); [SerializeField] private float smoothTime = 0.3f; private Vector3 velocity = Vector3.zero; void LateUpdate() { Vector3 targetPos = target.position + target.rotation * offset; transform.position = Vector3.SmoothDamp(transform.position, targetPos, ref velocity, smoothTime); transform.LookAt(target.position + Vector3.up * 1.5f); } }注意用LateUpdate而不是Update,确保相机在角色移动之后再更新位置,避免画面抖动。
4. 战斗系统:伤害计算、技能释放与敌人AI
4.1 伤害计算的核心公式设计
战斗系统的核心是伤害计算。一个简单但足够用的公式是:
最终伤害 = (基础攻击力 + 武器攻击力) × 技能倍率 × (1 - 防御减伤率) × 随机浮动
防御减伤率的计算方式有很多种,我推荐用这个:
防御减伤率 = 防御力 / (防御力 + 100 + 10 × 攻击方等级)
这个公式的好处是,防御力的收益是递减的,不会出现堆防御就无敌的情况。100这个常数可以根据你的数值体系调整,它决定了防御力的“半衰期”。
随机浮动我一般取0.9到1.1之间的随机值,让伤害有一点波动,避免玩家觉得战斗太机械。
public static int CalculateDamage(int baseAtk, int weaponAtk, float skillMultiplier, int defense, int attackerLevel) { int totalAtk = baseAtk + weaponAtk; float defenseReduction = defense / (float)(defense + 100 + 10 * attackerLevel); float randomFactor = Random.Range(0.9f, 1.1f); int finalDamage = Mathf.RoundToInt( totalAtk * skillMultiplier * (1 - defenseReduction) * randomFactor); return Mathf.Max(1, finalDamage); }Mathf.Max(1, finalDamage)确保伤害至少为1,避免出现0伤害的尴尬情况。
4.2 技能系统的数据结构设计
技能系统我建议用ScriptableObject来定义。每个技能是一个ScriptableObject资产,包含以下字段:
[CreateAssetMenu(fileName = "New Skill", menuName = "RPG/Skill")] public class SkillData : ScriptableObject { public string skillName; public string description; public Sprite icon; public float cooldown; public float manaCost; public float damageMultiplier; public SkillType skillType; public float range; public GameObject skillEffectPrefab; public AudioClip skillSound; } public enum SkillType { Melee, Ranged, AOE, Buff, Heal }用ScriptableObject的好处是,你可以在编辑器里直接创建和配置技能,不需要改代码。策划(或者你自己)可以很方便地调整数值和添加新技能。
技能释放的流程是:检查冷却和蓝量 → 播放动画 → 生成特效 → 计算伤害 → 进入冷却。这个流程我封装在一个SkillManager里:
public class SkillManager : MonoBehaviour { private Dictionary<SkillData, float> cooldownTimers = new(); public bool CanUseSkill(SkillData skill, int currentMana) { if (cooldownTimers.ContainsKey(skill) && cooldownTimers[skill] > 0) return false; if (currentMana < skill.manaCost) return false; return true; } public void UseSkill(SkillData skill, Transform caster, Transform target) { cooldownTimers[skill] = skill.cooldown; StartCoroutine(CooldownRoutine(skill)); // 播放动画、生成特效、计算伤害... } private IEnumerator CooldownRoutine(SkillData skill) { while (cooldownTimers[skill] > 0) { cooldownTimers[skill] -= Time.deltaTime; yield return null; } cooldownTimers[skill] = 0; } }4.3 敌人AI:有限状态机实现追击与攻击
敌人AI不需要太复杂,一个有限状态机就够了。敌人的状态包括:Idle(待机)、Patrol(巡逻)、Chase(追击)、Attack(攻击)、Hurt(受击)、Dead(死亡)。
状态切换的逻辑是:
- Idle状态下,如果玩家进入检测范围,切换到Chase
- Chase状态下,如果玩家超出追击范围,回到Idle;如果进入攻击范围,切换到Attack
- Attack状态下,播放攻击动画,造成伤害,然后回到Chase
- Hurt状态下,播放受击动画,短暂硬直后回到Chase
- 血量归零时,切换到Dead
检测玩家用Physics.OverlapSphere或者触发器。我推荐用触发器,性能更好:
public class EnemyAI : MonoBehaviour { [SerializeField] private float detectionRange = 8f; [SerializeField] private float attackRange = 2f; [SerializeField] private float moveSpeed = 3f; private Transform player; private EnemyState currentState = EnemyState.Idle; private CharacterController controller; void Start() { controller = GetComponent<CharacterController>(); player = GameObject.FindGameObjectWithTag("Player").transform; } void Update() { float distanceToPlayer = Vector3.Distance( transform.position, player.position); switch (currentState) { case EnemyState.Idle: if (distanceToPlayer < detectionRange) currentState = EnemyState.Chase; break; case EnemyState.Chase: if (distanceToPlayer > detectionRange * 1.5f) { currentState = EnemyState.Idle; } else if (distanceToPlayer < attackRange) { currentState = EnemyState.Attack; } else { Vector3 dir = (player.position - transform.position).normalized; controller.Move(dir * moveSpeed * Time.deltaTime); transform.LookAt(new Vector3( player.position.x, transform.position.y, player.position.z)); } break; case EnemyState.Attack: // 播放攻击动画,造成伤害 // 攻击完成后回到Chase break; } } }注意:
detectionRange * 1.5f这个设计是为了防止敌人在边界反复切换状态。如果检测范围和脱战范围一样,敌人会在边界疯狂抖动。
4.4 受击反馈与打击感
打击感是RPG战斗的灵魂。没有打击感的战斗,数值再漂亮也像在砍空气。提升打击感的手段有:
- 受击闪白:敌人受击时材质短暂变白。用MaterialPropertyBlock修改颜色,不要直接改sharedMaterial
- 击退效果:受击时给角色一个反向位移,用DOTween做补间
- 顿帧:命中瞬间短暂降低Time.timeScale,一般0.05秒左右
- 粒子特效:命中位置生成火花或冲击波
- 音效:不同武器不同命中音效,暴击用更清脆的声音
- 屏幕震动:用Cinemachine的Impulse系统
受击闪白的实现:
public class HitFlash : MonoBehaviour { [SerializeField] private float flashDuration = 0.1f; private MaterialPropertyBlock mpb; private Renderer rend; private Color originalColor; void Start() { rend = GetComponent<Renderer>(); mpb = new MaterialPropertyBlock(); originalColor = rend.sharedMaterial.color; } public void Flash() { StartCoroutine(FlashRoutine()); } private IEnumerator FlashRoutine() { rend.GetPropertyBlock(mpb); mpb.SetColor("_Color", Color.white); rend.SetPropertyBlock(mpb); yield return new WaitForSeconds(flashDuration); mpb.SetColor("_Color", originalColor); rend.SetPropertyBlock(mpb); } }5. UI系统与数据持久化:让游戏有头有尾
5.1 UI框架的搭建思路
RPG的UI系统包含:主菜单、HUD(血条蓝条技能栏)、背包界面、对话界面、暂停菜单、设置界面。如果每个界面都单独写一套显示隐藏逻辑,代码会非常冗余。
我的做法是定义一个UIPanel基类:
public abstract class UIPanel : MonoBehaviour { protected CanvasGroup canvasGroup; protected virtual void Awake() { canvasGroup = GetComponent<CanvasGroup>(); if (canvasGroup == null) canvasGroup = gameObject.AddComponent<CanvasGroup>(); } public virtual void Show() { canvasGroup.alpha = 1; canvasGroup.interactable = true; canvasGroup.blocksRaycasts = true; } public virtual void Hide() { canvasGroup.alpha = 0; canvasGroup.interactable = false; canvasGroup.blocksRaycasts = false; } }然后用一个UIManager来统一管理所有面板的显示和隐藏,处理面板之间的互斥关系(比如打开背包时自动关闭对话)。
5.2 背包系统的数据结构
背包系统的核心是数据结构和UI的分离。数据层用List或数组存储物品,UI层负责显示。物品用ScriptableObject定义:
[CreateAssetMenu(fileName = "New Item", menuName = "RPG/Item")] public class ItemData : ScriptableObject { public string itemName; public string description; public Sprite icon; public ItemType type; public int maxStack = 99; public int buyPrice; public int sellPrice; public bool isConsumable; public int healAmount; public int manaAmount; } public enum ItemType { Consumable, Weapon, Armor, Material, Quest }背包槽位的UI用Grid Layout Group自动排列。每个槽位是一个Prefab,包含图标、数量文字和选中高亮。点击槽位时显示物品详情面板。
实操心得:背包的拖拽功能不要自己写,用Unity的EventSystem实现IDragHandler和IDropHandler接口。我当初自己写拖拽,处理坐标转换和边界检测花了两天,后来发现用接口半天就搞定了。
5.3 对话系统的实现
对话系统我推荐用节点式的设计。每个对话节点包含:说话人名字、对话内容、下一个节点ID、可选的选项列表。数据存在ScriptableObject或者JSON文件里。
[System.Serializable] public class DialogueNode { public string id; public string speakerName; public string content; public string nextNodeId; public List<DialogueChoice> choices; } [System.Serializable] public class DialogueChoice { public string text; public string nextNodeId; public string requiredItemId; }对话的触发用触发器。玩家进入NPC的触发器范围时,显示交互提示,按E键开始对话。对话过程中禁用玩家移动和攻击。
打字机效果用协程实现:
private IEnumerator TypewriterRoutine(string text) { dialogueText.text = ""; foreach (char c in text) { dialogueText.text += c; yield return new WaitForSeconds(0.03f); } }5.4 存档系统的设计
存档系统我建议用JSON序列化。定义一个SaveData类,包含所有需要保存的数据:
[System.Serializable] public class SaveData { public string saveTime; public Vector3 playerPosition; public int playerLevel; public int playerHP; public int playerMP; public int playerGold; public List<InventorySlotData> inventory; public List<string> completedQuests; public string currentSceneName; }保存时用JsonUtility.ToJson转成字符串,写入Application.persistentDataPath目录下的文件。读取时反序列化,然后恢复游戏状态。
注意:
Application.persistentDataPath在不同平台上的路径不同,但都是可读写且不会被清理的目录。不要用Application.dataPath,那个目录在发布后是只读的。
6. 常见问题与排查技巧实录
6.1 新手最常遇到的报错与解决方法
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
| NullReferenceException | 引用了未赋值的对象 | 检查Inspector面板的字段是否拖拽赋值 |
| MissingReferenceException | 对象已被销毁但仍被引用 | 在访问前加null检查,或用if (obj != null) |
| IndexOutOfRangeException | 数组或列表越界 | 检查循环边界和索引值 |
| InvalidOperationException | 集合在遍历时被修改 | 用for循环倒序遍历,或先复制一份再遍历 |
| Coroutine couldn't be started | 协程所在对象未激活 | 确保GameObject是active的 |
NullReferenceException是新手遇到最多的报错,没有之一。90%的情况是你在Inspector里忘了拖拽赋值。我的习惯是,所有[SerializeField]字段在Start里做一次null检查,如果为空就Debug.LogError输出具体是哪个字段,这样排查起来快很多。
6.2 性能优化的几个实用技巧
RPG项目性能问题通常出现在几个地方:Draw Call过多、GC频繁触发、物理检测开销大。
Draw Call优化:把使用相同材质的静态物体标记为Static,开启Static Batching。动态物体用GPU Instancing。UI的图集要合并,不同图集的UI元素不要交叉排列。
GC优化:避免在Update里new对象。字符串拼接用StringBuilder。GetComponent的结果缓存到字段里,不要每帧调用。用对象池管理频繁生成销毁的特效和子弹。
物理优化:合理设置Layer Collision Matrix,让不需要碰撞的层之间不检测。用Physics.OverlapSphereNonAlloc替代Physics.OverlapSphere,避免每次调用分配新数组。
// 不好的写法:每帧分配新数组 Collider[] hits = Physics.OverlapSphere(transform.position, range); // 好的写法:复用预分配的数组 private Collider[] hitBuffer = new Collider[10]; int count = Physics.OverlapSphereNonAlloc( transform.position, range, hitBuffer); for (int i = 0; i < count; i++) { // 处理hitBuffer[i] }6.3 那些教程不会告诉你的避坑经验
第一,不要用Find和FindObjectOfType做频繁查找。这些方法会遍历整个场景,性能极差。正确的做法是在Awake里查找一次并缓存,或者用事件系统做解耦。
第二,Prefab的修改要小心。如果你在场景里修改了Prefab的实例,然后点击Apply,所有实例都会变。但如果你不小心点了Revert,你的修改就全没了。我建议在Prefab模式下修改,而不是在场景实例上修改。
第三,Scene切换时数据会丢失。如果你用SceneManager.LoadScene切换场景,当前场景的所有对象都会被销毁。跨场景的数据要用DontDestroyOnLoad或者静态类来保存。
第四,Unity的序列化有坑。Dictionary不能被Unity序列化,在Inspector里看不到。如果需要序列化字典,用两个List分别存key和value,或者用ISerializationCallbackReceiver接口手动处理。
第五,动画事件的时机。动画事件是在动画播放到指定帧时触发的,但如果你用了Animator的过渡,事件可能在过渡期间被跳过。我建议用代码控制关键逻辑,动画事件只用来做特效和音效。
6.4 从做完到做好:后续扩展方向
当你把基础RPG跑通之后,可以往以下几个方向扩展:
- 装备系统:武器、防具、饰品,带属性和套装效果
- 任务系统:主线、支线、日常任务,带追踪和奖励
- 商店系统:买卖物品,价格浮动,库存刷新
- 技能树:升级获得技能点,解锁新技能
- 多场景:世界地图,场景切换,传送点
- 存档槽位:多个存档位,自动存档和手动存档
- 本地化:多语言支持,用Unity的Localization包
- 成就系统:记录玩家里程碑,展示成就列表
每扩展一个系统,你对Unity的理解就会深一层。我做完第一个RPG之后,第二个项目加了装备和任务系统,第三个项目加了技能树和多人存档,每次都在进步。
最后分享一个我个人的习惯:每完成一个功能模块,就写一篇简短的开发笔记,记录这个模块的设计思路、遇到的问题、解决方案。这些笔记在你后续做新项目时,就是最好的参考文档。我现在翻看两年前写的笔记,很多当时觉得复杂的问题,现在一眼就能看出解决方案,这种成长感是实打实的。