一个动作 RPG 原型能让角色在场景里移动,并不代表它已经形成了游戏。
真正让玩家愿意持续操作的,是一条完整循环:探索地图,发现敌人,进入战斗,获得掉落,整理装备,接受任务,完成目标,再进入下一片区域。这里的每一步都不算孤立难题,困难在于它们必须共享状态并互相驱动。
我在
animal-knight-source-code
中实现的,就是这套单机动作 RPG 核心系统。仓库以 Unity 和 C# 为基础,保留了 50 个业务脚本,覆盖角色控制、敌人 AI、战斗数值、背包装备、对话任务、场景切换、存档和 UI。
它不是一份包含完整场景与美术资源、克隆后即可运行的发行版工程,而是一套游戏玩法源码。也正因为资源层被剥离,代码中的系统关系反而更加清晰:
鼠标输入
→ NavMesh 寻路
→ 敌人状态机
→ 动画事件结算伤害
→ 击杀与掉落
→ 背包和装备
→ 对话与任务
→ 场景切换和存档
这篇文章重点复盘的,不是某一个角色脚本,而是这些系统如何组成可持续游玩的闭环。
从输入开始:鼠标不直接控制角色
项目采用类似传统俯视角 RPG 的点击操作方式。
MouseManager 每帧从摄像机向鼠标位置发射射线,根据命中对象的标签切换光标,并将操作转成事件:
Ground → 移动
Enemy → 攻击
Portal → 前往传送点
Item → 靠近可拾取物
它没有直接调用玩家对象,而是公开两个事件:
public event Action<Vector3> OnMouseClicked;
public event Action<GameObject> OnEnemyClicked;
PlayerController 在启用时订阅事件,在禁用时取消订阅。这样,输入系统只负责回答“玩家点了什么”,角色控制器负责决定“角色应该怎么做”。
这种分离带来的好处很实际:
- UI、输入与角色移动不会写在同一个脚本中。
- 切换角色或控制方式时,不必重写射线检测。
- 鼠标悬停样式可以独立扩展。
- 指针落在 UI 上时,可以统一阻止场景操作。
玩家点击地面后,控制器把目标位置交给 NavMeshAgent。Unity 导航系统负责计算路径、绕过障碍和持续更新速度,Animator 则根据速度切换待机与移动状态。
因此,一次移动操作形成了清晰链路:
鼠标射线命中地面
→ OnMouseClicked
→ 设置 NavMeshAgent.destination
→ 导航网格计算路径
→ 角色移动
→ Animator 根据速度播放动画
输入层、导航层和表现层各自完成一件事。
攻击不是按下按钮,而是一段流程
点击敌人后,角色不会立即扣除对方生命值。
控制器先记录攻击目标,让 NavMeshAgent 移动到武器攻击范围内,然后停止移动、面向目标,等待冷却时间结束并触发攻击动画。
while (distance > attackRange)
{
agent.destination = target.position;
yield return null;
}
agent.isStopped = true;
anim.SetTrigger("Attack");
真正的伤害结算发生在动画事件 Hit() 中。只有当武器挥动到命中帧时,动画才回调代码,对目标调用 TakeDamage。
这比点击后立刻扣血更自然,因为画面和数值在同一时间点发生:
点击敌人
→ 自动接近
→ 停在攻击距离
→ 播放攻击动画
→ 动画命中帧
→ 计算伤害
→ 更新生命值与受击表现
动画事件在这里不仅是视觉工具,也是战斗节奏的一部分。更换武器动画或调整挥击时机时,可以在动画资源中移动事件点,而不必把命中延迟硬编码成某个秒数。
用 ScriptableObject 管理战斗数据
攻击距离、技能距离、冷却时间、最小伤害、最大伤害、暴击率和暴击倍率被放进 AttackData_SO。
角色属性和任务数据也采用 ScriptableObject 表达。它让数值成为 Unity 编辑器中的资源,而不是散落在多个 MonoBehaviour 里的常量。
[CreateAssetMenu(menuName = "Attack/Attack Data")]
public class AttackData_SO : ScriptableObject
{
public float attackRange;
public float coolDown;
public int minDamage;
public int maxDamage;
public float criticalChance;
}
装备武器时,角色会应用武器自己的攻击数据并切换 Animator Controller;卸下装备后,再恢复基础攻击数据和基础动画。
这已经具备数据驱动战斗的雏形:
- 程序定义伤害如何计算。
- 攻击资源决定具体数值。
- 物品资源关联武器模型、战斗数据与动画。
- 装备操作改变角色当前能力。
为了避免运行时直接修改项目资源,角色启动时会实例化一份 ScriptableObject 副本。这个细节很重要:模板负责提供初始值,运行时对象负责保存当前生命值和装备状态。
敌人 AI:状态机比一串判断更清楚
敌人控制器定义了四种状态:

