Unity 学习笔记 · 问答集

一边看教程一边问,问过就记下来——每次的"问与答"按时间顺序收录,方便翻查。

开始于 2026-09-14 | 已收录多项目问答(RPG 教程 / FPS Microgame…)

第 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 hitInfoout 表示"这个参数由函数来填"。检测没撞到时 hitInfo 里是垃圾数据,所以必须先看返回值
hitInfo.transform.GetComponent<EnemyCtrl>()撞到"物体"不等于撞到"敌人"——去被撞物体身上找 EnemyCtrl 这个组件。像查身份证:有这个脚本的才算敌人
TakeDamage(5)通知敌人:"你扣了 5 点血"
EndCast() + return关特效、结束施法动作;return 跳出本帧剩余逻辑,防止技能结束后还继续跑

四、两个小白容易卡住的点

1. 为什么用 SphereCast(球)不用 Raycast(线)?

Raycast 是一根无限细的激光——擦着敌人衣角飞过去就检测不到,玩家会觉得"我明明打中了!"。SphereCast 是一根"粗 1 米的管道",火球本体多大、检测范围就多大,命中判定宽松,手感好。

2. 为什么撞到墙不会误伤 / 报错?

球形检测半径 1 米,很容易蹭到地面或墙壁。蹭到时 GetComponent<EnemyCtrl>() 返回 nullif (enemyCtrl != null) 把它筛掉——这个判空不是保险丝,是正常流程的一部分,每帧都可能走到。

五、资深视角把关(照抄前必看)

问题说明建议
⚠ 检测距离是"无限远" 这个 4 参数重载的 maxDistance 默认是无限。发射第一帧只要正前方直线上(管道内)有敌人——哪怕 30 米外——立刻命中结束,火球根本没机会飞过去 补第 5 个参数:SkillInfo.MoveSpeed * Time.deltaTime + 0.5f,只查"这一帧飞过的这段路"(业内叫"扫掠检测",帧间穿透也顺便解决了)
伤害写死 5 不读表,所有技能都扣 5 点 后续给 skill.jsondamage 字段,读表扣血
TakeDamage 还不存在 项目里的 EnemyCtrl 是空壳,直接抄会编译报错 视频后面几集会补;或先加个最简单的版本
截图里没看到 break; C# 规定每个 case 结尾必须有 break/return,否则报错 CS0163 可能只是截图截掉了;照抄报错就在 } 前补一个
移动对象不一样 视频版动的是 _effect(特效本体);项目重构版动的是技能根物体 transformTranslate 整合时二选一,别两边都加,否则一帧飞两倍距离

六、和现有项目(重构版)的关系

现在 SkillCtrl.Update() 对应位置是这样,只有"飞"、没有"撞":

if (SkillInfo.SkillMoveSort == SkillMoveSortEnum.直线向前)
{
    transform.Translate(Vector3.forward * SkillInfo.MoveSpeed * Time.deltaTime);
}

视频这段就是往里补"撞到敌人 → 结算伤害 → 提前结束"。

第 2 问除了球体检测 Physics.SphereCast,还有什么检测方式?

记录于 2026-09-14

这些方法虽然多,但本质就四类,记住一个口诀就行:

口诀:Cast 问"路",Overlap 问"地盘",Trigger 让引擎"盯梢",量距离是"土办法"。

(当时项目里一次物理检测都还没用过——视频这段 SphereCast 将会是第一个。)

一、Cast 家族——问"路":"从这里往那边走,路上撞不撞?"

和 SphereCast 同族,都是主动出击:沿一条路径"扫"过去,返回撞到的第一个东西。

