第 1 问帮我详细解释下这段技能伤害检测的代码
记录于 2026-09-14
SkillCtrl.Update() 里只有移动和持续时间判断,而且 EnemyCtrl 还是空壳(没有 TakeDamage 方法)。
一、这段代码整体在干嘛
它应该在 Update() 的 Casting == true 分支里,每帧跑一次,循环做三件事:
每帧(技能生效中):
① 火球沿自己的正前方挪一小段(速度 × 这一帧的耗时)
② 从火球当前位置,向正前方"滚"一颗半径 1 米的虚拟球,看撞没撞到东西
③ 撞到了敌人 → 扣血、结束技能、本帧到此为止
撞到墙/地面/什么都没撞 → 当无事发生,下一帧继续
二、原代码
switch (SkillInfo.SkillMoveSort)
{
case SkillMoveSortEnum.直线向前:
{
// —— ① 移动 ——
_effect.transform.position += _effect.transform.forward * Time.deltaTime * SkillInfo.MoveSpeed;
// —— ② 碰撞检测 ——
RaycastHit hitInfo;
if (Physics.SphereCast(_effect.transform.position, 1, _effect.transform.forward, out hitInfo))
{
// —— ③ 确认撞的是"敌人"再结算伤害 ——
EnemyCtrl enemyCtrl = hitInfo.transform.GetComponent<EnemyCtrl>();
if (enemyCtrl != null)
{
enemyCtrl.TakeDamage(5); // 扣 5 点血
EndCast(); // 结束技能
return; // 本帧到此为止
}
}
}
}
三、逐行拆解(人话翻译版)
| 代码 | 人话翻译 |
|---|---|
_effect.transform.forward | 特效自己的"正前方"方向(自身蓝轴),长度为 1 的单位向量——只管方向,不管快慢 |
Time.deltaTime | 这一帧耗了几秒(60 帧时 ≈ 0.017 秒)。乘上它,速度就变成"每秒几米"而不是"每帧几米"——换高配电脑帧率变高,火球也不会飞得更快 |
position += | 新位置 = 老位置 + 方向 × 速度 × 帧耗时。相当于"沿着方向盘指的方向,这一帧走了这么远" |
RaycastHit hitInfo | 一张空白的"事故报告单",等着检测函数往里填(撞到谁、撞在哪、距离多远) |
Physics.SphereCast(起点, 半径, 方向, out hitInfo) | 核心:从火球位置沿飞行方向滚一颗半径 1 米的虚拟球,碰到东西返回 true 并把报告单填好 |
out hitInfo | out 表示"这个参数由函数来填"。检测没撞到时 hitInfo 里是垃圾数据,所以必须先看返回值 |
hitInfo.transform.GetComponent<EnemyCtrl>() | 撞到"物体"不等于撞到"敌人"——去被撞物体身上找 EnemyCtrl 这个组件。像查身份证:有这个脚本的才算敌人 |
TakeDamage(5) | 通知敌人:"你扣了 5 点血" |
EndCast() + return | 关特效、结束施法动作;return 跳出本帧剩余逻辑,防止技能结束后还继续跑 |
四、两个小白容易卡住的点
1. 为什么用 SphereCast(球)不用 Raycast(线)?
Raycast 是一根无限细的激光——擦着敌人衣角飞过去就检测不到,玩家会觉得"我明明打中了!"。SphereCast 是一根"粗 1 米的管道",火球本体多大、检测范围就多大,命中判定宽松,手感好。
2. 为什么撞到墙不会误伤 / 报错?
球形检测半径 1 米,很容易蹭到地面或墙壁。蹭到时 GetComponent<EnemyCtrl>() 返回 null,if (enemyCtrl != null) 把它筛掉——这个判空不是保险丝,是正常流程的一部分,每帧都可能走到。
五、资深视角把关(照抄前必看)
| 问题 | 说明 | 建议 |
|---|---|---|
| ⚠ 检测距离是"无限远" | 这个 4 参数重载的 maxDistance 默认是无限。发射第一帧只要正前方直线上(管道内)有敌人——哪怕 30 米外——立刻命中结束,火球根本没机会飞过去 |
补第 5 个参数:SkillInfo.MoveSpeed * Time.deltaTime + 0.5f,只查"这一帧飞过的这段路"(业内叫"扫掠检测",帧间穿透也顺便解决了) |
| 伤害写死 5 | 不读表,所有技能都扣 5 点 | 后续给 skill.json 加 damage 字段,读表扣血 |
TakeDamage 还不存在 |
项目里的 EnemyCtrl 是空壳,直接抄会编译报错 |
视频后面几集会补;或先加个最简单的版本 |
截图里没看到 break; |
C# 规定每个 case 结尾必须有 break/return,否则报错 CS0163 |
可能只是截图截掉了;照抄报错就在 } 前补一个 |
| 移动对象不一样 | 视频版动的是 _effect(特效本体);项目重构版动的是技能根物体 transform(Translate) |
整合时二选一,别两边都加,否则一帧飞两倍距离 |
六、和现有项目(重构版)的关系
现在 SkillCtrl.Update() 对应位置是这样,只有"飞"、没有"撞":
if (SkillInfo.SkillMoveSort == SkillMoveSortEnum.直线向前)
{
transform.Translate(Vector3.forward * SkillInfo.MoveSpeed * Time.deltaTime);
}
视频这段就是往里补"撞到敌人 → 结算伤害 → 提前结束"。
第 2 问除了球体检测 Physics.SphereCast,还有什么检测方式?
记录于 2026-09-14
这些方法虽然多,但本质就四类,记住一个口诀就行:
(当时项目里一次物理检测都还没用过——视频这段 SphereCast 将会是第一个。)
一、Cast 家族——问"路":"从这里往那边走,路上撞不撞?"
和 SphereCast 同族,都是主动出击:沿一条路径"扫"过去,返回撞到的第一个东西。
| 方法 | 扫过去的形状 | 打个比方 | 典型用途 |
|---|---|---|---|
Physics.Raycast | 一根细线 | 激光笔 | 鼠标点地面/点敌人、枪械、视线判断 |
Physics.SphereCast | 粗管道 | 滚保龄球 | 火球、箭矢(第 1 问讲的这个) |
Physics.BoxCast | 长方体 | 推着大纸箱扫过道 | 宽体攻击(横扫一片)、车辆预判 |
Physics.CapsuleCast | 胶囊 | 拖着胶囊药丸扫 | 自带"长度"的子弹(弹体比球长时) |
Physics.Linecast | A→B 两点连线 | 两点间拉根线,看断没断 | "我和敌人之间有没有墙"(AI 视线) |
Rigidbody.SweepTest | 自己的真实模型 | 拿本人的模型去试撞 | 移动前预判"这么走会不会撞墙" |
- Raycast(激光笔):最基础也最常用。同样有"无限射程"坑。缺点:擦着敌人衣角飞过去就检测不到,玩家觉得"我明明打中了"。
- Linecast:就是"只问一次、不问多远"的 Raycast——A 点到 B 点之间有没有障碍物,是怪物 AI 判断"能不能看见玩家"的标准姿势。
- 同族两个后缀认识一下就行:
XXCastAll(一次拿回所有撞到的东西,不只第一个);XXCastNonAlloc(结果填进自己提前开好的数组,每帧都跑的检测用它,不产生垃圾、不卡顿)。
二、Overlap 家族——问"地盘":"此刻,这一块区域里有谁?"
不走路,就停在原地清点:以某个点为中心、某种形状为范围,把这一瞬间在里面的所有东西列出来。
| 方法 | 问的问题 | 典型用途 |
|---|---|---|
Physics.OverlapSphere | 以这点为圆心、半径 R 的球里现在有谁? | 爆炸/AOE 伤害结算——炸弹一响,全场谁在圈内全查出来挨个扣血 |
Physics.OverlapBox / OverlapCapsule | 这个盒子/胶囊里有谁? | 持续范围伤害、矩形判定 |
Physics.CheckSphere 等 | 这块区域里有没有东西?(只回答是/否) | 出生点防重叠、检查"这个格子能不能放东西" |
三、事件家族——Trigger / Collision:不主动问,物理引擎自动"盯梢"
前两类都是每帧主动打电话查;这一类是反过来——装个铃铛,撞上自动响。
| 方法 | 行为 | 比方 |
|---|---|---|
OnTriggerEnter(Collider 勾选 IsTrigger) | 幽灵模式:穿过对方身体,只报信、不拦、不弹 | 火球穿过敌人,喊一声"碰到了!" |
OnCollisionEnter(不勾 IsTrigger) | 真撞:产生物理推力,双方会弹开 | 保龄球撞瓶子 |
做法:给火球加一个 SphereCollider(勾 IsTrigger),敌人身上有 Collider + Rigidbody,Unity 发现重叠时自动调用火球脚本里的 OnTriggerEnter,在里面判断"进来的是不是敌人"。
- ✅ 优点:不用自己每帧查,物理引擎替你盯;检测范围在 Scene 面板里所见即所得(绿线框)。
- ❌ 致命缺点:快子弹会"隧穿"。物理引擎是"拍照式"工作——每 0.02 秒拍一张快照,不是连续录像。子弹太快时,这一帧在墙前面、下一帧已经在墙后面,快照里压根没有"撞上"那一瞬间,直接漏检。所以快的飞行物必须用 Cast(Cast 是一帧内问完一整条路,不怕快)。
四、土办法——不用物理,拿尺子量
完全绕开物理引擎,纯数学:
// 爆炸结算:够近就算命中
if (Vector3.Distance(bomb.position, enemy.position) < 5f) { /* 扣血 */ }
// 近战扇形:够近 且 在我面前的扇形里
float dist = Vector3.Distance(player.position, enemy.position);
float angle = Vector3.Angle(player.forward, enemy.position - player.position);
if (dist < 3f && angle < 45f) { /* 砍中 */ }
- ✅ 优点:简单、可控、不依赖任何碰撞体设置(很多手游技能就这么做)。
- ❌ 缺点:不分层、不挡墙——隔着墙也能"砍中"。
五、场景 → 选型简表(初版)
| 你要做的事 | 首选 | 备选 |
|---|---|---|
| 火球 / 箭命中 | SphereCast(视频方案) | Trigger 碰撞体(慢弹)、CapsuleCast(长弹) |
| 爆炸 / AOE 结算 | OverlapSphere | 距离丈量 |
| 近战挥一刀 | OverlapSphere + 角度筛扇形 | BoxCast、武器挂 Trigger |
| AI 视线(有没有墙挡) | Linecast | Raycast |
| 鼠标点地面选落点 | Raycast(相机发出射线) | 数学 Plane |
- LayerMask(层级过滤):上面所有
Physics.XXX都支持"只查指定层"参数。给敌人设 "Enemy" 层,检测时只查这一层,就永远不会蹭到地面/墙,连判空都省了,性能还更好。 - 以后做 2D 游戏,整个家族有镜像版
Physics2D.*,方法名、思路一模一样,3D 学会了直接平移。
第 3 问场景 → 选型速查表(完整版:把所有常用的伤害检测都列全)
记录于 2026-09-14
全表贯穿两条铁律,所有"为什么首选它"都是从这两条推出来的:
铁律二:在飞的问"路";瞬间/就地结算的问"地盘";被动等人撞的挂"铃铛"。
一、子弹 / 飞行物类(在飞,问"路")
| 场景 | 首选 | 备选 | 关键点 / 坑 |
|---|---|---|---|
| 慢速火球、魔法弹(RPG 常见)⭐ | SphereCast | Trigger 碰撞体 | 视频这套。记得传 maxDistance,否则无限射程 |
| 高速步枪子弹(FPS) | Raycast(瞬发 hit-scan) | CapsuleCast | 快到看不见弹道,干脆一帧问完整条线 |
| 箭矢、投矛 | CapsuleCast | SphereCast | 弹体比球"长",胶囊更贴形;maxDistance = 本帧移动距离 |
| 追踪导弹(贴脸跟着飞) | Trigger 碰撞体 | SphereCast | 本来就慢慢追,不隧穿,Trigger 最省事 |
| 穿透弹(一枪穿一排) | RaycastAll / SphereCastAll | 路径逐段 OverlapSphere | All 后缀:一次拿回撞到的全部,不是只有第一个 |
| 散弹枪(一次一片) | Raycast × N 条(扇形随机) | — | 每颗弹丸一条射线,各自结算 |
| 激光炮(持续照射) | Raycast + 每帧/定时结算 | — | 射线本身不动,动的是"打谁" |
二、近战类(就地结算,问"地盘")
| 场景 | 首选 | 备选 | 关键点 / 坑 |
|---|---|---|---|
| 单刀挥砍(扇形范围)⭐ | OverlapSphere + Vector3.Angle 筛 | BoxCast | 标准套路:先圈出身边所有人,再筛"在我面前 45° 内" |
| 直刺、突刺 | BoxCast | CapsuleCast | 细长判定盒,方向即出枪方向 |
| 全身横扫 360° | OverlapSphere | — | 不用筛角度,圈到就中 |
| 刀刀入肉(判定跟武器动画走) | 武器模型挂 Trigger | SphereCast | 判定范围所见即所得;快挥仍会隧穿,快刀补 Cast |
| 重锤砸地、跳劈 | 落点 OverlapSphere | — | 落地帧定点清点,和飞行物无关了 |
| 格挡 / 招架判定 | 武器 Trigger | BoxCast | 双方武器都要有碰撞体 |
三、范围 / AOE 类(瞬间或持续,问"地盘")
| 场景 | 首选 | 备选 | 关键点 / 坑 |
|---|---|---|---|
| 即时爆炸(火球落地炸开)⭐ | 落点 OverlapSphere | 距离丈量 | 爆的一瞬间清点一次,挨个 TakeDamage |
| 持续伤害区(毒圈/火墙) | OverlapSphere 每 0.5s 清点 | OnTriggerStay | 坑:TriggerStay 每物理帧触发,直接扣血会一秒扣几十次——必须配计时器降频 |
| 陨石术 / 天降 | 落点 OverlapSphere | — | 和爆炸同款,落点即爆点 |
| 全屏技能 | 距离丈量(Distance) | 超大半径 OverlapSphere | 不用物理引擎反而最快 |
| 陷阱(地刺、捕兽夹) | Trigger 碰撞体 | — | 被动型:等敌人踩上来自动响铃 |
| 推开/击退(带物理力) | OverlapSphere + Rigidbody.AddForce | — | 结算伤害用查询,加力用物理组件 |
四、锁定 / 点选类
| 场景 | 首选 | 备选 | 关键点 / 坑 |
|---|---|---|---|
| 锁定目标的单体技能(点谁谁挨打)⭐ | 不用物理!直接持有目标 Transform | — | 都知道打谁了,别再问路;很多新手在这滥用射线,白绕一圈 |
| 链电 / 闪电链 | 距离丈量逐跳找"最近的下一个" | OverlapSphere 逐跳 | 目标列表记得排除已跳过的 |
| 鼠标点选敌人(选目标) | Raycast + LayerMask(Enemy) | — | 相机发射线;只查敌人层,点不中地面 |
| 鼠标点地面(AOE 落点)⭐ | Raycast(射线打地面) | 数学 Plane | 教程后面做 AOE 精确落点大概率会用到 |
| 范围内最近目标(智能锁定) | OverlapSphere + 按距离排序 | — | Sort 一下取第一个 |
五、AI / 预判类(顺带,做敌人 AI 那集就靠它们)
| 场景 | 首选 | 备选 | 关键点 / 坑 |
|---|---|---|---|
| 视线检测(怪能不能看见我) | Linecast(敌人眼 → 玩家心口,有没有墙) | Raycast | 视线起点用"眼部",终点用"胸口",别用脚 |
| 巡逻会不会撞墙 | SweepTest | CapsuleCast | 用自己真实碰撞体形状试走一步 |
| 前方有没有悬崖/台阶 | SphereCast 向斜下方 | Raycast | 从脚尖往斜前下方探 |
| 声援/仇恨范围(谁进圈我就打谁) | OverlapSphere 定时清点 | Trigger | AI 决策用,0.2~0.5s 一次足够 |
六、Buff / 光环类
| 场景 | 首选 | 备选 | 关键点 / 坑 |
|---|---|---|---|
| 光环(周围友军持续回血/敌军减速) | OverlapSphere 定时清点 | TriggerStay | 同持续伤害区:定时器降频是命门 |
| 荆棘/反伤(近身才触发) | Trigger | OverlapSphere | 事件型最直观 |
| 装备特效"命中时溅射周围" | 命中点 OverlapSphere | — | 主命中用上面的方式,溅射就地清点 |
七、高频组合套路(真实项目都是"组合拳")
- 近战一刀 =
OverlapSphere(圈人)→Vector3.Angle(筛扇形)→ 逐个TakeDamage - 爆炸箭 = 飞行段
SphereCast(问路)→ 命中点OverlapSphere(问地盘)→ 二段伤害 - 激光炮 =
Raycast(拿最远命中点)→ 命中点OverlapSphere(小范围溅射) - 本项目的指向性技能(以后升级)= 点选时
Raycast选敌 → 飞行段瞄准targetTransform → 近身SphereCast触发命中 →OverlapSphere溅射
八、查到目标之后的三件套(全场景通用)
| 工具 | 说明 |
|---|---|
| LayerMask | 检测时只查 Enemy 层——不蹭地、不蹭墙、少判空、还更快 |
| NonAlloc | 写在 Update 里的每帧检测,用 NonAlloc 版本防 GC 卡顿 |
| 计时器降频 | OverlapSphere 这类清点式检测,0.2~0.5 秒一次足够,不必每帧 |
第 4 问修改动画剪辑的方法
记录于 2026-09-14
Soldier@combat_shoot.FBX 得到"xxx 1.FBX"是走不通的:复制 FBX 等于把整个模型文件(含几十个动画)原样再导入一遍,里面的剪辑依然是导入动画,照样只读不可编辑,白占空间还容易在搜索时认错剪辑。正确姿势是复制"剪辑子资产"。
一、把导入剪辑变成"可编辑副本"
- Project 窗口搜索框输入
combat_shoot_burst→ 找到 Soldier@combat_shoot_burst.FBX → 点左边小箭头展开 → 里面有个叫combat_shoot_burst的剪辑(注意:选中的是剪辑,不是 FBX 本身) - 选中那个剪辑 → Ctrl+D
- Unity 在同目录生成
combat_shoot_burst 1.anim—— 从这一刻起它是独立资产,关键帧可编辑(原来的 FBX 剪辑依然是只读的)
二、让动画状态用这份副本
- 双击进
Enemy_Soldier.prefab(Prefab 模式)→ 选中 SoldierModel → 打开 Animator 窗口 - 点 Shoot 状态 → Inspector 的 Motion 槽位:把原来指向 FBX 内的
combat_shoot_burst换成刚生成的combat_shoot_burst 1
三、编辑姿势(重点也是难点)
- 仍选中 SoldierModel → 打开 Animation 窗口(Window → Animation → Animation)→ 顶部剪辑选
combat_shoot_burst 1→ 时间轴拖到想改的帧 - 点亮录制红点 → 在 Hierarchy 展开 SoldierModel 的骨骼,找到想动的骨头(比如
Bip001 Spine脊柱)→ 在 Scene 视图旋转它 → 时间轴自动打关键帧 - 拖时间轴前后检查姿势变化 → 满意后保存 Prefab → 进 Play 验证
四、三个必须提前知道的"现实"
| 现实 | 说明 |
|---|---|
| 人形剪辑的曲线是"肌肉值" | 这个 rig 是 Humanoid,Animation 窗口里骨骼属性显示为肌肉曲线,不是普通 Transform 旋转,改起来比想象中别扭。实用守则:只改一节脊柱(Spine/Spine1)、只在两三个关键帧上改、幅度小步试。改四肢很容易出鬼畜姿势(肌肉系统会"纠正"你的直觉) |
| 姿势是"长死"在剪辑里的 | 不管玩家在楼上楼下、远近高低,这个角度都固定不变——这是 C 和 B(脚本动态转)的本质区别。想"动态"只有 B 能做到 |
| 改的是资产 | 这份 .anim 所有引用它的士兵通用——正好,全体士兵姿势统一;但原版 FBX 内的剪辑不受影响(两者是不同资产) |
五、怎么选 B 还是 C
| 需求 | 选哪个 |
|---|---|
| "整体往右转一个角度"(动态、可调、只攻击时生效) | 做法 B(脚本)才是对的工具:15 行代码、角度随时拖、平滑插值。C 做不到"动态" |
| "射击姿势的形状不对"(手的位置、头的朝向、想低头瞄准) | 这才是 C 的主场:把姿势改在剪辑里 |
| 两个都想要 | 不冲突,可叠加:B 管整体动态旋转,C 管姿势形状 |
第 5 问你刚才提到的伤害检测常用函数能都写一个简短的示例代码吗?
记录于 2026-09-14
TakeDamage 还没写(EnemyCtrl 还是空壳),等教程补上、或先加个空方法就能跑。示例风格对齐本项目:中文注释、老版 Input。
一、Cast 家族(问"路")
1. Raycast —— 枪械激光
// 从枪口正前方打一条"激光",最远 100 米,只打 Enemy 层
RaycastHit hitInfo;
if (Physics.Raycast(muzzle.position, muzzle.forward, out hitInfo, 100f, LayerMask.GetMask("Enemy")))
{
EnemyCtrl enemy = hitInfo.transform.GetComponent<EnemyCtrl>();
if (enemy != null) enemy.TakeDamage(10);
// hitInfo.point 是命中点坐标,可以在那放一个命中特效
}
2. Raycast(Ray 写法)—— 鼠标点选
// 鼠标左键:从摄像机穿过鼠标位置发一条射线(老版 Input 写法)
if (Input.GetMouseButtonDown(0))
{
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
RaycastHit hitInfo;
if (Physics.Raycast(ray, out hitInfo, 100f, LayerMask.GetMask("Enemy")))
{
EnemyCtrl enemy = hitInfo.transform.GetComponent<EnemyCtrl>();
if (enemy != null) enemy.TakeDamage(10);
}
// 想点地面选 AOE 落点:把层换成 "Ground",hitInfo.point 就是落点
}
3. RaycastAll —— 穿透弹
// 一条线上的敌人全部拿到(注意:结果不保证按远近排序)
RaycastHit[] hits = Physics.RaycastAll(muzzle.position, muzzle.forward, 100f, LayerMask.GetMask("Enemy"));
foreach (RaycastHit hitInfo in hits)
{
EnemyCtrl enemy = hitInfo.transform.GetComponent<EnemyCtrl>();
if (enemy != null) enemy.TakeDamage(10);
}
4. SphereCast —— 火球(修正了"无限射程"的版本)
// 放在 Update() 的 Casting 分支里:先查"这一帧要飞的这段路",没撞到才真往前飞
float moveStep = SkillInfo.MoveSpeed * Time.deltaTime; // 这一帧要飞多远
RaycastHit hitInfo;
if (Physics.SphereCast(transform.position, 0.5f, transform.forward, out hitInfo,
moveStep + 0.1f, LayerMask.GetMask("Enemy")))
{
EnemyCtrl enemy = hitInfo.transform.GetComponent<EnemyCtrl>();
if (enemy != null)
{
enemy.TakeDamage(5);
EndCast();
return; // 命中了就别再飞了
}
}
transform.Translate(Vector3.forward * moveStep); // 查完没撞到,才往前飞
5. BoxCast —— 直刺 / 横扫
// 从身前推出一个盒子往前扫 3 米(参数是"半边长":写 0.8,盒子实际宽 1.6)
RaycastHit hitInfo;
if (Physics.BoxCast(transform.position + transform.forward, new Vector3(0.8f, 0.8f, 0.4f),
transform.forward, out hitInfo, 3f, LayerMask.GetMask("Enemy")))
{
EnemyCtrl enemy = hitInfo.transform.GetComponent<EnemyCtrl>();
if (enemy != null) enemy.TakeDamage(10);
}
6. CapsuleCast —— 箭矢 / 长弹
// p1、p2 是胶囊"尾、头"两个球的球心,半径 0.2,往前扫 50 米
Vector3 p1 = transform.position - transform.forward * 0.5f; // 箭尾球心
Vector3 p2 = transform.position + transform.forward * 0.5f; // 箭头球心
RaycastHit hitInfo;
if (Physics.CapsuleCast(p1, p2, 0.2f, transform.forward, out hitInfo, 50f, LayerMask.GetMask("Enemy")))
{
EnemyCtrl enemy = hitInfo.transform.GetComponent<EnemyCtrl>();
if (enemy != null) enemy.TakeDamage(10);
}
7. Linecast —— AI 视线
// 怪物眼睛 → 玩家胸口,中间有没有墙(只把 Ground 层当障碍,队友/小怪不挡视线)
if (Physics.Linecast(eyePos.position, playerHeart.position, LayerMask.GetMask("Ground")))
{
// 返回 true = 中间被墙挡住,看不见玩家
}
8. SweepTest —— 移动预判
// 用我自己的真实碰撞体形状,试着往前"预走" 2 米看会不会撞
RaycastHit hitInfo;
if (rigidbody.SweepTest(transform.forward, out hitInfo, 2f))
{
Debug.Log("前方要撞到:" + hitInfo.transform.name);
}
二、Overlap 家族(问"地盘")
9. OverlapSphere —— 爆炸 / AOE 结算
// 以爆点为圆心、半径 5 米,把圈内所有敌人一次性全查出来挨个扣血
Collider[] cols = Physics.OverlapSphere(explodePos, 5f, LayerMask.GetMask("Enemy"));
foreach (Collider col in cols)
{
EnemyCtrl enemy = col.GetComponent<EnemyCtrl>();
if (enemy != null) enemy.TakeDamage(20);
}
10. OverlapSphere + Angle —— 近战扇形(组合拳)
// 先圈出 3 米内所有敌人,再筛"在我面前 45° 内"的(左右各 45°,合计 90° 扇形)
Collider[] cols = Physics.OverlapSphere(transform.position, 3f, LayerMask.GetMask("Enemy"));
foreach (Collider col in cols)
{
Vector3 dirToEnemy = col.transform.position - transform.position;
if (Vector3.Angle(transform.forward, dirToEnemy) < 45f)
{
EnemyCtrl enemy = col.GetComponent<EnemyCtrl>();
if (enemy != null) enemy.TakeDamage(10);
}
}
11. CheckSphere —— 只要"是/否"
// 不需要名单,只问"这个点周围 1 米内有没有东西?"(出生点防重叠、能不能放陷阱)
if (Physics.CheckSphere(spawnPos, 1f))
{
Debug.Log("这个位置被占了,换一个");
}
12. OverlapSphereNonAlloc —— 每帧检测防卡顿
// 类成员:数组只开一次,反复填,不每帧产生新垃圾
Collider[] results = new Collider[20];
void Update()
{
int count = Physics.OverlapSphereNonAlloc(transform.position, 5f, results, LayerMask.GetMask("Enemy"));
for (int i = 0; i < count; i++) // 只处理前 count 个!后面是上次剩下的旧数据
{
EnemyCtrl enemy = results[i].GetComponent<EnemyCtrl>();
if (enemy != null) enemy.TakeDamage(1); // 例如毒圈伤害,实际配 0.5 秒计时器降频
}
}
三、事件家族(挂铃铛)
13. OnTriggerEnter —— 触发器版火球(和第 4 条二选一)
// 挂在火球上。前提:火球加 SphereCollider 并勾 IsTrigger;敌人有 Collider;双方至少一方有 Rigidbody
void OnTriggerEnter(Collider other)
{
EnemyCtrl enemy = other.GetComponent<EnemyCtrl>();
if (enemy != null)
{
enemy.TakeDamage(5);
Destroy(gameObject); // 火球撞到人后消失
}
}
14. OnCollisionEnter —— 真碰撞(对比用)
// 不勾 IsTrigger 才走到这:双方会互相推动、弹开
void OnCollisionEnter(Collision collision)
{
Debug.Log("撞到了:" + collision.gameObject.name);
// 注意参数和 Trigger 不同:要拿对方碰撞体得写 collision.collider
}
LayerMask.GetMask("Enemy") 需要先在 Unity 里建一个名为 Enemy 的层并分给敌人;不想用代码写字符串的话,也可以声明 public LayerMask enemyLayer; 然后在 Inspector 里拖选——教程里常见这种配法。
第 6 问玩家开枪到敌人被击中,整个过程调用链是什么?其中的特效调用过程是什么?
记录于 2026-09-15
onDamaged?.Invoke()(事件广播)和 m_LastTimeDamaged(闪白秒表),本篇把整条链串起来:玩家按鼠标 → 子弹出膛 → 命中敌人 → 扣血 → 一连串特效和 AI 反应。
一、总调用链全景图
【输入层】
鼠标左键
→ PlayerInputHandler(把鼠标翻译成"刚按下/按住/刚松开")
→ PlayerWeaponsManager.Update() 每帧轮询
→ activeWeapon.HandleShootInputs(按下, 按住, 松开)
【武器层】WeaponController(一把枪的大脑)
HandleShootInputs 按开火类型分流(Manual单发 / Automatic连发 / Charge蓄力)
→ TryShoot() 闸门:弹药够吗?距上枪 ≥ DelayBetweenShots 吗?
→ HandleShoot() ★真正开火,一次干 6 件事:
① Instantiate(子弹预制体, 枪口) × BulletsPerShot ← 霰弹枪一发多颗
└→ newProjectile.Shoot(this) 记录主人/继承枪口速度
② 火光:Instantiate(MuzzleFlashPrefab, 枪口) 2 秒后 Destroy
③ 抛弹壳 ShootShell()(对象池取壳→抛出→塞回)
④ 音效:PlayOneShot(ShootSfx)
⑤ 开火动画:WeaponAnimator.SetTrigger("Attack")
⑥ 广播 OnShoot / OnShootProcessed 事件
← 返回 true → 管理器加后坐力 m_AccumulatedRecoil += RecoilForce(枪往后顶)
【子弹层】ProjectileStandard(自生自灭的快递员)
OnShoot() 出生初始化:初速度 = 朝向 × Speed;主人碰撞体进忽略名单;
玩家专用"轨迹修正":把子弹漂到摄像机正对的延长线上(枪口在屏幕右下,但你要打屏幕中心)
Update() 每帧:
移动 → 漂移修正 → 朝向速度 → 重力 → Physics.SphereCastAll(上帧位置→本帧位置, Radius)
扫到最近的合法命中 → OnHit(点, 法线, 碰撞体)
【结算层】
OnHit()
① 伤害:Damageable.InflictDamage(Damage, false, 开枪者)
→ 部位倍率 DamageMultiplier(爆头加成在这层)
→ Health.TakeDamage(最终伤害, 来源) → 扣血 → OnDamaged?.Invoke(伤害, 来源) ─┐
② 弹着点火花:Instantiate(ImpactVfx, 命中点+法线偏移),寿终 Destroy │
③ 命中音效:AudioUtility.CreateSFX(ImpactSfx, 命中点) │
④ Destroy(子弹自己) │
│
【敌人反应层】EnemyController.OnDamaged ←— 订阅了 Health.OnDamaged(Start 里 +=)────┘
① DetectionModule.OnDamaged(来源) 感知层记账:"谁打我,我就该找谁"
② onDamaged?.Invoke() 二次广播 → SoldierEnemyMobile.OnDamaged:
├ RandomHitSparks[n].Play() 随机受击火花
└ Animator.SetTrigger(OnDamaged) 受击抖动动画
③ m_LastTimeDamaged = Time.time 按下"闪白秒表"(不改材质,只记时刻)
④ DamageTick 音效(每帧限播一次)
而 EnemyController.Update() 每帧:渐变取色 → 写进身体材质自发光 → 闪白渐隐
血量归零 → Health.OnDie → EnemyController.OnDie:Instantiate(DeathVfx 爆炸) + 掉落 + 延迟销毁
SoldierDeathAnimation.OnDie:死亡动画 + 藏枪 + 关大脑
二、特效调用点一览(5 个触发点)
| 时机 | 特效 | 触发代码位置 | 生成方式 |
|---|---|---|---|
| 开枪瞬间 | 枪口火光 | WeaponController.HandleShoot | Instantiate 克隆式:克隆 MuzzleFlashPrefab 挂枪口,2 秒后 Destroy |
| 子弹飞行 | 弹道拖尾 | 子弹预制体自带 TrailRenderer | 预制体资产配置,代码零参与 |
| 命中瞬间 | 弹着点火花 | ProjectileStandard.OnHit | Instantiate 克隆式:克隆 ImpactVfx 贴着命中面,寿终 Destroy |
| 敌人挨打 | 受击火花 ×N | OnDamaged 广播链 | 预置 Play() 式:激活预置在敌人身上的粒子组件,零克隆 |
| 敌人挨打 | 身体闪白渐隐 | EnemyController.Update | 只记时刻+渐变取色,MaterialPropertyBlock 写自发光(不实例化材质) |
| 敌人死亡 | 爆炸特效 | EnemyController.OnDie | Instantiate 克隆式:DeathVfx,5 秒后 Destroy |
三、特效的两种玩法(关键区别)
| 玩法一:Instantiate 克隆式 | 玩法二:预置 Play() 式 | |
|---|---|---|
| 代表 | 枪口火光、弹着点火花、死亡爆炸 | 受击火花 RandomHitSparks |
| 原理 | 每次触发克隆一个新特效物体,Destroy(vfx, N秒) 定时自毁 | 特效物体一直摆在敌人预制体里当子物体,平时不播;挨打时 Play() 点亮 |
| 开销 | 每次克隆有少量 GC(官方示范项目不在乎这点) | 零克隆、零 GC |
| 代价 | 用哪都行,打完即走 | 要提前把粒子摆进敌人预制体 + 填进 RandomHitSparks 数组(换装/换脚本时最容易丢这步引用) |
四、链路里的三处巧思(值得细品)
- 子弹靠"球体扫射"防穿透:子弹不走物理引擎,每帧 Update 手动平移,用
Physics.SphereCastAll从上一帧位置向这一帧位置"扫"一个半径 Radius 的球——哪怕一帧飞 2 米,中间的墙也照样扫得到(这正是第 1 问 SphereCast 的实战应用)。 - 轨迹修正:第一人称枪口其实在屏幕右下角,但玩家瞄的是屏幕中心——子弹出生后在 TrajectoryCorrectionDistance 米内"悄悄漂移"到摄像机正对的延长线上,玩家完全察觉不到。贴脸开枪时还有一条摄像机→枪口的射线预判,命中直接当场结算。
- 两次事件广播实现解耦:
Health.OnDamaged(血量系统喊一嗓子)→EnemyController.onDamaged(基类转播给大脑脚本)——子弹和敌人从头到尾互不认识,靠 Damageable / Health 这几个"中间人"传话。这就是这套架构能同时服务玩家、士兵、悬浮机、龙 BOSS 的原因。
五、四层伤害管线(和 RPG 项目对照)
子弹 OnHit → Damageable.InflictDamage(部位倍率加工) → Health.TakeDamage(扣血+广播)
↑ 相当于 RPG 项目里 EnemyCtrl.TakeDamage 的角色拆成了两层:
Damageable 管"伤害怎么算",Health 管"血量账本+广播"
以后想在 RPG 里加"爆头 ×2",对应的位置就是 Damageable.DamageMultiplier 这一层——伤害管线的分层就是为这种扩展预留的。
第 7 问广播事件、订阅事件的机制原理(项目实例版)
记录于 2026-09-15
onDamaged?.Invoke()、Health.OnDie),本篇把这套机制一次讲透——原理 + 该项目里真实存在的两套用法:组件事件(点对点)和 EventManager(全局事件总线)。
一、原理:委托 = 一张"订阅名单"
C# 的委托(delegate)类型,本质是"能装方法的容器"——可以装一个,也可以装一串。事件机制就是围绕名单的三件事:
public UnityAction<float, GameObject> OnDamaged; // ① 声明名单:只装"带(伤害,来源)两个参数的方法",出生 = null
...
OnDamaged += OnDamaged; // ② 订阅:把我的方法登记到名单尾
...
OnDamaged?.Invoke(20f, player); // ③ 广播:按登记顺序挨个调用;?. 防空名单崩溃
UnityAction<参数>是 Unity 提供的现成委托类型(等价 C# 的Action),尖括号限定名单上方法必须长什么样;- 一张名单可以登记多个方法 → "广播一次、多方收听";
- 发布方不需要知道谁在听——想听的人自己
+=。以后加新表现,新脚本订阅即可,发布者一行代码不用改。
二、机制一:组件间事件(点对点广播)
项目做法:把名单声明成组件的公开字段,谁想听谁在 Start 里 +=。全项目"广播台"清单:
| 广播台(声明处) | 事件 | 真实订阅者 |
|---|---|---|
Health.cs | OnDamaged / OnHealed / OnDie | EnemyController、SoldierDeathAnimation、PlayerCharacterController、Destructable、EnemyTurret、UI红闪 |
DetectionModule.cs | onDetectedTarget / onLostTarget | EnemyController |
EnemyController.cs | onAttack / onDamaged / onDetectedTarget / onLostTarget | 各"大脑"(Soldier/Dragon/EnemyMobile)、罗盘UI |
WeaponController.cs | OnShoot / OnShootProcessed | 武器特效组件 |
PlayerWeaponsManager.cs | OnSwitchedToWeapon 等 | UI 弹药显示 |
三、四个真实例子
例1:一个事件、多个订阅者(Health.OnDie 双订阅)
士兵死亡时,OnDie 名单上登记着两个方法,广播一次、各干各的:
// Health.cs —— 只广播,不知道有谁在听
if (CurrentHealth <= 0f) { m_IsDead = true; OnDie?.Invoke(); }
// EnemyController.Start() —— 订阅者A:爆炸、掉落、注销、延迟销毁
m_Health.OnDie += OnDie;
// SoldierDeathAnimation.Start() —— 订阅者B:倒地动画、藏枪、关大脑
m_Health.OnDie += OnDie; // 同一张名单登记两个方法,广播时按登记顺序先后被调
想加第三个表现(比如死亡音效),新写脚本 += 即可,Health 一行不用改——这就是广播模式的价值。
例2:两级转播链 + 反向订阅(AI 发现目标链)
DetectionModule(眼睛) EnemyController(基类) SoldierEnemyMobile(大脑) onDetectedTarget ─广播→ OnDetectedTarget ─转播 onDetectedTarget ─→ OnDetectedTarget ("我看见玩家了!") (接住+眼睛变红) (再广播一嗓子) (切状态机:巡逻→追击)
// EnemyController.Start():同时是订阅者(下行)和发布者(上行)
DetectionModule.onDetectedTarget += OnDetectedTarget; // 订阅眼睛的事件
onAttack += DetectionModule.OnAttack; // ★反向订阅:把"眼睛"的动画方法登记进我"开枪了"的名单
发布/订阅的方向完全取决于谁需要谁——EnemyController 开枪时,感知模块自动收到通知播动画。
例3:UI 订阅游戏逻辑(跨系统收听)
CompassMarker.cs:订阅敌人的onDetectedTarget→ 敌人一发现玩家,罗盘标记变色;FeedbackFlashHUD.cs:订阅玩家Health.OnDamaged→ 玩家一掉血,屏幕红一下。
AI 和 UI 之间没有任何直接调用,全靠名单传话。
例4:event 关键字 = 只能订阅、不能代喊
WeaponController.OnShootProcessed 用的是 public event Action:外部只能 += / -=,不能替它 Invoke。普通 UnityAction 字段谁都能喊一嗓子(有人乱喊就乱了),event 把广播权锁在发布者手里,更安全。
四、机制二:EventManager 全局事件总线(邮局)
组件事件是"你认识那个组件"才能订阅它。互不相识的跨系统(任务 ↔ 流程 ↔ UI)怎么通信?项目用了 static 的全局总线:
// ① 事件先定义成"数据信封"(Events.cs):class EnemyKillEvent : GameEvent { ... }
// ② 订阅(Awake 里):
EventManager.AddListener<EnemyKillEvent>(OnEnemyKilled);
// ③ 广播:
EventManager.Broadcast(new EnemyKillEvent());
// ④ 退订(OnDestroy 里,必须配对!):
EventManager.RemoveListener<EnemyKillEvent>(OnEnemyKilled);
原理:一个 static Dictionary<Type, 委托>——键是事件的类型,值是该类型所有回调的名单;Broadcast 按事件真实类型查字典、挨个调用。它比"字符串事件名"(Broadcast("OnEnemyKill"))优雅在哪:拼错类型名编译期就报错;拼错字符串只有运行时悄悄失效——巨坑。这叫类型安全。
真实链路(击杀任务 → 胜利,4 个互不相识的系统全靠邮局转信):
EnemyController.OnDie → EnemyManager.UnregisterEnemy
→ EventManager.Broadcast(EnemyKillEvent)
→ ObjectiveKillEnemies.OnEnemyKilled (任务系统:击杀计数+1)
够数 → Broadcast(AllObjectivesCompletedEvent)
→ GameFlowManager (结算胜利)
同时 → Broadcast(ObjectiveUpdateEvent)
→ NotificationHUDManager / ObjectiveHUDManager(UI 刷新任务面板)
玩家死亡链同理:PlayerCharacterController → Broadcast(PlayerDeathEvent) → GameFlowManager(结算失败)
五、两套机制对比:什么时候用哪个
| 组件事件(UnityAction 字段) | EventManager 总线 | |
|---|---|---|
| 订阅方式 | 拿到对方组件引用再 += | AddListener<事件类型>(),不需要认识对方 |
| 范围 | 点对点(同一物体 / 我找到的那个物体) | 全局(跨场景、跨系统) |
| 典型用户 | 同物体各组件、AI 内部 | 任务 ↔ 流程 ↔ UI |
| 信封 | 直接传参数(伤害值、来源) | 传"事件对象"(可打包多字段数据) |
六、守则与坑
- 订阅在 Start/Awake,退订在 OnDestroy——EventManager 是
static,字典活过场景重载!不退订的话,下一局名单上还躺着已销毁物体的"空号",一广播就空引用。项目里 GameFlowManager、ObjectiveKillEnemies 全都 Add/Remove 严格配对,回主菜单还调Clear()清空。 ?.Invoke()不是装饰:名单没人订阅时是 null,裸调Invoke()直接崩——组件事件没人保证"一定有订阅者"(比如大脑脚本没挂时 onDamaged 就是空的)。+=两次会被调两次:普通 UnityAction 没有防重复机制(EventManager 里的 s_EventLookups 映射表就是专门防这个的)。订阅代码别写进 Update,放 Start 只跑一次。- 事件订阅天然抗"换装":事件靠代码
+=连线,换模型/换组件重跑 Start 自动重连;而 Inspector 拖的序列化引用(如 RandomHitSparks)换装就丢——两种连线方式的本质区别(FPS 项目 9-12 换士兵模型丢引用的坑正是活例子)。
Health.OnDamaged → EnemyController.onDamaged 的"两次广播",就是本篇例 2 转播链 + 例 1 多订阅的实战现场。RPG 项目以后做"任务系统/成就/UI刷新"这种跨系统通信,直接抄 EventManager 这套类型安全总线的思路。