Boss 场景将追击、技能、投掷物、受击反馈和角色状态组织在同一套战斗循环中。
GUARD
PATROL
CHASE
DEAD
守卫型敌人会停留在出生区域,巡逻型敌人则在一定范围内随机寻找 NavMesh 可达点。当玩家进入视野范围后,敌人切换到追击状态;进入攻击或技能范围后停止移动并播放对应动画;生命值归零后进入死亡状态。
每一帧的核心逻辑可以概括为:
检查死亡
→ 检查是否发现玩家
→ 根据当前状态执行行为
→ 更新动画参数
状态机解决了两个问题。
第一,敌人行为有明确边界。巡逻状态只处理随机移动,追击状态只关心目标和攻击距离,死亡状态负责关闭碰撞并销毁对象。
第二,不同敌人可以复用基础逻辑。Golem 和 Grunt 都继承 EnemyController,但各自增加击退、投掷石块等技能。
项目中的石块也不是简单特效。它拥有刚体、飞行方向、伤害和碰撞状态,甚至可以被玩家反击后改变目标:
Boss 投掷石块
→ 石块飞向玩家
→ 玩家攻击石块
→ 石块状态改为 HitEnemy
→ 反向击中 Boss
这是一个很好的玩法例子:同一套物理对象通过状态变化,同时承担敌人技能和玩家反制机制。
战斗结果如何进入其他系统
敌人死亡后,处理并没有结束。
EnemyController 在对象禁用时执行两项操作:
- 如果挂载了
LootSpawner,生成掉落物。 - 如果任务系统已经初始化,按敌人名称更新任务进度。
这让一次击杀同时影响战斗、物品和任务:
敌人生命归零
├─ 播放死亡动画
├─ 生成掉落物
├─ 增加角色经验
└─ 更新击杀任务进度
LootSpawner 使用带权重的掉落列表。每个掉落对象配置一个概率,敌人死亡后根据随机值决定生成哪些物品。
当前实现允许同一次死亡命中多个概率条件,因此可能掉落多个物品。如果设计目标是“只掉一个”,循环命中后就应立即退出;如果目标是“每件物品独立判定”,更适合为每项单独生成随机数。
代码写法必须与策划语义一致,否则概率系统看起来正常,实际分布却会与设计不同。
背包不是列表,而是多个容器之间的交换
项目中的物品系统包含三个主要容器:

属性面板与背包同时打开,直观展示物品、装备槽和角色数值之间的联动。
背包 Inventory
快捷栏 Action
装备栏 Equipment
三者都使用 InventoryData_SO 保存格子数据,再由 ContainerUI 和 SlotHolder 映射到界面。
拖拽物品时,系统会记录原始格子和父节点。松开鼠标后,根据目标格子类型判断是否允许放置:
- 普通物品可以进入背包。
- 武器只能进入武器槽。
- 盾牌只能进入防具槽。
- 消耗品可以进入快捷栏。
如果目标格子中是同一种可堆叠物品,就合并数量;否则交换两个格子的数据。
开始拖拽
→ 保存原始 Slot
→ 图标进入拖拽 Canvas
→ 判断目标容器
→ 校验物品类型
→ 合并或交换数据
→ 刷新两个 Slot
装备格子的刷新还会触发角色模型和战斗数据变化。也就是说,UI 格子不是单纯展示层,它连接了背包数据、装备规则、角色外观、Animator 和攻击属性。
快捷栏则通过按键绑定调用消耗品,使用后恢复生命并减少数量。背包、快捷栏和任务系统还会共同检查任务物品数量。
这部分让我看到,RPG 背包真正复杂的地方从来不是“显示几十个格子”,而是物品在不同容器间移动时,必须同时维护规则、数据和角色状态。
对话如何驱动任务状态
对话系统同样使用 ScriptableObject 保存数据。