方法扫过去的形状打个比方典型用途
Physics.Raycast一根细线激光笔鼠标点地面/点敌人、枪械、视线判断
Physics.SphereCast粗管道滚保龄球火球、箭矢(第 1 问讲的这个)
Physics.BoxCast长方体推着大纸箱扫过道宽体攻击(横扫一片)、车辆预判
Physics.CapsuleCast胶囊拖着胶囊药丸扫自带"长度"的子弹(弹体比球长时)
Physics.LinecastA→B 两点连线两点间拉根线,看断没断"我和敌人之间有没有墙"(AI 视线)
Rigidbody.SweepTest自己的真实模型拿本人的模型去试撞移动前预判"这么走会不会撞墙"
  • Raycast(激光笔):最基础也最常用。同样有"无限射程"坑。缺点:擦着敌人衣角飞过去就检测不到,玩家觉得"我明明打中了"。
  • Linecast:就是"只问一次、不问多远"的 Raycast——A 点到 B 点之间有没有障碍物,是怪物 AI 判断"能不能看见玩家"的标准姿势。
  • 同族两个后缀认识一下就行:XXCastAll(一次拿回所有撞到的东西,不只第一个);XXCastNonAlloc(结果填进自己提前开好的数组,每帧都跑的检测用它,不产生垃圾、不卡顿)。

二、Overlap 家族——问"地盘":"此刻,这一块区域里有谁?"

不走路,就停在原地清点:以某个点为中心、某种形状为范围,把这一瞬间在里面的所有东西列出来

方法问的问题典型用途
Physics.OverlapSphere以这点为圆心、半径 R 的球里现在有谁?爆炸/AOE 伤害结算——炸弹一响,全场谁在圈内全查出来挨个扣血
Physics.OverlapBox / OverlapCapsule这个盒子/胶囊里有谁?持续范围伤害、矩形判定
Physics.CheckSphere这块区域里有没有东西?(只回答是/否)出生点防重叠、检查"这个格子能不能放东西"
Cast 和 Overlap 最核心的区别:Cast 问"我飞过去会撞到谁"(技能在动);Overlap 问"此时此刻这块圈里有谁"(技能已经炸了,就地清点)。

三、事件家族——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 视线(有没有墙挡)LinecastRaycast
鼠标点地面选落点Raycast(相机发出射线)数学 Plane
配套必学两个提醒:
  • LayerMask(层级过滤):上面所有 Physics.XXX 都支持"只查指定层"参数。给敌人设 "Enemy" 层,检测时只查这一层,就永远不会蹭到地面/墙,连判空都省了,性能还更好。
  • 以后做 2D 游戏,整个家族有镜像版 Physics2D.*,方法名、思路一模一样,3D 学会了直接平移。

第 3 问场景 → 选型速查表(完整版:把所有常用的伤害检测都列全)

记录于 2026-09-14

全表贯穿两条铁律,所有"为什么首选它"都是从这两条推出来的:

铁律一:动得快 → 用 Cast(怕"隧穿"漏检);动得慢/不动 → 随便选。
铁律二:在飞的问"路";瞬间/就地结算的问"地盘";被动等人撞的挂"铃铛"。

一、子弹 / 飞行物类(在飞,问"路")

场景首选备选关键点 / 坑
慢速火球、魔法弹(RPG 常见)SphereCastTrigger 碰撞体视频这套。记得传 maxDistance,否则无限射程
高速步枪子弹(FPS)Raycast(瞬发 hit-scan)CapsuleCast快到看不见弹道,干脆一帧问完整条线
箭矢、投矛CapsuleCastSphereCast弹体比球"长",胶囊更贴形;maxDistance = 本帧移动距离
追踪导弹(贴脸跟着飞)Trigger 碰撞体SphereCast本来就慢慢追,不隧穿,Trigger 最省事
穿透弹(一枪穿一排)RaycastAll / SphereCastAll路径逐段 OverlapSphereAll 后缀:一次拿回撞到的全部,不是只有第一个
散弹枪(一次一片)Raycast × N 条(扇形随机)每颗弹丸一条射线,各自结算
激光炮(持续照射)Raycast + 每帧/定时结算射线本身不动,动的是"打谁"

二、近战类(就地结算,问"地盘")

场景首选备选关键点 / 坑
单刀挥砍(扇形范围)OverlapSphere + Vector3.AngleBoxCast标准套路:先圈出身边所有人,再筛"在我面前 45° 内"
直刺、突刺BoxCastCapsuleCast细长判定盒,方向即出枪方向
全身横扫 360°OverlapSphere不用筛角度,圈到就中
刀刀入肉(判定跟武器动画走)武器模型挂 TriggerSphereCast判定范围所见即所得;快挥仍会隧穿,快刀补 Cast
重锤砸地、跳劈落点 OverlapSphere落地帧定点清点,和飞行物无关了
格挡 / 招架判定武器 TriggerBoxCast双方武器都要有碰撞体

