今日目标
今天的主目标是把怪物 AI 与战斗反馈闭环往前推进一大步,尽量打通下面这条链路:
怪物发现玩家 → 追击 → 脱战归位 → 受击扣血 → 死亡 → 掉落 → 伤害跳字反馈
最终结果是:这条链路已经基本跑通,今天预定任务完成。
今日完成内容
1. 怪物移动方案确定为 NavMeshAgent
在玩家已经使用 CharacterController 的前提下,怪物没有继续沿用同样的控制方式,而是改用 NavMeshAgent 处理寻路与移动。
这样拆分后的职责比较清晰:
- 玩家:输入驱动、强调手感,继续使用
CharacterController - 怪物:追击、归位、路径规划,使用
NavMeshAgent
这一步确认之后,怪物 AI 的思路也稳定了下来,后续很多问题都围绕导航参数、状态切换和受击反馈展开。
2. 骷髅怪实现了基础状态切换思路
今天明确决定:怪物逻辑不继续堆大量 if,而是往轻量状态机方向推进。
目前核心状态主要围绕这几类展开:
IdleChaseReturn- 后续准备接
Attack/Dead
同时也明确了一个设计判断:
- 主行为状态适合用普通枚举表示
- 附加状态/效果以后如果真有必要,再考虑位标记或其他叠加方案
这一步很重要,因为它把“怪物当前主要在做什么”从散乱条件判断里抽了出来,后续加攻击和死亡会顺很多。
3. 怪物脱战归位方案确定
没有急着做巡逻点和关卡编辑器,而是先落一个最小可用方案:
- 记录怪物出生点
_spawnPosition - 追击结束后
SetDestination(_spawnPosition) - 回到出生点后切回待机
这个方案的价值在于:
- 足够简单,能快速闭环
- 不会把当前阶段带进巡逻编辑器和路径系统的复杂度里
- 以后仍然可以继续保留为“脱战回归点”的基础能力
另外还讨论了对象池场景下出生点记录的问题,最终确认:
- 如果怪物未来会被对象池复用,且每次启用前都会先被放到新的刷怪位置
- 那么在
OnEnable记录出生点是有合理性的
这属于为后续扩展埋下的一个小伏笔。
4. 受击与死亡逻辑开始拆分成独立模块
今天明确了一个关键原则:
- AI 管理行为状态
- Health 管理生命事实
也就是说,死亡不应该由 AI 脚本“猜出来”,而是由 Health 判断“已经死亡”,再通过事件通知 AI 脚本切到 Dead。
最终决定的思路是:
Health内部处理扣血- 当生命值归零时触发死亡事件
- AI 脚本监听死亡事件,进入
Dead状态并处理后续逻辑
这一步把“死亡事实”和“行为状态”拆开了,模块边界清晰很多。
5. IDamageable 接口开始接入受击流程
目前怪物和测试假人都在往统一的受击入口靠拢:
- 攻击方只关心目标是否实现了
IDamageable - 命中后统一调用
TakeDamage(DamageInfo)
这里已经形成了一个比较明确的方向:
IDamageable表示“这个对象可以接收伤害”Health是当前阶段的具体实现之一
这样后续不管是怪物、玩家还是可破坏物,理论上都可以接这套统一入口。
6. 解决了怪物明明被扫到范围却不掉血的问题
这是今天一个比较典型的排查点。
现象是:
- 假人能正常受到伤害
- 骷髅怪的
SkeletonHealth却始终不生效
最后确认的真实原因非常直接:
怪物身上根本没有 Collider。
因为前面在做 NavMeshAgent 导航时,注意力都放在寻路和 AI 上,忘了怪物要想被 OverlapSphereNonAlloc 扫到,前提是它本身必须有碰撞器。
这个问题解决后,也顺带理顺了几层概念:
NavMeshAgent负责导航Collider负责被物理查询命中IDamageable负责受击入口Health负责生命值结算
7. 完成怪物随机掉落的第一版
掉落系统今天也跑通了第一版。
最终没有把掉落写死成“怪死了固定生成某个 prefab”,而是采用了更合理的最小结构:
- 掉落条目:
prefab + weight - 掉落表:
List<掉落条目> - 死亡时按权重随机抽取一个条目生成
同时还确认了一个很重要的设计分层:
- 谁死了要触发掉落:由挂在具体怪物上的掉落逻辑处理
- 掉什么:由掉落表数据决定
目前这条线已经可以支持:
- 不同怪物挂不同掉落表
- 同一种 prefab 在不同怪物身上有不同掉落权重
- 通过空条目实现“不掉落”结果
8. 完成伤害跳字第一版
今天最后一个收尾点是伤害跳字。
这部分最终选择的方向是:
- 做一个挂在
DamageTextRoot上的DamageTextService - 跳字本体是 UI 预制体
- 使用
TextMeshProUGUI显示数值 - 伤害真正生效后,请求 service 显示跳字
这一步也明确了几个关键判断:
- 跳字系统更像一个服务,不是一个主动监听全场对象的超级管理器
- 由受伤对象在伤害真正生效后,主动请求显示跳字
- 当前阶段先不做对象池,优先把功能跑通
- 动画先用协程做简单版,不急着上 DOTween
最终效果上,已经可以看到伤害数字出现、漂浮并销毁。
今日遇到的问题与解决方案
下面按“问题 → 原因 → 解决”整理一下今天的关键坑。
问题 1:怪物不追玩家
现象:
写了 SetDestination(target.position),但怪物完全不动。
排查后原因:
- 一开始怀疑是导航没生效
- 后来确认
NavMesh已经烘焙成功 - 最终发现追击范围配置不合理,导致实际并没有进入追击分支
解决:
- 调大追击范围进行验证
- 明确区分
chaseDistance和stoppingDistance - 认识到:前者决定“追不追”,后者决定“追到多近停”
问题 2:怪物进入 stoppingDistance 后动画还在播放移动
现象: 明明怪物已经靠近目标停下了,但移动动画还在播。
原因:
最开始用了 _agent.isStopped 作为动画判断依据,但它表示的是“有没有被命令停止”,不是“当前是否真的在移动”。
解决:
- 改成根据
NavMeshAgent.velocity判断是否播放移动动画 - 理清了“导航命令状态”和“实际运动状态”的区别
问题 3:怪物归位后始终回不到 Idle
现象: 怪物追击结束后能往出生点走,但很难切回待机。
原因:
- 一开始用
distance2Spawn < 0.01f - 后来发现
NavMeshAgent.stoppingDistance = 1.2 - 导致怪物在 1.2 范围内就停了,但代码还在等更严格的距离条件
解决:
- 重新理解“导航允许停下的范围”和“归位成功判定”的关系
- 放宽归位判定阈值
- 明确后续还可以进一步改成更贴近导航语义的判断方式
问题 4:Health 脚本导致死亡事件重复触发
现象: 死亡逻辑被反复进入,掉落和后续流程异常。
原因:
最开始把死亡判定写在 Update 中,导致只要血量小于等于 0,每帧都会 Invoke 一次死亡事件。
解决:
- 死亡判定改为在扣血后立即判断
- 明确“死亡事件只能触发一次”这一原则
- 后续逻辑统一基于这个单次死亡事件推进
问题 5:怪物受击逻辑不生效
现象: 攻击范围检测跑通了,但骷髅怪怎么都不掉血。
原因:
怪物身上没有 Collider,OverlapSphereNonAlloc 根本扫不到它。
解决:
- 给怪物补上碰撞器
- 同时进一步理解了:导航和受击是两套不同层面的系统
问题 6:掉落报 NullReferenceException
现象:
死亡掉落逻辑里 Instantiate 报空引用异常。
原因:
当掉落表中存在“空条目”表示不掉落时,如果命中了这个条目却仍然直接 Instantiate,就会报空引用。
解决:
- 在实例化前判断
dropsInfo.gameObject != null - 空条目命中时直接返回,不生成掉落物
问题 7:掉落物虽然生成了,但场景里看不到
现象: 日志打印显示掉落逻辑执行成功,但场景里看不到生成的对象。
原因: 最开始把掉落物生成成了怪物的子对象。怪物死亡后被销毁/隐藏时,掉落物也跟着一起没了。
解决:
- 理清“独立场景掉落物”和“怪物子节点”的区别
- 后续按世界位置独立生成掉落物,而不是作为怪物子物体生成
问题 8:伤害跳字获取 TMP 组件失败
现象:
知道 Inspector 里组件叫 TextMeshPro - Text (UI),但不知道代码里怎么获取。
原因: 不清楚 Inspector 显示名和代码类型名不是同一个东西。
解决:
- 确认代码里使用的是
TextMeshProUGUI using TMPro;- 由生成出来的跳字实例去获取并修改文本,而不是改预制体模板本体
问题 9:伤害跳字目标销毁后协程仍然继续跑
现象:
InstantiateDamageText 入口已经判空,但目标销毁后,协程里仍然会继续访问 target.position。
原因: 入口判空只能挡住“开始时 target 就为空”的情况,挡不住“协程运行过程中 target 被 Destroy”。
解决:
- 明确协程内也要处理 target 失效问题
- 当前阶段先采用最务实方案:入口为空则直接返回,后续再考虑更优雅的收尾方式
问题 10:跳字上浮速度不对
现象:
直接把 offset.y += 1 改成 offset.y += Time.deltaTime 后,字几乎不动。
原因:
当前偏移是在屏幕坐标系下,Time.deltaTime 每帧只有约 0.016,换算下来每秒只上浮 1 个单位,太慢了。
解决:
- 改为“上浮速度 ×
Time.deltaTime” - 并进一步梳理出:
moveDistance表示总共上浮多少animationTime表示持续多久speed = moveDistance / animationTime
今天形成的几个重要设计判断
今天除了把功能往前推,还逐渐形成了几个比较重要的架构判断。
1. 玩法节奏和表现节奏要分开
攻击 CD 由攻击脚本掌控,动画不应该自己维护一份 CD,更不应该自己读输入后推断“要不要播攻击”。
更合理的方式是:
- 输入只发起“攻击请求”
- 攻击脚本判断是否成功攻击
- 攻击成功后广播结果
- 动画脚本订阅这个结果
这样攻击规则只有一份真相源。
2. “事实”和“行为响应”要分开
例如死亡:
Health负责宣布“已经死亡”这个事实AI负责把这个事实翻译成Dead状态和后续行为
这样比让 Health 直接越权改 AI 状态要清晰得多。
3. 管理器不一定是主动监听者,也可以是被调用的服务
伤害跳字这块就是一个很典型的例子。
不是让一个 manager 主动去追踪全场所有对象, 而是让真正受伤的一方在伤害生效后,主动调用这个服务去显示表现。
这一步对后面理解 UI、音效、特效等“全局表现能力”很有帮助。
当前阶段成果总结
如果用一句话概括今天的成果,那就是:
怪物战斗闭环已经从“能追人”推进到了“能追人、能受击、会死亡、会掉落、会显示伤害反馈”。
这一天虽然细碎问题很多,但都是很关键的底层连接点。一旦这些链路跑通,后面继续往上加 Boss、掉落扩展、受击表现、Boss 血条等内容会顺很多。
今日结语
今天最大的收获不是多做了几个功能,而是把很多原本混在一起的概念慢慢分开了:
- 导航不是碰撞
- 受伤不是死亡逻辑
- 输入请求不是攻击成功
- 全局表现不等于全局监听
这些判断会直接决定后面代码是越写越稳,还是越写越缠。
就今天这个推进速度来看,主线是健康的。下一步继续在“不急着过度抽象,但也不把代码写死”的节奏里往前推。
Comments
评论区
欢迎在这里留言交流。