NPC 对话通过选项连接任务接受、目标推进与奖励结算。
一段对话由多个 DialoguePiece 组成,每个节点拥有:
- 唯一 ID
- 头像
- 文本
- 可选分支
- 关联任务
选项通过 targetID 跳转到下一个节点,从而形成简单的对话图。对话 UI 只负责显示当前节点和生成按钮,真正的分支关系存在于数据资源中。
NPC 对话
→ 显示当前节点
→ 玩家选择选项
→ 根据 targetID 跳转
→ 接受任务或提交任务
QuestGiver 会根据任务状态切换四组对话:
未接受 → startDialogue
进行中 → progressDialogue
已完成 → completeDialogue
已提交 → finishDialogue
因此 NPC 不需要在一个巨大脚本里判断所有情况。任务状态决定使用哪份对话数据,对话选项再决定是否接受或提交任务。
任务目标使用名称和数量描述。击杀敌人、拾取物品或使用物品时,各系统调用统一的 UpdateQuestProgress。任务管理器遍历未完成任务,找到匹配目标并更新数量。
提交任务时,系统还能处理两类奖励:
- 正数表示向背包添加奖励。
- 负数表示上交任务物品。
这条设计让收集任务形成了完整闭环:
与 NPC 对话
→ 接受收集任务
→ 战斗获得掉落
→ 背包数量更新任务进度
→ 返回 NPC
→ 扣除任务物品
→ 发放奖励
场景切换为什么必须绑定存档
场景传送分为同场景移动和异场景加载。
同场景传送时,系统暂时关闭 NavMeshAgent,把角色移动到目标 TransitionDestination,再恢复导航。异场景传送则需要:
- 保存角色、背包和任务。
- 播放淡入效果。
- 异步加载新场景。
- 在目标入口生成玩家。
- 读取角色状态。
- 播放淡出效果。
触发传送点
→ 保存当前状态
→ 屏幕淡入
→ 异步加载场景
→ 按目标标签定位出生点
→ 生成角色并读档
→ 屏幕淡出
目标点不依赖固定坐标,而是使用 ENTER、A、B、C 等标签匹配。这让两个场景可以通过成对入口连接,场景设计人员只需放置正确标签的目标对象。
GameManager、MouseManager、SaveManager 和 SceneController 使用单例并跨场景保留,负责维持全局流程。新场景中的角色注册后,GameManager 会重新绑定 Cinemachine 摄像机的 Follow 和 LookAt。
场景切换由此不只是 LoadScene,而是状态、角色、摄像机、导航和视觉过渡共同参与的一次生命周期重建。
存档系统的价值与边界
项目使用 JsonUtility 将 ScriptableObject 序列化为 JSON,再写入 PlayerPrefs。
保存内容包括:
- 角色属性
- 背包
- 快捷栏
- 装备栏
- 任务列表
- 当前场景名称
对于单机教学项目,这种实现足够直接,也便于验证系统闭环。但 PlayerPrefs 更适合保存简单设置,不适合长期承载复杂存档。
继续演进时,可以改为独立存档文件,并加入:
- 存档版本号
- 多存档槽
- 校验与损坏恢复
- 原子写入
- 数据迁移
- 可选的加密或压缩
任务加载逻辑还需要在重复读档前清理旧列表,否则同一运行周期多次加载可能追加重复任务。
这套源码最值得保留的设计
1. 输入通过事件连接角色
鼠标系统不直接依赖 PlayerController,职责简单,也方便将来替换键盘、手柄或移动端输入。
2. 动画事件决定命中时机
攻击数值与动画动作保持同步,武器和敌人可以拥有不同节奏。
3. ScriptableObject 承担数据资产
战斗、角色、物品、背包、对话和任务都能在编辑器中配置,代码负责规则,资源负责内容。
4. 敌人基础状态机支持玩法扩展
守卫、巡逻、追击和死亡逻辑被复用,具体敌人只添加自己的技能。
5. 击杀事件贯通多个系统
一次战斗结果会驱动经验、掉落和任务进度,形成真正的玩法闭环。
6. 场景切换考虑了完整生命周期
保存、加载、玩家重建、入口定位、摄像机绑定和淡入淡出被组织在同一流程中。
从原型走向完整游戏,还需要什么
当前仓库只包含核心脚本和 .meta 文件,缺少 Unity 工程设置、场景、Prefab、动画、美术资源和 ScriptableObject 实例,因此它更适合阅读与复盘,不能仅靠仓库内容直接还原完整游戏。
从代码层面继续完善,我会优先处理以下问题:
- 为状态机建立统一的进入、更新和退出接口。
- 将输入迁移到 Unity Input System,支持键鼠与手柄。
- 为战斗增加攻击前摇、硬直、无敌帧和伤害类型。
- 将掉落概率改成语义明确、可测试的随机表。
- 降低 SlotHolder 对 GameManager 和角色对象的直接依赖。
- 为任务目标使用稳定 ID,而不是显示名称。
- 把场景切换整理成明确的异步状态流程。
- 使用文件存档替代 PlayerPrefs,并支持版本迁移。
- 为背包交换、任务提交和伤害计算增加自动化测试。
- 补全 README、Unity 版本、依赖、启动步骤和操作说明。
其中最重要的改造,是减少依赖字符串和全局单例来连接系统。原型阶段这样开发很快,但功能增长后,事件总线、接口、依赖注入或更明确的领域边界会更容易维护。
结语
这个项目让我理解了,单机动作 RPG 的复杂度不只来自战斗。
玩家点击一个敌人时,输入系统、导航、动画、战斗数值、敌人状态机和血条 UI 同时工作;敌人死亡后,经验、掉落、背包和任务又接管结果;玩家走向传送门时,存档、场景加载、出生点和摄像机继续维持这段体验。
真正的游戏系统不是几十个彼此孤立的脚本,而是一条能够不断循环的状态链:
探索
→ 战斗
→ 成长
→ 任务
→ 新区域
→ 再次探索
当这条链路能够稳定运转时,一个技术演示才开始变成一款游戏。
Discussion
评论