三、范围 / 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视线起点用"眼部",终点用"胸口",别用脚
巡逻会不会撞墙SweepTestCapsuleCast用自己真实碰撞体形状试走一步
前方有没有悬崖/台阶SphereCast 向斜下方Raycast从脚尖往斜前下方探
声援/仇恨范围(谁进圈我就打谁)OverlapSphere 定时清点TriggerAI 决策用,0.2~0.5s 一次足够

六、Buff / 光环类

场景首选备选关键点 / 坑
光环(周围友军持续回血/敌军减速)OverlapSphere 定时清点TriggerStay同持续伤害区:定时器降频是命门
荆棘/反伤(近身才触发)TriggerOverlapSphere事件型最直观
装备特效"命中时溅射周围"命中点 OverlapSphere主命中用上面的方式,溅射就地清点

七、高频组合套路(真实项目都是"组合拳")

  • 近战一刀 = OverlapSphere(圈人)→ Vector3.Angle(筛扇形)→ 逐个 TakeDamage
  • 爆炸箭 = 飞行段 SphereCast(问路)→ 命中点 OverlapSphere(问地盘)→ 二段伤害
  • 激光炮 = Raycast(拿最远命中点)→ 命中点 OverlapSphere(小范围溅射)
  • 本项目的指向性技能(以后升级)= 点选时 Raycast 选敌 → 飞行段瞄准 target Transform → 近身 SphereCast 触发命中 → OverlapSphere 溅射

八、查到目标之后的三件套(全场景通用)

工具说明
LayerMask检测时只查 Enemy 层——不蹭地、不蹭墙、少判空、还更快
NonAlloc写在 Update 里的每帧检测,用 NonAlloc 版本防 GC 卡顿
计时器降频OverlapSphere 这类清点式检测,0.2~0.5 秒一次足够,不必每帧
标记的是这个 RPG 项目按当前设计大概率会走到的路径。

第 4 问修改动画剪辑的方法

记录于 2026-09-14

背景:想调敌人射击状态动画的姿势(比如攻击时身体往右转个角度)。当时讨论了三档做法——A 静态旋转模型(最快)、B 写脚本按状态动态转(推荐)、C 直接改动画剪辑本身(正统动画师做法,本篇讲它)
⚠ 第一步先避坑:别复制 FBX 文件! 直接复制 Soldier@combat_shoot.FBX 得到"xxx 1.FBX"是走不通的:复制 FBX 等于把整个模型文件(含几十个动画)原样再导入一遍,里面的剪辑依然是导入动画,照样只读不可编辑,白占空间还容易在搜索时认错剪辑。正确姿势是复制"剪辑子资产"。

一、把导入剪辑变成"可编辑副本"

  1. Project 窗口搜索框输入 combat_shoot_burst → 找到 Soldier@combat_shoot_burst.FBX → 点左边小箭头展开 → 里面有个叫 combat_shoot_burst剪辑(注意:选中的是剪辑,不是 FBX 本身)
  2. 选中那个剪辑 → Ctrl+D
  3. Unity 在同目录生成 combat_shoot_burst 1.anim —— 从这一刻起它是独立资产,关键帧可编辑(原来的 FBX 剪辑依然是只读的)

二、让动画状态用这份副本

  1. 双击进 Enemy_Soldier.prefab(Prefab 模式)→ 选中 SoldierModel → 打开 Animator 窗口
  2. Shoot 状态 → Inspector 的 Motion 槽位:把原来指向 FBX 内的 combat_shoot_burst 换成刚生成的 combat_shoot_burst 1

三、编辑姿势(重点也是难点)

  1. 仍选中 SoldierModel → 打开 Animation 窗口(Window → Animation → Animation)→ 顶部剪辑选 combat_shoot_burst 1 → 时间轴拖到想改的帧
  2. 点亮录制红点 → 在 Hierarchy 展开 SoldierModel 的骨骼,找到想动的骨头(比如 Bip001 Spine 脊柱)→ 在 Scene 视图旋转它 → 时间轴自动打关键帧
  3. 拖时间轴前后检查姿势变化 → 满意后保存 Prefab → 进 Play 验证

