今日目标

今天的主目标是把怪物 AI 与战斗反馈闭环往前推进一大步,尽量打通下面这条链路:

怪物发现玩家 → 追击 → 脱战归位 → 受击扣血 → 死亡 → 掉落 → 伤害跳字反馈

最终结果是:这条链路已经基本跑通,今天预定任务完成。


今日完成内容

1. 怪物移动方案确定为 NavMeshAgent

在玩家已经使用 CharacterController 的前提下,怪物没有继续沿用同样的控制方式,而是改用 NavMeshAgent 处理寻路与移动。

这样拆分后的职责比较清晰:

  • 玩家:输入驱动、强调手感,继续使用 CharacterController
  • 怪物:追击、归位、路径规划,使用 NavMeshAgent

这一步确认之后,怪物 AI 的思路也稳定了下来,后续很多问题都围绕导航参数、状态切换和受击反馈展开。


2. 骷髅怪实现了基础状态切换思路

今天明确决定:怪物逻辑不继续堆大量 if,而是往轻量状态机方向推进。

目前核心状态主要围绕这几类展开:

  • Idle
  • Chase
  • Return
  • 后续准备接 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 已经烘焙成功
  • 最终发现追击范围配置不合理,导致实际并没有进入追击分支

解决:

  • 调大追击范围进行验证
  • 明确区分 chaseDistancestoppingDistance
  • 认识到:前者决定“追不追”,后者决定“追到多近停”

问题 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 血条等内容会顺很多。

今日结语

今天最大的收获不是多做了几个功能,而是把很多原本混在一起的概念慢慢分开了:

  • 导航不是碰撞
  • 受伤不是死亡逻辑
  • 输入请求不是攻击成功
  • 全局表现不等于全局监听

这些判断会直接决定后面代码是越写越稳,还是越写越缠。

就今天这个推进速度来看,主线是健康的。下一步继续在“不急着过度抽象,但也不把代码写死”的节奏里往前推。