四、三个必须提前知道的"现实"

现实说明
人形剪辑的曲线是"肌肉值" 这个 rig 是 Humanoid,Animation 窗口里骨骼属性显示为肌肉曲线,不是普通 Transform 旋转,改起来比想象中别扭。实用守则:只改一节脊柱(Spine/Spine1)、只在两三个关键帧上改、幅度小步试。改四肢很容易出鬼畜姿势(肌肉系统会"纠正"你的直觉)
姿势是"长死"在剪辑里的 不管玩家在楼上楼下、远近高低,这个角度都固定不变——这是 C 和 B(脚本动态转)的本质区别。想"动态"只有 B 能做到
改的是资产 这份 .anim 所有引用它的士兵通用——正好,全体士兵姿势统一;但原版 FBX 内的剪辑不受影响(两者是不同资产)

五、怎么选 B 还是 C

需求选哪个
"整体往右转一个角度"(动态、可调、只攻击时生效)做法 B(脚本)才是对的工具:15 行代码、角度随时拖、平滑插值。C 做不到"动态"
"射击姿势的形状不对"(手的位置、头的朝向、想低头瞄准)这才是 C 的主场:把姿势改在剪辑里
两个都想要不冲突,可叠加:B 管整体动态旋转,C 管姿势形状
快速核对表(改完自查):复制的是剪辑(Ctrl+D 后缀 .anim)而不是 FBX | Shoot 状态的 Motion 指向新副本 | 录制红点亮着才打得进关键帧 | 只动脊柱别动四肢 | 改完保存了 Prefab。如果发现人形曲线编辑太折磨——随时切回 B,两者不冲突。

第 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

前情提要:本篇来自 FPS Microgame 项目(Tuanjie 2022.3,非本笔记的 RPG 教程项目,但伤害检测思路通用——正好和第 1/2/3/5 问串起来看)。此前已经逐行拆过 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.HandleShootInstantiate 克隆式:克隆 MuzzleFlashPrefab 挂枪口,2 秒后 Destroy
子弹飞行弹道拖尾子弹预制体自带 TrailRenderer预制体资产配置,代码零参与
命中瞬间弹着点火花ProjectileStandard.OnHitInstantiate 克隆式:克隆 ImpactVfx 贴着命中面,寿终 Destroy
敌人挨打受击火花 ×NOnDamaged 广播链预置 Play() 式:激活预置在敌人身上的粒子组件,零克隆
敌人挨打身体闪白渐隐EnemyController.Update只记时刻+渐变取色,MaterialPropertyBlock 写自发光(不实例化材质)
敌人死亡爆炸特效EnemyController.OnDieInstantiate 克隆式:DeathVfx,5 秒后 Destroy

三、特效的两种玩法(关键区别)

玩法一:Instantiate 克隆式玩法二:预置 Play() 式
代表枪口火光、弹着点火花、死亡爆炸受击火花 RandomHitSparks
原理每次触发克隆一个新特效物体,Destroy(vfx, N秒) 定时自毁特效物体一直摆在敌人预制体里当子物体,平时不播;挨打时 Play() 点亮
开销每次克隆有少量 GC(官方示范项目不在乎这点)零克隆、零 GC
代价用哪都行,打完即走要提前把粒子摆进敌人预制体 + 填进 RandomHitSparks 数组(换装/换脚本时最容易丢这步引用)

四、链路里的三处巧思(值得细品)

  1. 子弹靠"球体扫射"防穿透:子弹不走物理引擎,每帧 Update 手动平移,用 Physics.SphereCastAll 从上一帧位置向这一帧位置"扫"一个半径 Radius 的球——哪怕一帧飞 2 米,中间的墙也照样扫得到(这正是第 1 问 SphereCast 的实战应用)。
  2. 轨迹修正:第一人称枪口其实在屏幕右下角,但玩家瞄的是屏幕中心——子弹出生后在 TrajectoryCorrectionDistance 米内"悄悄漂移"到摄像机正对的延长线上,玩家完全察觉不到。贴脸开枪时还有一条摄像机→枪口的射线预判,命中直接当场结算。
  3. 两次事件广播实现解耦Health.OnDamaged(血量系统喊一嗓子)→ EnemyController.onDamaged(基类转播给大脑脚本)——子弹和敌人从头到尾互不认识,靠 Damageable / Health 这几个"中间人"传话。这就是这套架构能同时服务玩家、士兵、悬浮机、龙 BOSS 的原因。

五、四层伤害管线(和 RPG 项目对照)

子弹 OnHit → Damageable.InflictDamage(部位倍率加工) → Health.TakeDamage(扣血+广播)
                ↑ 相当于 RPG 项目里 EnemyCtrl.TakeDamage 的角色拆成了两层:
                  Damageable 管"伤害怎么算",Health 管"血量账本+广播"

以后想在 RPG 里加"爆头 ×2",对应的位置就是 Damageable.DamageMultiplier 这一层——伤害管线的分层就是为这种扩展预留的。

复习挂钩:第 1 问的 SphereCast(这里成了子弹的防穿透检测)| 第 2 问的"Cast 问路"(子弹在飞,问路)| 第 3 问速查表的"高速飞行物首选 Cast"(FPS 子弹正是这么做的)| 第 5 问的示例代码几乎就是这条链路的 RPG 版雏形。

第 7 问广播事件、订阅事件的机制原理(项目实例版)

记录于 2026-09-15

前情提要:来自 FPS Microgame 项目(跨项目收录)。第 6 问的调用链里反复出现"事件广播"(onDamaged?.Invoke()Health.OnDie),本篇把这套机制一次讲透——原理 + 该项目里真实存在的两套用法:组件事件(点对点)和 EventManager(全局事件总线)。
一句话原理:事件 = 一张"方法订阅名单"。+= 登记、-= 划掉、Invoke 挨个打电话,?. = 名单空就不打。发布方和订阅方编译时互不认识,靠名单在运行时连线——这就是"解耦"。

一、原理:委托 = 一张"订阅名单"

C# 的委托(delegate)类型,本质是"能装方法的容器"——可以装一个,也可以装一串。事件机制就是围绕名单的三件事:

public UnityAction<float, GameObject> OnDamaged;  // ① 声明名单:只装"带(伤害,来源)两个参数的方法",出生 = null
...
OnDamaged += OnDamaged;          // ② 订阅:把我的方法登记到名单尾
...
OnDamaged?.Invoke(20f, player); // ③ 广播:按登记顺序挨个调用;?. 防空名单崩溃
  • UnityAction<参数> 是 Unity 提供的现成委托类型(等价 C# 的 Action),尖括号限定名单上方法必须长什么样;
  • 一张名单可以登记多个方法 → "广播一次、多方收听";
  • 发布方不需要知道谁在听——想听的人自己 +=。以后加新表现,新脚本订阅即可,发布者一行代码不用改。

二、机制一:组件间事件(点对点广播)

项目做法:把名单声明成组件的公开字段,谁想听谁在 Start 里 +=。全项目"广播台"清单:

广播台(声明处)事件真实订阅者
Health.csOnDamaged / OnHealed / OnDieEnemyController、SoldierDeathAnimation、PlayerCharacterController、Destructable、EnemyTurret、UI红闪
DetectionModule.csonDetectedTarget / onLostTargetEnemyController
EnemyController.csonAttack / onDamaged / onDetectedTarget / onLostTarget各"大脑"(Soldier/Dragon/EnemyMobile)、罗盘UI
WeaponController.csOnShoot / OnShootProcessed武器特效组件
PlayerWeaponsManager.csOnSwitchedToWeapon 等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 换士兵模型丢引用的坑正是活例子)。
复习挂钩:第 6 问调用链里 Health.OnDamagedEnemyController.onDamaged 的"两次广播",就是本篇例 2 转播链 + 例 1 多订阅的实战现场。RPG 项目以后做"任务系统/成就/UI刷新"这种跨系统通信,直接抄 EventManager 这套类型安全总线的思路。