#3 ue5 c++

快速跳转:
标准C++
从欧拉角到四元数
数据结构

目录


地编

在UE5.6中创建一个游戏-空白-蓝图 命名为Slash

点击窗口上方的绿色三角 开始模拟 点一下视口 进入PIE模式 play in editor 在编辑器里玩游戏 现在就可以用WASD进行移动 E上升 C下降 按shift+F1就可以退出视口 找回鼠标 按红色正方形就停止模拟 也可以按esc

现在我们不是模拟模式 不能移动任何东西 但是我们可以一直按着鼠标左键拖动 就可以前后左右移动
但我们不能调节俯仰角 在之前的模拟模式下 一直按着鼠标左键就可以调节俯仰视角 现在我们需要按着鼠标右键移动来调节俯仰视角 但是我们所在的位置并没有发生任何变化
同时按鼠标左右键 就可以上下左右移动 但不会发生前后移动
最好的办法当然是一边按鼠标按键 一边按WASD进行移动 按C放大 Z缩小 都是聚焦于鼠标所在之处的视野 E上升 F下降 ctrl+鼠标滚轮也可以放大缩小
右上角那个旁边有数字的摄像机图标可以调节摄像头速度 我们将其调节为3

视图右上角点击眼睛图标 最上边视口统计数据 FPS就可以查看帧率
再往下 随便开关一个复选框比如大气或者地形 就可以看到一些变化
注意到碰撞是没有被勾选的 尝试勾选 发现CPU占用率就开始提高 看到物体周围会有一些蓝紫色轮廓线 这个轮廓显示的是它们的碰撞体积 碰撞体积让引擎的物理计算更高效 碰撞体积越简单 计算越快 游戏性能越好 所以往往是比较简单的形状
再往下翻 尝试启用或禁用一些后处理与光源类型

仍然是视图右上角 光照按钮 可以切换很多模式 无光照模式就是灰色的 线框模式是呈现几何结构 可以看到每个网格体中多边形的每条边 调节一下俯仰 就会发现无论是地底还是天空 都是圆形 这也就是天空球 完全是球体
1.png

切换回光照模式 再看另一个按键 透视按钮 可以切换各种正交模式 通过鼠标滚轮缩放 这样就可以根据网格精确摆放物体了
再往下翻可以看到书签功能 可以设置书签 按ctrl+1就是设置书签1 按键盘上的数字1 就可以跳转到书签1 这些书签其实就是摄像机的位置
继续往下可以看到游戏视图 快捷键是G 开启之后 一些莫名其妙的灰白色图标就消失了 只显示游戏中会出现的物体
继续往下 可以看到高分辨率截图 左上角的方形可以选择位置 点击捕获 它就存储在了Slash\Saved\Screenshots\WindowsEditor文件夹里 上面的图片就是用这个功能截图的 虽然我也不认为这到底哪里很高清

最右上角的田字格 点击就可以变成4个窗格 每个窗格都有属于它自己的功能图标 这样就可以同时查看不同模式的 田字格左侧的三个点 点击就可以切换成不同的窗格排列格式
在下方还有一个沉浸式视图 就可以全屏显示 快捷键是F11 无论是模拟还是非模拟下都可以使用

要移动对象 我们首先要先有对象 鉴于我们的项目是空白的 现在就需要导入资产 去Epic Games的Fab里下载资产 为了拯救我们的C盘 现在需要修改一个缓存路径 打开UE的安装目录 找到Epic Games\UE_5.6\Engine\Config里的BaseEngine.ini 用记事本打开 搜索ENGINEVERSIONAGNOSTICUSERDIR%DerivedDataCache 将其替换为GAMEDIR%DerivedDataCache 就可以把缓存放在项目所在目录 接下来来到Epic Games的设置 找到下载与安装 点击转至下载设置 就会弹出download settings 下面的Advanced - Fab library Data Cache Directory 修改目录 总之放到非C盘的地方

然后去Fab里挑选喜欢的资产 Add to my library 然后去My library里 将它添加到项目里 这样我们打开UE 按ctrl+空格 或者鼠标点击左下角内容侧滑菜单 按右上角的停靠在布局中 就可以将其固定在视口下方 点×就可以直接关掉 现在我们将它关掉 以看到更大的视口 还可以拖动视口右侧边缘来放大

我们再次调出内容侧滑菜单 在 内容 文件夹里 就可以找到很多资产 静态网格体都是在Mesh文件夹里 随便拖动一个到视口

视口左下角有个x y z轴 我们旋转视角的时候 它也在旋转 让我们知道x y z轴的方向 蓝色为z轴 红色为x轴 绿色为y轴

视图左上角有4个图标 选择Q 平移W 旋转E 缩放R
点击选择 被选中的资产就会有黄色边缘 按delete就可以删除它
点击移动 这样就是选中并平移对象 在对象上会出现坐标轴 鼠标放在坐标轴上 坐标轴箭头亮了 这个方向拖动就可以移动 鼠标放在小方块上就可以同时选中两个方向 两个坐标轴箭头都会亮 按住shift的同时拖动 相机就会跟着选中的对象一起移动
旋转 缩放 都是类似 缩放数值改为-1 就是对应方向镜像
在移动模式下按alt 随便往一个箭头方向拖动 就能复制这个资产 按shift挨个点击就可以同时选中多个 这时再按alt 就可以批量复制 旋转模式下按alt也可以复制 发生旋转的是副本

发现我们在做这些操作时 是不流畅的 稍微右边的3个旁边有数字的蓝色图标就是调整这个的 都是网格吸附功能 吸附到网格 旋转固定角度 缩放固定增量 点按一下就关闭了 旁边的数字是每次能移动的距离 左侧紧挨着的灰色的图标是吸附到平面 数值可以调整它距离平面的默认距离

再左边那个旁边有3个点的圆地球图标 是切换世界坐标轴和对象自己的box的坐标轴 通常认为对象的局部x轴是它的前进方向 这样就可以决定到底是按世界坐标方向移动它 还是按它自己的朝向移动它

2.png

如果找不到自己想选中的物体 就可以去看视口右侧的大纲选项卡 可以发现光源、地面、出生点也在里面 在大纲视图里点击 就会发现对象在视口里被选中了 双击或者按F键 就可以在视口里聚焦到它 左侧的眼睛图标可以隐藏它 上方有搜索框可以搜索 按ctrl挨个点选就可以同时选中多个 按shift点选目标的第一个和最后一个 就可以选中这之间的所有对象 这时再按ctrl点击某个不想要的对象 就可以取消点选 这时再按delete 就可以一起删除

在视口里随便找个地方 右键 放置Actor 比如放置一个定向光源 就可以看到影子的位置改变了

大纲视图的下方 有细节选项卡
变换这一栏中 有位置 旋转 缩放 右侧三角箭头可以选择是绝对坐标还是相对坐标 再右侧可以修改数字进行调整 而在我们使用WER快捷键的3种模式进行移动时 这些数字也在发生改变 在修改数字时 按下tab 就会跳到右侧的下一个框继续进行修改 最右侧的弯曲箭头 是重置为默认
看变换上方有一些选项按钮 目前我们默认所在的是所有
如果点击比如物理 就是在 所有 中筛选成只保留 物理 相关的属性 现在我们把一个物体让它腾空 找到物理这一栏中的模拟物理 点击视口上方绿色三角进入模拟模式 我们会发现它发生了坠落 甚至可能发生倾倒与滚动 比如下面这棵树
3.png

在下方也可以勾选是否启用重力 现在我们挑选一个质量较轻的物体 将它置于空中 打开模拟物理 但是关闭启用重力 然后我们飞过去撞这个物体 它就会飘到远处 比如这个箱子
4.png

选中这个物体 按一下end 它就会吸附到地面上了

现在我们看整个UE编辑器的最右下角 大概会显示有什么未保存 或者我们直接按ctrl+S 保存当前关卡 建议在内容文件夹里创建一个名为Maps的文件夹 这样我们下次进入这个UE项目时 就可以点击左上角 文件 - 打开关卡 找到那个我们保存的关卡 来继续我们上次编辑的部分

5.png
试着练习了基本操作 使用了Fab商店里下载的 Stylized Fantasy Provencal、RPG Hero Squad PBR Ployart、Sallon Interior

上方绿色三角稍微左侧 有一个盾牌形状右下角是绿色加号的按钮 点击它 可以看到导入内容和Fab 也能看到Quixel Bridge 点击它 里面有很多扫描真实物体得到的素材 点击右上角人物头像 登录账号 就可以下载一些素材 但我们还是倾向于使用Fab

现在打开内容侧滑菜单 找到一个静态网格体 静态网格体图片下方 名字上方的线是蓝绿色的 这是它的颜色标识 双击这个静态网格体 会有一个弹窗 我们就进入了静态网格体编辑器 在此之前我们的视口一直都是关卡编辑器 按滚轮就可以对其进行缩放 比如我们可以在右边切换它的材质

开放世界

一般来说 大型世界都是分成好几个关卡 你只会加载一个关卡 角色在这里移动 角色到达边缘后 就加载下一个关卡 这样玩家到达下一个地图时 前一个地图就卸载了 现在UE支持开放世界 与其用很多小地图 不如用一张大地图 UE会把这个世界分成很多块 只加载玩家所在的那一块 这叫世界分区

现在点击左上角文件 - 新建关卡 - 空白开放世界 先把这个关卡保存一下 我们希望这个新的关卡成为打开项目时的默认关卡 点击左上角编辑 - 项目设置 左侧边栏 找到地图和模式选项 将编辑器开始地图和游戏默认地图都修改为我们刚刚新建的地图

创建之后就是一片漆黑 什么都没有 现在我们可以从零开始创建了 现在大纲视图里只有WorldDataLayers世界数据层和WorldPartitionMiniMap世界分区小地图

左上角点击 窗口 - 世界分区 - 世界分区编辑器 现在可以看到右下方细节选项卡旁边出现了一个名为世界分区的选项卡 我们将可以在这个小地图上看到我们整个世界的地图 并且看到我们在地图上的位置

天空

  1. 天空大气 sky atmosphere 和真实的大气层一样 如果有光源 它就会散射光线 产生各种美丽的气象效果 最多可以有两个大气光源 比如用两个光源分别模拟太阳和月亮
  2. 定向光源 directional light 模拟一个无限远处的光源 所有物体上的阴影都是平行的 我们将使用定向光源来模拟太阳

点击左上方那个盾牌右下角绿色加号按钮 选择放置Actor面板 我们直接搜索天空 或者在 更多 里找到天空大气 将它拖到视口里随便一个地方 然后再光源里找到定向光源 随便拖到一个地方 现在我们可以看到天空和太阳 我们还可以拖入一个定向光源 会提醒我们 多个定向光源正在竞争

必须要按下ctrl的同时移动 才能移动太阳 现在我们有两个太阳 可以通过更改它们的大气层太阳索引来区分二者 再大纲视图里找到DirectionalLight 在细节选项卡里搜索 大气太阳光 可以看到大气太阳光索引 现在显示为0 我们不动它 现在在大纲视图里找到DirectionalLight2 修改它的大气太阳光索引为1 天空中仍然只有一个太阳 这是因为如果按照之前那种移动位置的办法 我们只是拖动了光源 改变了它的坐标 却没有修改光的方向 需要按ctrl+L 同时鼠标移动 才会改变光的方向 不再是两个平行的光源 天空中终于出现两个太阳 但是ctrl+L只能移动DirectionalLight的方向 而对于移动DirectionalLight2 需要ctrl+shift+L
6.png
也可以完全不用快捷键 在大纲视图中选中那个光源 直接在细节选项卡 - 通用 - 变换 - 旋转 里修改旋转角度数值

可以发现 变换里还有一行是移动性 目前是固定 还有静态和可移动两个选项
光源的移动性设置决定了它在游戏中的行为和操作方式

  • 静态
    其照明效果在游戏中无法更改 无法更改该光源的位置 方向 强度 颜色 静态光源的计算速度最快 因为它们在游戏中不会发生变化 这也允许我们烘焙光照 如果某个静态物体的阴影永远不会改变 就可以把阴影和光照信息烘焙进场景 这样计算就在游戏开始前完成了 而不是在游戏里实时计算
  • 固定
    可以在游戏中改变颜色和亮度 但是位置和方向不能改变 这样能实现部分光照烘焙 照在不动的静态物体上的光 它的阴影就可以被烘焙 但是动态物体的阴影还是得跟着动
  • 可移动
    最耗费计算资源 可以在游戏中移动和改变属性 就像会移动的太阳一样 会投射动态阴影 无法烘焙

现在我们把这两个太阳都修改成可移动 顺便我们把这两个定向光源分别重命名为Sun0和Sun1 对应索引为0和1

  1. 天空光照 sky light 捕捉整个场景 并将捕捉到的光照信息应用到场景中 就像天空 山脉 和所有其它事物都在将光反射到场景中 这是获得全局光照效果的方式 天空光照只在特定条件下执行这些捕捉 取决于天空光照的移动性
    静态天空光照 它会在你构建光照时更新 对于静止和可移动的天空光照 它会在加载时更新一次 如果你手动捕捉也会更新 所以你可以运行一个函数来执行捕捉 现在有一个选项叫做实时捕获 如果你启用它 我们将持续执行此捕捉 所以当我们的场景发生变化 例如太阳升起落下或者其他变化时 天空光照将持续更新 执行此捕捉并将光线应用到场景中

在放置Actor面板里搜索天空 或者在光源选项卡里找到 天空光照 将其拖入视口 在细节- 通用 - 变换里 将它设置成可移动的 稍微往下翻 在 光源 里 可以找到实时捕获 将其勾选上

现在就可以实现昼夜循环了 天空光照会随着天空中的光源变化而自动调整

  1. 指数级高度雾 exponential height fog 雾的浓度会随着高度变化 越低的地方雾越浓 模拟的是气压 指数高度雾可以设置两种颜色 一种用于星球向阳面 一种用于背阳面
  2. 体积云 volumetric clouds 以前 云只是天空球体网格上的材质 但现在我们可以拥有体积云了 它是动态的三维的 并且由材质驱动 所以你可以改变材质 云也会随之改变 它们还会向大气一样散射光线 所以我们可以获得一些非常酷炫的效果 让光线穿过这些云

我们搜索指数级高度雾 或者在 更多 - 视觉效果 里找到指数级高度雾 将其拖入视口
7.png
注意到下方不再是黑色了 而是填充成了蓝色 在细节选项卡里 我们可以修改雾的密度 雾高度衰减

在 更多 - 视觉效果 里找到体积云 拖入视口

现在我们选中一个太阳 将其旋转方向到地平线附近 在细节选项卡里找到使用色温并勾选 然后修改温度 这样就会呈现不同的颜色

我们在大纲视图里新建一个名为sky的文件夹 把刚刚添加的所有这些关于天空的Actor都移动到里面 按住ctrl可以批量选中 顺便把它们的x y都修改成0 同时把z轴坐标拉开一些距离 方便我们找到它 同时也方便我们通过聚焦它来找到世界的中心 当然也可以通过双击世界分区上的某个位置来快速跳转
8.png

地形

项目名称下方 我们现在是选择模式 接下来切换到地形模式

在左侧面板 将组件数量修改成16×16 当然这个数字是随意的 然后点击下方的创建 创建的时候一定要选一个材质 这样才能看到地面

看左侧面板 我们现在处于雕刻模式 这种状况下鼠标就不能轻易使用了 于是我们最好使用快捷键shift+1 切换到选择模式 这样就可以摆放各种物体 选择模式下 我们点选地面 发现是选中了一个格子 在大纲视图下是定位到了LandscapeStreamingProxy一串数字 这代表的就是一个组件 而我们之前创建了16×16个这样的组件 选中它时 按下delete 就可以删除

9.png
圆形笔刷 笔刷衰减的意思就是 内圆和外圆之间的差距 其余笔刷的各种功能 请自行体会

10.png
选择了草地材质创建地形 试着使用雕刻模式下的星星图标Alpha笔刷雕刻了

材质一般都是和网格体绑定在一起 我们随便拖入一个静态网格体 静态网格体都是SM_一串字母命名的 在大纲视图里 右键 浏览至资产 双击这个静态网格体 进入静态网格体编辑器 就可以查看它的材质 或者在细节面板 往下翻 就能看到它的材质 如果是复杂的静态网格体 比如房子 它应该有多种材质 这些很多的材质都是MI_一串字母 这是基于M_材质创建的一个材质实例 我们在内容浏览器里搜索M_ 随便找到一个材质 右键 引用查看器 就能知道到底有哪个静态网格体使用了这个材质 我们先把使用了这个材质的静态网格体 随便选一个拖入视口中
11.png

双击这个材质 就可以看到它的各种参数
12.png

我们可以把这个弹出来的编辑器选项卡 拖到我们的关卡旁边 从此之后 我们再点击静态网格体或者材质 它都不会是一个新的弹窗 而是作为选项卡 出现在我们的关卡选项卡旁边 我们点击Texture Sample里的方形纹理贴图 它就会在内容管理器里 以文件夹视图显示
13.png

我们回到那个材质 按住alt键 然后鼠标左键点击纹理采样的RGB输出节点 就能看到没有这个贴图时的样子
14.png

点击左上角的应用 所有使用这个材质的东西就会都发生变化 可以回到关卡里查看一下差异
15.png

再刚才点击的RGB那里 鼠标左键拖拽连接到基础颜色 就重新连接上了

我们整个地形都是完全相同的草地材质 之前我们用的材质都是从静态网格体借用的 现在去Fab里专门下载一些适用于地形的各种材质

16.png
现在打开的这个是雪地材质 这种蓝紫色的就是法线贴图 名字常常是一串字母_normal 它是管理材质的凹凸不平的

现在 我们在内容文件夹里 创建一个名为landscape的文件夹 顺便整理一下我们之前导入的资产 如果想要移动资产的位置 请务必在UE里移动 而不是在windows文件资源管理器里移动 会发生混乱
17.png

进入Landscape文件夹 右键新建一个材质 命名为M_Landscape 然后双击进入材质编辑器 左侧细节面板 往下翻 可以找到一个名为完全粗糙的复选框 因为地面是没有反射性的 它没有光泽和闪光 应该是粗糙的

我们现在主要关心基础颜色和法线 其实我们可以导入纹理作为基础颜色 或者 导入一个法线贴图 连接上就可以了 但我们希望地形可以使用多种材质 就需要使用图层 在材质图表的空白处右键 搜索Landscape Layer Blend

左侧细节选项卡出现了 材质表达式地形层混合 图层 0数组元素 点击右边的加号 这样我们就添加了一个数组元素 点击 索引[0] 左侧的小三角 就可以展开 看到这5个成员 在材质列表里现在显示的是Layer None 这是因为我们在细节面板里命名的图形名称为None
18.png

我们将其重命名为Snow 不要使用空格 混合类型可选权重混合、透明度Alpha混合、高度混合 我们使用权重混合来混合这个材质层和其它图层

回到内容侧滑菜单里 找到一个想要的纹理 拖拽到M_Landspace的选项卡上 然后放到材质图表里 将纹理的RGB连接到Landscape Layer Blend上 再将Layer Blend连接到基础颜色上
19.png

现在我们需要添加法线纹理 选中Layer Blend 按ctrl+D直接复制这个图层列表 或者按ctrl+C再ctrl+V也可以 同样是连接上 再将图层连接到法线上 我稍微更换了一些材质 调整了布局 增加了AO和Normal纹理 如下图
20.png

现在把我们的Landscape的材质切换为刚才我们编辑的材质M_Landscape 然后切换到地形模式 - 绘制 - 目标层 - 层 点击右侧倒数第二个像地球一样旁边有箭头的图标 如下图中黄色高亮部分 从指定的材质创建层
21.png

然后可能还是没有任何变化 重新打开UE 先把地形材质换成其它的 再换回来 就会出现 如果还没出现 就再重启UE
22.png

点击Grass1右边的加号 创建层信息 会让我们选择 是权重混合层 还是非权重混合层 权混合图层会彼此混合 当我们在一个图层上绘制另一个图层时 它们会混合在一起 非权重混合层不会混合在一起 是一层一层叠加的 比如泥土上的雪 不会混合在一起 而就是呈现出 雪覆盖在泥土之上 我们现在使用权重混合层 会出现一个弹窗 创建新地形层信息对象 我们就将其保存在Landscape文件夹里 也不需要修改名字 它默认的名字就是 Grass1_LayerInfo 我们对每一个图层都这样做
23.png

现在可以开始绘制了 先在绘制面板中右键Grass1选择填充图层 这样整个地图就都是这个材质了 选择材质和笔刷进行绘制就好

24.png

有时候我们打开地图时 发现之前做的东西都消失了 这时候打开世界分区选项卡 随便用鼠标选定框选一个区域 右键 从选择加载区域 这样我们做的东西就又出现了
25.png

为了方便地形的快速加载 我们可以打开M_Landscpae 选中所有纹理 都换成 共享:包裹 然后点击应用
31.png

植被

现在我们添加植被 从选择模式切换到植被模式 在资产中找到静态网格体植物 拖入+植被下方的空白处 使用静态网格体植物 就可以使很大数量的相同植物共享纹理的内存位置 效率更高 它们唯一不同的地方就是位置、旋转角度、缩放比例 我们也可以将静态网格体拖入其中 它会有弹窗 创建成为新的静态网格体植物

植物面板里的植被 左上方有复选框 按住ctrl或者shift可以多选 这时点击其中一个植物的复选框 其余被选中的都会跟着一起勾选 进入绘制模式 请尽量将绘制密度缩小 否则加载地图会很慢

对于静态网格体 我们可以在编辑器里打开它 找到它的材质 它的材质的参数组里有一栏为wind 我们可以将遥远的批量植被的风力关掉 优化性能 可以对会动的物体使用Nanite的 Nanite允许你使用极高多边形网格 而计算成本只是很小的一部分

26.png

如果想批量修改某些资产的属性 可以批量选择后 右键 - 资产操作 - 编辑属性矩阵中的选择 这个操作对于静态网格体和静态网格体植物都能使用
27.png

我们就使用这个功能 对于这几种树的静态网格体植物 打开属性矩阵编辑器 在右侧 固定列 面板中 搜索碰撞 将碰撞描述文件名修改为 BlockAll 现在双击某一种树的静态网格体植物 在弹窗中往下拉 可以看到碰撞预设现在是BlockAll 我们进入PIE模式 就能发现自己无法穿过这些树

后期处理特效

回到选择模式 点击左上方那个盾牌右下角绿色加号按钮 打开放置Actor面板 在更多 - 体积 里找到 后期处理体积 将其拖入视口 在细节面板 - 通用 里找到 后期处理体积设置 - 无限范围(未限定) 勾选它 这样就可以应用到整个关卡

后期处理体积可以改变场景的显示效果 在细节面板可以看到很多调色功能面板 颜色分级 电影 透镜 都可以尝试 透镜 - bloom就是光晕效果 会对场景中的太阳和灯光造成影响 透镜 - Exposure里的Min EV100和Max EV100 都是亮度

28.png
仅调节色温5500 global绿色饱和度3 bloom2.5 变化不明显

关卡实例

可以批量选中静态网格体 右键 - 关卡 - 创建关卡实例 也可以创建已打包关卡Actor 使我们将关卡实例打包到称为Actor的东西中 我们还没有正式讨论过Actor 但我们知道场景里的所有东西都是Actor 我们就选择创建已打包关卡Actor New Packed Level Actor 它让我们选择枢纽点类型 枢纽点的意思就是旋转中心 我们想让它怎么旋转 它会将旋转中心放在网格体最低高度的中心位置 我们就使用默认的中心最小Z 接下来就出现一个另存为的弹窗 我们在Maps文件夹内新建一个名为 PackedLevelInstances的文件夹 将这个关卡起个名字保存起来 之后又出现 资产另存为的弹窗 我们就将它存储在刚才保存的关卡旁边
29.png现在可以发现 大纲视图中那些零散的静态网格体消失了 而是整合成了一个蓝图类 现在它们可以整体地拖动 点击这个关卡 因为没有灯光所以漆黑一片 但是在大纲视图里可以看到组成它们的静态网格体 点击其中的某一个就可以看到轮廓 也可以从光照模式切换到无光照或者线框模式 看到它们 而在现在的这个小关卡内 我们就可以随意移动它们 修改结束之后 右键这个关卡 点击更新压缩蓝图 大关卡里的对应部分就会发生变化
不存储成蓝图 直接将其它关卡拖拽进现在的关卡也可以 但是就不能像蓝图一样能保持更新

双击我们刚刚保存的蓝图类 就会打开蓝图编辑器 我们进来的时候默认是 事件图表 选项卡 点稍微左边的 视口 我们就可以看到这个蓝图的图形 这就是Actor 它是由许多单独组件组成的 在左侧的 组件 选项卡中 可以看到组成它的组件的名字 这些都是关卡实例组件 比普通的静态网格体效率要高

回到我们的关卡 点选视口里的这个蓝图类 右键 - 关卡 - 中断 - 破坏关卡实例 这样它就变回了离散的静态网格体

现在去Epic Games里 下载Project Titan示例 我们将从中获取资产 顺便使用一些蓝图类的资产 继续地编
30.png

地图中的某个静态网格体或者关卡摆好了位置 可以在大纲视图对其右键 替换选中的Actor 在它原来的位置上替换为其它Actor 也可以在细节面板里切换
如果某个关卡导入后 拖拽时 不能使所有部件都一起移动 可以先移动其主要部分到达理想位置 再在细节面板 所有 - 关卡 里先换成None 再切换为我们导入的关卡 就会全都跟着过来了

如果有静态网格体的某个部分很多余 可以从选择模式切换到建模模式 选中静态网格体 在左侧面板中 选择 - 三角形选择 用笔刷在静态网格体上选中需要删除的部位 之后点击 网格体编辑 - 删除 最后点击视口下方的 选择三角形 - 接受 如果是想把这一部分分离出来 比如把门从房子上拆下来 成为独立的静态网格体 就点选 网格体编辑 - 分隔 还需要做一步改变旋转轴 选中门 在想作为旋转中心的地方右键 - 锚点 - 在此处设置枢轴偏移

UEC++

向量

向量 包含大小和方向 那么它是如何存储的呢 和二维坐标一样 也是存储在一个有序对中 x, y分别是顶点到终点在x轴或y轴上的距离之差

假设有一个角色 它有个坐标 敌人也有自己的坐标 假如有个AI系统 那么就需要知道敌人到角色的距离和方向 也就是敌人到角色的向量 才能追上角色 那么(x2-x1, y2-y1, z2-z1)就是向量 又比如角色要瞄准敌人 射出一支箭 那么就需要角色到敌人的向量 假设角色打偏了 枪声使敌人注意到角色 试图反击 此时就需要知道敌人到角色的向量 这是角色到敌人向量的反向向量 只有符号相反

向量也有数乘 加减法

假设有一个敌人 在空间中有坐标 并且占据一定范围的空间 也有一个角色 在某个坐标 假设这个角色有一个特殊攻击 比如火球 火球只能飞那么远 假设角色正瞄准敌人 那么就是需要角色到敌人的向量 但是向量的长度是只有那么远 还不足以打到敌人 假设这个角色得到了某种buff或强化 射程翻倍 这时候就需要向量数乘 ×2

我们可以用向量来表示一个东西的坐标 也就是从原点到这个坐标的向量 位置向量 坐标也用向量表示之后 就可以通过 一个东西的位置向量 一个东西到另一个东西的向量 使用向量加法 就得到另一个东西的位置向量

敌人要接近玩家 每个敌人都有指向玩家角色的向量 但是这个向量的长度要根据敌人的移动速度进行缩放 这就需要单位向量 再乘以敌人的移动速度 就是我们需要的向量 将向量除以向量的长度 就可以得到单位向量

UE使用的是左手系 但是物体也有它自己的坐标 也就是相对于世界坐标的那个local坐标系 规定物体的前进方向为x轴 z代表它的上方 而既然使用左手系 y轴就是它的右侧方向 那么绕y轴旋转就是改变它的俯仰角 pitch 绕z轴旋转是改变了它的偏航角度 yaw 绕x轴旋转是改变它的滚转角 roll

UE的Visual Studio推荐设置

工具 - 选项 - 项目和解决方案 - 常规 取消勾选生成完成时有错误则始终显示错误列表

工具 - 选项 - 文本编辑器 - 所有语言 - 滚动条 勾选使用垂直滚动条的缩略图模式

工具 - 选项 - 文本编辑器 - C/C++ - 高级 - 浏览/导航 将 隐藏外部依赖项文件夹 置为True

工具 - 选项 - 文本编辑器 - C/C++ - IntelliSence - 启用64位IntelliSence

工具 - 选项 - 文本编辑器 - C/C++ - 查看 将 显示非活动块 置为False
因为UE5建议我们使用实时编码 而不是热重载

工具 - 选项 - 调试 - 常规 - 启用热重载 取消勾选

工具 - 自定义 - 命令 - 工具栏 将下拉菜单修改为 标准 然后点击下方预览中的 解决方案配置 再点击其右侧的 修改所选内容 将宽度变更为200

如果涉及到清理Binaries Intermediate Saved文件夹 并且删除了.sln文件 右键.uproject - Generate Visual Studio project files 之后需要在VS打开这个.sln 并且在解决方案中把当前项目 本例中就是HelloWorld 右键 - 设为启动项目
这样之后 按Ctrl+F5 才能打开UE项目

UE 类的继承

UObject
  
AActor
  
APawn  
  
ACharacter

UE继承体系的顶层是名为UObject的类 UObject的实例称为object
UObject类派生出AActor类 称为actor
APawn类继承自AActor ACharacter类继承自APawn

所有继承自UObject但不是Actor的类 类名前都加U前缀 这样就能从类名看出来它不是Actor 而是一个UObject
所有继承自Actor的类 类名前都加A前缀

  1. UObject类 很基础 能存储数据 但不能自己放到场景里
  2. Actor类 继承了UObject类的一切 可以放进关卡里 所以在游戏关卡里看到的一切都至少是从Actor类及其子类开始 Actor可以有视觉表现 比如网格体
  3. APawn类 继承自Actor 它们可以被控制器controller操控 控制器是一种专门用来控制Pawn的Actor 当你按下WASD角色发生移动时 是因为控制器接收到了你的输入 进行处理 并将信息转化为角色向前移动的动作 对于那些需要根据某种输入移动的事物 无论是玩家按键移动鼠标 还是通过AI计算的输入 就用Pawn
  4. ACharacter类 继承自APawn类 因此也能被控制器操控 它还具有更多功能 更适合像人类这样的双足生物 它包含一个角色移动组件 负责各种与移动角色相关的计算 所以Character只是具有更多功能的一种特殊类型的Pawn 一般来说如果你需要一种非常基础的生物 只需要接收输入并四处移动 用Pawn就够了 但如果想要更多Character特定的功能 就要用Character了

我们把父类的名字命名为Parent类 子类命名为Child类 我们可以说一个Child类的实例同时也是一个Parent 它继承了Parent类的功能和变量 却不能说一个Parent是一个Child

假设你有一个类 它可以拥有自己的变量 这些变量中 有些可能是其它类或者结构体的自定义数据类型 所以你的类里可能有个成员变量 它本身就是另一个类的实例 我们把第一个类称为外层类 另一个类称为内层类 内层类也可以有它自己的变量 有一些变量的数据类型是来自于其它类和结构体的 所以内层类里还是有内层类

UE中 类就经常这样嵌套 游戏项目的顶层 有一个名为Package包的类 Package类里嵌套/包含着一个名为World世界的类 World类里嵌套/包含着一个名为Level关卡的类 现在一些obejct就放在关卡里 这些都是从Actor或者其更低的继承层级派生的类 所以Level包含Actor Actor有它自己变量 有些是Component组件 Component是Actor拥有的子类 它提供Actor可以使用的附加功能 所以Actor可以拥有一个或多个Component 有些object不像这些类一样放在Package里 这些通常是资源 比如meshes、textures、sound files等等
32.png

反射与垃圾回收

反射reflection是程序在运行时自检的能力 程序会分析自身内部的情况 然后收集程序数据 C++没有自带反射功能 但是UE有自己的反射系统来收集这些数据 这个反射系统负责把数据和UE编辑器系统整合 把部分数据暴露给蓝图 并用垃圾回收自动管理内存 不用的时候自动删除对象 这样就释放了内存

假设你动态分配了一个堆对象 又创建了一个指向这个对象的指针 指针变量一出作用域 指针就没了 在UE中 如果一个对象参与垃圾回收系统 垃圾回收系统就会追踪有多少个变量引用它 如果没有任何变量引用它了 这个对象就自动删掉了 在普通C++里 你需要自己释放内存 如果你遗忘了就会内存泄露 但是垃圾回收会自动删掉它 不需要我们手动delete了

要让类参与UE的垃圾回收 需要用特殊的宏去标记 来让反射系统识别它

#include "Fighter.generated.h"

UCLASS()
class AFighter : public AActor
{
    GENERATED_BODY()

    UPROPERTY()
    UStaticMesh SwordMesh;
    
    UFUNCTION()
    void SwingSword();
}

UE的类声明顶部有一个UClass宏 用于所有继承自Uobject的类 这样这个类就能参与反射系统 也就参与了垃圾回收
要让变量和函数也参与反射系统 就要分别用UProperty宏UFunction宏标记它们 这样我们才能把这些变量和函数暴露给蓝图
GENERATED_BODY宏 是UE头文件工具 Unreal Header Tool, UHT自动生成的
带括号的宏 有时可以像函数一样接收输入 这些输入叫作specifier说明符 可以改变这些宏的行为 所以我们可以自定义UPROPERTY宏和UFUNCTION宏 来控制它们如何把变量和函数暴露给反射系统和蓝图

这些宏会在编译时触发UTH去生成反射系统的代码 我们可以看到 使用了反射系统的类 在头文件顶部都会有一个特殊的include文件 这就是className.geneated.h文件 这个generated.h文件中就包含UE因为这些宏自动生成的代码 这样反射系统就能从这个类里获取信息

创建Actor

现在我们的项目是蓝图项目 任何蓝图项目 都可以通过创建第一个类转换成C++项目 点击顶部工具栏 工具 - 新建C++类 这里面有很多常用的类 鼠标悬停在上面就可以知道什么意思 但是UE里的类 远不止这些 点击所有类 就是所有的类

选择Actor 并点击下一步 类的类型可以选择public还是private 如果选择了public 就会发现下面的路径里 头文件在Public文件夹里 源文件在Private文件夹里
33.png

这时候就是按照visual studio的提示装一些插件 重新回到UE打开Slash项目时 出现了这样的问题
34.png点yes也无效 所以 建议在创建C++类之前先备份 这样随时就可以回到蓝图模式

总之我尝试多种办法仍没能解决上面的问题 这里其实应该是装了Visual Studio Integration Tool插件之后发生的问题 但我尚未解决 于是最终在.uproject中用文本编辑器禁用了这个插件

现在还是选择 新建一个新的C++项目 为了配合名为HelloWorld的地图关卡 将这个项目命名为HelloWorld 并把之前这个蓝图项目的content文件夹全部在UE编辑器里迁移过去 现在再去新建Actor就可以成功了

现在 收起内容文件夹 可以看到下方还有一个C++类文件夹
35.png

可以看到 父类是Actor 模块/module名是HelloWorld 我们的游戏项目是Visual Studio解决方案里的一个module module的相对路径是Public/Items/Item.h 双击就会转到Visual Studio打开这个C++类

在VS解决方案资源管理器可以看到
36.png

这几个.cs文件控制着项目包含哪些module 不用管它们 至于HelloWorld.cpp HelloWorld.h是项目自动生成的 里面只有头文件和宏

// Item.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Item.generated.h"

UCLASS()
class HELLOWORLD_API AItem : public AActor
{
    GENERATED_BODY()
    
public:    
    // Sets default values for this actor's properties
    AItem();

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public:    
    // Called every frame
    virtual void Tick(float DeltaTime) override;
};

我们现在逐行查看Item.h

// Item.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Item.generated.h"

如果想要继承某个类 就必须包含它的头文件 在Item.h里 则是包含了Actor.h的头文件 而Actor的头文件在Game框架里 这个generated.h文件就是前文提到的 它包含了使我们的类参与反射系统所需的所有代码 所以后面使用了UCLASS宏 这样AItem类就可以参与UE的反射系统

// Item.h
UCLASS()
class HELLOWORLD_API AItem : public AActor // 继承自AActor类
{

鼠标悬停在HELLOWORLD_API上 可以看到它展开后是__declspec(dllexport) 意思是这个类型可以被dll动态库使用 这是UE自动添加的 我们不用管它 创建新类时它都会自动存在

// Item.h 接着上一个代码块
    GENERATED_BODY()

编译时 这个GENERATED_BODY宏会被generate.h文件中的部分代码替换 从而增强这个Actor的功能 所以可以把我们的这个类理解为一个功能增强的普通C++类 它能参与UE的幕后工作 从而与引擎的反射系统连接 这样就可以做很多事情 比如可以基于这个类创建蓝图 并将属性创建给蓝图

// Item.h 接着上一个代码块
public:    
    // Sets default values for this actor's properties
    AItem();

protected:
    // Called when the game starts or when spawned
    virtual void BeginPlay() override;

public:    
    // Called every frame
    virtual void Tick(float DeltaTime) override;
};

public中 默认情况下已经有了一个构造函数 注释也告诉我们 构造函数会设置这个Actor属性的默认值
proctected中 包含一个虚函数void BeginPlay的重写 意思是BeginPlay是一个虚函数 继承自Actor类 我们正在重写它 注释说 游戏开始或者游戏中实体生成时调用
底部的public中 有一个名为Tick的函数 是虚函数的重写 接收一个名为DeltaTime的浮点数参数 注释说每一帧都会调用 所以tick函数会每秒钟调用多次

// Item.cpp

#include "Items/Item.h"

// Sets default values
AItem::AItem()
{
     // Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
    PrimaryActorTick.bCanEverTick = true;

}

// Called when the game starts or when spawned
void AItem::BeginPlay()
{
    Super::BeginPlay();
    
}

// Called every frame
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

在Item.cpp中 可以看到构造函数的定义
PrimaryActorTick.bCanEverTick = true;
注释说 设置这个Actor在每一帧调用Tick() 如果不需要就可以关闭它来提升性能 所以如果我们不需要这个Actor使用Tick函数 就可以把这个true设置为false
我们将鼠标悬停在PrimaryActorTick上 或者直接右键速览定义 可以看到它的类型是FActorTickFunction 这是一个结构体 在EngineBaseTypes.h文件中定义 包含名为bCanEverTick的bool变量 可以看到它的定义 uint8 bCanEverTick:1 意思是 本来这是一个uint8 无符号8位整数 通常占1字节 但是使用:1 这是位域 表示这个变量只占用1bit 而不是一整个byte 节省内存
所以不是所有的变量都直接在Actor类中声明 而是引擎将其打包成了结构体 这样Actor类就不会被变量和函数填满了 这就是UE将 类 按行为的类别 组织起来的一种方法

还有BeginPlay和Tick 发现它们的前面还有一个Super 这是作用域解析符 后面跟着函数名 这是UE的一个功能 意思是我们正在调用这个函数的父类版本 我们现在的Item类继承自Actor类 所以父类的版本就是Actor类的版本 我们现在调用的BeginPlay和Tick都是Actor类的版本

现在有两个public 所以我们可以整理一下 顺便把注释删掉一些

// Item.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Item.generated.h"

UCLASS()
class HELLOWORLD_API AItem : public AActor
{
    GENERATED_BODY()
    
public:    
    AItem();
    virtual void Tick(float DeltaTime) override;

protected:
    virtual void BeginPlay() override;
};
// Item.cpp
#include "Items/Item.h"

AItem::AItem()
{
    PrimaryActorTick.bCanEverTick = true;
}

void AItem::BeginPlay()
{
    Super::BeginPlay();
}

void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
}

创建蓝图

现在我们可以从内容侧滑菜单 把我们的Item拖入视口 于是我们在大纲视图里就可以看到它 在细节面板的Actor选项卡中 甚至没有改变它的位置的选项 因为它现在太简陋了 什么都没有 一般情况下我们不会直接把C++类拖拽进来 我们做的是基于C++类创建蓝图 我们的Item继承自Actor类 我们可以把蓝图理解成C++类的子类 我们创建从C++类继承的蓝图 C++类是父类 所以蓝图会继承C++的功能 蓝图让我们在编辑器里操作类时 能以更好的方式与之交互 所以我们现在先把拖拽进去的这个Item类先删掉 然后基于Item类创建一个蓝图

在内容文件夹里新建文件夹 命名为Blueprints 在其中再新建一个文件夹 命名为Items

我们可以在C++类里 右键Item类 点击 创建基于Item的蓝图类
或者进入我们的想要存放蓝图类的文件夹 也就是Blueprints/Items文件夹 在空白处右键 新建一个蓝图类 就会有一个弹窗 让我们选取父类 在 所有类 中找到我们的Item类 命名为BP_Item 这样就知道这是Item的蓝图类 而不是C++类

现在我们把它拖拽进视口 这一次可以移动它的位置了 我们在内容侧滑菜单中双击BP_Item 就可以进入蓝图编辑器 可以看到视口左下角有一个左手系 这是它自己的局部坐标系 左边是组件面板 默认情况下 所有Actor至少含有一个组件 默认自带一个根组件和一个组件
37.png

双击DefaultSceneRoot 在右侧细节面板会显示一些信息 这里显示的是蓝图类自身的全部属性

视口上方有几个选项卡 视口、构造脚本、事件图表

事件图表就是我们放蓝图节点 执行蓝图逻辑的地方 蓝图是一个独立的系统 用来编写功能 用蓝图节点代替写代码 现在这里已经有几个节点了 BeginPlay、ActorBeginOverlap、Tick 因为我们现在还没有用到它们 所以显示灰色

现在我们可以右键新建节点 比如我们希望游戏开始之后就打印字符串 我们右键搜索print 选择Print String 这前面花体f的意思是函数 我们把BeginPlay的执行引脚连接到Print String上 鼠标悬停在Print String上粉色的圆圈上 信息提示要输出到日志的字符串 我们可以连接变量等输入 来决定要打印的字符串 或者直接双击Hello文本框里进行编辑 我们就修改成BeginPlay
38.png

我们修改了蓝图 在整个蓝图编辑器的左上角 有一个 编译 按钮 这就像在C++里修改代码 然后在IDE里编译一样 可以点击编译进行编译 或者直接点击右上角的绿色三角开始玩游戏 开始游戏后蓝图会自动编译 我们现在把这个蓝图编辑器选项卡拖拽到关卡选项卡旁边 然后点击蓝图编辑器里右上方的的绿色三角 现在游戏就在一个单独的窗口运行 并且可以全屏 我们回到关卡里 再次进入PIE模式 就会发现左上角会短暂显示一条信息

Print String节点中 仅限开发 的下方小箭头是下拉菜单 可以显示一些额外的属性 比如文本颜色Text Color 持续时间Duration 单位是秒 所以Print String可以用来验证某个函数或者事件是否执行了 就像我们常常在写代码时中使用的print

BeginPlay节点是红色 是事件 Event
Print String节点是蓝色 是函数

将鼠标悬停在BeginPlay节点右上角的红色方块上 会显示Output Delegate委托 所以“事件”是蓝图术语中对于“委托”的称呼 虽然我们现在完全不知道委托是什么意思 和函数不一样 事件没有输入执行引脚

我们再看构造脚本选项卡 有点像事件图表 但是它在游戏开始前就运行了 事实上只要这个蓝图的任何属性发生变化 它就会执行 比如我们进入蓝图的细节面板 对其进行修改 每次修改任何内容 这个构造脚本都会触发 所以很适合在游戏开始之前做一些准备工作 而事件图表中的节点是在游戏过程中执行的

如果我们开始游戏时在地图中的位置 距离我们刚刚摆放这个蓝图太远 它就不会输出我们刚才设置好的文本 因为太远了 这是开放世界的特性

日志系统 TEXT宏

我们刚才的操作能验证蓝图版本的BeginPlay确实被调用了 现在我们要去验证C++版本的BeginPlay是否被调用了 所以我们先把那个Print String节点删掉 回到Visual Studio 打开Item.cpp 这就是BeginPlay函数 我们想打印一些文本到日志里 要使用UE的专用宏UE_LOG

我们在BeginPlay中添加一行 UE_LOG() 当我们打字到这里时 将鼠标悬停在UE_LOG上 就可以看到一个庞大的提示框
39.png
里面写了它需要的参数

第1个是CategoryName 类别名称 现在有很多日志类别 甚至你可以自定义 但我们在这里使用LogTemp 是临时日志类别 用于调试时频繁添加和删除的日志

现在Visual Studio又提示我剩下的参数
40.png
也可以使用ctrl+shift+space来触发这个提示框

第2个参数Verbosity是日志详细级别 控制到Log Warning Error 我们这里就使用Warning级别

第3个参数 Format 指定日志输出的格式 比如
在这个小悬浮窗提示框里 Format再后面是__VA_ARGS 而在之前的那个大的提示框里 显示的Fromat后面是省略号 总之意思就是 UE日志可以接收任意数量的参数 VA是variable变量的缩写 ARGS是arguments参数的缩写 现在我们就只传一个 现在我们要传入的是一段文本字符串 来打印到输出日志 此处使用TEXT宏 它接收一个字符串字面量作为输入参数 比如 TEXT("Begin Play called!") 那么它就会把这个字符串字面量转换为Unicode格式 Unicode能包含的字符远比ANSI格式多得多 比如汉字

我们现在去查看UE官方文档 C++ Coding Standard 在网页中按ctrl+F 搜索TEXT() 就可以看到它写着

在字符串字面量周围固定使用 TEXT() 宏。
若未使用 TEXT() 宏,在文字中构建 FStrings 的代码将导致不理想的字符转换过程。

现在我们完全不知道FStrings是什么 这是UE的字符串类型 我们使用print string时 注意到输入是字符串类型 但是在蓝图编辑里 UE会把F去掉 但它确实就是作为输入的FString 就像编辑器会去掉AActor前面的A

虽然你不用TEXT宏 看起来也能正常运行 但它背后会发生一些你看不到的流程 比如字符串转换 总而言之 用TEXT宏包装字符串字面量是一个好的做法

最终我们是在AItem::BeginPlay里添加了一行 UE_LOG(LogTemp, Warning, TEXT("Begin Play called!")); 这后面的分号 也可以不加 因为这是宏

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    UE_LOG(LogTemp, Warning, TEXT("Begin Play called!"));
}

现在 要编译它 我们只需要保存就可以了 我们并没有在VS里编译 现在回到UE
41.png

点击UE右下角的这个图标 这就是热重载 旁边的三个点是编译选项菜单 点击它 可以看到已经勾选了 启用实时代码编写 实施编码就是将C++函数的修改热补丁到当前进程 这比编译VS项目要快很多 我们就点击这个热重载图标 会出现一个实时编码窗口 稍等就会提示成功 然后我们关掉这个窗口

点击绿色三角进入PIE模式 点击视口左下方调出 输出日志 我们将其停靠在布局中 然后按绿色三角进入PIE模式 现在就会看到黄颜色的LogTemp: Warning: Begin Play called!

如果是在关闭UE编辑器时对于VS项目进行了修改 只点击热重载就会看不到变化 必须要在VS中编译整个解决方案 才能使改动在项目中生效 可以在解决方案资源管理器中右键 生成解决方案 或者在上方菜单栏 生成 - 生成解决方案 或者直接快捷键ctrl+shift+B 如果你是在打开着UE编辑器时去生成 VS会提示你把UE关掉 否则可能失败 也可以只生成我们修改了的module 本例中就是Build HelloWorld

VS还有一个快捷键 ctrl+F5 可以编译并启动UE编辑器 于是我们又打开了刚刚关掉的UE编辑器

刚才已经说过 当距离太远时 日志系统并不会打印 所以我们要找到一个较小的地图 新建关卡 - 基本 然后把我们的BP_Item拖拽进新的地图里 进入PIE模式 可以看到输出了Begin Play的日志

进入BP_Item的蓝图编辑器 Event BeginPlay没有连接引脚 但它却不是灰色 我们再拖拽Print String出来 连接上 这样从蓝图编辑器进入PIE模式时 左上角会输出文本 在搜索Print String时 还可以看到一个叫Log String的函数 这和UE_LOG宏很像

42.png

这之后回到关卡 进入PIE模式 在输出日志里查找 我们可以在C++日志输出的黄颜色Warning的上一行找到 LogBlueprintUserMessages: Message printed to the Output Log 这是LogBlueprintUserMessages 日志蓝图用户消息 而不是LogTemp临时日志 再上一行是 LogBlueprintUserMessages: [BP_Item_C_1] Hello 这是PIE模式下浮现在视口左上角的字符串

Print String节点 点击 仅限开发 的下方小箭头 可以看到Key 鼠标悬停在上面 可以看到它的类型是Name命名 可以让我们指定一个值 来控制这些打印字符串消息的行为 现在是None ctrl+D再创建一个相同的节点 连接上 分别重命名
43.png

继续进入PIE模式 可以看到两个都打印出来了 2nd在上 1st在下 说明新消息会显示在上面 但是如果两个Print String使用相同的Key 新消息就会替换旧消息 而不是像现在这样叠加 所以如果我们把这两个Print String的Key都设为1 就会发现只打印2nd 因为第一个被第二个替换了

下面的tick事件 每一帧都会被调用 给它连接一个Print String
44.png

现在再进入PIE模式 它会一直满屏打印 如果我们把Key设置成有效的值 比如0 它就会一直保持在一个地方持续打印 持续时间超过我们设置的Duration2.0
Delta Seconds是一个浮点数 表示自上一帧以来经过的时间 增量时间 帧的持续时间很短 所以这个值会很小 毫秒级 将Delta Seconds连接到In String 会显示 浮点单精度to字符串 就会出现一个新节点 绿色进去 粉色出来 这是一个转换节点 负责把双精度浮点数转换成字符串 鼠标悬停在Delta Seconds 会显示它是单精度浮点数 但是这个转换节点 既能接受双精度数也能接受浮点数作为输入 这背后其实有一个隐式转换的过程
45.png

现在进入PIE模式 会打印出来Delta Seconds 这个数值一直在变 每一帧我们都会打印出Delta Seconds的值 而且这个值在不同的帧之间是会变化的

我们将所有这些Print String节点都删除 编译保存 回到C++

在Begin Play里 我们想向屏幕打印一条信息 而不是直接把它扔到输出日志里

输入GEngine并悬停 可以看到它是一个UEngine*类型的指针变量 提示我们全局引擎指针可能为空使用前需检查 这是一个全局指针变量 我们可以在任何类中访问它 这个指针有自己的函数 可以用->使用它

先检查一下GEngine是不是空指针 通常情况下它都是有效的 尤其是在BeginPlay函数执行后 BeginPlay函数通常在GEngine初始化完成后调用 但检查一下总是好的

我们使用GEngine全局变量的一个函数AddOnScreenDebugMessage

GEngine->AddOnScreenDebugMessage(1, 2.f, FColor::Cyan, FString("Item OnScreen Message!"))

IntelliSense会提示我们要传入的参数
46.png

1个(共2个)的意思是 这个函数有两个重载版本 按小三角查看不同版本 第1个参数Key就是键值 用不同的键值对应不同的消息 第2个参数是显示时长 我们这里用2.f 也就是2的浮点数 消息会显示2秒 或者直接写2 那就是一个int 会自动从整数转换成浮点数 这里我们还是直接用浮点字面量 省去编译器隐式转换的麻烦 用浮点值时 我们常用.f指定它是浮点字面量 第3个参数是颜色 是FColor类型 输入FColor::就能看到一些选项 Fcolor类有一些静态变量 不需要创建实例就可以调用 静态变量里有一些现成的颜色 包含rgb和Alpha透明度这4个数值 cyan是蓝绿色 第4个参数是FString类型 在这里我们调用FString的构造函数利用字符串字面量创建一个FString 第5个参数是名为bNewerOnTop的bool值 默认情况下为true 意味着新的消息会显示在旧消息上方 再后面的其它参数也都有默认值 我们不必须传入它们

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    UE_LOG(LogTemp, Warning, TEXT("Begin Play called!"));

    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(1, 2.f, FColor::Cyan, FString("Item OnScreen Message!"));
    }
}

我们回到UE进行热重载 成功地输出日志

现在我们想在tick函数里打印一下deltaTime的值

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    UE_LOG(LogTemp, Warning, TEXT("Delta Time: %f"), DeltaTime);
}

UE_LOG函数宏里 参数个数随便加 所以直接把deltaTime放在这 使用格式说明符%f指定位置 UE_LOG就会用文本宏后面给出的第一个参数来替换%f 这种格式说明符来自于C语言的printf
回到UE热重载就会发现输出日志里一直在打印LogTemp: Warning: Delta Time: 0.008333

现在将其打印到屏幕上

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    UE_LOG(LogTemp, Warning, TEXT("Delta Time: %f"), DeltaTime);

    if (GEngine)
    {
        FString DeltaTimeMessage = FString::Printf(TEXT("Delta Time: %f"), DeltaTime);
        GEngine->AddOnScreenDebugMessage(1, 2.f, FColor::Cyan, DeltaTimeMessage);
    }
}

FString::Printf是用于格式化字符串的静态函数 类似于C语言的sprintf sprintf和printf的区别就是 printf常常是输出到控制台 sprintf是输出到内存中的字符串变量 不会直接输出到屏幕

FString是UE自己的类型 和STL里的类似 最好还是用UE自带的类型 因为UE会尽力保证它用的所有类型和函数都是跨平台的

但是我们怎么能知道有FString::Printf这个函数的存在呢 我们可以直接右键FString 转到定义 然后查看它有什么方法 当然这高达2000行 我们可以阅读它 最前面是构造函数和重载操作符之类的 或者查看FString官方文档

如果我们现在想得到关卡里这个物体的名字

// Item.cpp
// 错误代码
if (GEngine)
{
    FString Name = GetName(); // 会返回关卡里这个物体的名字
    FString NameMessage = FString::Printf(TEXT("Item Name: %s"), Name);
    GEngine->AddOnScreenDebugMessage(1, 2.f, FColor::Cyan, NameMessage);
}

我们简单地以为可以这样写 但其实我们要写成
FString NameMessage = FString::Printf(TEXT("Item Name: %s"), *Name);

这个*Name看起来是指针的解引用 但实际上是FString类重载了字符串的*运算符 这个*的作用是提供一个C风格的字符串 对于这个FString::Printf的格式说明符比如%s 我们不能对它传入FString字符串 只能对它传入C风格的字符串 也就是字符串字面量const char* 对于UE而言准确来说是const TCHAR* TCHAR是宽字符数组的别名 能存储比char更多的信息 UE使用的是Unicode编码 比ANSI编码的普通字符信息量更大

UE文档里写着 使用%s参数包含FStrings时 必须使用*运算符返回%s参数所需的TCHAR*

所以现在要使用这个重载了的oprator* 将它转换成C风格的字符串 其实C语言的printf的%f 也是只能传入字符串字面量const char* 说到底 C并没有像C++那样的String类型 C的字符串本质上都是const char* 所以UE这个大概是模仿了C语言才这样规定的 而C++是没有这个格式说明符功能的 String是重载了operator+或者用append

UE_LOG使用%s时也是一样
UE_LOG(LogTemp, Warning, TEXT("Item Name: %s"), *Name);

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    UE_LOG(LogTemp, Warning, TEXT("Delta Time: %f"), DeltaTime);

    if (GEngine)
    {
        FString Name = GetName();
        FString NameMessage = FString::Printf(TEXT("Item Name: %s"), *Name);
        GEngine->AddOnScreenDebugMessage(1, 2.f, FColor::Cyan, NameMessage);

        UE_LOG(LogTemp, Warning, TEXT("Item Name: %s"), *Name);
    }
}

修改后热重载 输出日志就会出现 LogTemp: Warning: Delta Time: 0.008334 LogTemp: Warning: Item Name: BP_Item_C_1

现在在大纲视图选中我们的BP_Item 按ctrl+D再创建一个BP_ITem2 就会发现输出日志中既有BP_ITem_C_1也有BP_Item_C_0 这是它们的内部名称 每次tick都会打印2个DeltaTime 分别来自不同Actor对于Tick的调用

LogTemp: Warning: Delta Time: 0.008334
LogTemp: Warning: Item Name: BP_Item_C_1
LogTemp: Warning: Delta Time: 0.008334
LogTemp: Warning: Item Name: BP_Item_C_0

调试形状

调试球

对于我们的Item 它并不是静态网格体 没有形体 在PIE模式下我们看不到它们 但是现在我们还没有往Item类中添加任何网格 但还是想看看它们在游戏中的位置

需要头文件DrawDebugHelpers.h 里面有很多调试辅助函数 其中就包括一个绘制调试球体的函数DrawDebugSphere 翻译成中文就是绘制调试球 第1个参数是名为Inworld的UWorld类型指针 还记得吗在之前谈到UE类的继承时说过 World是Package的子类 是Layer的父类 现在绘制调试球需要知道我们在哪个世界 可以使用GetWorld()获取我们所在的世界 VS有个小技巧是选中一些代码 使用鼠标拖拽就可以换到另一行里

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    GetWorld();

先在这个地方写一个GetWorld(); 鼠标悬停在这个函数上面 可以看到它是返回一个UWorld类型的指针 提示信息里显示 这是一个缓存世界指针的名为Getter的指针 如果这个Actor实际上没有在一个关卡里生成就会返回null 为了遵守良好的C++编程规范 我们还是先检查一下它是否非空

第2个参数 中心坐标 类型是FVector 第3个参数 半径 float型 在数字后面用.f表示浮点数 第4个参数 segments 分段 因为球是网状的 就是网格分段的数目 int32类型 不同于我们平时使用的int 整数类型通常是32位 但大小会因平台而异 UE要确保我们一直用32位整数 所以是int32 第5个参数 颜色 第6个参数 bPresistentLines 会不会有持久线 如果设定为true 就会有持久线 也就是说调试球体不会消失 设置为false 就需要在第7个参数 设置球体的生命周期 单位是秒

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    UWorld* world = GetWorld();
    if (world)
    {
        FVector Location = GetActorLocation();
        DrawDebugSphere(world, Location, 25.f, 24, FColor::Red, false, 30.f);
    }
}

回到UE热重载 进入PIE模式 就可以看到那些我们本来看不到的物体 周围是红色的球

函数宏

如果可以不要输入这么多参数也能制造一个调试球就好了 假如我们并不会修改这个球的半径 segements 颜色 生命周期 那么就使用函数宏

#define THIRTY 30
这样代码里所有用到整数30的地方 都可以用这个宏来替换 而函数宏就可以接受输入

// Item.cpp
#define DRAW_SPHERE(Location) if (GetWorld()) DrawDebugSphere(GetWorld(), Location, 25.f, 12, FColor::Red, true)

用括号指定输入 这里不指定类型 只给出输入名称 本例中就是Location 在同一行内写完的if语句是合法的 当然还是写成大括号会更清晰

现在我们再在代码里使用DRAW_SPHERE(GetActorLocation()) 它就会被替换成if (GetWorld()) DrawDebugSphere(GetWorld(), GetActorLocation(), 25.f, 12, FColor::Red, true)这样的代码

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    DRAW_SPHERE(GetActorLocation());
}

使用宏时 DRAW_SPHERE(GetActorLocation())或者DRAW_SPHERE(GetActorLocation()); 有没有分号都可以

使用调试形状时 性能会迅速下降 因为它并不是为发布的游戏准备的 就只是用来调试

现在这个宏是在Item.cpp里 只能在这个类里使用 如果把它放在头文件里 那么所有包含那个头文件的类就都可以使用这个宏 我们不妨将它放在HelloWorld.h里 然后在Item.cpp里include这个头文件

发现Item.cpp的路径是???\HelloWorld\Source\HelloWorld\Private\Items\Item.cpp ???的意思是存放UE项目的文件夹路径 而HelloWorld.h的路径是???\HelloWorld\Source\HelloWorld\HelloWorld.h 直接写#include HelloWorld.h是找不到这个头文件的

于是我们在VS解决方案资源管理器里 右键HelloWorld项目 - 属性 - VC++目录 找到包含目录 这里面有很多包含目录 是由HelloWorld.Build.cs文件自动构建的
进入编辑 看到计算的值 就可以看到里面有

..\..\Source
..\..\Source\HelloWorld\Private
..\..\Source\HelloWorld\Public

所以 之前我们在Item.cpp里include Item.h 就需要写成#include Items/Item.h 因为Item.h是在???\HelloWorld\Source\HelloWorld\Private\Items\Item.h

所以现在如果想要include HelloWorld.h 就要从..\..\Source开始 它的这两个.. 是向上找两次父级 那么它是相对于谁的父级呢 然而HelloWorld.sln或者HelloWorld.uproject的位置 都是和source同级的 实际上它是位于???\HelloWorld\Intermediate\ProjectFiles里面的HelloWorld.vcxproj 它向上找两次父级之后 就是???\HelloWorld 所以???\HelloWorld\Source对它而言就是..\..\Source

而HelloWorld.h是在???\HelloWorld\Source\HelloWorld\HelloWorld.h 那么从Source开始写就是 #include "HelloWorld/HelloWorld.h"

#include "HelloWorld/HelloWorld.h"

调试线

调试线 从Actor位置开始 到Actore位置加上前向向量的位置结束 这样就能看到一条线 从Actor位置出发 沿着前向向量方向延伸到空间中的某个点 UE的默认单位是厘米 调试线对于表示特定向量的方向非常有用

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    UWorld* World = GetWorld();
    FVector Location = GetActorLocation();
    if (World)
    {
        FVector Forward = GetActorForwardVector();
        DrawDebugLine(World, Location, Location + Forward * 100.f, FColor::Red, true, -1.f, 0, 1.f);
    }
}

GetActorForwardVector() 返回的是前向方向的单位向量 也就是其本地坐标x轴的正方向 乘以100.f 就是将单位向量缩放为100个单位的向量 明明写了持续时间为true 但是为了后面能修改线条的粗细参数 还是要把持续时间写上来占位 默认值是-1.f 那么就写这个 这之后的参数是深度优先级 类型是uint8 无符号8位整数 决定了这条线是绘制在其它线的上面还是下面 在我们这里线条相互覆盖也无所谓 默认值是0 那我们就写成0 数值越低优先级越高 所以它会显示在其它线条上面 再下一个参数是线条粗细

回到UE热重载就可以看到线条

也可以使用宏

// Item.cpp
#define DRAW_LINE(StartLocation, EndLocation) if (GetWorld()) DrawDebugLine(GetWorld(), StartLocation, EndLocation, FColor::Red, true, -1.f, 0, 1.f)
// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    DRAW_SPHERE(GetActorLocation());
    DRAW_LINE(GetActorLocation(), GetActorLocation() + GetActorForwardVector() * 100.f);
}

调试点

离得远 看起来点会变大 离得越近反而点会变小 实际上它的大小是没有变的

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    UWorld* World = GetWorld();
    FVector Location = GetActorLocation();
    if (World)
    {
        FVector Forward = GetActorForwardVector();
        DrawDebugPoint(World, Location, 20.f, FColor::Red, true);
    }
}

函数宏

// Item.cpp
#define DRAW_POINT(Location) if (GetWorld()) DrawDebugPoint(GetWorld(), Location, 20.f, FColor::Red, true)
// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    DRAW_SPHERE(GetActorLocation());
    DRAW_LINE(GetActorLocation(), GetActorLocation() + GetActorForwardVector() * 100.f);
    DRAW_POINT(GetActorLocation() + GetActorForwardVector() * 100.f); // 这里是把点画在线的终点
}

47.png

调试向量

现在是画了一个线段和一个点 但为什么不能直接画成箭头呢

函数宏

// Item.cpp
#define DRAW_VECTOR(StartLocation, EndLocation) if (GetWorld()) \
    { \
        DrawDebugLine(GetWorld(), StartLocation, EndLocation, FColor::Red, true, -1.f, 0, 1.f); \
        DrawDebugPoint(GetWorld(), EndLocation, 20.f, FColor::Red, true); \
    }

末尾用反斜杠\换行 表示宏定义未完成

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    DRAW_SPHERE(GetActorLocation());
    DRAW_VECTOR(GetActorLocation(), GetActorLocation() + GetActorForwardVector() * 100.f);
}

效果和刚才用line+point是一样的 只需要传入起点和终点

修改了头文件 关闭UE 用VS Build HelloWorld 编译 再重新启动UE

调试宏头文件

直接写HelloWorld.h里感觉不太好 最好是有个专门的调试宏所在的头文件 这样如果需要调试宏功能 只要include那个头文件就可以了 在解决方案资源管理器 对HelloWorld项目右键 - 添加 - 新建项 创建头文件DebugMacros.h 将它放在\Source\HelloWorld里 和Private Public并列 将HelloWorld.h里的宏转移到里面

// DebugMacros.h
#pragma once

#include "DrawDebugHelpers.h"

#define DRAW_SPHERE(Location) if (GetWorld()) DrawDebugSphere(GetWorld(), Location, 25.f, 12, FColor::Red, true)
#define DRAW_LINE(StartLocation, EndLocation) if (GetWorld()) DrawDebugLine(GetWorld(), StartLocation, EndLocation, FColor::Red, true, -1.f, 0, 1.f)
#define DRAW_POINT(Location) if (GetWorld()) DrawDebugPoint(GetWorld(), Location, 20.f, FColor::Red, true)
#define DRAW_VECTOR(StartLocation, EndLocation) if (GetWorld()) \
    { \
        DrawDebugLine(GetWorld(), StartLocation, EndLocation, FColor::Red, true, -1.f, 0, 1.f); \
        DrawDebugPoint(GetWorld(), EndLocation, 20.f, FColor::Red, true); \
    }

这样Items.cpp中就可以改成#include "HelloWorld/DebugMacros.h"

移动Actor

修改位置

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    SetActorLocation(FVector(0.f, 0.f, 100.f));

    DRAW_SPHERE(GetActorLocation());
    DRAW_VECTOR(GetActorLocation(), GetActorLocation() + GetActorForwardVector() * 100.f);
}

SetActorLocation接收FVector类型来设置位置 现在进入UE热重载 进入PIE模式 就会发现调试球到达了我们设定好的位置(0,0,100) 这样我们就修改了Actor的位置

修改旋转角度

现在使用SetActorRotation修改旋转角 输入SetActorRotation并输入括号之后提示框会显示有两种选项 第1个接收FQuat类型参数 第2个接收FRotator类型参数 FRotator重载了接收3个值的构造函数 第1个值是俯仰角 第2个是偏航角 第3个是滚转角 从上往下看 这是顺时针旋转

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    SetActorRotation(FRotator(0.f, 45.f, 0.f));

    DRAW_SPHERE(GetActorLocation());
    DRAW_VECTOR(GetActorLocation(), GetActorLocation() + GetActorForwardVector() * 100.f);
}

偏移量 WorldOffset

偏移量offset会加到Actor的位置上 进而修改Actor的位置 如果每一帧都添加一个world offset 这样就可以看到Actor在移动了 但我们用调试球看不到这个效果 因为我们只在BeginPlay里调用了调试球 现在需要创建一个新的宏来绘制单帧的调试球 这样就能每帧绘制更新后的位置了

进入DebugMacros.h

// DebugMacros.h
#define DRAW_SPHERE_SingleFrame(Location) if (GetWorld()) DrawDebugSphere(GetWorld(), Location, 25.f, 12, FColor::Red, false, -1.f)

持续时间不再是true 如果只想绘制1帧 就需要将生命周期设为-1.0f 当然我们也可以创建调试线 调试点 调试向量的单帧版本

// DebugMacros.h
#define DRAW_SPHERE(Location) if (GetWorld()) DrawDebugSphere(GetWorld(), Location, 25.f, 12, FColor::Red, true)
#define DRAW_SPHERE_SingleFrame(Location) if (GetWorld()) DrawDebugSphere(GetWorld(), Location, 25.f, 12, FColor::Red, false, -1.f)
#define DRAW_LINE(StartLocation, EndLocation) if (GetWorld()) DrawDebugLine(GetWorld(), StartLocation, EndLocation, FColor::Red, true, -1.f, 0, 1.f)
#define DRAW_LINE_SingleFrame(StartLocation, EndLocation) if (GetWorld()) DrawDebugLine(GetWorld(), StartLocation, EndLocation, FColor::Red, false, -1.f, 0, 1.f)
#define DRAW_POINT(Location) if (GetWorld()) DrawDebugPoint(GetWorld(), Location, 20.f, FColor::Red, true)
#define DRAW_POINT_SingleFrame(Location) if (GetWorld()) DrawDebugPoint(GetWorld(), Location, 20.f, FColor::Red, false, -1.f)
#define DRAW_VECTOR(StartLocation, EndLocation) if (GetWorld()) \
    { \
        DrawDebugLine(GetWorld(), StartLocation, EndLocation, FColor::Red, true, -1.f, 0, 1.f); \
        DrawDebugPoint(GetWorld(), EndLocation, 20.f, FColor::Red, true); \
    }
#define DRAW_VECTOR_SingleFrame(StartLocation, EndLocation) if (GetWorld()) \
    { \
        DrawDebugLine(GetWorld(), StartLocation, EndLocation, FColor::Red, false, -1.f, 0, 1.f); \
        DrawDebugPoint(GetWorld(), EndLocation, 20.f, FColor::Red, false, -1.f); \
    }

现在回到Item.cpp 删掉我们对BeginPlay修改的内容

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();
}

而是对tick函数进行修改

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    AddActorWorldOffset(FVector(1.f, 0.f, 0.f));
    DRAW_SPHERE_SingleFrame(GetActorLocation());
}

AddActorWorldOffset需要接收一个FVector FVector(1.f, 0.f, 0.f) 写在tick函数里 就会在每一帧使Actor的坐标x增加1

修改之后 关闭UE 在VS中编译 再重新打开UE 进入PIE模式 就可以看到调试球正在进行移动 是沿着世界坐标系的x轴移动

我们每一帧移动一次 所以很流畅 但是帧率是会变动的 如果设备的帧率降低 游戏里的移动速度就会慢很多 在UE - 编辑 - 项目设置 - 引擎 - 一般设置 可以找到帧率 勾选 使用固定帧率 它就限定在了默认的30帧 再次进入PIE模式 就会发现调试球的移动变慢了

我们当然希望无论帧率是多少 这些Actor的移动速度都是一样的 就要使用DeltaTime DeltaTime会告诉我们上一帧之后过了多久 也就是每帧消耗的时间 以秒为单位 按DeltaTime缩放 就能保证移动速度恒定

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    // 设定以cm/s为单位的移动速度
    float MovementRate = 50.f;

    // 而我们想要知道 如果希望Actor以上面这样的速度移动 每一帧要移动多少距离
    // 只有知道了这个数值 才能设置在tick函数里的偏移量
    // 每一帧的持续时间是DeltaTime秒 而速度的单位是cm/秒
    // 那么每一帧移动的距离就是 ( MovementRate * DeltaTime )cm
    AddActorWorldOffset(FVector(MovementRate*DeltaTime, 0.f, 0.f));
    DRAW_SPHERE_SingleFrame(GetActorLocation());
}

也可以旋转偏移 AddActorWorldRotation

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    float MovementRate = 50.f;
    float RotationRate = 45.f;

    AddActorWorldOffset(FVector(MovementRate*DeltaTime, 0.f, 0.f));
    AddActorWorldRotation(FRotator(0.f, RotationRate*DeltaTime, 0.f));
    DRAW_SPHERE_SingleFrame(GetActorLocation());
    DRAW_VECTOR_SingleFrame(GetActorLocation(), GetActorLocation()+GetActorForwardVector()*100.f);
}

现在它就会一边旋转一边向前移动 每秒旋转45°

三角函数

打开Item.h 创建private私有变量RunningTime 表示运行时间

// Item.h
UCLASS()
class HELLOWORLD_API AItem : public AActor
{
    GENERATED_BODY()
    
public:    
    AItem();
    virtual void Tick(float DeltaTime) override;

protected:
    virtual void BeginPlay() override;
private:
    float RunningTime;
};
// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    RunningTime += DeltaTime;

    float DeltaZ = 0.25f * FMath::Sin(RunningTime * 5.f);
    AddActorWorldOffset(FVector(0.f, 0.f, DeltaZ));

    DRAW_SPHERE_SingleFrame(GetActorLocation());
    DRAW_VECTOR_SingleFrame(GetActorLocation(), GetActorLocation()+GetActorForwardVector()*100.f);
}

DeltaZ是我们要设置的z值变化 FMath是一个包含很多数学函数的函数库 符号函数是FMath类里的一个静态函数 Sin是正弦函数 接收double作为参数 也可以传float 再缩放一下RunningTime 最后再将Sin乘一个系数 修改它的振幅

回到UE热重载 可以发现调试球在上下移动

现在代码里的数字0.25f和5.f 过于magic number了 所以可以在Item.h类的定义中 定义Private变量

// Item.h
private:
    float RunningTime;
    float Amplitude;
    float TimeConstant;

不需要初始化 然后在构造函数里再赋值 注意是构造函数 不是tick函数
当然也可以就在定义里直接初始化float Amplitude = 0.25f; 效率更高

// Item.h
AItem::AItem() : Amplitude(0.25f), TimeConstant(5.f)
{
    PrimaryActorTick.bCanEverTick = true;
}

本例中我们选择直接在定义里初始化

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    RunningTime += DeltaTime;

    float DeltaZ = Amplitude * FMath::Sin(RunningTime * TimeConstant);
    AddActorWorldOffset(FVector(0.f, 0.f, DeltaZ));

    DRAW_SPHERE_SingleFrame(GetActorLocation());
    DRAW_VECTOR_SingleFrame(GetActorLocation(), GetActorLocation()+GetActorForwardVector()*100.f);
}

现在就没有magic number了

暴露给蓝图

将变量暴露给蓝图

进入BP_Item的蓝图编辑器 我们可以把C++类里的变量添加到详细信息面板 本例中就主要关注Amplitude TimeConstant

需要把Amplitude公开到蓝图 需要为Amplitude添加一个UPROPERTY宏 这样就能参与反射系统 从而将这个浮点型变量暴露到蓝图中

UPROPERTY()有好几种说明符
首先尝试使用UPROPERTY(EditDefaultOnly)

// Item.h
private:
    float RunningTime;

    UPROPERTY(EditDefaultsOnly)
    float Amplitude = 0.25f;

    float TimeConstant = 5.f;

这样修改之后 在蓝图编辑器 细节面板里 可以找到Item栏的Amplitude参数 默认是0.25 可以在蓝图编辑器里编辑它了

现在这样 只能在蓝图编辑器的细节面板里找到这个值并且编辑 但是在关卡编辑器中 在大纲面板点选这个BP_Item 就无法在关卡编辑器右下角的细节面板里找到这个值

UPROPERTY(EditInstanceOnly)

// Item.h
private:
    float RunningTime;

    UPROPERTY(EditDefaultsOnly)
    float Amplitude = 0.25f;

    UPROPERTY(EditInstanceOnly)
    float TimeConstant = 5.f;

现在就可以在关卡编辑器右下角细节面板找到Time Constant变量 可以看到它是5.0 还可以修改

UPROPERTY(EditAnywhere)
这样在蓝图编辑器的细节面板和关卡编辑器的细节面板就都可以找到被这个宏标记的变量

在蓝图编辑器进行修改后 要记得点左上角的编译

UPROPERTY(VisibleDefaultsOnly)
我们自然可以猜到 在这样之后 对于蓝图编辑器的细节面板可见 但是不可修改

// Item.h
UPROPERTY(VisibleDefaultsOnly)
float RunningTime;

UPROPERTY(EditAnywhere)
float Amplitude = 0.25f;

UPROPERTY(EditAnywhere)
float TimeConstant = 5.f;

在UE热重载 在蓝图编辑器的细节面板可以看到 RunningTime默认值是0 不可编辑 其实我们没有初始化它 但是加了一个UPROPERTY 它就能保证这个值初始化 默认是0 现在进入PIE模式 在蓝图编辑器里 这个Running Time的值没有发生变化 是因为默认蓝图的tick函数没有运行 它并不是BP_Item蓝图类的实例或对象 所以在蓝图编辑器里无法看到Running Time的更新

UPROPERTY(VisibleInstanceOnly)
关卡编辑器的细节面板可见 Running Time一直在更新

UPROPERTY(VisibleAnywhere)
蓝图编辑器和关卡编辑器的细节面板都可见

这个Running Time 是C++代码计算出来的 不能手动更改 所以至多设置成可见

UPROPERTY(EditAnywhere, BlueprintReadOnly)
也可以暴露给蓝图编辑器里的事件图表 UPROPERTY可以接收多个输入 蓝图只读 BlueprintReadOnly是不能用于private变量 要放在protected里

UPROPERTY(EditAnywhere, BlueprintReadWrite)
蓝图编辑器的事件图表可写

// Item.h
protected:
    virtual void BeginPlay() override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float Amplitude = 0.25f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite)
    float TimeConstant = 5.f;
private:
    UPROPERTY(VisibleAnywhere)
    float RunningTime;

现在在蓝图编辑器的事件图表视口里 右键 搜索 Amplitude 可以看到get Amplitude读 和 set Amplitude写

想要在蓝图编辑器左侧 我的蓝图 面板中的 变量一栏 看到Amplitude 需要点击 我的蓝图 面板内部 右上方的齿轮 勾选 显示继承的变量
48.png

现在就可以看到变量一栏的Item栏 出现了Amplitude和Time Constant

现在无论是在蓝图编辑器右侧细节面板中 还是左侧我的蓝图面板中 Amplitude和Time Constant变量都是在Item类别之下 但是如果接下来变量越来越多 就会变得杂乱 如果还能继续分类整理一下就好了

// Item.h
protected:
    virtual void BeginPlay() override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Sine Parameters")
    float Amplitude = 0.25f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Sine Parameters")
    float TimeConstant = 5.f;

现在这些参数就不在Item类别下了 而是Sine Parameters类别下 无论是在关卡编辑器视口右侧的细节面板里 还是在蓝图编辑器左侧我的蓝图面板 蓝图编辑器右侧细节面板里 都完成了分类 按我们目前的设置 它在这些地方都是可编辑的

// Item.h
private:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
    float RunningTime;

这样就可以将变量放到private 又能在蓝图的事件图表中只读 meta是元说明符

将函数暴露给蓝图

前面在Item.cpp中 Amplitude * FMath::Sin(RunningTime * TimeConstant) 是经过变换的正弦函数 我们接下来要将它暴露给蓝图

将函数放在public protected private 取决于我们是否打算从类外面调用这个函数 是否有其它对象可以访问这个对象并调用这个函数 如果有 就要设置成是public 否则没有必要public 那么它就可以是protected或者private 接下来 考虑是否要在这个类的子类里调用这个函数 如果需要 就设置成protected 如果确定只有当前这个类要用这个函数 那就设置成private 不确定的时候就将函数先放在protected 之后再将它们挪到其它区域

将这个正弦函数的声明放在Item.h 的 class HELLOWORLD_API AItem : public AActorprotected:

// Item.h
float TransformedSin(float Value);

鼠标悬停在TransformedSin上 点击下方出现的螺丝刀标志右侧的向下小三角 点击 创建”TransformedSin”的定义(在Item.cpp中)
49.png
这样它就会自动在Item.cpp中创建一个静态函数

// Item.cpp
float AItem::TransformedSin(float Value)
{
    return 0.0f;
}

将它补全

// Item.cpp
float AItem::TransformedSin(float Value)
{
    return Amplitude * FMath::Sin(Value * TimeConstant);
}

这样就可以让调用这个函数的人 可以直接传入任何它们想让这个经过变换后的正弦函数处理的内容

再使用UFUNCTION宏将它暴露给蓝图

UFUNCTION(BlueprintCallable)
放在Item.h中的函数声明前面

// Item.h
UFUNCTION(BlueprintCallable)
float TransformedSin(float Value);

然后把tick函数中的对于正弦函数的调用注释掉

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    RunningTime += DeltaTime;

    //float DeltaZ = Amplitude * FMath::Sin(RunningTime * TimeConstant);
    //AddActorWorldOffset(FVector(0.f, 0.f, DeltaZ));

    DRAW_SPHERE_SingleFrame(GetActorLocation());
    DRAW_VECTOR_SingleFrame(GetActorLocation(), GetActorLocation()+GetActorForwardVector()*100.f);
}

现在tick函数里就只更新运行时间了 回到UE热重载 进入BP_Item的蓝图编辑器 事件图表视口 右键 搜索找到TransformedSin 就可以使用它

UFUNCTION(BlueprintPure)
蓝图纯节点 和刚才的区别就是在蓝图没有白色五边形的执行引脚 那么就不会参与程序执行流程 不会改变游戏世界的状态 但是它左侧也是有一个可以设置的输入参数 就只是根据输入计算一个值 并返回结果输出

现在回到VS把value参数删掉 换回Running Time

// Item.h
UFUNCTION(BlueprintPure)
float TransformedSin();
// Item.cpp
float AItem::TransformedSin()
{
    return Amplitude * FMath::Sin(RunningTime * TimeConstant);
}

我们可以完全复制出一个余弦函数

// Item.h
UFUNCTION(BlueprintPure)
float TransformedCos();
// Item.cpp
float AItem::TransformedCos()
{
    return Amplitude * FMath::Cos(RunningTime * TimeConstant);
}

在AddActorWorldOffset节点的DeltaLocation上 右键 分割结构体引脚 然后将TransformedSin节点的输出连接到DeltaLocationX 将TransformedCos节点的输出连接到DeltaLocationY
50.png
上图中 AddActorWorldOffset节点就是BlueprintCallable 是蓝色的 左右两侧具有白色多边形引脚 TransformedSin和TransformedCos节点就是BlueprintPure 是绿色的

点击左上角编译 现在再进入PIE模式 可以通过调试球看到 BP_Item在转动 这是因为正弦余弦存在相位差

模板函数

在 Item.h 的AItem类中 protected里 接着TransformedSin和TransformedCos的声明后面 声明一个模板函数

// Item.h
template<typename T>
T Avg(T First, T Second);

鼠标悬停在Avg上 点击下方出现的螺丝刀图标 选择 创建”Avg”的定义(在Item.h中) 于是在Item.h中 AItem类的外面 目前是文件末尾 就会出现

// Item.h
template<typename T>
inline T AItem::Avg(T First, T Second)
{
    return T();
}

这个inline是 在调用这个AItem::Avg函数时 它只会将这个函数{ }中的内容 也就是函数体 在编译时替换掉函数调用 并没有一个实际的函数 就像宏一样

// Item.h
template<typename T>
inline T AItem::Avg(T First, T Second)
{
    return (First + Second) / 2;
}

现在T可以接收任何类型 只要该类型支持加法和除法运算

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    int32 AvgInt = Avg<int32>(1, 3);
    UE_LOG(LogTemp, Warning, TEXT("Avg of 1 and 3: %d"), AvgInt)
}

UE热重载 输出日志里就会出现LogTemp: Warning: Avg of 1 and 3: 2

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    int32 AvgInt = Avg<int32>(1, 3);
    UE_LOG(LogTemp, Warning, TEXT("Avg of 1 and 3: %d"), AvgInt)
        
    float AvgFloat = Avg<float>(3.45f, 6.78f);
    UE_LOG(LogTemp, Warning, TEXT("Avg of 3.45 and 6.78: %f"), AvgFloat)
}

LogTemp: Warning: Avg of 3.45 and 6.78: 5.115000

现在把AItem::BeginPlay里新增的上面这些计算平均数的代码删掉吧 目光转到Item.h中的AItem::Tick

// Item.h
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    RunningTime += DeltaTime;

    //float DeltaZ = Amplitude * FMath::Sin(RunningTime * TimeConstant);
    //AddActorWorldOffset(FVector(0.f, 0.f, DeltaZ));

    DRAW_SPHERE_SingleFrame(GetActorLocation());
    DRAW_VECTOR_SingleFrame(GetActorLocation(), GetActorLocation()+GetActorForwardVector()*100.f);

    FVector AvgVector = Avg<FVector>(GetActorLocation(), FVector::ZeroVector);
    DRAW_POINT_SingleFrame(AvgVector);
}

FVector::ZeroVector是一个变量 x y z坐标都为0 但是是静态向量 所以可以直接从类访问它 不需要从FVector的实例获取
现在平均值是Actor当前位置和零向量 两个向量的平均值 得到的结果就是这两个向量的中点向量 我们使用调试点来显示 使用 DRAW_POINT_SingleFrame

在UE热重载 我们已经可以看到一个悬空的点 显示的是原点和Actor之间的中点

模板函数可以用同一个函数处理各种数据类型的输入参数 实现同样的功能 在使用我们的这个Avg时 要求数据类型必须支持加法和除法运算 模板函数的限制就是 我们传入的数据类型必须能应付函数里要做的操作

在刚才的代码块中 AItem::Tick函数的末尾 再加上FRotator AvgRotator = Avg<FRotator>(GetActorRotation(), FRotator::ZeroRotator); 尝试热重载 但是编译失败了 说找不到除法操作符 它需要一个UE::Math::TRotator<double>类型的左操作数 但是两个Rotator的除法没有任何意义 如果要实现这个功能 就要自己编写代码 引擎本身没有这个功能

组件 Component

Actor可以包含组件 这样能让Actor拥有原本没有的功能 比如在游戏世界中显示模型 这样就可以直接看到它 而不用画调试球了

假如我们有个叫Weapon武器的Actor 比如你可能需要给它一个模型 所以我们就会为此添加一个组件 这样我们就能在游戏中看到这个武器的样子 你挥动武器的时候 可能想知道武器什么时候打到东西 这样就需要物理 所以可以在武器模型刀刃周围放一个隐形的碰撞盒组件 用来检测我们打到敌人时的碰撞事件 给武器添加这些组件 提升它的功能性

每个Actor至少带一个组件 之前创建BP_Item之后 我们会在组件面板看到它有一个默认的DefaultSceneRoot默认场景根节点 这是一个场景组件 蓝图编辑器左侧 我的蓝图面板 组件栏中 在我们之前把Amplitude和TimeConstant暴露到我的蓝图中时见到过
51.png

这个DefaultSceneRoot就是Actor的根组件 在C++的Actor.h文件中声明了这个变量 在C++中 它叫RootComponent 类型是USceneCompnent 前缀U表示 这是一个Uobject 不是Actor的派生类 所以它在UE的继承体系中比较靠上

现在场景组件的功能较弱 它有一个特性 就是变换transform 其实就是在关卡编辑器中 选中某个物体后 在右下角细节面板 通用 - 变换 中的 位置、旋转、缩放信息 实际上变换是一种数据结构 包含3个主要信息 位置 是一个FVector 旋转 FRotator 缩放 FVector

场景组件可以附加到其它组件上 所以场景组件之间也可以相互附加 Actor的根组件DefaultSceneRoot包含了Actor的变换信息 所以调用GetActorLocation()获取Actor的世界坐标FVector时 实际上返回的是根组件的位置 也就是说 它返回的是根组件transform变量里保存的位置信息 所以Actor必须至少包含一个组件 也就是根组件 因为它包含变换的信息

蓝图中有一个组件面板 最上面显示着组件的名字(自我) 下面的第一个组件就是DefaultSceneRoot 因为场景组件支持附加 所以我们可以给Actor添加一个新的场景组件 并把它附加到根组件上 附加到根组件上后 场景组件就会跟着根组件一起移动 两者之间的相对距离始终保持不变

下面了解一下继承自场景组件的类
UE中我们最常遇到的就是静态网格组件 我们有USceneComponent类 其中一个派生类是UStaticMeshComponent 我们已经知道 USceneComponent有自己的变换 并且可以附加到其它组件上 而UStaticMeshComponent作为它的子类 也继承了这些特性 所以静态网格组件有自己的变换 并且可以附加到其它组件上 但是它还有一个额外特性 也就是拥有一个网格mesh 所以UStaticMeshComponent类包含一个UStaticMesh类型的变量

回到武器的例子 如果我们创建一个Weapon的新Actor 它会有一个默认的DefaultSceneRoot 也就是一个场景组件 现在我们添加一个静态网格组件 命名为WeaponMesh WeaponMesh将附加到DefaultSceneRoot上 因此 DefaultSceneRoot移动时 网格也会跟着移动 即使WeaponMesh附加到DefaultSceneRoot上 我们也可以向上移动网格 比如修改它相对于DefaultSceneRoot的相对变换 但是即便如此 当我们再次移动DefaultSceneRoot时 网格也还是会跟着一起动 并保持相对着相对位置

现在DefaultSceneRoot的类型是USceneComponent 而UStaticMeshComponent是USceneComponent的子类 所以我们可以把网格WeaponMesh重新指定成路径 这样一来 我们的DefaultSceneRoot就会被UStaticMeshComponent类型所替换 也就是说WeaponMesh成为了根组件 这样是没有问题的 因为父类指针可以指向子类对象 根组件是USceneComponent类型指针 现在指向了一个UStaticMeshComponent类型对象 所以我们可以用这种方式覆盖根组件

打开UE 进入BP_Item的蓝图编辑器 进入视口标签页 里面是基本上空空如也 有一个像折叠镜一样的东西 其实这个就是左侧组件面板里的DefaultSceneRoot 那就是根组件 点击左上角绿色加号 添加 就能从下拉菜单选择各种组件 找到静态网格体组件 就保持它默认的名字StaticMesh 但是添加了之后 视口也没有发生什么变化 我们在组件中选中这个StaticMesh 可以看到右侧细节面板中 有一栏是静态网格体 显示是None 这是这个StaticMesh的变量 类型为StaticMesh 所以说 我们刚才创建的是静态网格体组件UStaticMeshComponent类型的对象 这是一个组件 是从场景组件派生的 可以附加到其它组件上 并不是静态网格体UStaticMesh类型的对象 是一个装网格信息的类 我们可以点击小箭头在下拉菜单指定要用哪个网格给这个静态网格体变量 目前我们随便指定一个 如果想重置为默认值 就点右侧的弯箭头 也可以在关卡编辑器的内容侧滑菜单找到网格体 用鼠标拖入到BP_Item的蓝图编辑器
52.png

有些静态网格体的旋转中心不是在正中心 是在底部 这取决于建模师在Maya Blender等软件里建模时做的设计选择

保存一下 回到关卡编辑器 就可以看到我们的BP_Item已经有了静态网格模型 进入PIE模式 它还是像以前一样依据正弦余弦在动 调试球的中心在静态网格体的旋转中心 要记得我们目前是在蓝图里使用蓝图节点让它们动的 而不是在C++中修改了tick函数

再一次进入BP_Item的蓝图编辑器 我们可以把这个网格体进行移动 这样它就相对于那个形状犹如折叠镜的根节点有一定的偏移 进入PIE模式 就发现调试球的中心并不在网格体的旋转中心了 而是发生了偏移 但是它的运动方式和根节点是一样的

现在我们把根节点就换成这个StaticMesh 在蓝图编辑器左侧 组件面板 选中StaticMesh 将它拖拽到DefaultSceneRoot上 提示框显示 放置到此处使StaticMesh成为新的根 默认的根将被删除 拖拽之后 DefaultSceneRoot就消失了 StaticMesh成为了新的根 在视口中可以看到那个折叠镜形状也消失了 再一次进入PIE模式 调试球中心再一次和静态网格体的旋转中心重合

现在我们无法在蓝图编辑器视口里移动这个网格体了 因为现在它就是蓝图的参考坐标系 它是根节点 不能移动蓝图的根组件 我们现在也可以添加附加到根节点的新组件 点绿色加号添加 再添加一个静态网格StaticMesh1 把它从None变成选择一个静态网格 我们就可以看到两个静态网格体叠加的样子 并且可以通过拖拽StaticMesh1 来修改它们的相对位置 因为这个StaticMesh1并不是根组件 进入PIE模式 也能看到它们 保持着相对位置 也可以右键某个组件选择删除 或者选中后直接按delete 如果删除了现在作为根组件的StaticMesh 视口中的网格体会消失 DefaultSceneRoot会重新出现 视口中也会再次出现那个折叠镜形状 UE不允许Actor没有根组件 所以删除根组件后 它会创建一个新的场景根组件 因为Actor必须有变换信息 需要位置和旋转信息

C++

我们的Item类 是继承自Actor类 UE会根据这个类创建一个类默认对象 Item Class Default Object 简称CDO 这个类默认对象 它是在UE启动时自动创建的 编译代码其实也顺便把它给生成了 它保存着这个类的默认值 当你创建了这个类的实例时 如果没有特别指定 就会使用CDO中的值来初始化这个实例的属性 这个类默认对象存在于UE的反射系统里 UE会为每个类创建类默认对象 然后它会执行每个类的构造函数设置默认值 这对于蓝图来说特别关键 蓝图的默认值由引擎初始化 这些信息来自于蓝图对应的类和默认对象 所有这些过程都在后台完成 UE自动为我们做了大量的初始化工作

类的构造函数在游戏引擎启动的早期阶段执行 也就是说 在类的构造函数中执行某些操作还为时过早 如果想在游戏开始时确保所有游戏对象都初始化 那么我们必须在BeginPlay函数中执行 而不是在对象的构造函数中
比如有一个名为Bomb的类 它会在游戏一开始就爆炸 你可能希望它检测附近有哪些Actor 然后对它们造成伤害 那么你肯定不会想在Bomb的构造函数里这么做 时机太早了 不是所有Actor都已完成初始化 引擎还没有创建它们的类默认对象来初始化这些Actor的默认值 这种功能必须使用BeginPlay 因为那时所有Actor都已初始化完毕

我们之所以提到类默认对象 是因为组件的处理过程是类似的

组件是附加到Actor上的对象 组件本身也是UObject 在UE中 一个UObject可以拥有其它UObject 这些被拥有的UObject 就是子对象 所以组件就是Actor的子对象

默认子对象DefaultSubobject是指 在Actor的构造函数中创建的组件 这些组件会成为这个Actor类的CDO的一部分 也就是说 当这个Actor类被创建时 它就会默认带有这些组件
当你定义一个Actor类时 你可能希望它默认包含一些组件 比如静态网格组件 这些组件在Actor被创建时就应该存在 所以我们要把创建组件的代码 写在Actor的构造函数里 这样我们就能告诉UE 这个Actor的每一个实例都应该默认包含这个组件 并且这个组件的初始值由CDO中的这个默认子对象提供

到这里我们已经发现了 类本身是有一个UE为它自动生成的类默认对象CDO的 我们并不需要显式地创建 但是UE并不会自动为我们的Actor添加组件 如果我们想要一个组件 就必须手动地显式创建 组件是Actor的一部分 我们需要在Actor的构造函数中显式创建默认子对象 这样这个组件就会成为Actor的CDO的一部分

假设我们有一个Item类 想要添加一个叫ItemMesh的组件 那么我们就需要把这个组件创建成类的一个默认子对象

创建默认子对象时我们需要提供一些信息

  1. 我们需要确定子对象的类型 比如UStaticMeshComponent
  2. 然后取一个内部名称 这个名称和实际的变量名不一样 UE用它来做很多事情 主要是追踪不同的对象
  3. 创建默认子对象 要用一个模板函数 这样就可以接收不同的数据类型

CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ItemMeshComponent"))
UStaticMeshComponent就是子对象的类型 TEXT("ItemMeshComponent")是它的内部名称

创建默认值对象函数会返回新创建子对象的地址 所以我们可以把它存储在指针里 像这样的帮我们创建对象的函数 叫做工厂函数 工厂Factory就是一个帮我们创建对象的函数 在C++里你可能习惯于用new关键字创建对象的新实例 然后把对象的地址存储在指针里 在UE里我们基本不用new关键字 引擎自带的工厂函数会帮我们创建对象并处理很多内部事务 比如注册对象让引擎知道它的存在

所以现在我们明白了 要在C++里创建新的组件 就是要创建一个默认子对象 这样它就会伴随着CDO被创建 然后我们就是类似于用new创建 但是我们不是使用new 而是使用工厂函数 这样做是为了集成到UE的对象管理系统中 在传统C++里 我们会使用new在堆上分配内存并调用构造函数 然后在不需要时用delete释放内存 但是UE中 就是使用UE的智能指针 或者让UE的垃圾回收系统来管理 对于组件 我们使用CreateDefaultSubobject 在Actor或父组件的构造函数中创建 这样这些组件就会成为该Actor或父组件的子对象 并随着父对象的销毁而销毁

回到VS 首先清理一下我们原本的演示代码 暂时保留正弦余弦函数和RunningTime 删掉调试形状 因为我们马上就要添加一个组件 到时候就能在场景里看到它了

// Item.cpp
#include "Items/Item.h"
#include "HelloWorld/DebugMacros.h"

AItem::AItem()
{
    PrimaryActorTick.bCanEverTick = true;
}

void AItem::BeginPlay()
{
    Super::BeginPlay();
}

float AItem::TransformedSin()
{
    return Amplitude * FMath::Sin(RunningTime * TimeConstant);
}

float AItem::TransformedCos()
{
    return Amplitude * FMath::Cos(RunningTime * TimeConstant);
}

void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    RunningTime += DeltaTime;

}
// Item.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Item.generated.h"

UCLASS()
class HELLOWORLD_API AItem : public AActor
{
    GENERATED_BODY()
    
public:    
    AItem();
    virtual void Tick(float DeltaTime) override;

protected:
    virtual void BeginPlay() override;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Sine Parameters")
    float Amplitude = 0.25f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Sine Parameters")
    float TimeConstant = 5.f;

    UFUNCTION(BlueprintPure)
    float TransformedSin();

    UFUNCTION(BlueprintPure)
    float TransformedCos();

    template<typename T>
    T Avg(T First, T Second);


private:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
    float RunningTime;
};

template<typename T>
inline T AItem::Avg(T First, T Second)
{
    return (First + Second) / 2;
}

现在我们要在Item.h添加一个组件 就放在private的末尾

// Item.h
UPROPERTY(VisibleAnywhere)
UStaticMeshComponent* ItemMesh;

UStaticMeshComponent* ItemMesh; 我们只是创建了一个UStaticMeshComponent类型的空指针 然后用UPROPERTY将这个指针暴露给反射系统和蓝图 当我们创建新组件并将地址保存在这个指针里之后 子对象就存在于我们的Actor中了 UE通常会进行垃圾回收 它会检查世界里的所有对象 看看有没有指针指向它们 如果没有 它就会直接删除这些对象 添加UPROPERTY会让这个变量参与反射系统 这样UE就知道这个指针指向哪个对象了 它就会知道这个对象还在使用 不会被回收

现在我们有了ItemMesh指针 接下来要创建一个新的静态网格组件子对象 打开Item.cpp 我们要在Actor的构造函数里完成这个操作 在构造函数里 我们创建一个新的静态网格组件

// Item.cpp
AItem::AItem()
{
    PrimaryActorTick.bCanEverTick = true;

    ItemMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ItemMesh"));
    RootComponent = ItemMesh;
}

CreateDefaultSubobject是一个模板函数 这个函数会返回一个指向新创建对象的指针 所以我们要把这个工厂函数返回的地址 存到ItemMesh指针变量里

根组件可以重新指定到另一个场景组件 ItemMesh是静态网格组件 继承自场景组件 所以我们可以把Actor继承的根组件变量重新指定给ItemMesh 就像蓝图一样 根组件指针变量指向的默认场景根会自动删除 这是因为垃圾回收系统会发现根组件不再指向它了 因为它没有任何指针指向 所以会被垃圾回收并删除 现在我们的根组件指针RootComponent指向我们新创建的ItemMesh子对象

回到UE热重载 进入BP_Item的蓝图编辑器 看左上角组件选项卡 已经有我们创建的ItemMeshComponent了 显示为Item Mesh(ItemMeshComponent) 而且它就是根组件 点击视口 什么都没有 连折叠镜都没有了 这是因为我们的ItemMesh是一个静态网格组件 它有个静态网格变量 但是默认是空的None

如有需要 我们可以直接在C++里硬编码静态网格体使其不是None 但是通常的做法是在C里创建组件 然后在蓝图里设置它的静态网格体属性 这样就更灵活了我们可以创建多个BP_Item 然后在每一个蓝图里分别设置它们的静态网格体

53.png
可以看到 我们原来的是ItemMesh中间没有空格 因为C++变量名不能有空格 现在在组件选项卡里 UE看到了我们使用了两个词 因为Item和Mesh都是首字母大写 所以它自动为我们处理成有空格 让蓝图更易读 括号里的ItemMeshComponent是我们创建默认子对象时提供的内部名称 而且我们使用UPROPERTY宏标记的是VisibleAnywhere 如果让它是EditableAnywhere 那就意味着我们可以直接修改指针值 其实对于编辑器就只是一个地址 对于场景组件没什么意义 我们一般不会让场景组件EditableAnywhere 只是VisibleAnywhere就足够了

场景组件有自己的属性 可以在右侧细节面板修改 可以看到 变换 一栏 有一个缩放 我们已经知道 场景组件拥有完整的变换 包含位置 旋转 缩放 但是 如果这个组件是根组件 我们就不能改变它的位置或旋转 如果想要改变它的位置 就只能在世界中的Actor实例上进行操作

进入PIE模式 会发现BP_Item还是在旋转 这是因为我们之前使用了蓝图纯函数 使用正弦和余弦函数分别对X和Y产生了偏移 现在我们把正弦函数的引脚从Delta X换成连接到Delta Z 鼠标悬停在连线上 直到这条连线高亮 然后按alt 就可以断开连线 顺便把余弦函数删掉 编译后再次进入PIE模式 就发现我们的BP_Item换成了上下浮动

在C++中创建组件 我们所做的就只是

  1. 在Item.h中 用给定的组件类型(比如UStaticMeshComponent类型) 创建一个 指向我们所要创建的组件 的指针
    UStaticMeshComponent* ItemMesh; 这样当这个类的所有实例被创建时 就都会拥有这个指针变量 我们将在这个类的构造函数中为这个指针赋值
    实际上之前在做Amplitude的时候 最开始我们也是打算在Item.h里创建这个变量 之后在Item.cpp 类的构造函数中对其赋值
  2. 然后使用UPROPERTY(VisibleAnywhere)将它暴露给蓝图编辑器 这样我们就能在蓝图编辑器里修改它的静态网格变量了
  3. 在Item.cpp中 在类的构造函数中 创建类的默认子对象
    ItemMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("ItemMeshComponent")); 这样在这个类的每一个实例创建之后 就都会拥有这个组件
  4. 然后重新赋值根组件 存储新创建的ItemMesh的地址 这样所有Actor实例默认的DefaultSceneRoot就都被删掉了 根组件就指向了我们新创建的ItemMesh
    RootComponent = ItemMesh;

Pawn类

已经说过 Pawn除了继承Actor类的功能 还有一些额外功能 首先可以被控制器操控 我们可以输入键盘按键 鼠标点击 移动鼠标 用这些数据来控制Pawn的移动

我们创建一个pawn 鸟类 这样就能有一个飞翔的鸟类
打开UE 关卡编辑器左上角 - 工具 - 新建C++类 选择Pawn 点击下一步 选择公共 命名为Bird 路径设置为???/HelloWorld/Source/HelloWorld/Pawns/ 然后点击创建类 这时候UE会自动开始热重载 回到VS 会有一个弹窗说 已在环境外修改解决方案 我们点击全部重新加载 就可以看到Bird.cpp和Bird.h 稍作整理

// Bird.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "Bird.generated.h"

UCLASS()
class HELLOWORLD_API ABird : public APawn
{
    GENERATED_BODY()

public:
    ABird();
    virtual void Tick(float DeltaTime) override;

    // Called to bind functionality to input
    virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

protected:
    virtual void BeginPlay() override;
};
// Bird.cpp
#include "Pawns/Bird.h"

ABird::ABird()
{
    PrimaryActorTick.bCanEverTick = true;

}

void ABird::BeginPlay()
{
    Super::BeginPlay();
    
}

void ABird::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

// Called to bind functionality to input
void ABird::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

}

我们的类名叫Bird Pawn派生自Actor 所以C++中它的名字前面会有一个A 但是在UE编辑器中 比如蓝图编辑器里 UE会自动帮我们去掉这个A
还可以发现 比Item类多了一个SetupPlayerInputComponent 设置玩家输入组件 允许我们接收输入 比如按下键盘 移动鼠标 我们可以访问并利用这些数据来控制我们的pawn

UE 在内容侧滑菜单 所有 - C++类 - HelloWorld - Public - Pawns - Bird 就可以看到我们的Bird类 现在在内容文件夹 - Blueprints 里 创建一个Pawns文件夹 然后进入Pawns文件夹 右键 新建蓝图类 在弹出窗口 所有类一栏中 搜索Bird 选择Bird 创建一个蓝图类 命名为BP_Bird

先去Fab商城里下载一个具备动画的鸟类的资产 然后双击这个BP_Bird 进入蓝图编辑器 可以看到它现在有组件DefaultSceneRoot 我们肯定需要个网格体才能看到小鸟 但这不是我们要添加的第一个组件

胶囊组件

首先用一个胶囊体capsule 用来做基本的碰撞检测 游戏里的每一个网格mesh都是由多边形构成的 这只鸟类就有很多多边形 多边形数量对于做碰撞检测而言还是太多了 检测与实际网格的碰撞需要引擎进行大量计算 判断飞过来的这个物体是否与任何多边形相撞 所以需要用更简单的胶囊体来处理碰撞 当物体靠近并与胶囊体发生碰撞 引擎就能检测到碰撞 计算量大幅减少 我们通常会使用一个包含基本几何形状的组件来处理碰撞

UE有一个名为UCapsuleComponent胶囊组件的类 U前缀表示它源于UObject 而不是Actor UObject类 派生出USceneComponent场景组件 可以变换 还支持附加 而UCapsuleComponent是从USceneComponent派生出来的 所以它支持附加 也支持变换 当然还有其他类也源于这些对象 这条继承链实际上更复杂 在这里我们只关注胶囊组件

胶囊组件用于简单的碰撞检测 它的几何形状非常简单 在编辑器中还会以红线显示 这样我们就能看到它的碰撞体积了

打开Bird.h 因为胶囊组件是这个Actor的内部信息 所以放到private

private:
    UCapsuleComponent* Capsule;

写完这一句之后 我们发现VS对UCapsuleComponent标红了 提示我们说UCapsuleComponent标识符未定义 关闭UE 使用VS编译 BuildHelloWorld 输出信息里出现报错 都是围绕着这个指针产生的问题 因为编译器并不认识UCapsuleComponent

现在只要确保我们不用未定义的类型 就能避免所有这些错误 其实这个类型在UE中定义了 只不过是在一个特定的头文件里
我们打开UE官方文档 在右侧搜索栏输入UCapsuleComponent直接按搜索 我们并没有找到任何有效的东西 在搜索时要保证页面语言是英文 可以按地球图标进行切换 输入完UCapsuleComponent之后 不能立刻按搜索 而是要在那个输入时悬浮出来的选项卡里 选择后缀为API Documentation的 UCapsuleComponent 实际上在我们上一次在官方文档中搜索FString时 也可以使用这种方法
54.png

最后我们进入了UCapsuleComponent文档

在文档开头 它告诉我们这个类在引擎中定义的头文件的位置 /Engine/Source/Runtime/Engine/Classes/Components/CapsuleComponent.h 也告诉我们如果要包含这个头文件的包含路径 #include "Components/CapsuleComponent.h"

稍微往下翻 我们就可以看到它的继承层级Inheritance Hierarchy

UObjectBase  UObjectBaseUtility  UObject  UActorComponent  USceneComponent  UPrimitiveComponent  UShapeComponent  UCapsuleComponent

继承自UObject 之后是UActorComponent 然后是USceneComponent 剩下就是我们没接触过的东西 比我们之前说的复杂许多 继承体系中越往下 每个子类添加的功能就越多

再后面就是一些函数

在UE中include头文件时 一定要放在.generated.h之前 也就是说.generated.h必须放在最后 否则就会报错 这是因为当UE引入.generated.h时 预处理器会自动插入大量代码 使得这个类能够工作并与反射系统集成

现在我们

// Bird.h
#include "Components/CapsuleComponent.h"

这样之后 就发现UCapsuleComponent的标红消失了 关闭UE 按ctrl+F5编译并重新打开

想在蓝图中看到这个胶囊组件 就要把它暴露到蓝图 需要使用PROPERTY宏

// Bird.h
private:
    UPROPERTY(VisibleAnywhere)
    UCapsuleComponent* Capsule;

现在这个Capsule只是一个指针变量 我们还没有创建胶囊对象 需要在胶囊的构造函数中为胶囊创建一个默认子对象 并将Capsule设置为根组件

// Bird.cpp
ABird::ABird()
{
    PrimaryActorTick.bCanEverTick = true;

    Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
    SetRootComponent(Capsule);
}

SetRootComponent(Capsule);和我们之间写的RootComponent = Capsule; 功能是一样的 SetRootCoponent是我们继承的设置根组件的函数 可以通过传入一个场景组件来设置根组件 而Capsule确实就是一个场景组件 符合输入要求
使用SetRootCoponent 那么在将来UE决定将RootComponent设置成private变量 我们的代码仍能编译 因为SetRootCoponent是public函数

在UE热重载 然后再内容侧滑菜单 双击BP_Bird 之后点击上方的打开完整蓝图编辑器 我们可以在组件面板看到Capsule 它现在是根节点 点击视口 可以看到胶囊体的形状

我们可以调整胶囊的高度和半径 需要在蓝图编辑器 组件面板 选中Capsule组件 右侧细节面板 形状一栏 可以设置 胶囊体半高 胶囊体半径 我们将它变小 直到像鸟类那样小 也可以在C++中修改

ABird::ABird()
{
    PrimaryActorTick.bCanEverTick = true;

    Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
    Capsule->SetCapsuleHalfHeight(20.f);
    Capsule->SetCapsuleRadius(15.f);
    SetRootComponent(Capsule);
}

这个SetCapsuleHalfHeight函数的第二个参数是bool bUpdateOverlapes = true 意思是如果我们的胶囊体设置为在重叠时触发事件 那么像这样缩放胶囊体就能检查修改HalfHeight后是否与其它物体重叠 暂时我们先不讨论 无需传入参数 就保持它的默认值true

前向声明

在前面我们通过#include "Components/CapsuleComponent.h" 解决了声明UCapsuleComponent指针时的报错问题 其实这并不是最佳实践 下面讲解原因

// Creature.h
class ACreature : public APawn
{
    UCapsuleComponent* Capsule;
    
};

在Creature里 我们想要一个胶囊组件指针 现在编译器就会报错标红 当我们include胶囊组件的头文件之后 标红就消失了 但是我们点击编译之后 预处理器就会看到这个include语句 然后找到CapsuleComponent.h文件 直接用CapsuleComponent.h文件里的所有内容替换掉那行include语句 CapsuleComponent是在CapsuleComponent.h里面定义的类 所以编译器处理到我们的变量时就知道这个类型已经定义过了 但是如果在Creature.h里又复制CapsuleComponent.h 这样的话Creature.h文件就会变得很大了 Creature.h文件比CapsuleComponent.h还要大 如果头文件之间这样反复嵌套互相调用 也就是循环依赖 编译器根本处理不了这种问题 而且即使我们用了pragma once 最终每一个头文件也都会变得很大 而且在某一个头文件中 很有可能不需要那么多其它头文件的功能

先看Creature.cpp中对于CapsuleComponent类的使用

// Creature.cpp
ACreature::ACreature()
{
    Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
    
};

在Creature.h中创建指针时 类的尺寸大小无关紧要 因为指针只负责存储内存地址 每一个指针的size都是相同的 在Creature.cpp文件中 Creature类自带构造函数 我们使用CreateDefaultSubobject在它的构造函数里创建CapsuleComponent对象 直到这个操作 我们才需要知道CapsuleComponent的大小 才能在内存中分配足够的空间

我们的解决方案是 既然所有对于CapsuleComponent的实际使用都是发生在Creature.cpp文件里的 而Creature.h中对于CapsuleComponent的使用 也只是使用了固定大小的指针 并不涉及CapsuleComponent类的实例本身的尺寸大小 那么我们就希望交给Creature.cpp文件来解决 也就是说 不在Creature.h中包含CapsuleComponent.h 而是只在Creature.cpp中包含 我们只在cpp中include这个cpp真正需要使用的.h文件

所以在Creature.h中 我们可以在声明CapsuleComponent指针类型变量时 直接进行forward declare前向声明/提前声明

// Creature.h
class ACreature : public APawn
{
    class UCapsuleComponent* Capsule;
    
};

这里的class关键字 表示我们正在对UCapsuleComponent类型进行前向声明 前向声明的意思就是 声明一个class类型 我们没明确定义它 只是告诉编译器 UCapsuleComponent类型不在这个Creature.h文件里定义 但你可以在别处找到

我们真正需要用到UCapsuleComponent的地方 比如创建这个类型的新对象 都是发生在Creatue.cpp里 我们提前在Creature.h里声明UCapsuleComponent时 编译器认为这里是不完整类型 意思是描述了一个标识符 但缺少确定它大小的信息 而确定它大小的信息 在UCapsuleComponent.h里面的类的声明里 在得到 对于不完整类型 使用起来是受限的 我们只能创建它的指针 却不能访问成员变量 函数 或者创建对象 因为我们并不知道这个类型的大小 我们只知道这是一个指针 所以在Creature.cpp里 创建UCapsuleComponent实例时 只要它是不完整的类型 就会报错 因为类定义在头文件里 所以我们要在Creature.cpp里 #include "Components/CapsuleComponent.h"

必须使用头文件的场合

  1. 继承父类时 必须包含父类的头文件
    比如 Bird类继承自Pawn类 我们不可能在不知道Pawn类的尺寸 成员函数 变量 等细节的情况下继承它 所以UE在生成类时自动提供了头文件 #include "GameFramework/Pawn.h" 我们还可以看到#include "CoreMinimal.h"#include "Bird.generated.h" 这些也都会自动包含进来 因为我们需要在CoreMinimal.h中的一些类型 比如我们的Item.h中 也包含了CoreMinimal.h 很多文件都包含了它 这样就不会出现循环依赖了 因为CoreMinimal.h中包含了很多基础信息 我们可以把CoreMinimal.h包含到所有头文件中 并且永远不会重复包含 因为我们的每一个头文件都有pragma once

  2. 需要知道类型大小的时候 需要包含这个类的头文件
    比如我们之前用工厂函数创建类的实例时

  3. 要访问某个类的成员变量和函数 需要包含这个类的头文件
    因为我们想要访问的 类的变量和函数 的信息 就在头文件里 所以必须要包含这个类的头文件

我们将遵循最佳实践 只在需要的地方包含头文件

我们回到Brid.h 不再在Brid.h里包含胶囊组件 而是将它删掉 换成在Brid.cpp里include

// Bird.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "Bird.generated.h"

UCLASS()
class HELLOWORLD_API ABird : public APawn
{
    GENERATED_BODY()

public:
    ABird();
    virtual void Tick(float DeltaTime) override;

    // Called to bind functionality to input
    virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

protected:
    virtual void BeginPlay() override;

private:
    UPROPERTY(VisibleAnywhere)
    class UCapsuleComponent* Capsule;
};
// Bird.cpp
#include "Pawns/Bird.h"
#include "Components/CapsuleComponent.h"

ABird::ABird()
{
    PrimaryActorTick.bCanEverTick = true;

    Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
    Capsule->SetCapsuleHalfHeight(20.f);
    Capsule->SetCapsuleRadius(15.f);
    SetRootComponent(Capsule);
}

void ABird::BeginPlay()
{
    Super::BeginPlay();
    
}

void ABird::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

// Called to bind functionality to input
void ABird::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

}

当我们在头文件中前向声明某个类 就可以继续在头文件中使用它们了

private:
    UPROPERTY(VisibleAnywhere)
    class UCapsuleComponent* Capsule;

    UCapsuleComponent* SecondCapsule;

最后一行并不会报错 因为编译器已经知道这个类型被前向声明过了 但是如果试图在这个前向声明的上方声明这个变量 编译器就会报错 编译器从文件顶部开始逐行读取 当编译器读到那一行时 它还不认识这个类型

所以有些人喜欢把所有的前向声明都写在文件的开头 我们可以就写在头文件下面

// Bird.h
#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "Bird.generated.h"

class UCapsuleComponent;

UCLASS()
class HELLOWORLD_API ABird : public APawn
{

但我们还是会在private里再写一遍 作为类的成员变量

// Bird.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "Bird.generated.h"

class UCapsuleComponent;

UCLASS()
class HELLOWORLD_API ABird : public APawn
{
    GENERATED_BODY()

public:
    ABird();
    virtual void Tick(float DeltaTime) override;

    // Called to bind functionality to input
    virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

protected:
    virtual void BeginPlay() override;

private:
    UPROPERTY(VisibleAnywhere)
    class UCapsuleComponent* Capsule;
};

还记得我们在Item.h中使用了

UStaticMeshComponent* ItemMesh;

它没有报错 我们也没有前向声明它 也没有在开头声明 这是因为默认已经包含进去了 我们的项目是基于AActor的 AActor的头文件伦理就包含了这些头文件 在某个地方 UStaticMeshComponent已经被包含或者前向声明过了

所以遇到因为类型未定义报错 可以考虑前向声明 如果不确定 直接前向声明总是没错的 然后只要在cpp文件中包含对应的头文件就可以了

骨骼网格组件

我们之前学的都是静态网格体 但是鸟是有动画的 需要用骨骼网格组件

UObject
  
USceneComponent
  
USkeletalMeshComponent

USkeletalMeshComponent骨骼网格体组件继承了UObject的属性 并且功能更强大 就像静态网格组件有自己的StaticMesh变量一样 骨骼网格组件也有自己的SkeletalMesh变量 SkeletalMesh包含一个Skeleton骨架 有了骨架 就能进行动画

现在找到下载的鸟类资产中的骨骼网格体 前缀一般为SK_ 让我们想起静态网格体的前缀SM 双击进入骨骼网格体编辑器 和静态网格体编辑器差不多 但是有一些额外的功能 右下角资产详情面板 显示了材质和一些其它信息 右上角有骨骼树 显示了骨骼的层级结构 里面的命名都是什么Body Arm Wing Head Eye Leg Foot Tail 我们在视口里默认是看不到骨骼的
55.png

选择视口上方的眼睛图标 - 骨骼 - 所有层级 就会在视口中显示骨骼 现在骨架中的骨骼是按照网格赋予权重的 也就是 一根骨头动 对应的网格就会跟着变形移动 我们可以点击并移动这些骨头 通过双击选中 或者可以在骨骼树中选中 它就会出现旋转工具 我们当然也可以通过QWER切换编辑工具 比如我们对翅膀根部进行旋转 会发现整个翅膀都会发生移动 这是因为网格做了权重绘制 网格上的顶点会跟着骨头发生移动 这通常是在Maya或Blender等建模软件中完成的 给骨骼添加骨架并进行权重绘制 这个过程叫做绑定 所以绑定要做的工作就是 为骨骼创建骨架 让网格能随着骨骼变形和动画 动画资源包含骨骼运动信息 每块骨骼都会以不同的方式影响模型网格

再次点击上方眼睛图标 - 骨骼 - 仅显示选定 这样我们就能一次只选择一根骨骼 只显示选中的那根骨骼 因为骨骼网格带有骨骼 骨骼移动时 网格也会跟着动 所以我们可以利用这个网格制作动画

现在进入鸟类资产的动画文件夹 可以看到一些动画序列 这些数据包含了骨骼运动的信息 可以双击任何一个 看到底端有时间线 随着进度条从左到右移动 模型也在动画播放 我们可以随时暂停动画 逐帧查看动画效果 这个动画只和这个模型关联 动画编辑器右上角的图标可以让我们直接跳转到骨骼网格或骨骼

骨骼网格体编辑器 点开默认是下图这样
56.png

点击右上角左边的骷髅图标就是进入骨架编辑器页面
我们可以看到这个骨架对应的网格模型 如果还有使用同一个骨架的骨骼网络 就可以在 右侧 预览场景设置面板 - 网格体 - 预览网格体(骨骼) 右侧向下三角箭头 切换成不同的网格 只要共用一个骨架就可以 但是目前我们只有一只鸟的骨骼网格用到了这个骨架

点击右上角最右侧的跑步小人图标就是动画界面 在右下角资产浏览器就可以切换查看多个不同动画

接下来给鸟的Pawn添加一个骨骼网格组件 回到VS

在Bird.h中 我们之前在文件开头 前向声明的部分 再加一行class USkeletalMeshComponent;

// Bird.h
#include "CoreMinimal.h"
#include "GameFramework/Pawn.h"
#include "Bird.generated.h"

class UCapsuleComponent;
class USkeletalMeshComponent;

然后在类的结尾 Private中添加 并将其设置为蓝图可见

private:
    UPROPERTY(VisibleAnywhere)
    class UCapsuleComponent* Capsule;

    UPROPERTY(VisibleAnywhere)
    USkeletalMeshComponent* BirdMesh;

再次强调 这只是一个指针 并没有初始化 还需要在Bird.cpp创建一个默认子对象

我们先去官方文档API中搜索USkeletalMeshComponent 找到它的头文件 #include "Components/SkeletalMeshComponent.h" 将它写在Bird.cpp的开头

// Bird.cpp
#include "Pawns/Bird.h"
#include "Components/CapsuleComponent.h"
#include "Components/SkeletalMeshComponent.h"

我们需要在Bird的构造函数中创建默认子对象 这个USkeletalMeshComponent类继承自场景组件 所以支持附加 我们要把它附加到根组件上 这样 如果根组件发生移动 鸟的网格也会跟着发生移动 我们需要使用场景组件的函数SetupAttachment 来将它附加到其它场景组件上 SetupAttachment需要接收一个USceneComponent类型的输入参数 根组件RootComponent是protected 所以我们可以在子类Bird.cpp中访问它 BirdMesh->SetupAttachment(RootComponent); 这里我们可以用RootComponent 也可以用Capsule 或者用GetRootComponent() 得到的都是根组件 因为我们之前已经把根组件绑定成胶囊体Capsule了 这个函数的第2个参数InSocketName 接收FName类型 可以用它指定一个socket名称 我们还没有学到socket 此时我们只需要知道 场景组件可以有socket 如果我们指定一个socket的名称 SetupAttachment就会将我们的BirdMesh连接到这个socket 我们暂时不传入socket参数 默认值就是NAME_None 这是一个FName类型的变量 表示我们还没有连接到任何socket

BirdMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("BirdMesh"));
BirdMesh->SetupAttachment(RootComponent);

现在我们的BirdMesh已经设置为 附加到根组件 也就是胶囊体上 回到UE热重载 打开BP_Bird 在左侧组件面板中 可以看到在Capsule下方有BirdMesh 双击选中BirdMesh 在右侧细节面板中 可以查看它的属性 往下翻可以看到网格体一栏 默认是None 但我们可以进行修改 点击下拉菜单 可以查看项目里所有骨骼网格 我们选择一个 就出现了网格 它默认朝向Y轴 左手边是X轴 我们按E 使用旋转工具 将其逆时针旋转90度至面朝X轴 也就是前进方向为X轴

我们适当调整胶囊体Capsule的大小 使其匹配骨骼网格的大小 发现胶囊体的中心 并不在鸟的重心上 鸟只在胶囊体的上半部分中 所以我们需要按W 使用平移工具将鸟向下移

回到关卡编辑器 拖动一个BP_Bird的实例到关卡中 为了匹配关卡 将其适当缩放 进入PIE模式 就可以看到它

按键绑定

默认状况下 我们并不会控制这只鸟 它也没有任何动作 但这只鸟是一个Pawn 我们可以操纵它 设置好之后 它就能接收输入了

回到BP_Bird的骨骼网格编辑器 选择BirdMesh 右侧细节面板 动画一栏 有一个动画模式 默认是使用动画蓝图 我们暂且不谈动画蓝图 可以在下拉菜单中选择使用动画资产 就可以选择 要播放的动画 目前是None 图标下方是绿色的线 我们之前看到的动画序列素材下方都是绿色的线 这种绿色就代表动画序列的类型 在这里选择一个飞行动画 编译 进入PIE模式 就可以看到鸟在飞了 这是因为我们让它播放了这个动画序列

在UE中 一启动游戏 就会生成一个控制器并分配给你 这个控制器代表你在游戏中的形象 游戏会识别这个控制器 认为那就是你 控制器允许我们操控一个Pawn 而Actor不能操控 我们要操控这只鸟 配置项目 以便接收用户控制器输入

所有Pawn都有一个叫做autopossessed_player的变量 我们可以设置它的默认值 如果将它设置成Player 0 意思是游戏世界里的第一个玩家 如果是在多人游戏中 第一个玩家是Player 0 第二个玩家就是Player 1 但我们目前只打算做单人游戏 所以就只有Player 0 所以将我们的Pawn的autopossessed_player变量设置为Player 0 意思就是 我们游戏中唯一的一个玩家将默认控制这只鸟

现在我们进入PIE模式 我们不能操控这只鸟 它只是按照动画自己在飞 我们能操控的仍然只是我们的视角 所以现在需要设置这个Pawn的autopossessed_player变量 这样之后 我们之前在PIE模式中的默认pawn将不再存在 现在进入PIE模式 可以看到上方有暂停标志和红色方块 而在它们的右侧有一个横线上方三角形的图案 鼠标悬浮在上面 显示了它的功能是 和玩家控制器分离 允许常规编辑器控制 快捷键为F8 点击它 就可以看到这个玩家控制器了 是一个表面类似足球条纹的灰色球形 可以看到它在细节面板中的名字是DefaultPawn0 在游戏开始前 它是不会出现在游戏世界里的 游戏开始后会自动生成 我们还能发现 分离之后 鼠标再也不会沉浸式地进入视口了 也就是说不用再使用Shift+F1分离鼠标 它自动就是分离的 UE自动为我们生成玩家控制器 是因为它发现我们没有配置任何Pawn

现在退出PIE模式 在大纲面板中选中BP_Bird 在右侧细节面板 往下翻 可以在Pawn一栏中找到 自动控制玩家 默认状态下是 已禁用 下拉菜单可以选择将其分配给玩家0到玩家7 这里的多个控制器 并不是指多人联网游戏 而是指本地多人游戏 也就是我们当前的电脑上有多少个控制器 在这里选择玩家0 这样之后我们再也看不到鸟的模型

进入PIE模式 发现鼠标重新变得沉浸了 但是我们无法进行移动 也看不到我们的鸟 或者我们能看到穿模后的鸟的一部分 但我们似乎可以通过物体间的相对位置 来判定 我们现在的视角 确实就是这只鸟面朝的方向 按Shift+F1 找回鼠标 再按横线上方三角形的那个图标 分离 现在可以看到我们的鸟 现在没有灰色足球了 因为我们没有再生成默认Pawn 引擎知道我们已经控制一个角色了 再按F8 就再次控制了这个角色 动不了是因为还没有添加移动功能

autopossessed_player这个变量 可以用C++设置 刚才我们是用蓝图设置的 回到VS

ABird::ABird()
{
    PrimaryActorTick.bCanEverTick = true;

    Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
    Capsule->SetCapsuleHalfHeight(900.f);
    Capsule->SetCapsuleRadius(900.f);
    SetRootComponent(Capsule);

    BirdMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("BirdMesh"));
    BirdMesh->SetupAttachment(RootComponent);

    AutoPossessPlayer = EAutoReceiveInput::Player0;
}

AutoPossessPlayer是一个TEunmAsbyte<EAutoReceiveInput::Type>类型 这是一个枚举 只是被一个TEnumAsByte包装器包起来了 有尖括号 表示模板 这是为了确保枚举在内存中只占用一个字节 所以这个包装器存储的是EAutoReceiveInput类型 我们设置它的时候 可以选择一个EAutoReceiveInput枚举常量 输入EAutoReceiveInput:: 我们就可以看到提示框里显示了这个枚举包含的所有常量值 Disabled Player0 - Player7 我们在这里选择Player0 我们可以对EAutoReceiveInput右键 - 转到定义

/** Specifies which player index will pass input to this actor/component */
UENUM()
namespace EAutoReceiveInput
{
    enum Type : int
    {
        Disabled,
        Player0,
        Player1,
        Player2,
        Player3,
        Player4,
        Player5,
        Player6,
        Player7,
    };
}

所以如果我们想调用Player0 就要写成EAutoReceiveInput::Player0 因为它在EAutoReceiveInput这个命名空间里 枚举Type本身并不是一个命名空间 其实更现代的C++会直接使用枚举类

enum class EAutoReceiveInput : int
{
    Disabled,
    Player0,
    Player1,
    Player2,
    Player3,
    Player4,
    Player5,
    Player6,
    Player7,
};

回到UE 现在要为我们的项目配置输入 关卡编辑器上方菜单栏 - 编辑 - 项目设置 - 引擎 - 输入 - 绑定 这里有文字提示我们 轴和操作映射现已废弃 请改用增强输入操作和输入映射上下文 但我们还是决定使用轴映射 暂时忽略操作映射

点击轴映射右侧加号 就会出现 新建轴映射_0 我们将其改名为MoveForward 向前移动 下面有个None 还有缩放 默认值为1 暂时我们先不过多讨论 点击向下三角箭头
下拉菜单可以选择输入类型 弹出窗口里有手柄 键盘 鼠标 运动 触摸 等等 在这里我们点击 键盘 会出现所有按键 选择W 也可以点击左侧键盘图标 然后再按键盘上的W 它就会变成W

一个轴映射可以绑定一个或多个按键 现在我们把W键绑定到了MoveForward上 它将 在我们的Pawn上 也就是Bird类上 创造一个函数

回到VS Bird.h 现在创建MoveForward函数的声明 返回值void 接收参数float 打算将它设置为protected 因为不打算在类的外部使用 这样也可以在子类中调用

// Bird.h
protected:
    virtual void BeginPlay() override;
    void MoveForward(float Value);

鼠标悬停在MoveForward上 会出现螺丝刀图标 如果没有出现 就对MoveForward右键 - 快速操作和重构 发现没有出现 创建”MoveForward”的定义(在Bird.cpp中) 只出现了 选择创建声明/定义 点击后 它为我们跳转到了ArchVisCharacter.cpp中的AArchVisCharacter::MoveForward 这是一个已经写好了的方法 我们还是手动在Brid.cpp中创建 就放在ABird::BeginPlay函数下方

// Brid.cpp
void ABird::BeginPlay()
{
    Super::BeginPlay();
    
}

void ABird::MoveForward(float Value)
{

}

void ABird::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

我们要把它绑定到前进轴映射上 在ABird::SetupPlayerInputComponent中

// Bird.cpp
void ABird::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    PlayerInputComponent->BindAxis(TEXT("MoveForward"), this, &ABird::MoveForward)
}

SetupPlayerInputComponent的设置会在游戏开始的很早阶段就执行 它接收一个名为PlayerInputComponent的UInputComponent类型指针参数 UInputComponent类有把回调函数绑定到轴映射的方法BindAxis 因为PlayerInputComponent是指针 所以使用->运算符可以调用BindAxis BindAxis的第1个参数 FName类型 可以接收字符串字面量 最好放在TEXT宏中 TEXT("MoveForward") 或者可以直接传入一个FName类型 FName("MoveForward") BindAxis的另一个重载版本中 第2个参数是UserClass类型指针 UserClass用户类位于继承层次结构的较高位置 我们的Bird类是这个类的子类 所以这里可以传入我们的Bird Pawn 使用this指针 指向我们当前类的这个对象 我们确实是想要把这个MoveForward轴映射绑定到我们当前的的Bird Pawn实例上 第3个参数是FOnFloatValueChanged::TMethodPtr<UserClass> Func 能体会到这里是需要传入一个函数指针 那么我们就应该把MoveForward的地址传入 也就是&ABird::MoveForward 这里要写完整 不能只是MoveForward 于是这里的用法 确实就是之前学习C++时提到的函数指针的回调 我们成功将MoveForward函数绑定到了前进的轴映射上 这样之后 游戏运行时 我们的MoveForward函数就会每一帧执行 有点类似于Tick函数 区别是它不是DeltaTime 而是Value值 Value值取决于我们是否按下了绑定到这个轴映射的按键

因为现在我们还没有写下MoveForward的具体实现 所以我们可以使用日志系统来测试一下

// Bird.cpp
void MoveForward(float Value)
{
    UE_LOG(LogTemp, Warning, TEXT("Move Forward Value: %f"), Value);
}

回到UE 热重载 进入PIE模式 发现输出日志中在持续打印LogTemp: Warning: Move Forward Value: 0.000000 确保鼠标是沉浸在视口内 按下W Value就变成了1 这个Value值会随着你按或不按W键而发生变化 可以利用这个信息来控制Bird的移动

那么如何移动Bird?

// Bird.cpp

AddActorWorldOffset通常不是移动Pawn的方法 需要使用移动组件 if (Value != 0.0) 那么我们就知道有一个控制器正在控制着这个Pawn 世界上其它没被控制的Pawn都不能动 所以再加一个判断条件 检查控制器controller是不是空指针
if ((Controller != nullptr) && (Value != 0.0))
按住ctrl点击Controller 就跳转到了Pawn.h 上方注释写Controller currently possessing this Actor 定义是TObjectPtr<AController> Controller; Controller是一个指向AController类型的TObjectPtr智能指针 可以直接写成if (Controller && (Value != 0.0)) 效果是一样的 如果Controller有效且Value不为0 就继续执行下一步

我们需要知道移动的方向 可以使用GetActorForwardVector()得到 将其存储再一个名为Forward的局部FVector变量中

现在我们得到了前进方向向量 调用Pawn类继承的AddMovementInput函数 第一个参数是FVector类型 WorldDirection 第2个参数是ScaleValue 第3个参数是bool bForce 默认是false 决定我们是否使用力
AddMovementInput(Forward, Value);
Value不会改变向量的长度 AddMovementInput只关心Value是0 正数还是负数 如果是0 AddMovementInput不会有任何效果 如果是正数 AddMovementInput会直接使用这个Forward向量 如果是负数 就使用Forward向量的反方向

转到AddMovementInput的定义 跳转到了Pawn.cpp

// Pawn.cpp
void APawn::AddMovementInput(FVector WorldDirection, float ScaleValue, bool bForce /*=false*/)
{
    UPawnMovementComponent* MovementComponent = GetMovementComponent();
    if (MovementComponent)
    {
        MovementComponent->AddInputVector(WorldDirection * ScaleValue, bForce);
    }
    else
    {
        Internal_AddMovementInput(WorldDirection * ScaleValue, bForce);
    }
}
// PawnMovementComponent.cpp
void UPawnMovementComponent::AddInputVector(FVector WorldAccel, bool bForce /*=false*/)
{
    if (PawnOwner)
    {
        PawnOwner->Internal_AddMovementInput(WorldAccel, bForce);
    }
}
// Pawn.cpp
void APawn::Internal_AddMovementInput(FVector WorldAccel, bool bForce /*=false*/)
{
    if (bForce || !IsMoveInputIgnored())
    {
        ControlInputVector += WorldAccel;
    }
}

所以它就是把Forward交给了移动组件来处理 这个移动组件负责处理所有移动操作

现在回到UE热重载 然后打开BP_Bird的蓝图编辑器 在组件面板 没有看到移动组件 这是因为Pawn默认情况下不包含移动组件 在左侧组件面板 点击 添加 搜索 floating pawn movement 选择浮动Pawn移动 默认名称为FloatingPawnMovement 我们就新建了一个移动组件 选中它 在右侧细节面板 可以看到 我们可以修改它的很多属性 速度 加速度等等 保持默认值 点击左侧编译 之后进入PIE模式 现在按下W键 我们可以发生移动了 虽然只能向前移动 也只能看到鸟模型的穿模状态

点击关卡编辑器上方 编辑 - 项目设置 - 输入 我们目前只有W 现在继续点击MoveForward的右侧加号 添加一个S 缩放设置为-1 现在再次进入PIE模式 我们就既可以向前走 也可以向后走 但是如果传入的数值是非1的正数 速度不会翻倍 移动组件只需要AddMovementInput为它提供方向信息 剩下的速度加速度等等都是它自己的参数

添加摄像机

我们总是使用热重载 最好偶尔关掉UE 在VS中编译一下 ctrl+F5 这是因为热重载并没有真正编译代码 只是在UE中热重载 VS项目其实没有编译 所以如果关闭再打开UE 之前在C++中的改动就会消失 所以最好每一次都是通过ctrl+F5来打开UE

现在来解决我们移动时只能看到穿模后的鸟模型的问题 将摄像机组件添加到我们的Pawn上

将摄像机绑定到根组件也就是胶囊组件上 是可以做 但不是最佳方案 最好是连接到弹簧臂SpringArm组件 在BP_Bird蓝图编辑器中 组件面板 新建一个SpringArm组件 绑定到Capsule上 再新建一个Carema组件 绑定到SpringArm上 选中SpringArm 可以看到它看起来像是一条红线 连接着根组件和摄像机组件 弹簧臂的作用是 当摄像机撞到墙上 弹簧臂就会收缩回去 这样即使墙挡着 我们也能放大pawn 看到我们的pawn 在右侧细节面板可以看到 摄像机 - 目标臂长度TargetArmLength 想要调整摄像机位置时 就调整目标臂长度 这样就能在运行时动态控制摄像机距离 想调整角度就旋转机械臂而不是摄像机 我们设置一个障碍物 发现BP_Bird撞过去的时候 摄像机确实会因为撞到墙而放大

删掉刚才在蓝图中添加的SpringArm和Camera组件 回到VS Bird.h private中 添加弹簧臂组件和摄像机组件 这些组件都是基于场景组件的 所以都有附加功能 并且在前面写前向声明

// Bird.h
// private: 末尾
    UPROPERTY(VisibleAnywhere)
    USpringArmComponent* SpringArm;

    UPROPERTY(VisibleAnywhere)
    UCameraComponent* ViewCamera;
// Bird.h
class USpringArmComponent;
class UCameraComponent;

在官方文档中找到头文件

// Bird.cpp
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"

在构造函数末尾 AutoPossessPlayer = EAutoReceiveInput::Player0;之前

// Bird.cpp
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("=SpringArm"));
SpringArm->SetupAttachment(GetRootComponent());
SpringArm->TargetArmLength = 300.f;

ViewCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("ViewCamera"));
ViewCamera->SetupAttachment(SpringArm);

现在到蓝图编辑器仔细调整SpringArm的长度和角度 按弯箭头恢复默认 数值就会回到我们在C++中设置的值

控制器输入

UE的Controller类 没有位置loacation 但是有旋转信息rotation 没有网格mesh 是隐形的 所以为它设置什么大小和缩放都没有意义 淡水有旋转 可以用于鼠标输入 借助轴映射 可以用一个回调函数来获取鼠标移动的信息 然后用它来控制controller控制器的旋转 是一个FRotator 包含俯仰 偏航 滚转信息 我们可以修改这些参数 使用AddControllerPitchInput函数 可以添加俯仰输入到控制器的旋转中 AddControllerYawInput偏航输入 AddControllerRollInput滚转输入

在UE 关卡编辑器 上方 编辑 - 项目设置 - 输入 新建一个轴映射 命名为Turn 点击右侧加号 新建一个键值 在下拉菜单选择 鼠标 - 鼠标X 这样就可以绕z轴旋转小鸟 让它左右移动 轴映射每一帧都会被评估 就像tick函数一样

打开BP_Bird的蓝图编辑器 在事件图表中右键 搜索turn 找到Input-Axis Values一栏之下的前面带有箭头图标的Turn 再右键搜索 print string 将InputAxis Turn的执行引脚连接到Print String的执行引脚 将Axis Value连接到In String 现在屏幕就会被字符串消息刷屏 鼠标沉浸入视口中 鼠标左移 就会打印出大量负数 右移就是正数 这个数值代表移动鼠标的速度

回到蓝图编辑器 删除print string 右键 搜索 add controller yaw 点击Add Controller YawInput 将Turn的执行引脚连接到Add Controller YawInput上 将Axis Value连接到Val上 现在就能为控制器添加偏航旋转 但控制器是隐形的 只旋转控制器 我们什么都看不到 编译 进入PIE模式 左右移动鼠标 什么都不会发生 我们可以让小鸟跟随这个偏航旋转 在蓝图编辑器左侧组件面板 点击BP_Bird(自我) 在右侧细节面板找到Pawn一栏 勾选 使用控制器旋转Yaw 这样之后 这个pawn的yaw将会和控制器的旋转偏航角保持一致 编译 进入PIE模式 现在小鸟就可以跟随鼠标移动了 输入控制台命令 我们的根组件也就是capsule 正在跟随控制器一起移动 因为所有组件都是绑定在Capsule 所以网格 弹簧臂 摄像机 全都在跟着控制器一起移动

我们也可以调整俯仰角 为此要再在 关卡编辑器 - 编辑 - 项目设置 - 输入 添加一个轴映射 命名为LookUp 添加键值 鼠标Y 回到蓝图编辑器 事件图表 右键 搜索LookUp 选择左侧有箭头图标的LookUp 再右键 搜索AddControllerPitchInput 将InputAxis LookUp的执行引脚连接到Add Controller Pitch Input上 将Axis Value连接到val上 这时候我们可以框选多个蓝图节点 右键 - 对齐 选择合适的对齐方式 整理一下节点 然后再左侧组件面板选择BP_Bird 在右侧细节面板 Pawn一栏 勾选 使用控制器旋转Pitch 编译后 进入PIE模式 现在鼠标确实可以控制俯仰了 现在鼠标向上 视角却是向下的 可以重新到添加轴映射那里 把LokkUp的鼠标Y 缩放从1.0改成-1.0 这样再进入PIE模式 鼠标向上就是视角向上

此时可以顺便把AD键绑定上 添加轴映射 命名为MoveRight D键缩放为1 A键缩放为-1

现在在BP_Bird事件图表删除所有蓝图节点 打开VS

// Bird.h
protected:
    virtual void BeginPlay() override;
    void MoveForward(float Value);
    void MoveRight(float Value);
    void Turn(float Value);
    void LookUp(float Value);
// Bird.cpp
void ABird::MoveForward(float Value)
{
    if (Controller && (Value != 0.0))
    {
        FVector Forward = GetActorForwardVector();
        AddMovementInput(Forward, Value);
    }
}

void ABird::MoveRight(float Value)
{
    if (Controller && (Value != 0.0))
    {
        FVector Right = GetActorRightVector();
        AddMovementInput(Right, Value);
    }
}

void ABird::Turn(float Value)
{
    AddControllerYawInput(Value);
}

void ABird::LookUp(float Value)
{
    AddControllerPitchInput(Value);
}
// Bird.cpp
void ABird::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    PlayerInputComponent->BindAxis(TEXT("MoveForward"), this, &ABird::MoveForward);
    PlayerInputComponent->BindAxis(TEXT("MoveRight"), this, &ABird::MoveRight);
    PlayerInputComponent->BindAxis(TEXT("Turn"), this, &ABird::Turn);
    PlayerInputComponent->BindAxis(TEXT("LookUp"), this, &ABird::LookUp);
}

注意这个TEXT文本宏里的名字 必须和项目设置 - 输入 里绑定的轴映射名字一样

ctrl+F5 打开UE 进入PIE模式 现在前后左右和鼠标旋转全都可以使用了

现在打开我们的HelloWorld关卡 也就是之前做了地编的那个关卡 将我们的BP_Bird拖入其中 在细节面板 Pawn一栏 自动控制玩家 设置为 玩家0 这是因为我们在C++构造函数中这样设置了 所以它默认是这样的 进入PIE模式 发现多了一个灰色小球 这是因为多了一个DefaultPawn0 点击 和玩家控制器分离 就可以在视口中选中它 在细节面板里发现它就是DefaultPawn0
点击上方编辑 - 项目设置 - 地图和模式 可以看到 默认模式 显示默认游戏模式是GameModeBase 我们暂且先不讨论游戏模式 关掉这个设置界面 继续点击上方 窗口 - 世界场景设置 现在在细节面板旁边 就会出现 世界场景设置面板 往下拉 可以看到 游戏模式重载 现在是None 如果在这里进行设置 就会覆盖项目设置的游戏模式 但如果在这里不进行设置 这个关卡就会默认使用项目的游戏模式 游戏模式可以指定默认Pawn类 我们现在没有设置重载 所以现在这个默认Pawn就是和项目设置里相同的DefaultPawn 所以会出现DefaultPawn0

打开内容侧滑菜单 找到BluePrints文件夹 右键新建一个文件夹 命名为GameMode 之后会使用C++创建游戏模式类 但是现在先解决这个问题 打开这个文件夹 右键新建蓝图类 在 通用 里 选择 游戏模式基础 命名为BP_BirdGameMode 双击打开这个游戏模式蓝图的编辑器 在细节面板 类 一栏 找到默认pawn类 默认是DefaultPawn 在下拉菜单选择BP_Bird 这里也可以选择Bird 这是C++类 但是C++类没有骨骼网格 所以要选择蓝图版本 保存并编译 然后关闭蓝图编辑器 在世界场景设置中 将游戏模式重载选为BP_BirdGameMode 可以看到 选中的游戏模式一栏 显示的默认pawn类就变成了BP_Bird 现在我们可以尝试在大纲视图里删除BP_Bird 然后进入PIE模式 它会自动生成一个BP_Bird 灰色的足球也消失了 在细节面板里是搜索不到DefaultPawn的 它是会生成在视口里当前视角所在的位置 如果在地图里放置一个PlayerStart 就可以控制它生成的位置 点击上方 选择模式右侧的 右下角带有加号的 盾牌图案 - 放置Actor面板 - 基础 - 玩家出生点 拖拽一个到视口中 然后进入PIE模式 pawn就会创建在那个出生点

现在我们在非PIE模式下 是没有BP_Bird的 现在在世界场景设置 把游戏模式重载切换回None 再次进入PIE模式 发现没有鸟了 会像之前从来没有任何Pawn一样 停止PIE模式 将BP_Bird拖入视口 再次进入PIE模式 就会发现 我们在操控这只鸟 但是灰色足球出现了 在开放世界地图就会出现一个问题 飞到很远的地方之后 就卡住了 之后鸟消失了 这是因为鸟被卸载了 没有了BP_Bird 我们就不再控制它 这是因为游戏没有默认将那只鸟当成默认pawn 现在回到BP_BirdGameMode 飞过地图的任何地方 鸟都不会消失 所以把默认pawn设置成我们打算操控的pawn非常重要

现在的碰撞几乎等同于没有 打开BP_Bird蓝图编辑器 在左侧组件面板 选中Capsule 在右侧细节面板 找到 碰撞 - 碰撞预设 这是一个下拉菜单 默认是OverlapAllDynamic 改成BlockAll 编译后重新进入PIE模式 现在这样就不会穿过任何物体

Character类

关卡编辑器上方 工具 - 新建C++类 - 角色 公共 命名为HelloWorldCharacter 选择右侧浏览文件夹图标 在Items Pawns文件夹旁边 新建一个Characters文件夹 那么路径就是???/HelloWorld/Source/HelloWorld/Public/Characters/ 创建类 关闭UE 回到VS ctrl+F5重新打开 HelloWorldCharacter.h 也无非是构造函数 BeginPlay Tick SetupPlayerInputComponent是用来绑定轴映射的 把注释都删掉 将public都整理到一起

回到UE 在内容侧滑菜单 Blueprints文件夹中 新建文件夹Characters 在这个文件夹内 右键 - 蓝图类 搜索HelloWorldCharacter 选中 命名为BP_HelloWorldCharacter 双击它进入蓝图编辑器 它默认有胶囊体组件 箭头组件 网格体组件 角色移动组件

  • 我们的根组件是胶囊体CollisionCylinder 鼠标悬停在上面 就会显示 这是默认场景根组件 不能被重命名或删除 从父类继承而来 这是一个C++变量 被暴露给了蓝图 在蓝图中无法删除暴露给它的C++变量 要在C++中编辑
  • 箭头组件Arrow 用来显示哪个方向是正前方 就是视口里的蓝色箭头 默认指向X轴方向
  • 网格体组件CharacterMesh0 是骨骼网格体组件 我们不再需要创建新的网格体变量
  • 角色移动组件CharMoveComp 是看不到的 像控制器一样 是隐形的 双击它 在右侧细节面板查看 发现它和之前的浮动pawn移动组件是很相似的 但有很多新功能 行走 上跳下落 重力

在组件面板双击网格体组件 我们直接在细节面板 - 网格体 - 骨骼网格体资产 将其指定为我们要操作的角色资产 它现在面对的方向并不是箭头组件指向的方向 将其旋转 调整网格体位置 使得其重心在胶囊体中心 顺便调整胶囊体大小 使得角色的脚和胶囊体底端齐平 把BP_HelloWorldCharacter拖入test关卡 再在蓝图编辑器正交左视图微调脚部与胶囊体的相对位置 使脚站到地面上

在蓝图编辑器中 在左侧组件面板 选中网格体 在右侧细节面板 - 动画 -使用动画资产 选择一个idle动画 也就是空闲动画 角色会有轻微的动作

按键绑定

在关卡编辑器 打开项目设置 - 输入 Character也可以使用轴映射 为此我们需要写MoveForward MoveRight Turn LookUp函数

// HelloWorldCharacter.h
protected:
    virtual void BeginPlay() override;
    void MoveForward(float Value);
    void MoveRight(float Value);
    void Turn(float Value);
    void LookUp(float Value);
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::MoveForward(float Value)
{
    if (Controller && (Value != 0.0f))
    {
        FVector Forward = GetActorForwardVector();
        AddMovementInput(Forward, Value);
    }
}

void AHelloWorldCharacter::MoveRight(float Value)
{
    if (Controller && (Value != 0.0f))
    {
        FVector Right = GetActorRightVector();
        AddMovementInput(Right, Value);
    }
}

void AHelloWorldCharacter::Turn(float Value)
{
    AddControllerYawInput(Value);
}

void AHelloWorldCharacter::LookUp(float Value)
{
    AddControllerPitchInput(Value);
}

在Character类是可以调用AddMovementInput的 因为Character类继承自Pawn类

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    PlayerInputComponent->BindAxis(FName("MoveForward"), this, &AHelloWorldCharacter::MoveForward);
    PlayerInputComponent->BindAxis(FName("MoveRight"), this, &AHelloWorldCharacter::MoveRight);
    PlayerInputComponent->BindAxis(FName("Turn"), this, &AHelloWorldCharacter::Turn);
    PlayerInputComponent->BindAxis(FName("LookUp"), this, &AHelloWorldCharacter::LookUp);

}

现在删掉地图里的鸟Pawn 打开内容文件夹 - Blueprints - GameMode BP_BirdGameMode 先将其重命名为BP_HelloWorldCharacterGameMode 打开蓝图编辑器 将默认pawn类改为BP_HelloWorldCharacter 保存 编译 在test关卡的世界场景时间设置面板 将游戏模式重载修改为BP_HelloWorldCharacterGameMode 热重载 然后进入PIE模式 现在可以移动 虽然只能看到穿模的模型 所以我们继续添加摄像机

添加摄像机

// HelloWorldCharacter.h
class USpringArmComponent;
class UCameraComponent;
// HelloWorldCharacter.h
private:
    UPROPERTY(VisibleAnywhere)
    USpringArmComponent* SpringArm;

    UPROPERTY(VisibleAnywhere)
    UCameraComponent* ViewCamera;
// HelloWorldCharacter.cpp
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"

AHelloWorldCharacter::AHelloWorldCharacter()
{
    PrimaryActorTick.bCanEverTick = true;

    SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    SpringArm->SetupAttachment(GetRootComponent());
    SpringArm->TargetArmLength = 300.f;

    ViewCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("ViewCamera"));
    ViewCamera->SetupAttachment(SpringArm);

    AutoPossessPlayer = EAutoReceiveInput::Player0;

}

如果角色走到地图边缘 就会往下掉 这是因为有重力 现在这样之后 鼠标只能控制左右视角转换 不能控制俯仰视角转换 需要打开BP_HelloWorldCharacter蓝图编辑器 在组件面板 选中BP_HelloWorldCharacter 在右侧细节面板找到 Pawn - 使用控制器旋转Pitch 勾选 再次进入PIE模式 发现角色在进行俯仰时 是僵直地飘起来的 现在回到蓝图编辑器 把使用控制器旋转Pitch和使用控制器旋转Yaw都关了

回到VS HelloWorldCharacter.cpp 在构造函数的此处添加

// HelloWorldCharacter.cpp
AHelloWorldCharacter::AHelloWorldCharacter()
{
    PrimaryActorTick.bCanEverTick = true;

    bUseControllerRotationPitch = false;
    bUseControllerRotationYaw = false;
    bUseControllerRotationRoll = false;

现在就不会再出现那种漂浮现象了

打开蓝图编辑器 我们不希望角色继承旋转Pitch和Yaw 但又希望摄像机跟着控制器一起动 选中弹簧臂组件 在右侧细节面板找到 摄像机设置 发现 继承Pitch 继承Yaw 继承Roll 都是勾选了的 此时我们勾选上 使用Pawn控制旋转 现在再进入PIE模式 就会变成 只有摄像机移动 而角色不动 现在就是摄像机跟随了控制器 而角色不动

但是现在移动角色时 其前进方向并不是我们摄像机所看着的方向 这是因为我们绑定的时角色的前进向量FVector 我们的MoveForward函数是使用了FVector Forward = GetActorForwardVector(); 它获取的是根组件也就是Capsule的前进方向向量 但我们希望是朝着我们视线的方向移动 也就是控制器朝向的方向

我们希望角色向着控制器朝向的方向移动 但是控制器本身没有前进向量 但是有旋转角度 因此我们首先要想办法获取控制器的前进向量 控制器旋转包含偏航yaw 俯仰pitch 滚转roll 如果控制器向上旋转45度 也就是pitch 45度 那么它的前进向量就会向上倾斜45度 也会让它的Z轴倾斜45度 可以使用旋转矩阵来实现

\[R(\theta) = \begin{pmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{pmatrix}\]

比如现在有个二维向量逆时针旋转了 \(\theta\) 角度 那么首先需要把这个向量从有序对形式换成写成列向量形式的 \(\vec{v}\) 才能与旋转矩阵 \(R(\theta)\) 相乘 那么旋转得到的结果向量就是

\[\vec{v_\tau}=R(\theta)\cdot\vec{v}\]

在游戏引擎中 我们使用的都是三维坐标 在UE中 假如人面朝x轴 右手边就是y轴 垂直向上方向是z轴

绕x轴旋转 roll

\[R_\mathcal{X}(\alpha)=\begin{pmatrix} 1 & 0 & 0 & 0\\ 0 & cos\alpha & -sin\alpha & 0\\ 0 & sin\alpha & cos\alpha & 0\\ 0 & 0 & 0 & 1\\ \end{pmatrix}\]

绕y轴旋转 pitch

\[R_\mathcal{Y}(\beta)=\begin{pmatrix} cos\beta & 0 & sin\beta & 0\\ 0 & 1 & 0 & 0\\ -sin\beta & 0 & cos\beta & 0\\ 0 & 0 & 0 & 1\\ \end{pmatrix}\]

绕z轴旋转 yaw

\[R_\mathcal{Z}(\gamma)=\begin{pmatrix} cos\gamma & -sin\gamma & 0 & 0\\ sin\gamma & cos\gamma & 0 & 0\\ 0 & 0 & 1 & 0\\ 0 & 0 & 0 & 1\\ \end{pmatrix}\]

而任何一个三维的旋转都可以写成绕x轴 y轴 z轴旋转的组合
\(R_{\mathcal{X}\mathcal{Y}\mathcal{Z}}(\alpha,\beta,\gamma)=R_{\mathcal{X}}(\alpha)R_{\mathcal{Y}}(\beta)R_{\mathcal{Z}}(\gamma)\)

详情可查看 #4 从欧拉角到四元数

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::MoveForward(float Value)
{
    if (Controller && (Value != 0.0f))
    {
        const FRotator ControlRotation = GetControlRotation();
        const FRotator YawRotation(0, ControlRotation.Yaw, 0);

        const FVector DirectionX = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
        AddMovementInput(DirectionX, Value);
    }
}

GetControlRotation() 会返回一个FRotator 表示控制器的旋转角度 FRotator其实是一个pitch yaw roll三个角度组成的结构体 在平地上走路 我们只关心yaw 于是我们得到了只有yaw的FRotator类型变量YawRotation
那么现在我们已经有旋转角度了 就需要利用旋转矩阵 得到方向向量
UE内置的FRotationMatrix有好几个重载版本 其中有一个是接收FRotator类型参数 就将YawRotation传入 这样就得到一个旋转矩阵 这个旋转矩阵其实就是按照那个FRotator类型参数给定的三个角度 绕x轴 y轴 z轴旋转 三个旋转矩阵 再按照x y z的顺序相乘 得到的那一个结果旋转矩阵 当然实际上这个旋转矩阵是3×3的 它并不是按照齐次坐标的形式来写的

现在我们已经有旋转矩阵了 但我们如何得到前进方向向量呢? 每一个物体都有它自己的局部坐标轴 可以大致理解为一个人的面前方向 右手方向 头顶垂直方向 因为我们操纵着这个角色 玩游戏时 在我们的视角中 我们通常所能看到的就是它的局部坐标轴 我们的视角前方一定永远是这个角色的面朝方向 我们能看到的右手边一定就是这个角色的右手边 但是当我们需要将它旋转 我们就是在旋转它的局部坐标轴

我们想要得到的是方向向量 向量代表的是一种要去变化的方向 类似于一个趋势 一种变化 而不是变化的结果 如果用旋转矩阵去乘坐标轴方向的单位向量 我们就能知道物体的一个旋转的趋势 也就是它相对于原本的位置 在x y z方向上分别旋转了多少 我们得到的会是一种变化

只需要把x y z坐标轴 也就是(1,0,0) (0,1,0) (0,0,1)这三个单位列向量 都乘以这个旋转矩阵 这样就可以完成坐标轴的旋转
\(R_{\mathcal{X}\mathcal{Y}\mathcal{Z}}(\alpha,\beta,\gamma)\begin{pmatrix} 1\\ 0\\ 0 \end{pmatrix}=\begin{pmatrix} a & b & c\\ d & e & f\\ g & h & i\\ \end{pmatrix} \begin{pmatrix} 1\\ 0\\ 0 \end{pmatrix}=\begin{pmatrix} a\\ d\\ g \end{pmatrix}\)
那么实际上x轴乘了的结果 就是等效于取出第一列

调用FRotationMatrix的函数GetUnitAxis 使用.调用 因为我们刚才得到的FRotationMatrix变量并不是指针类型 GetUnitAxis函数接收一个EAxis枚举类型参数 输入到EAxis:: 就看到可选X Y Z None 这里选择EAxis::X 这样GetUnitAxis函数就可以返回刚才那个结果旋转矩阵的第一列 同样地 EAxis::Y就是返回第二列 EAxis::Z是返回第三列 既然只是列向量 那么它就是FVector类型

得到x轴的方向变化 我们就可以知道角色的前进方向向量了

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::MoveRight(float Value)
{
    if (Controller && (Value != 0.0f))
    {
        const FRotator ControlRotation = GetControlRotation();
        const FRotator YawRotation(0, ControlRotation.Yaw, 0);

        const FVector DirectionY = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
        AddMovementInput(DirectionY, Value);
    }
}

现在y轴的方向变化也得到了

热重载进入PIE模式 角色的运动还是很奇怪 打开BP_HelloWorldCharacter蓝图编辑器 在左侧组件面板点击角色移动组件 在右侧细节面板 找到角色移动(旋转设置)一栏 勾选 将旋转朝向运动 编译后再次进入PIE模式 就是我们在游戏中最熟悉的样子了

现在我们想用C++实现这个功能 只需要在构造函数中添加一行 GetCharacterMovement()->bOrientRotationToMovement = true; 需要添加一个头文件 查看官方文档找到 #include "GameFramework/CharacterMovementComponent.h"
还可以添加一行 设置旋转速度 如果想修改yaw的旋转速度 就是指定这个FRotator的第2个分量 对应的就是蓝图编辑器里 角色移动(旋转设置) - 旋转速率 的z轴 数值

// HelloWorldCharacter.cpp
AHelloWorldCharacter::AHelloWorldCharacter()
{
    PrimaryActorTick.bCanEverTick = true;

    bUseControllerRotationPitch = false;
    bUseControllerRotationYaw = false;
    bUseControllerRotationRoll = false;

    GetCharacterMovement()->bOrientRotationToMovement = true;
    GetCharacterMovement()->RotationRate = FRotator(0.f, 400.f, 0.f);

    SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    SpringArm->SetupAttachment(GetRootComponent());
    SpringArm->TargetArmLength = 300.f;

    ViewCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("ViewCamera"));
    ViewCamera->SetupAttachment(SpringArm);

    AutoPossessPlayer = EAutoReceiveInput::Player0;
}

导入MMD模型与动作

之前使用的都是LowPlay模型 没有很精细的各种部件 Fab中的人物资产也不尽人意 现在要在UE中导入MMD人物模型 需要经过一次Blender的处理

当前演示使用Blender5.0
在Blender中 添加插件MMD Tools 或者使用cats插件 选择与Bleneder相对应的版本 本次演示中我们使用MMD Tools
这样再在Blender左上角点击文件 - 导入 就可以选择MikuMikuDance模型 导入之后可以看到模型 什么都不要做 再在文件 - 导出 选择FBX格式导出 在导出时的弹窗中找到几何数据一栏 平滑 默认是仅法向 将它修改为平滑组
打开UE 在内容侧滑菜单中 找到一个合适的文件夹 右键将这个.fbx导入 导入时会有一个弹窗 如果将来要导入表情 需要勾选骨骼网格体 - Skeletal Meshes一栏中的 导入变形目标 如果将来要导入动作 在动画 - Animations一栏 勾选 导入动画 然后点击下方的导入 于是导入成功

打开刚刚导入的骨骼网格体 可以看到人物是膨胀的 这不是因为导入错误 而是因为MMD模型中有一些UE用不到的信息 所以现在要回到Blender 在右侧场景集合面板 找到我们的模型 里面有joints rigidbodies 是灰色的文件夹 对它们分别右键 - 删除层级 在导出弹窗右侧 包括 - 物体类型 只选择骨架和网格 现在再导出 在UE里重新导入 打开骨骼 网格体 现在是一个正确的灰模 于是再回到Blender

在视口右侧 场景集合面板左侧 交界处有一个小的三角箭头图标 点击它 就会出现一个选项栏 现在在场景集合中选中模型文件夹 - _arm文件夹 中的_mesh 也可以直接点击文件夹右侧的橙黄色倒三角图标 就同样可以选择到_mesh 右下方面板默认是变换什么的 可以看到左侧有一列菜单栏 目前是定位到橙色方块 点击下方红色镂空圆形图标 就进入了材质面板 现在表(曲)面模式显示的是MMDShaderDev 这个模式下 导出fbx 没有办法将材质和贴图内嵌到fbx文件里 所以我们要在选项栏中的MMD 找到 材质 选择 转换给Blender 有的模型导入之后是粉紫色 点击一下点击转换给Blender就好了 点击转换给Blender之后 表面模式就会变成原理化BSDF 这样就可以将材质贴图内嵌到fbx里 现在视口左下角就会出现一个 转换材质 悬浮菜单 点一下 取消勾选 清理节点 这个选不选都可以
这之后可以选中网格 点击按材质分开(高风险) 删掉一些腮红和阴影 这些通常在UE中表现不佳 如果导入UE之后 颜色和贴图还是很奇怪 就回到这里再次修改
57.png

现在再导出 在导出弹窗里 右侧 操作模式 默认是自动 要将其修改为 复制 右侧有一个立体长方形盒子的图形 它现在就是灰色的没有高亮 点击一下 然后就会有高亮 鼠标悬浮在上面 显示内嵌纹理 FBX二进制文件的内嵌纹理(仅供“复制”路径模式) 意思就是把贴图内嵌到fbx文件里了 而不是一些分离的文件 在骨架一栏 可以选择性地取消勾选 添加叶骨 之后点击导出FBX

现在再导入UE 可以成功看到贴图
58.png

现在继续导入动作 去模之屋随便下载一个动作 回到Blender 在右侧场景集合面板 选中整个模型的那个文件夹 本例中就是YYB式初音ミクver1.02文件夹 而不是_mesh 点击上方菜单栏 文件 - 导入 - 导入MikuMikuDance动作 选择刚刚下载的动作 不需要修改什么 点击下方的导入VMD文件 导入后 在右侧场景集合面板 可以看到多了一个动画文件夹

在MMD面板里 模型设定 - 可见性 可以看到一个小人图标 旁边的文字是骨架 取消高亮它 对于人物模型我们就可以看得更清楚了 按空格就可以播放这个动作 或者点击下方播放图标也可以进行播放

如果想要删除模型上的全部动画 就选中整个模型的文件夹 然后点击左上方 窗口 - 新建窗口 现在会得到一个名为3D视图的弹窗 点击左上角最左侧有一个类似于画板的小图标 选择 动画摄影表 可以看到有一些关键帧序列 点击序列下方的空白处 也就是下图中黄色斜杠高亮区域 按A 就会选中全部关键帧 会变成多个橙黄色的点 再按del 就全删了
59.png
回到主窗口 按下播放 确实没有动作了 但是角色还是有姿势的 而不是静置姿态 现在去左上角 从物体模式切换到姿态模式 在右侧场景集合面板 点击整个模型文件夹 然后按下A 这样就会全选 再点击左上方姿态按钮 - 清空变换 - 全部 就会恢复到静置姿态 再到右侧场景集合面板 找到_arm下面的动画文件夹右键点击清除动画数据 再找到整个模型文件夹里的动画文件夹 右键点击清除动画数据

这里似乎有一个更简单的办法 无需特意删除这些关键帧 只需要先在姿态模式全选之后清空变换 然后在动画文件夹右键清除动画数据

现在发现表情还没有变回来 需要点击_mesh 在右下方面板 左侧菜单栏选择绿色倒三角形 也就是物体数据属性面板 如果前面把模型按材质分开了 就没有这个面板 需要点击MMD Tools里 按材质分开 这个按钮旁边的 合并 把这些部件都合并成单一一个mesh再操作 在形态中 从Basis开始 把下面所有键右侧的数字 都改成0

删完导入下一个动画之前 要把下方动画时间线的进度条拖到0 但仍然总像是没有删干净 导入UE之后可能会多了一些多余的静止帧动作文件

确认动作没有什么问题之后 导出为FBX 导出弹窗中 最下方的 动画 一定要勾选 然后导入到UE 就会多了一个动画序列 我们还是把材质纹理整理到独立的Materials Textures文件夹里

如果我们还想为这个模型导入新的动作 不需要把之前导入的全都删掉 只需要在导出含有新动作的同一个模型的fbx时 在导出弹窗中 包括 - 物体类型 只勾选骨架和其它 并且把导出的fbx命名成和之前导入时不同的名字
然后回到UE 在模型文件夹中 右键导入 导入时弹窗里 动画一栏 Common Skeletal Meshed and Animations - 骨架 它会默认是我们之前的骨架 下面还有一个 导入骨骼层级中的网格体 默认是取消勾选的 点击下方导入 就会只导入一个动作序列

播放动作时 视口一直提示我们 要去材质编辑器 结合变形目标使用 那么现在就挨个打开Materials文件夹里的材质 在左下角细节面板 搜索 变形 勾选 使用变形目标

IK重定向

以上的流程 可以使我们在UE中使用MMD的模型和动作 但是使用不了UE的动作资产 因为MMD模型的骨架和UE的默认人物的骨架是非常不相似的 下面要做骨骼重定向

在UE 用第三人称模板创建一个新的项目 在Characters文件夹找到一个叫Quinn的女性骨骼网格体 在它旁边空白处 右键 - 动画 - 重定向 - IK绑定 命名为IK_Quinn 打开 在右侧细节面板 选择预览骨骼网格体为Quinn的模型
然后导入我们的模型fbx 整理一下 在模型旁边空白处 右键创建一个IK绑定 命名为IK_角色模型名 打开 在右侧细节面板 选择预览骨骼网格体为我们的模型 点击视口上方眼睛图标 骨骼 - 所有层级 将骨骼绘制大小调小 让骨骼显示得更细一些

先打开IK_Quinn 在视口中点击双腿中间的垂直骨骼 那就是根骨骼 也可以看到左侧 绑定元素面板中 root确实被选中了 在绑定元素面板中 对root右键 - 新建重定向链 什么也不用修改 点击 添加链 现在右下方IK重定向面板 就会出现刚刚创建的链条
现在选中plevis 也就是盆骨 右键 - 设置骨盆
骨盆的上方有竖着的脊柱 挨个点它们的时候 就会看到左侧绑定元素面板 spine_01到spine_05被依次选中了 按shift 把这5个spine全部选中 右键 - 新建重定向链 对于弹窗什么也不用修改 点击添加链
再选中左手的上臂 下臂 手 也就是upperarm_l lowerarm_l hand_l 也不要选clavicle 不要选lowarm的twist 这个是什么旋转 也不要选手指 就只选这3个骨骼 右键 - 新建重定向链 右臂也一样
clavicle 单独创建一个只有1个节点的链 命名为LeftClavicle 右侧也一样
再找到neck01和neck02 一起选中后右键 - 新建重定向链
head 这1个骨骼 单独创建与i个链
找到右腿 选中thigh_r calf_r foot_r ball_r 对于twist不要管 将这4个右键 - 新建重定向链 左腿也是一样
找到左手 index是食指 选中metacarpal 这1个骨骼单独创建一个链 再选中3个指关节 创建重定向链
thumb拇指 middle是中指 ring是无名指 pinky是小指
之后保存
其实 UE5.6有一个新功能 点击窗口上方 自动创建重定向链 就会自动创建成功我们刚才所做的一切 只针对于与UE类似的骨骼结构有效

打开IK_角色模型名 点击双腿中间的竖直骨骼 就会在左侧层级面板选中 全ての親 这个就是根骨骼 选中这个全ての親 右键 - 新建重定向链 为它改个名字 因为重定向对应的时候 是按照名字模糊搜索 进行自动对应 这里就命名为root
选中腰 右键 - 设置骨盆
首 这1个节点 创建链 命名为Neck
頭 这1个节点 创建链 命名为Head
从腕L开始 到手首 都是手臂 命名为LeftArm 右臂同理
肩P_L 肩_L 肩C_L这3个骨骼 命名为LeftClavicle 负责旋转 右侧同理
腿部是足D_R到ひざD_R 3个骨骼 命名为RightLeg 左腿同理
上半身 上半身2 这2个骨骼 是脊椎 命名为Spine
再做一下手指 每个手指只绑3个骨骼 不要选择指先
点击保存 不同的MMD模型会稍有不同但大致类似 在绑定的时候可以根据骨骼的位置 选择性地绑定X先 或者不绑

在内容侧滑菜单中 IK_角色模型名旁边空白处 右键 - 动画 - 重定向 - IK重定向器 命名为RTG_角色模型名 双击打开 源IKRig资产 选择为IK_Quinn 目标Default Target IK Rig选择为IK_角色模型名

可能看到 MMD模型悬浮在空中 这是因为UE和Blender的缩放单位不同 回到Blender
点击右下方面板 找到场景面板 - 单位 长度默认是米 改成厘米 缩放单位默认是1 改成0.01
然后导入模型 在导入模型窗口 右侧 缩放默认是0.08 在这里改为8 再按照之前的流程进行一些操作 后导出

重新导入UE 无需全部重新做IK绑定 现在复制原来的IK_角色模型名 制作一个副本 打开这个副本 在右侧细节面板 将预览骨骼网格体替换为修改后的角色模型 当然也可以在原IK绑定文件中清除骨骼网格体后 直接进行替换 可以发现 左侧层级面板 似乎基本都对应上了 我们只需要检查并进行细节的修改
之后新建IK重定向器 现在就不会出现浮空问题

在IK重定向器中 左侧层级面板 选中 源 点击创建 - 创建 命名为RetargetPose 然后点击自动对齐 - 对齐所有骨骼 然后点击上方 添加默认操作

左下方资产浏览器 随便双击一个动作 就可以播放动作进行演示 点击右侧细节面板中 取消勾选调试绘制 MMD模型上的绿色宽线条就消失了

可能会发现下半身完全不动 这是因为当前的MMD模型在センター和グルーブ之间 缺少一个名为 腰 的节点 现在回到Blender 为MMD模型添加一个腰骨骼
因为我们没有Blender基础 强烈建议找到一些 较小的没什么用的骨骼 比如センター先 下半身先 XX先 将其移动到上半身和下半身中间的位置 改名为腰 然后通过调整父子继承关系 制作成 センター - 腰 - グルーブ的层级结构 至于如何修改父子继承关系 请使用ctrl+F页面内搜索 父级 尽量避免对于原本主干骨骼位置的改动 至多对其名字进行修改 修改时 最好导入MMD动作辅助观察 防止由于骨骼的改变造成对于动作的改变
在Blender中导入MMD动作时 注意也要从0.08缩放成8再导入 否则角色的脚是不会动的

修改结束后 导出fbx 再导入到UE

60.png

我们回到IK重定向器 还是会发现MMD模型和UE小白人的动作仍然不是很同步 这是因为二者没有的静置姿势没有重合 右侧细节面板中 将目标网格体偏移改成0 0 0 在右侧细节面板 调整 目标网格体缩放 使二者身高一致 再调整目标网格体偏移 使二者大致重合 然后在层级面板中 确保现在选中的是目标 现在视口中默认就是旋转工具 选中MMD模型的腕 将其旋转至与UE小白人重合 最好是在左视图和前视图调整 不要在透视模式下 如果长度不一样就确保平行 基本上要保证对应骨骼重合 如果旋转了脚 最好也把足IK旋转同样的角度

微调的时候 可以选定姿势进行演示 点击左下方的 播放参考姿势 就可以停止当前播放的参考姿势 如果不满意 就选中 源 之后点击删除 这样RetargetPose就被删除了 之后再在选中 源 的时候点击创建 创建一个RetargetPose 旋转工具才会再次出现 更快捷的方式是 切换到目标 先随便点一下MMD角色身上的某块骨骼 在左侧层级面板点击删除 再Ctrl+Z撤回 就会重新出现旋转工具
这之后不要再使用自动对齐 否则UE小白人会到达奇怪的位置 最后再切换到 目标 进行调整 如果骨骼差异很大 导出选定动画之前 也可以针对每一个动画演示的效果 都分别精细地调整

我们是UE5.6版本 之前的命名也很规范了 所以现在是正确对应的 如果想要确认是否真的正确对应 之前我们做了 添加默认操作 就可以打开IK重定向器中 左侧操作栈面板 点击Retarget FK Chains 就可以看到目标链 源链 的对应关系 点击右上方自动映射链 可以选择精确对应或者模糊对应 也可以手动地挨个调整

现在发现最严重的问题是 手臂骨骼不一致导致的动作变形
MMD模型手臂的5个骨骼 和UE小白人的3个骨骼 位置是不一致的 尝试了多次修改链和映射链 都没有任何作用 MMD模型的5个骨骼是 腕 腕捩 ひじ 手捩 手首 它们是依次继承的 现在希望把 腕 ひじ 手首 作为 upperarm lowerarm hand 但是 链条里不能跳过中间的腕捩 手捩 只选择这3个 捩的意思是旋转 这2个捩应该是映射到twist的
所以现在需要去Blender里修改MMD模型的父子继承关系 修改之前记得备份

进入Blender编辑模式 打开之前处理过 缩放单位 和 材质转换给Blender 的MMD模型工程 在场景集合 选中ひじ 注意要只选择它 不需要选择它的子项 然后按住ctrl再选择腕 点击视口上方 骨架按钮 - 父级 - 生成 - 保持偏移量 其原理是 最后选中的作为父级 这样之后 腕就会变成ひじ的父级 接下来选中手首 再按ctrl选中ひじ 同样地去修改父级

此时可以顺便把模型的骨骼精简一下 选中不需要的骨骼 把鼠标放在视口中 按del 再选中 骨骼 就可以删掉 发现_dummy和_shadow删不掉 需要随便选择一个骨骼 在右下方面板 左侧工具栏选择绿色小人 物体数据属性 在骨骼集合里 找到mmd_dummy mmd_shadow 点击右侧眼睛 它们就可见了 可见之后就可以删了

导入UE 在IK绑定中 LeftArm只保留腕 ひじ 手首 再分别为腕捩 手捩添加链 其位置就对应UE小白人的upperarm_twist lowerarm_twist

现在再进入IK重定向 手臂手腕的极端扭曲问题已经解决了 在大致上已经可以做到动作一致了 但是手臂和腿部的表现还不能令人满意 会发生扭曲

61.png

对于当前演示中的这个模型 它的腿部的网格不在 足 - ひざ - 足首这个链条上 而是在另一个链条 もも - 膝 - すね 上 もも是大腿 膝是膝盖 すね是小腿 这个链条上没有脚 这导致根本无法重定向到UE小白人 为此我们需要为足 - ひざ - 足首链条绘制蒙皮权重 然后删掉 もも - 膝 - すね 链条的蒙皮权重
进入物体模式 选中_mesh 如果现在是按材质分开了的 就用MMD Tools合并一下 找到右下角面板左侧菜单栏的绿色倒三角图标 在顶点组选项卡 找到もも 点选它 在左上角物体模式那里 切换为权重绘制 可以看到 我们选中的这个骨骼 上面有红色黄色绿色 可以挨个点击一下 もも 膝 すね 发现本例中 もも和すね 分别有大腿和小腿的蒙皮 而脚的蒙皮在足首上 所以现在我们要做的 就是把もも的蒙皮转移给足 すね的蒙皮转移给ひざ
在在顶点组列表里 如果能找到 足 和 ひざ 就选中足 在顶点组最右侧 点击-号 把它们都挨个删掉 然后把もも重命名为足 膝删了 すね重命名为ひざ
62.png

如果整条腿的蒙皮绘制零散地分布在多个顶点组中 就只能用笔刷手动绘制了 找到一个最主要的顶点组 在上面绘制
左上角权重右侧数值为0 就可以当作橡皮擦使用 右侧衰减可以调整为常值 这样之后擦除更快捷 请善用模糊 梯度渐变 采样权重功能 切换权重绘制模式的按钮右侧还有两个按钮 点击其中一个后 再点选择 就可以使得笔刷只作用于选区 刷选就是可以按三角形选中 再点击一下图标 就能回到原来的模式

回到UE里 查看UE小白人的骨骼网格体 左侧面板 蒙皮 - 编辑权重 就会发现它手臂的蒙皮是在upperarm_twist上的 而upperarm上没有蒙皮 我们也可以模仿它去做MMD模型的蒙皮 当前演示的模型有好几个腕捩 手捩 整条手臂的蒙皮权重也分散在多个顶点组上面 我们可以修正它们的蒙皮权重位置 使其分别与UE小白人的upperarm的2个twist lowerarm的2个twist位置相对应 然后直接把这几个twist IK重映射到MMD模型的腕捩 手捩 这样就不需要为腕和ひじ绘制蒙皮权重了
腿部也是可以同理 但是当前模型大腿只有一个もも UE小白人的大腿却有2个twist 很难映射 所以对于腿部我们还是手动绘制修改蒙皮权重
其实如果一些动画里面 网格体上的一些部分明显位置错误或者扭曲 就是蒙皮权重的问题 仔细查看之后重新刷一下就可以了

至此 我们已经可以通过IK重定向为MMD模型角色制作大量动作资产了 无论是UE动作资产 还是MMD动作 都可以兼容了

动画蓝图

打开BP_HelloWorldCharacter 在左侧细节面板选中网格体 在右侧细节面板就可以看到 动画模式 现在我们使用的是单一的动画资产 修改成使用动画蓝图 可以看到动画类现在是无 所以我们需要创建一个动画蓝图 内容侧滑菜单 BP_HelloWorldCharacter周围空白处 右键 - 动画蓝图 它会让我们选择一个骨架 选择之前做好的MMD模型RacingMiku 然后命名为ABP_RacingMiku 打开后就是动画蓝图编辑器

事件图表和其它蓝图是一样的 还有一个AnimGraph 找到右下角资产浏览器 如果默认没有调出 就点击上方窗口中的资产浏览器调出

先找到一个跑步姿势拖入视口中 然后将这个节点右侧的小人 与Output Pose节点左侧的Result小人连接 选中这个跑步姿势的节点 在细节面板 找到 设置 - 循环动画 勾选它 之后点击左上角编译 动画就会循环播放

现在打开BP_HelloWorldCharacter 左侧组件面板选中BP_HelloWorldCharacter 在右侧细节面板 动画模式选中 使用动画蓝图 动画类选择ABP_RacingMiku 就可以看到角色一直在跑

动画蓝图可以根据角色提供的数据 选择不同的序列节点播放不同的动画
回到动画蓝图编辑器 左下方有一个 我的蓝图 面板 点击左上角添加 - 变量 创建一个变量 命名为Character 默认是bool型 搜索BP_HelloWorldCharacter 它有四种类型 对象引用 类引用 软对象引用 软类引用 软的意思是延迟加载 只有在需要这些对象的时候 它们才会被加载 在此之前没有任何值 意思就是它现在只存储指向项目中对应资源的文件路径 对象引用和类引用 就是普通的变量 对象引用就像C++里对于对象的引用一样 类引用像是一个保存类型的变量 最接近我们常用的C++变量的就是对象引用 所以我们选择对象引用 这就像是在C++中创建指针变量 它现在是空的 没有初始化 长按这个Character拖拽到事件图表视口中 选择获取Character 现在有了一个节点 但它仍然不是一个有效的变量 我们现在要初始化它

一旦我们的角色使用了这个动画蓝图 我们的角色就是它的owner/拥有者 然后动画蓝图就可以用Try Get Pawn Owner节点来获取owner 也不一定非得是角色才能被动画蓝图获取 只要是带有骨骼网格体组件的Pawn 都可以使用动画蓝图
实际上打开事件图表 我们可以看到 已经存在着一个Try Get Pawn Owner节点

另一个节点是Event Blueprint Update Animation 它有一个Delta Time节点 这个节点就像tick函数一样 每一帧都会调用 动画每次更新 它都会被调用

还有一个节点的功能类似于BeginPlay 可以在事件图表中空白处右键 搜索 Event Blueprint Initialize Animation 和Begin Play事件一样 在游戏开始 或者在拥有这个动画蓝图的Pawn角色生成时 只调用一次 我们可以利用它在游戏早期初始化一些东西 比如设置Character节点的值

Try Get Pawn Owner返回的是一个Pawn对象的引用 还记得吗 对于那个名为Character的变量 我们为它设置的类型是 对于Charcter对象的引用 但是Pawn是Character的父类 我们不能直接用父类的数值 传给子类 也就是说 Try Get Pawn Owner返回的值 不能直接赋给Character变量 所以需要一步类型转换 右键搜索 cast to BP_HelloWorldCharacter 注意不是cast to BP_HelloWorldCharacter class 把Try Get Pawn Owner的Return Value引脚连接到cast to BP_HelloWorldCharacter的object引脚上 然后把刚才拖进来的Character节点删了 重新拖动一次 这次要选择 设置Character 这样就会变成一个SET Character节点 然后将cast to BP_HelloWorldCharacter的As BP BP Hello World Character引脚连接到SET Character节点的Character引脚上 现在就可以用这个获取到的owner来初始化我们的Character变量了 然后把Event Blueprint Update Animation右侧的执行引脚连接到cast to BP_HelloWorldCharacter的左侧执行引脚上 把cast to BP_HelloWorldCharacter右侧的执行引脚连接到SET Character左侧的执行引脚上 这样才可以执行流程

我们现在的动画蓝图owner其实就是一个BP_HelloWorldCharacter类型的变量 而现在这个变量被赋给了Character变量 那么现在我们就可以访问这个变量了

首先我们需要一个移动组件 之前的BP_HelloWorldCharacter蓝图编辑器左侧组件面板里的是角色移动组件 这个角色移动组件里存放着大部分移动相关的数据 比如行走 上跳下落 重力
从SET Character节点的右下角蓝色引脚往右边拖 搜索get character movement 现在就可以得到角色移动组件了
接下来将它存储到一个变量里 点击我的蓝图面板左上角添加 - 变量 或者就把蓝图里节点的返回值提升成变量 从get character movement节点右侧引脚往右边拖的过程中 会出现一个悬浮窗 显示 放置一个新节点 然后选择Promote to variable 这样就可以把这个返回值提升为变量 在视口中会得到一个SET Character Movement节点 在左侧我的蓝图面板 可以对其重命名 默认的名字是Character Movement 这有个空格 我们将其重命名为CharacterMovement 可以看到这个变量的类型是 角色移动组件
现在把SET Character节点右侧的执行引脚与SET Character Movement左侧的执行引脚相连

现在我们的流程就是 获取这个动画蓝图的owner 将其转换为名为Character的变量并存储 然后从这个Character变量中获取角色移动组件 并将其存储到一个名为CharacterMovement的变量中

现在我们希望每一帧都访问它们 因为比如速度 是否在空中 是否在下落 这种信息在每一帧都会发生变化
找到Event Blueprint Update Animation节点 它每一帧都会被调用

首先想要一个速度变量 准确来说是地面速度 如果角色有一个速度 那么就是一个既有大小又有方向的向量 速度是一个向量 我们并不关心速度的z分量 只想知道x y分量如何变化 也就是我们想知道平行于地面移动的速度有多快 而移动组件是能获取速度的 现在把CharacterMovement变量拖入事件图表 点击 获取CharacterMovement 从Character Movement节点右侧引脚往右边拖 搜索 get velocity 然后往下翻 找到速度一栏的get velocity 从get velocity右侧引脚往右边拖 搜索 vector length 来获取这个速度向量的大小 而vector length XY 就是不考虑z轴分量的向量长度 这正是我们想要的 我们将这个值设置为一个变量 命名为GroundSpeed 然后将Event Blueprint Update Animation右侧的执行引脚 与SET Ground Speed左侧的执行引脚连接 这样就可以每一帧都更新

那么我们可以使用这个GroundSpeed来控制动画姿态 回到AnimGraph 我们希望不跑步的时候播放idle动画 跑步时播放jog动画

我们可以创建不同的状态 并在它们之间切换 要切换不同的动画状态 我们需要创建状态机
把刚才拖入的jog动画节点删除 右键 搜索state machine 选择 动画 - 状态机一栏下的 state machine 会出现一个黑色节点 将其命名为 Ground Locomotion 蓝图的命名是允许空格的 但是C++不允许
Ground Locomotion会输出动画姿态 也就是它右下角的小人 将这个小人连接到Output Pose左侧的Result小人上 这样Ground Locomotion的输出就会驱动角色动画

双击这个Ground Locomotion节点 就可以进入它的内部 上方显示 ABP_RacingMiku > AnimGraph > Ground Locomotion 意思就是我们已经从AnimGraph进入了Ground Locomotion状态机 点击AnimGraph就可以返回 发现即使是返回后 Ground Locomotion选项卡也并没有被关掉 所以也可以点击选项卡进行切换
现在误触把AnimGraph选项卡关了 可以在左侧我的蓝图面板 双击 动画图表一栏的AnimGraph重新打开 注意到这里也有Ground Locomotion 双击也可以打开

打开Ground Locomotion状态机 有一个Entry节点
这个状态机可以有多个状态 我们可以随意切换 创建新状态的方法有2种 从Entry节点拖动并选择 添加状态 或者在空白处右键点击 添加状态
将这个状态命名为Idle 再从Idle的右侧边缘往右拖 创建一个新状态 命名为Run
Idle和Run之间会出现一个双向箭头 鼠标悬停再上面 说是过渡规则 现在是Idle到Run 单向的 可以从Run的左侧边缘往左拖到Idle上 就可以从Run到Idle 这也是一个过渡规则 我们可以为每个规则设置条件
我们当前的状态决定了Ground Locomotion的返回结果 而Ground Locomotion的返回结果是连接到Output Pose的Result的 会直接决定角色的动作

我们双击进入Idle状态 里面有一个Output Animation Pose节点 当我们出于Idle状态时 这里的任何内容都将会作为Ground Locomotion的返回值 最终作用于Output Pose
将idle动画拖入视口 然后将这个idle动画节点右下角的小人 连接到Output Animation Pose左侧的Result小人上 现在我们就为Idle状态设置了动画 只要是处于Idle状态 就会播放这个idle动画

然后现在回到Ground Locomotion标签页
从Entry入口点进入 就会直接跳转到Idle状态 先点击一下编译 左侧视口里角色动作就变成了idle动作
63.png
Idle状态被高亮了 显示这是当前活动的状态 这里显示的是100% 实际上是因为动画蓝图具有从一种状态到另一种状态平滑过渡的功能 过渡期间就是同时处于两种状态 但是比例是会发生变化的

那么什么时候才会从Idle状态跳转到Run状态呢 我们就需要设置一个切换到Run状态的逻辑 也就是地面速度不为0的时候 自然就进入Run状态
双击进入Run状态 可以看到它的Output Animation Pose上有个note 鼠标悬停在上面就可以查看内容 说是Result was visable but ignored 意思是Result可见但是被忽略了 这个节点上没有连接任何内容时 就会出现这个note
现在将jog动画拖入 实际上如果把jog动画直接拖到result上 它就会自动连接

回到Ground Locomotion标签页 现在我们只需要编写从Idle到Run 从Run到Idle的逻辑就行了

先双击从Idle到Run的箭头上的双向箭头 就会进入这个转换规则 里面有一个Result节点 输入参数是一个bool值 can enter transion 可以选择是否勾选 意思是 是否可以进入切换 我们需要在这里写一些返回bool值的逻辑 如果传给Result的bool值变成true 并且我们当前处于Idle状态 那么转换规则就会启动 我们就会从Idle切换到Run

现在把GroundSpeed变量 拖入 Idle到Run(规则) 视口中 选择 获取GroundSpeed 从Ground Speed节点右侧引脚向右拖 输入> 然后选择greater 这个节点里面的数字是0 意思就是检查地面速度是否大于0 把greater节点右侧红色引脚连接到Result节点左侧的Can Enter Transition红色引脚上
点击左上角编译 Can Enter Transition节点上的Warning就没了

现在进入PIE模式测试一下 角色确实从idle动画切换到了jog动画 然而只跑了几秒就卡住了 这是因为我们没有把jog动画设置成循环 而且就算停止移动 也不会切换回idle动画

继续进入Run到Idle(规则) 将GroundSpeed变量 拖入 Run到Idle(规则) 视口中 选择 获取GroundSpeed 从Ground Speed节点右侧引脚向右拖 输入== 选择equal 意思是地面速度是否为0 是就返回true 不是就返回false 再把equal节点右侧红色引脚连接到Result节点左侧的Can Enter Transition红色引脚上

C++

这个动画蓝图有个C++父类 蓝图编辑器的右上角其实显示了 父类:动画实例 AnimInstance
事件图表上方有一排工具栏 里面有一个类设置 右侧细节面板就会出现 类选项 - 父类 现在父类默认是 动画实例

在内容文件夹 - C++ 类中 找到HelloWorldCharacter类 ·在它旁边空白处右键 - 新建C++类 搜索AnimInstance 命名为HelloWorldAnimInstance 选择公有 就放在原来的路径 也就是???/HelloWorld/Source/HelloWorld/Public/Characters

即使游戏没运行 动画蓝图也是一直在更新的 一旦我们的C++类对它可见 C++类就也会持续更新 所以如果我们尝试访问空指针或者其它类似问题 就会导致编辑器崩溃 所以通常要在编译AnimInstance的改动时 关闭UE 现在把动画蓝图中的Character和MovementComponent变量删了 把事件图表里相关的东西也删了 只保留velocity和ground speed那3个节点
现在点击左上角编译 会报错 因为刚才我们把连接到velocity上的移动组件删了 接下来我们会用C++创建
Event Blueprint Initialize Animation 和 Event Blueprint Update Animation

// HelloWorldAnimInstance.h
UCLASS()
class HELLOWORLD_API UHelloWorldAnimInstance : public UAnimInstance
{
    GENERATED_BODY()
public:
    virtual void NativeInitializeAnimation() override;
    virtual void NativeUpdateAnimation(float DeltaSeconds) override;
};

NativeInitializeAnimation 就是 Event Blueprint Initialize Animation 我们要override它
NativeUpdateAnimation 就是 Event Blueprint Update Animation

鼠标悬浮在NativeInitializeAnimation上 点击下面螺丝刀 在cpp中创建定义
NativeUpdateAnimation也是一样
NativeUpdateAnimation和Tick函数一样 接收DeltaTime为参数

// HelloWorldAnimInstance.cpp
void UHelloWorldAnimInstance::NativeInitializeAnimation()
{
    Super::NativeInitializeAnimation();
}

void UHelloWorldAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
    Super::NativeUpdateAnimation(DeltaSeconds);
}

因为是重写 所以用super调用父版本

在AnimInstance类的构造函数中 可以创建一些变量 先将这些变量设置为public的

// HelloWorldAnimInstance.h
UCLASS()
class HELLOWORLD_API UHelloWorldAnimInstance : public UAnimInstance
{
    GENERATED_BODY()
public:
    virtual void NativeInitializeAnimation() override;
    virtual void NativeUpdateAnimation(float DeltaSeconds) override;

    UPROPERTY(BlueprintReadOnly)
    class AHelloWorldCharacter* HelloWorldCharacter;

    UPROPERTY(BlueprintReadOnly, Category = Movement)
    class UCharacterMovementComponent* HelloWorldCharacterMovement;
    
    UPROPERTY(BlueprintReadOnly, Category = Movement)
    float GroundSpeed;
};

class AHelloWorldCharacter* HelloWorldCharacter;
可以在一行内同时进行提前声明和声明变量 之前我们都是在头文件下面先写一个提前声明 然后在构造函数里再声明变量的 这两种方法都可以
因为我们只是需要获取这个值 所以设置成蓝图只读 还需要把GroundSpeed和HelloWorldCharacterMovement归类为Category Movement HelloWorldCharacter就不需要设置类别了

然后在NativeInitializeAnimation中初始化这些变量

// HelloWorldCharacter.cpp
#include "Characters/HelloWorldCharacter.h"
#include "GameFramework/CharacterMovementComponent.h"

我们之前在蓝图里使用的是try get pawn owner 然后将结果强制转换为了HellooWorldCharacter C++里也有这样的函数
首先需要在.cpp中补充include几个在.h中前向声明的头文件
鼠标悬停在TryGetPawnOwner上 可以看到它的返回类型是APawn* 现在我们将获取它 并进行类型转换
在传统C++中 我们使用的是static_cast 它需要目标类型和要转换的值 适用于编译时已知的类型 static_cast允许基本类型之间的转换 在用于类类型的转换时 会尝试调用相应的构造函数来创建目标类型的对象 只能用于将子类转换为父类 不能用于将父类转换为子类
如果要将父类转换为子类 就需要dynamic_cast 现在我们就是在将父类Pawn转换为子类HelloWorldCharacter

但是在UE中 我们使用的是Cast< >( ) 如果括号里的内容 没有指向尖括号里这种类型的对象 转换就会失败 并且返回null

我们就用这个转换后的结果 给HelloWorldCharacter变量赋值
使用HelloWorldCharacter这个变量之前 需要检查它是否是空指针
然后取得角色移动组件 来初始化HelloWorldCharacterMovement变量

接下来每一帧都要更新地面速度 需要从角色移动组件中获取速度
GroundSpeed在AnimGraph需要用到 状态机过渡规则也需要使用 所以在蓝图里先别删 把鼠标悬停在蓝图中调用vector length XY的地方 会显示 目标是Kismet数学库 所以我们把它的头文件在cpp里写上

// HelloWorldAnimInstance.cpp
#include "Kismet/KismetMathLibrary.h"

我们需要取得的就是在XY上的长度

// HelloWorldAnimInstance.cpp
void UHelloWorldAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
    Super::NativeUpdateAnimation(DeltaSeconds);

    if(HelloWorldCharacterMovement)
    {
        GroundSpeed = UKismetMathLibrary::VSizeXY(HelloWorldCharacterMovement->Velocity);
    }
}

Kismet数学库是一个类 它的开头是U 这个类里的函数都是静态的 不需要再创建Kismet数学库类型的对象 直接调用静态方法就可以 这些方法不改变任何状态和变量 只计算然后返回结果

现在把事件图表里的东西都删了 把变量也删了 关闭UE 按ctrl+F5编译
打开动画蓝图 把这个动画蓝图的父类设置为我们刚才写的C++类 点击上方工具栏的类设置 在右侧细节面板 - 类选项 - 父类 目前是动画实例 点击下拉菜单 搜索HelloWorldAnimInstance 之后报了很多错 现在需要重新做一下Run到Idle和Idle到Run的过渡规则 右键搜索GroundSpeed添加节点 然后连上

在左下角 我的蓝图 面板 点击它右上角的齿轮 勾选 显示继承的变量 就可以看到C++里的变量暴露到了蓝图 这样创建节点的时候就不需要右键了 拖进来就可以了 拖进来的时候注意到Set GroundSpeed是灰色的 因为我们设置的是蓝图只读 不是读写

跳跃

之前做轴映射的时候 应该可以看到 还有一种输入映射 叫 操作映射
轴映射是每一帧都会执行的 但是操作映射不会 操作映射就是一个一次性的事件 假设我们把某个函数绑定到操作映射上 我们的HelloWorldCharacter是自带一个Jump函数 因为它继承自Character 所以我们可以把这个Jump函数绑定到jump操作映射上 比如绑定到空格键上 那么只有按下空格键时 跳跃回调函数才会被执行 而且只执行一次 一按按键 回调函数就执行了 所以操作映射比轴映射效率高 如果不需要每一帧都发生点什么 操作映射就是首选 非常适合比如跳跃 攻击 捡东西 扔石头之类的单次动作 而角色移动是不用操作映射的 因为需要按住移动键 每一帧都检查 不可能动一下就按一下移动键

UE关卡 左上角 编辑 - 项目设置 - 输入
添加一个操作映射 命名为Jump 分配给空格键
按键按下和释放 是可以执行不同的逻辑

来到HelloWorldCharacter.cpp的SetupPlayerInputComponent

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    PlayerInputComponent->BindAxis(FName("MoveForward"), this, &AHelloWorldCharacter::MoveForward);
    PlayerInputComponent->BindAxis(FName("MoveRight"), this, &AHelloWorldCharacter::MoveRight);
    PlayerInputComponent->BindAxis(FName("Turn"), this, &AHelloWorldCharacter::Turn);
    PlayerInputComponent->BindAxis(FName("LookUp"), this, &AHelloWorldCharacter::LookUp);

    PlayerInputComponent->BindAction(FName("Jump"), IE_Pressed, this, &ACharacter::Jump);
}

我们只添加了一行 PlayerInputComponent->BindAction(FName("Jump"), IE_Pressed, this, &ACharacter::Jump);
IE_Pressed是一个枚举常量 枚举类是EInputEvent 第2个参数指定了我们是否希望在按下或释放时调用此函数 如果想要在释放时调用它 我们会使用IE_Released 但现在是按下时调用 第3个参数是用户类对象 是this 和绑定轴一样 第4个参数 跳跃函数是已经存在的 不需要我们自己写了 传入就可以了 注意是ACharacter::Jump 而不是AHelloWorldCharacter::Jump 根本就没有这么个函数 我们没有重写 只是调用Character类里现成的函数 以后如果想用自己写的跳跃函数 把这里替换掉就好了

编译 回到PIE模式 发现现在还没有跳跃动画 打开VS
在HelloWorldAnimInstance.h 构造函数末尾添加

// HelloWorldAnimInstance.h
UPROPERTY(BlueprintReadOnly, Category = Movement)
bool IsFalling;

我们需要每一帧都设置它 因为角色可能跳起来 也可能落地 所以需要在HelloWorldAnimInstance.cpp的NativeUpdateAnimation函数里设置 要判断是否下落 需要从角色移动组件里获取 调用HelloWorldCharacterMovement的IsFalling函数

// HelloWorldAnimInstance.cpp
void UHelloWorldAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
    Super::NativeUpdateAnimation(DeltaSeconds);

    if(HelloWorldCharacterMovement)
    {
        GroundSpeed = UKismetMathLibrary::VSizeXY(HelloWorldCharacterMovement->Velocity);
        IsFalling = HelloWorldCharacterMovement->IsFalling();
    }
}

热重载 现在在动画蓝图 左下角 我的蓝图面板 可以看到IsFalling变量 它每一帧都会被更新

打开AnimGraph
GroundSpeed负责处理从Idle到Run的状态 就负责地面上的移动
我们可以再创建一个状态机 命名为Main States 负责处理角色在不同状态的切换

双击这个Main States 进入其中
从Entry引脚往右拖 添加状态 命名为OnGround 这将处理在地面上的移动 如果我们双击OnGround 就能进入我们的Ground Locomotion状态机就好了 但是默认状况下我们做不到 但是我们可以保存Ground Locomotion返回的姿势 可以把姿势保存到一个缓存中 可以理解为是存储在一个变量中 但实际上是一个保存姿势数据的变量
按住alt并鼠标左键单击连线 就可以断开Ground Locomotion与Output Pose的连线 从Ground Locomotion节点右侧引脚向右拖 搜索cache pose 选择New Save cached pose 命名为Ground Locomotion 这就是缓存的Ground Locomotion姿势 它每一帧都会更新 但我们也可以在其它地方访问它 比如其它状态机 比如我们刚才的Main States

现在就进入Main States的OnGround状态机 在空白处右键 搜索use cached pose 选择Use cached pose ‘Ground Locomotion’ 然后把这个节点右侧的小人引脚连接到Output Animation Pose左侧的Result小人上 它返回的姿态和Ground Locomotion的一样 就就好像拥有了idle和run状态一样
现在这个OnGround是地面状态 但是希望能在跳跃 下落 飞行时 能切换到其它状态 从OnGround往右拖 创建新状态 命名为InAir 将跳起的动画拖入InAir状态机内连接上
双击OnGround到InAir的过渡规则 将IsFalling变量拖入 连接到Result节点的Can Enter Transition引脚上 这样在下落的时候就会从OnGround切换到InAir
我们可以再从InAir往右拖创建Land落地状态 但其实可以直接把动作拖入Main States 就把land的动作拖入 会出现一个新状态 将其命名为Land 双击点进去查看 发现已经连好动作了 然后从InAir拖拽箭头连接到Land 进入InAir到Land的过渡规则 拖入IsFalling变量 只有它为假的时候 才从InAir切换到Land 从IsFalling节点右侧引脚拖拽 搜索 not bool 选择NOT Boolean 然后将NOT Boolean节点右侧引脚连接到Result节点的Can Enter Transition
64.png
最好是像上图这样摆放

再从Land状态拖拽箭头到OnGround 落地动画结束后 应该自动切换回地面状态 单击过渡规则的双向箭头 在右侧细节面板中勾选 基于状态中序列播放器的自动规则 这样落地后就会自动回到地面状态
现在OnGound InAir Land三个状态是呈现一个倒三角
65.png

回到AnimGraph 只需要将Main States连接到Output Pose的Result节点 Main States里已经包括了Ground Locomotion 因此Ground Locomotion不需要再连了

编译 进入PIE模式 发现在跳跃过程中按下方向键 它会在空中有位移 jump动画时间太长了 而且连续跳跃会僵直 我们希望如果地面速度不为0的时候 动画能快点结束 再从InAir状态拖一个箭头到Land 现在它就会有两个过渡规则 鼠标悬浮在双向箭头上 发现一个是我们之前做的过渡规则 另一个现在显示的是False 双击进入 右键添加 get relevant anim time(InAir) 因为我们现在是从InAir状态转移到Land状态 从这个Get Relevant Anim Time(InAir)节点 Return Value引脚拖拽 搜索> 选择greater 将数字从0.0修改为0.1 这个是持续时间 当然可以根据不同的动作选择合适的数字 这个时长最好就是设置成跳跃动作跳到最高点的时长 呈现效果是最好的 但是在原地跳跃的时候我们希望呈现完整的动画 只有地面速度不为0的时候才需要缩短动画
右键 添加 get ground speed 从它右侧引脚拖拽 搜索> 选择greater 这样如果地面速度大于0 就应该快速结束跳跃动画 因此需要把这个限定动作时长的 和判断速度的 进行一个AND 右键添加 And Boolean 把两个greater右侧的引脚都连接到AND左侧两个引脚上 然后将AND右侧节点连接到Result节点的Can Enter Transition引脚上

对于从Land到OnGround的过渡规则 也可以做类似的工作 目前我们将持续时长都调成了0.1

Control Rig 脚IK

先给角色换一个双腿叉开的idle动作 随便找个斜坡或者楼梯网格体拖入 发现有一只脚根本就是悬空的

就需要反向动力学 利用脚部的末端计算出怎么摆放腿上的各个关节
解决方法是 首选在脚的骨头上画一个虚拟球体 然后把球体往下压到地面 这叫球体追踪 这个过程是可以在1帧内完成的 球体追踪可以检测碰撞 如果球体追踪击中了地板 球体追踪就返回球体底部与地板接触的位置 然后我们就能获得从起点到碰撞点的距离 现在不知道哪条腿是悬空 两条腿都有可能是悬空 所以两只脚都需要球体追踪 假设我们一只脚在更高一阶的楼梯 高度50 另一只脚在低一阶楼梯 高度0 我们不做IK的时候 会整个身体都处于高度50 现在IK后要做的就是 让整个身体都落到高度0 然后把一条腿抬到50

首先 进行球体追踪 计算脚到达下方表面的距离 也就是所谓的偏移量 ZOffset_L ZOffset_R 这两个数值肯定是不同的 找出偏移量更大的 也就是处于更低位置的脚

66.png

目前就是miku的右脚偏移量更大 那么就需要往下移动骨盆 把骨盆往下移之后 整个骨架都会跟着一起往下移 然后需要把左腿抬起来 利用反向运动学来调整左腿上其它骨骼的位置 让它自然弯曲 并且需要更平滑地移动 所以要用到插值
如果只是抬起腿 有很多种方案 但是我们只需要最合理的姿态 所以需要对于骨骼的约束条件 UE有这个功能 将末端骨骼称为末端执行器 我们移动的骨骼 以及与其连接的所有其它骨骼 它们会根据自动求解IK的方程随之移动
我们将创建一个用于移动骨骼的资源 控制骨架Control Rig

在内容侧滑菜单 Character蓝图旁边 新建文件夹 命名为Rigs 在文件夹内 空白处右键 动画 - 控制绑定 出现一个弹窗 写着父绑定 里面有ControlRig和ModularRig 是让我们选一个父类 我们将基于ControlRig类来创建 将其命名为CR_RacingMikuFoot_IK 双击进入 这个东西非常像事件图表 上方写着 向前解析Graph

先进入骨骼 RacingMiku_Skeleton 可以找到脚部IK

回到CR_RacingMikuFoot_IK 左侧面板 向右拖拽扩大 就可以看到有一个 绑定层级 进入这个面板 点击导入层级 在弹窗中选择我们的骨骼网格体

然后回到 我的蓝图 面板 点击+号 创建新函数 命名为FootTrace 现在就会出现一个新的标签页 名为FootTrace 在这个图表中点击Entry节点 在右侧细节面板 - 输入 点击+号 添加一个新的输入参数 类型选择为rig element key 也就是绑定元素键 重命名为IKFootBone 点Entry节点里 IKFootBone右侧的小三角 就能展开引脚 里面有 类型 和 名称 绑定元素键是一种有类型和名称的数据结构 鼠标悬浮在类型上面 显示绿色 绿色是枚举的编码颜色 所以我们可以使用一个枚举来指定这个类型 这个枚举有多种类型可供选择
在空白处右键 搜索get transform 并选中 会得到一个 Get Transform - 骨骼 节点 这个节点的作用是获取某个对象的变换信息 有类型和名称两个参数 类型现在是骨骼 在下拉菜单可以看到这个枚举的所有可能值 骨骼 控制点 曲线 参考 等等 点击这个节点里 项目 左侧的小三角 就可以收起展开 我们就可以把IKFootBone右侧的引脚 连接到 项目 左侧的这个引脚 这样 项目 就会拥有IKFootBone输入参数中传递的类型和名称 Get Transform节点的空间 选择全局空间 Get Transform节点右侧引脚 会返回一个变换 点击它旁边的小三角 就可以看到 旋转 平移 缩放3D
现在 我们就有了骨骼的变换 我们想从骨骼正上方进行球形扫描到骨骼正下方 但是需要相对于脚偏移一定距离 这样就不会追踪到脚上 展开Get Transform节点的变换节点 展开平移 从Y分量往右拖 然后搜索add并选择 把B的数值改成5 这样就是在Y轴方向移动了5个单位
在空白处右键 搜索sphere trace 选择sphere trace by trace channel 会得到一个 按追踪通道进行球体追踪 节点 可以看到 检测通道是可选的 因此可以针对特定的追踪通道进行检测 通常就针对于可视性通道进行碰撞检测 因为几乎所有物体都会设置成block可见性通道 这样每次进行碰撞检测并使用可见性通道时 它就会命中那些设置成block/阻挡可见性通道的物体
碰撞检测需要起点和终点
在空白处右键搜索 add 添加Add节点 然后在A或者B上右键 出现一个弹窗 里面有个下拉菜单 选择向量 然后展开A和B左侧的小三角 先把这个 添加 节点的右侧 结果 引脚 连接到 按追踪通道进行球体追踪 节点左侧的 开始 引脚上
把Get Transform的X引脚连接到向量那个添加节点的A的X引脚上 把B添加5的那个添加节点的结果引脚 连接到向量那个添加节点的A的X引脚上 把Get Transform的Z引脚连接到向量那个添加节点的A的Z引脚上
然后我们要为这个向量添加 0 0 50 因此向量那个添加节点的B的XYZ就分别设置成0 0 50 这样起点就是相对于脚骨变换的位置 向上移动了50个单位
终点就设置为脚骨向下移动100单位 右键 搜索subtract 就会得到一个 减 节点 仍然是换成向量 然后展开 A的XYZ都设置成与 添加 节点相同 B的XYZ设置成0 0 100 然后把 减 节点的结果引脚连接到 按追踪通道进行球体追踪 节点左侧的 结束 引脚上 然后把 按追踪通道进行球体追踪 节点里的半径设置为3
找到Return节点 选中它 在细节面板 - 输出 新建一个变量 在下拉菜单中改成向量类型 重命名为HitPoint
按追踪通道进行球体追踪 节点 会返回一个命中位置 把这个命中位置连接到Return节点的HitPoint上 但是本例中的模型 鞋底很厚 如果用脚骨的高度做 鞋底就会陷入地面 所以在连接return之前 从命中位置往右拖 添加一个Add节点 在B的Z轴上改成合适的值比如10 这样就可以整体上抬高 最后再把这个Add节点连接到Return节点的HitPoint上

回到向前解析Graph 在进行追踪时 想在Control Rig上添加一个bool变量 只有为真时才追踪 这样以后更好控制 在 我的蓝图 面板 点击 添加 - 变量 命名为ShouldDoIKTrace 默认就是bool类型 我们暂时先不考虑如何设置这个变量
将ShouldDoIKTrace拖入向前解析Graph 选择get 从它的Value引脚往右拖 搜索branch 分支就像if语句 检查bool值 根据真假执行对应的输出 将向前解析节点的Execute引脚连接到分支节点的执行引脚
bool值为真就进行球形追踪 所以从 分支 节点 True向右拖 搜索FootTrace 展开IKFootBone左侧的三角箭头 类型选骨骼 名称选择左脚IK 展开HitPoint引脚旁边的三角箭头 从Z向右拖 选择promote to variable 在我的蓝图面板里 将其重命名为ZOffset_LTarget 然后把FootTrace右侧执行引脚连接到ZOffset_LTarget节点左侧Execute引脚 再从ZOffset_LTarget节点右侧执行引脚 向右拖 搜索FootTrace 类型选择骨骼 名称选择右脚IK 展开HitPoint 向右拖 promote to variable 重命名为ZOffset_RTarget
如果bool值为假 比如是在游戏过程中 那么就应该将这些ZOffset重置为0 将ZOffset_LTarget变量拖入向前解析Graph 选择set ZOffset_LTarget 把 分支 节点的False连接到set ZOffset_LTarget节点的Execute引脚 value值就保持为0 再把ZOffset_RTarget拖入 选择set ZOffset_LTarget 把ZOffset_LTarget节点右侧执行引脚与ZOffset_RTarget左侧执行引脚连接上

上面是第一步 下面要做之前提到的插值 我们将有一些实际变量 而不是目标变量 而是实际的Z偏移量 我们将对它将进行平滑插值 使其逐渐逼近目标值 所以现在 在我的蓝图面板 创建变量 ZOffset_L ZOffset_R 都修改成浮点类型 之前的那两个Target就是插值的目标
我们接下来还要连很多节点 这样蓝图就会非常长 所以使用序列节点 先把向前解析节点 和分支节点 执行引脚之间的连线断开 然后从向前解析节点Execute引脚向右拖 搜索sequence 现在整个蓝图的起点是向前解析节点 它也是会像Tick一样不断更新 但是没有DeltaTime作为输入 现在把序列节点的A引脚 连接到分支节点的执行引脚 这样它就会先执行A 把后面一系列都执行完 再来执行B 现在把A后面连接的那些全都选中 然后键盘上按下C 就可以写注释 就写成 1.从脚到地面进行一次球体追踪 现在也可以通过移动注释 批量移动这些节点

我们要做的是 一条腿悬空 然后向下追踪到地面 比如50个单位 集中地面时 我们只保留了那个位置的Z值 我们将使用它作为目标来插值ZOffset_L ZOffset_LTarget拖入 选择get 从ZOffset_LTarget右侧value引脚往右拖 搜索AlphaInterp 这个函数将返回一个浮点数 它接收我们传入的Target值 并返回一个更接近它的值 到底有多接近 取决于插值速度 也就是那个 内插速度提高和降低 参数 把这两个参数都设置为15 把Get 把ZOffset_L拖入 选择set 然后把AlphaInterp节点右侧的结果引脚 连接到 ZOffset_L节点左侧的value引脚上 把ZOffset_LTarget的Value引脚连接到AlphaInterp节点的值引脚上 然后把ZOffset_RTarget拖入 选择get 从value引脚往右拖 搜索AlphaInterp 内插速度改成15 15 把ZOffset_R拖入 选择set 然后把AlphaInterp节点右侧的结果引脚 连接到 ZOffset_R节点左侧的value引脚上 最后把 序列 节点的B 连接到Set ZOffset_L的Execute引脚上 然后把ZOffset_L右侧的执行引脚 连接到Set ZOffset_R的Execute引脚上 选中序列节点B后面的这些节点 注释为 2.平滑地插值到目标偏移量 现在 当目标变化时 ZOffset_L和ZOffset_R就都会平滑地跟随 会稍微有点滞后

我们需要找出这两个Target变量中 哪个是最小值 哪个数值更低 也就是位置更低 我们就用它作为目标 让骨盆向下移动 现在把ZOffset_LTarget和ZOffset_RTarget都拖入 选择get 从ZOffset_LTarget的value引脚往右拖 搜索less 也就是浮点数比较 然后把ZOffset_RTarget的value引脚连接到 小于 引脚的B引脚上

蓝图使用branch分支 实现类似if语句的功能 但是control rig是使用if节点 一起选中ZOffset_LTarget和ZOffset_RTarget 然后按ctrl+D复制 从新建的ZOffset_LTarget的value节点往右拖 搜索if 选中 然后把新建的ZOffset_RTarget的value节点连接到if节点的False引脚上 这个if节点 需要的2个浮点数和1个bool值 如果bool值为真 它就输出连接到true的数值 本例中就是ZOffset_LTarget 如果bool值为假 它就输出连接到false的数值 本例中就是ZOffset_RTarget 所以要把 小于 节点的结果引脚 连接到if节点的condition节点上 小于节点 会判定ZOffset_LTarget是否小于ZOffset_RTarget 如果小于 它就返回真 这样if节点就会返回连接到true的值 也就是ZOffset_LTarget的值 如果不小于 if节点就会返回ZOffset_RTarget的值 那我就很想知道 为什么用一个节点来解决这个问题啊 就是三元运算符 float LowerZ = (ZOffset_LTarget < ZOffset_RTarget) ? ZOffset_LTarget : ZOffset_RTarget; 无论如何 返回的就是那个更小的值 我们将用它来偏移骨盆 暂时先保持对于蓝图的忍耐吧
现在再创建一个变量 命名为ZOffset_Plevis 设置为浮点类型 就把它设置为计算结果 拖入 选择set 把if节点的result引脚连接到 Set ZOffset_Plevis节点的value引脚上 然后把2.平滑地插值到目标偏移量 里面最后的Set ZOffset_R节点的右侧执行引脚 连接到Set ZOffset_Plevis节点的Execute引脚上 这部分计算的就是骨盆的偏移量 将这一块注释为3.用最低的脚部偏移量来防止过度伸展

我们总之就是计算出来了骨盆要偏移的量 而且可以移动得非常平滑 我们将要用这个数值来移动脚IK 接下来就需要变换实际上的骨骼了 但是为什么我们不直接操作实际的骨骼呢 因为我们想要使用IK优雅地处理其它的骨骼

在 2.平滑地插值到目标偏移量 外面的下面空白处右键 搜索modify tranforms 得到的 修改变换 节点 有一个数组元素 可以看到一个左边有三角箭头的0 这也就是一个数组类型的输入参数 是待修改的数组 点它左侧的三角箭头展开 类型默认是骨骼 名称选择左脚IK 再点击下方的左侧的有三角箭头的变换 展开它 再展开 平移 只需要改变Z轴 把ZOffset_L拖入 选择get 连接到这里的Z 再把下面的模式改成Additive全局 意思就是用这个修改变换节点里的这些值 对于左脚IK做变换 也就是XY都不变 就在Z上增加一个ZOffset_L的数值 现在把序列节点的C引脚 连接到修改变换节点的Execute引脚上 如果没有C引脚 点击添加引脚就有了 继续新建一个修改变换节点 把右脚也做了 最后把左脚的修改变换节点的右侧执行引脚 连接到右脚修改变换节点的Execute引脚上 然后我们就移动了脚IK

现在还需要移动实际上的身体 就用移动骨盆来实现这一点 继续新建修改变换节点 展开0 - 项目 名称选择骨盆骨骼 这是一个真正的有蒙皮的骨骼 展开变换 - 平移 把之前计算得到的ZOffset_Plevis拖入 选择get 连接到Z上 当然它实际上只是两个ZOffset里最小的那个 也选成Additive全局 现在把连接到C后面的这些节点都选中 命名为4.把插值偏移量添加到脚IK骨骼

现在来处理一下IK UE5有个功能是 全身IK 在空白处右键 搜索full body IK 它让我们可以对身体上任意一串骨骼使用IK 所以我们可以指定末端执行器 也就是节点里的执行器 要动哪块骨头 身体其它部位也会跟着动 把根选择为骨盆骨骼 把它接入序列节点的D 在全形体IK节点中 点击执行器右边的加号 骨骼下拉菜单 选择我们要移动的骨骼 是真正的骨骼 不是IK和虚拟骨骼 选择左脚 注意是左脚 不是左脚腕 在选中的瞬间 就可以看到视口里 左脚发生了一些扭曲 我们需要传入一个变换 来告诉它要怎么动 需要传入的就是IK骨骼的变换 右键 搜索get transform 将get transform节点 类型设置为骨骼 名称设置为左脚IK 然后把get transform节点的变换 连接到全形体IK的变换上 这样视口里就恢复正常了 再点执行器右边的加号 对右脚也这样做 全身IK可以使得在我们移动脚部的时候 腿会自然弯曲 把连接到序列节点D上的这一块 注释为5.使用全身IK节点来解决IK问题 并使用脚IK骨骼作为每只脚的效应器目标

现在对着蓝图 回顾一下我们做的事情 这非常重要

67.png

  1. 首先检查一下要不要做IK追踪 如果要追踪 就开始两只脚的球体追踪 然后把它从当前位置到击中地面的位置的差值 赋值给ZOffset_Target 注意这个值是一个差值 起点到地面的Z的差值 而不是一个绝对的Z坐标的值 这个值是用终点的Z坐标 减去起点的Z坐标得到的 因为球体必然是向下落的 所以这个ZOffset_Target是一个负数 那实际上我们的脚最后一定是要落地的 因为我们做IK的目的就是这个 最终的目的是落地 所以从最终落地到起点的那个Z坐标的差值 就是目标Target 如果不要做IK追踪 就把这个ZOffset_Target清零
    这一步做完 我们就得到了两个ZOffset_Target的值

  2. 但是突然迅速落地明显不符合人类运动规律 所以要用插值 缓慢地落地 所以我们再设置一个变量 就是ZOffset 它的意思就是当前我这个IK骨骼的位置与起点的差值 我们的目标是到达终点 也就是让ZOffset的值最终要变成ZOffset_Target 但是当下我们需要缓慢地接近 缓慢接近的这个速度或者说运动规律 就是通过插值控制的
    但是IK骨骼毕竟不是实际上有蒙皮的骨骼 我们真正的目的是要在蒙皮上看到效果 所以就要移动骨盆 我们有两只脚 如果不做IK 两只脚应该是一样高的 但是必然有一只脚是悬空的 最终我们希望悬空的脚应该落下去 悬空的脚最终到达的位置必然更低 也就是说它移动的距离是更大的 为了符合运动规律 整体的身体的趋势肯定也是要往下落 而那只 最终要到达更低位置的脚 所移动的数值 正是骨盆需要向下移动的数值 骨盆最终是要向下移动这么多的 所以这个数值就是处在更低位置的那只脚的ZOffset_Target的值 而因为ZOffset_Target是负数 所以也就是两个脚的ZOffset_Target中数值更小的那个
    这一步做完 我们就可以平滑地从起点移动到落地的终点了 并且还计算出了骨盆骨骼要移动的数值

  3. 由于我们之前在第1步已经得到了脚需要移动的数值 也就是两个ZOffset_Target的值 我们现在就先去移动脚IK骨骼 然后再移动实际的骨盆骨骼 并且要平滑地移动

现在回到动画蓝图 进入AnimGraph
右键 搜索 control rig 它是在Misc.一栏 需要往上翻 把Main States和Output Pose的连接断开 然后把Main States的小人引脚连接到Control Rig的Source小人引脚上 把Control Rig右侧的小人引脚连接到Output Pose的Result小人引脚上 选中Control Rig节点 右侧细节面板 在Control Rig一栏 可以找到 控制绑定类 在下拉菜单 选择CR_RacingMikuFoot_IK
还记得吗 那个我们并未设置的神秘ShouldDoIKTrace bool型变量 回到CR_RacingMikuFoot_IK 点击它右侧的眼睛 使其可见 之后对于CR_RacingMikuFoot_IK进行编译 现在这个编译终于不报错了
回到动画蓝图 在细节面板继续下拉 找到 输出 一栏 勾选 ShouldDoIKTrace的使用引脚 现在在Control Rig就出现了引脚 我们现在勾选ShouldDoIKTrace

编译动画蓝图后进入PIE模式查看 运动效果是非常奇怪 回到CR_RacingMikuFoot_IK 找到第5个注释那一块 全形体IK这里面的值 我们都是用的默认参数 展开设置左边的三角箭头 迭代的意思是 在找到解决方案之前 要进行多少次计算 这都可以用默认值 但是 根行为 需要修改成 锁定到输入

那么关于ShouldDoIKTrace这个参数 到底应该是怎样呢 当我们确定不在空中时 就应该进行射线追踪
回到动画蓝图 把IsFalling变量拖入AnimGraph 从IsFalling节点向右拖 搜索not boolean 再把NOT节点的右侧引脚连接到Control Rig的Should Do IKTrace引脚
但是如果是跑步时 也不会想要使用control rig 右键 搜索 blend poses by bool 这个节点接收一个bool值 如果是真 就使用一个姿势 如果为假 就使用另一个姿势 那么如果GroundSpeed为0 就正常做control rig 如果不为0 就是跑步姿势 拖入GroundSpeed变量 从它右侧节点往右拖 搜索== 选择Equal 然后将Equal节点右侧的引脚连接到Blend Poses by bool的Active Value引脚上那么 如果GroundSpeed为0 它就会返回我们连接到True Pose的动作 如果不为0 他就会返回连接到False Pose的动作 所以就把Control Rig右侧的小人引脚连接到 我们想把Main States连接到True Pose 但是Main States并不能直接复制 需要使用缓存姿势 把Main States与Control Rig的连接先断开 从小人引脚往右拖 搜索cached pose 选择New Save cached pose 命名为Main States 这样就可以用它来代替Main States了 并且还能多次使用 右键搜索use cached pose 选择use cached pose “Main States” 把这个节点右侧小人引脚连接到Control Rig节点的Source小人引脚 选中并且按ctrl+D就能又复制一个use cached pose “Main States”节点 连接到Blend Poses by bool的False Pose上 最后把Blend Poses by bool节点右侧的小人连接到Output Pose的Result小人引脚上 编译

现在显示效果上 MMD模型的IK绑定导致出了很多问题 因为MMD的脚IK不是UE想要的IK 还是使用虚拟骨骼 对之前做重定向到root的那个骨骼 本例中就是全ての親2 右键 - 添加虚拟骨骼 在这里继续选择全ての親2 就会在全ての親2的子项得到一个名为 VB全ての親2_全ての親2 的虚拟骨骼 再对这个 VB全ての親2_全ての親2 右键 - 添加虚拟骨骼 选择实际上的左脚骨骼 这个就可以充当IK骨骼 再添加一个右脚的 现在就用这些虚拟骨骼 代替IK骨骼 这样就很精准了 建议就算有IK骨骼 也还是使用虚拟骨骼

现在倾斜角度很奇怪 因为我们还没有做法向
打开FootTrace的编辑面板 在左侧 我的蓝图面板 找到FootTrace函数 在右侧细节面板 输出 添加 HitNormal 和HitNormal一样 也设置成向量型 这样最终的Return就会出现一个HitNormal引脚 然后把 按追踪通道进行球体追踪 右侧的 命中法线 引脚连接到Return的HitNormal引脚 回到向前解析Graph 可以看到注释1那一块里面 两个FootTrace节点上 都出现了HitNormal

直接使用法向数据 会导致如果碰到很凹凸不平的地面就会发生抖动 所以需要插值 从左脚的HitNormal往右拖 搜索节点 spring interp 添加一个弹簧插值节点 先创建两个FVector类型的变量 命名为CurrentNormal_L和CurrentNormal_R 把get CurrentNormal_L连接到弹簧插值节点的 当前 引脚 然后将弹簧插值的结果引脚 连接到set CurrentNormal_L 右脚同理 再从注释1那一块最右侧的SetZOffset_RTarget右侧执行引脚 连接到set CurrentNormal_L的左侧执行引脚 再从set CurrentNormal_L的右侧执行引脚 连接到set CurrentNormal_R的左侧执行引脚 将这一块注释命名为1.5.法线插值

我们需要计算的是 原本垂直向上的向量(0,,0,1)和地面法向向量之间差了多少度
右键 搜索 quat from two vectors 得到一个名为 从双向量 的节点 这样就能算出这两个角度之间相差的旋转 并且这个旋转是用四元数表示的 展开A 设置成0 0 1 将get CurrentNormal_L连接到B

找到注释4那一块里面左脚的 修改变换 节点 在 要修改的变换 - 变换 - 旋转 目前是0 0 0 这也就是没有旋转 注意到这个旋转引脚是蓝色的 鼠标悬浮在上面 意思就是这个引脚是一个四元数 把刚才的 从双向量 节点的结果引脚 连接到这个修改变换节点的旋转引脚上 右脚同理 将这一块命名为4.0.将法线旋转添加到脚IK骨骼

动画技术

现在在这里补充一些来自于 GAMES104(第8-9节) 的知识

刚体的3个方向的移动 XYZ轴的移动 3个方向的旋转 绕XYZ轴的旋转 这6个维度 6自由度 6DOF 就可以制作动画了

如果是一个非常软的东西 动画表现得又很好 要么是顶点动画 要么是物理动画 因为如果绑骨骼你根本不知道绑在哪里 要绑多少 先用离线的物理引擎模拟好 然后记录下来每一帧每个顶点和顶点法向的位置 就变成了顶点动画 水流的效果其实也是用顶点动画做的

69.png

整个世界是有世界坐标系的 world space 对于角色模型 有自己的坐标系model space模型坐标系 不是那个local space 从世界坐标系 到模型坐标系 就是6自由度在发生变化 这样就能定义唯一的一个在空间里的姿态
动画是在模型之上还有一个坐标系 是local坐标系 局部坐标系 动画里的每一根骨骼 都会发生平移和旋转 那么它相对于模型坐标系也是有变化的 实际上手掌的朝向 是从肩膀 手肘 手腕的坐标系 一个一个传递过去形成的 动画很多时候是在local坐标系的 而把local坐标系 一路从它的根节点累积上来 才能计算出来它的模型坐标系 把模型坐标系考虑到这个角色站的位置和它的旋转 才能变成世界坐标系 只有变到世界坐标系 这个角色才会被渲染

人类模型的骨骼 根骨是在盆骨 脊椎的最后一根骨头 向下就分化出两条腿 向上就是脊椎 到肩膀展开 分成两只手 但是对于四肢的动物 就不是这种骨架

我们一直都在说在用骨骼构建了骨架 skeleton 但实际上在游戏引擎里 真正表达的是joint关节 就是之前在Blender里导入MMD模型之后 删掉了的其中一个
实际上比如肘关节前面连了一个刚体的骨骼 所以当肘关节发生旋转变化或者平移偏移时 骨骼就会跟着动 但是我们并不会直接存储骨骼的数据 而是存储肘关节的数据 这个肘关节在层级上的下一个节点 是手腕关节 手腕关节前面就控制了手掌 真正在游戏引擎里的是joint关节 而两个关节之间的才是bone骨骼 joint是有很多自由度的 bone没有 bone是由2个joint联合在一起定义出来的 joint它是个刚体 它不会被twist 但是bone是可以twist的
对于武器 就是绑定在角色那个单一的武器骨骼上 还比如人骑到一个坐骑上 它是有一个骑的骨骼点

root骨骼 一般可以发现是在两脚中间 root并不是骨盆 为什么要放在两脚中间呢?
比如我们要表达一个角色的移动速度 或者它跳起来了 有一定的离地高度 如果是使用骨盆作为root 人物在不同姿势比如蹲和跳的时候 离地高度都是会变化的 但是这个root距离地面的高度是不会变的 等同于手办的底座支架 无论上面角色怎么换动作 支架还是在那里
但是对于四肢动物 骨盆是放在后两条腿的骨盆位置 root是在肚子下面的地上的中心点

object之间是有父子关系的 比如人骑马 其实是人有人的动画 马有马的动画 怎么让它的动画一致呢?
人的身上有一个骑的骨骼 马身上有一个乘坐的骨骼 这两个骨骼要重合在一起 坐标和旋转的的6自由度全部要重合 类似于一个插槽 当马前倾的时候 人也要跟着前倾 这就实现了bounded animation 载具系统会使用到

T-pose对于肩膀有挤压 所以现在都倾向于做A-pose 当我们真正表达一个pose姿态时 它是有9自由度的 平移旋转 还有一个缩放 缩放对于做人脸的变形 角色弹性的变化 是很有用的

我们关节上做的最多的事情是旋转 之前用的是旋转矩阵 欧拉角 但是欧拉角难以去插值 不能线性地去做 也很难进行旋转的叠加 而且对于旋转的表达是按着坐标轴 但是真实世界里的旋转 很难沿着坐标轴 如果给定空间中任意一个轴做旋转 就会很难 欧拉角是做物体的摆放时经常用 但是做动画时不用 而是使用四元数
四元数详情可以查看 #4 从欧拉角到四元数

多数时候动画都是joint做旋转 但也有平移 比如角色站起时 盆骨就要上下平移 做表情时也有平移 还有一项是缩放scale 在表情里常用

动画一般都会存储在它的local space里 而不是model space 只会存储相对于它父亲的变化 它的动作是从根节点 一个骨骼一个骨骼算下来的

如果一个顶点mesh只绑定在了一个骨骼上 那在骨骼运动的时候 这个顶点和骨骼的相对位置是不变的 这就是绑定
角色身上的顶点有几万个 但是骨骼就只有几十个 所以是把所有骨骼的位置先算好 之后把蒙皮的矩阵算好 这样计算顶点的时候 就可以重复使用蒙皮矩阵的值 蒙皮是有很多权重加在一起的 真正的蒙皮矩阵skinny matrix还会再乘一个模型坐标系到世界坐标系的转换

每一根骨骼 都是从根节点 一路累积上来的 这个transform包括旋转平移放缩 一路上来 就计算出来了它在模型空间的transform变形
得到这个transform之后 对于所有绑定的顶点 它的新位置怎么得到呢? 要乘上绑定时默认pose的时候矩阵的逆 再左乘在当前模型空间的pose矩阵 这样就能得到一个蒙皮矩阵 就可以计算出新的位置 在真正渲染的时候 需要再左乘一个从模型坐标系到世界坐标系的转换

实际上的蒙皮的顶点 几乎不止和一个joint起作用 这个顶点 相对于依赖的joint1 计算出在模型空间的新的坐标 再算出依赖的joint2的模型空间的坐标 然后加权平均 也就是插值 这样就可以做出来twist的效果 必须是在模型空间做 不能在joint的本地空间做 因为每个joint的本地空间都是不同的

动画资产 clips 帧数不会那么高 类似于关键帧 但是游戏的帧率是很高的 这中间就需要插值 让动作更平滑 平移的插值 线性就可以做了 旋转的插值 就是用四元数 前一帧的joint旋转是q1 下一帧是q2 那么只需要做q1和q2的线性插值就可以了 再做一个归一化变成单位四元数 这样就能得到一个新的旋转 这个方法叫NLERP 欧拉角的旋转矩阵是很难做这种插值的 其实插值就是做动画的中割

NLERP里面还有细节上的处理 因为人的直觉是 运动肯定是要在最短路径 用q1点乘q2 结果为正 直接插值 结果为负 说明两者之间为大于180°的角 需要反向插值 这样就保证了是最短路径的插值
但是即使是做了这样的插值 但它其实在角度空间中的插值旋转是不均匀的 比如做整个180°的旋转过程中 它是一开始快 中间慢 然后又加快

现在还有SLERP的做法 使用arccos的运算 把两个旋转之间的夹角 \(\theta\) 找到 然后利用 \(\theta\) 一点点地插值 这样的效果比较好 但是反三角函数运算是比较慢的 是要查表的 另外就是角度 \(\theta\) 很小的时候 sin很小 sin当分母 所以插值是很不稳定的
所以真正的做法是给一个magic number 两个旋转的夹角小于这个值的时候 就用NLERP 其它时候再用SLERP

总结一下动画的runtime pipeline 现在就是我们有动画的clips 里面就是这个角色的pose 需要找到当前帧和下一帧 如果这一帧在clips里没有的话 就做插值得到一个pose 这个pose我们要将其转换到模型空间 然后算出来一个蒙皮矩阵 这之后运算就转移到GPU里了 每一个顶点利用蒙皮矩阵去计算 这样每一个骨骼都可以表达出来它的蒙皮mesh 当然现代游戏引擎基本上是也会把前面的运算也放到GPU里

动画压缩

大部分的骨骼放缩都是1 位移也很少 主要就是旋转 而且比如走路的时候 手指也几乎没有什么旋转 上臂的数据量也很少 主要是腿
那么就把有些骨骼上不会用到的缩放平移全都删掉
对于旋转 引入关键帧key frame的概念 其他的只需要在关键帧之间插值就能做出来 从一个点开始走走走 在走到下一个点之前线性插值 当原始的值和插值出来的值之间的误差error到达一个阈值的时候 就把这个点退回来然后把上一个点作为关键帧 然后再以这个点为起点继续插值 这样就可以把很多帧的动画变成少量的关键帧 关键帧和关键帧之间的时间间距不一定是均等的 取决于你这个信号在时间轴上的变化频率 线性插值效果不是特别好 这里就需要引入catmull曲线 不详细介绍

对于数值的表达也不会用浮点数 因为浮点数是32位 如果这个数值在一定区间范围内 可以用定点数模拟 比如把这个范围内的数规划到0到1上面就可以用16位整数表达

四元数的归一化压缩是有一个数学特性的 虽然abcd中的每一个数值都有可能在-1到+1之间波动 但是如果把最大的那个数扔掉的话 其他三个数值每一个都必然在 \(-1/\sqrt{2}\) 与 \(1/\sqrt{2}\) 之间 那么就只需要2bit来存储哪一位是最大值 再把剩下的那三位存下来 通过四个数的平方和是1 就可以把最大值这一位数还原 这样用15个bit就可以表达的很准确

但是压缩也会带来问题 虽然每一个关节在压缩的时候error都控制住了 但是关节是一节一节传递的 这个误差会越来越大 动画的目的最终是为了视觉 所以最简单的办法就是在像素上计算压缩前和压缩后的差距 但是网格上的顶点太多了不可能一个一个地计算 所以一般就是在joint附近定义两个相互垂直的点 去计算压缩前后的误差 重要的骨骼就设计小一点的offset

dcc动画制作的顺序 大概是先制作一个mesh 然后绑定骨骼 之后绘制蒙皮权重 再制作关键帧动画之后导出 大部分动画是直接导出的 假设骨盆root也发生了位移 比如跳跃或者蹲下的动作 动画一般是不会把这个root存下来的 要把root单独导出成一个位移曲线

动画blending混合

比如要从走路动画过渡到跑步动画 想要更平滑 自然就想到线性插值 但是之前的关键帧插值是在同一个动画的帧之间插值 而现在的blending是在两个动画clips之间插值
就看从走到跑这个案例 怎么计算它的weight 如果是移动速度发生了变化 那就用当前速度作为一个自变量 将跑步的权值记为weight2 走路的权值记为weight1 然后将weight2逐渐提升到1 weight1逐渐降低为0 保证weight1+weight2=1

blend space 前后左右多个动作都能混合了

比如有鼓掌的动作 只发生在上半身 希望和多种下半身的姿势比如站着蹲下 都能进行混合 那么就需要对skeleton做一个mask 上半身使用鼓掌动画 下半身使用姿势的动画 这样还可以使得动画数据量更小 这也是一种blending混合 skeleton mask blending

additive blending 给动画再附加一个偏移量

状态机 state machine

之前已经说过 如果做跳跃动作 我们的做法是要把这个位移去掉的 是因为根本不知道高度有多高 在空中要悬浮多久 所以一般是做3个动画 1个起跳 1个落地 1个在空中的loop 这不是一个blending的问题 是彼此依赖的一个状态切换的问题 动画之间的切换 blending插值的时间 0.2s是一个经典的magic number

动画树

古典的时代 使用layered ASM 多层状态机 上半身 下半身 头部 都有不同的状态机 这样就可以组合出很多复杂的动作 而且角色可以看起来很灵活

但是现代游戏引擎都用动画树了 UE里的blend poses by bool就是动画树混合 能做出动画树的分支 像我们之前做的把两个状态机都连接到blend poses by bool节点 最后再连接到Output Pose节点

IK

人体的不同joint旋转的能力和可能的角度是不同的 比如胯部就只可能抬那么高 是有特定的活动范围的 这就是约束

表情

人类的表情可以划分为多少种 固定的模式 绝大多数的表情都可以用这些模式来表达 表情是用顶点动画morph做的
对于很多卡通风格的游戏 可以把live2d变成一张texture 反而比morph更实用
面部表情也可以做IK重定向

碰撞

首先是需要为我们的模型制作物理资产 因为MMD模型骨骼不是标准命名 它没有自动生成 我们需要自己制作物理资产
选中骨骼网格体 右键 - 创建 - 物理资产 - 创建 在弹出窗口把最小骨骼尺寸改成5 进入视口之后 最好精密地调整一下

然后在角色蓝图 在左侧组件面板 选中网格体组件 在右侧细节面板 - 物理资产覆盖 选中我们制作的物理资产 编译

现在找一块岩石的静态网格体拖入关卡 拖入的时候发生了什么呢?
岩石是静态网格体 不是Actor 是UStaticMesh类型的对象 但是世界里的所有东西都必须继承自Actor类或者其子类 所以当我们拖入这个静态网格体时 UE是自动创建了一个静态网格体Actor 在右侧大纲视图里就会出现一个StaticMeshActor类型 是Actor的子类
选中查看细节面板 这里就会有一个SM_Stone(实例) 它下面有一个StaticMeshComponent类型的组件 这是一个StaticMeshActor 它只有一个组件 StaticMeshComponent 选中这个组件 就可以看到这个静态网格体组件自带一个静态网格体 自动设置成了我们拖入的静态网格体

这有一个静态网格体组件 组件是具有碰撞设置 细节面板可以找到 现在碰撞预设是default 下拉菜单里有很多选项
打开BP_HelloWorldCharacter 在细节面板可以看到它的碰撞预设是CharacterMesh

回到那个石头静态网格体 碰撞预设下拉菜单里有一个custom自定义 下面就会出现很多选项 要是再从custom改成比如NoCollision无碰撞 这些选项就变成灰色了

现在选择custom来查看这一个个选项

  • 碰撞已启用 下拉菜单里有
    1. 无碰撞 该组件会直接穿过其它物体 反之亦然 不会有任何反应 没有任何碰撞
    2. 纯查询(无物理碰撞) 只进行查询 不进行物理碰撞检测 空间查询
      比如射线检测 就是进行某种追踪 之前的control rig里 我们对于每只脚都进行了球形追踪 想象一个球体向下碰到地面 获取碰撞结果 就能知道碰撞点的位置了 这是一种查询
      扫描 是指物体检测自己是否即将与其它物体重叠
      重叠检测 就是两个物体真的重叠了
      只有启用查询 才能进行上面这些种类的空间查询 仅查询模式之下没有物理模拟
      UE是自带物理的 叫做Chaos 启用组件的物理特性之后 就会进行各种物理计算 这样就能模拟重力 力 等各种物理现象 当两个模拟物理的物体碰撞时 就会产生反作用力 导致它们互相反弹
    3. 纯物理(不查询碰撞) 只进行物理模拟 这样就能实现物理碰撞 可以模拟重力 并对其它物理对象施加作用力 这个模式下不支持查询
    4. 已启用碰撞(查询和物理) 这个模式下就既可以做物理 也可以查询 但是成本也很高 通常用于那些可以被推动 抛掷 并且能够响应追踪(比如control rig用到的追踪)的对象
    5. 仅探测(接触数据,无查询或物理碰撞)
    6. 查询和探测(查询碰撞和接触数据,无物理碰撞)
  • 对象类型 里面有WorldStatic WorldDynamic Pawn PhysicsBody Vehicle Desturctible 所有带碰撞的组件都有各自的对象类型 决定其它组件如何与之交互
  • 碰撞响应 下面分为检测响应和物体响应 都可以设置为 忽略/重叠/阻挡 这意思是其它那些不同的对象类型的组件对于当前这个预设下的组件的反应 可以在这里对于每一种类型对于当前类型的反应进行设置

现在把石头设置为custom模式 并且把碰撞已启用设置为无碰撞 它现在对象类型默认是WorldStatic 进入PIE模式 果然这个石头没有什么碰撞

将石头设置成 纯查询(无物理碰撞) 进入PIE模式 发现有碰撞了 站在石头上 IK正常生效 查看碰撞 只能看到角色和地面上有很多碰撞体积 但是这块石头上没有碰撞体积 并且之前我们没有做物理资产的时候 脚IK还是在生效 这就说明control rig脚IK的生效不是依赖于碰撞体积/物理资产的 而是依赖骨骼的查询
结束PIE 打开这块石头资产的静态网格体编辑器 在视口右上角点击眼睛 - 简单碰撞 发现什么也没有 这是因为这块石头没有简单碰撞 simple collision指的是碰撞体积 静态网格体通常是有碰撞体积的 因为网格本身就有很多三角形 如果使用这个网格进行空间查询 就会导致大量计算 在眼睛那里 选择复杂碰撞 就可以看到大量三角形
现在关闭复杂碰撞 在上方菜单栏 点击碰撞按钮 在这里可以添加很多类型的碰撞 先选择添加球形简化碰撞 发现完全不匹配我们的石头 但是重新进入PIE模式 发现我们再也无法通过走路踩上这块石头 查看碰撞 就可以看到石头是具有球形的碰撞体积的 可以通过跳跃上去 脚IK仍然在生效 踩在了真实的石头上

回到静态网格体编辑器 移除这个球形碰撞 碰撞下拉菜单里的一串字母数字的很多种类碰撞 是生成简化碰撞的算法 10-DOP-X方法会在X轴方向创建一个类似圆柱体的形状 经过多次倒角处理后 最终形成一个具有10个平面的多面体 但相对于石头而言 形状还是太方正了 Y就是在Y轴方向做倒角处理 18DOP 26DOP 是生成轴向对齐的盒型碰撞体 所有倒角分别经过18面和26面倒角处理 26DOP是最合适石头的 这就是空间查询 但本例中对于这个碰撞体积还是难以直接走上去 还是需要跳上去

把石头改成纯物理(不查询碰撞) 发现角色是直接穿过了石头 因为胶囊体不再进行空间查询 所以只有启用空间查询 胶囊体才能阻止我们穿过去

现在将石头设置为 已启用碰撞(查询和物理) 仍然是比较艰难地跳上去了 启用物理之后 在细节面板 - 物理 就可以勾选模拟物理 我们原本是把这块石头放在半地下 现在已经完全到地面上来了 角色走过去就可以移动这块石头 这是因为物理引擎让我们可以施加作用力

打开角色蓝图 选中胶囊体组件 在细节面板 可以看到它的碰撞预设是Pawn 已启用碰撞(查询和物理) 所以我们可以对其它正在进行物理模拟的物体施加作用力

将这块石头上移到空中 勾选启用重力 进入PIE 就可以看到它掉下来了

下面看看碰撞响应 分为检测响应和物体响应 而我们的角色蓝图胶囊组件碰撞预设是Pawn 现在把石头的物体响应设置成忽略Pawn 现在角色是可以穿过石头的 但是停在它上面的时候 IK又是生效的
保持这个忽略Pawn 改成纯物理(不查询碰撞) 那么空间查询就不生效了 IK不再生效 直接走过去了 而且它不会影响摄像机的弹簧臂长度了 在已启用碰撞(查询和物理)模式下 是会影响弹簧臂长度的

所以如果只想使用脚部IK 但是不想摄像机的视角依据这个石头发生任何变化 那么就可以将检测响应里的camera修改为忽略 现在修改成已启用碰撞(查询和物理) 进入PIE 可以发现脚IK在正常工作 但是摄像机视角不会发生任何变化了 不会阻挡摄像机 就不会修改摄像机的弹簧臂长度

现在找一个木板资产 将它变成垂直于地面的 在细节面板 将碰撞预设修改为Custom 可以看到 这个对象类型默认是WorldStatic 这个就适用于我们不希望玩家移动的物体 设置为已启用碰撞(查询和物理) 碰撞响应里 将Camera设置成阻挡 Pawn设置成忽略 进入PIE 就可以看到 角色可以穿过它 并且在穿过它的瞬间 相机弹簧臂长度发生了改变 这样就类似于一个隐藏门的效果

打开BP_Item蓝图 事件图表 Event BeginPlay和Event Tick中间还有个Event ActorBeginOverlap节点 鼠标悬停在上面 显示这是一个Actor与另一个Actor重叠时调用的事件 比如玩家走入触发器时 如需了解对象拥有阻挡碰撞的事件(如玩家与墙壁发生碰撞) 请查看命中事件 意思就是 这个是重叠事件 对于玩家与墙壁发生碰撞的 是属于命中事件 不属于重叠事件 又说要出发重叠事件 需要将这个Actor和其它Actor上的bGenerateOverlapEvents属性都设置为true

重叠的意思是和任何组件重叠 只要组件配置正确 就能正确响应重叠事件

在左侧组件面板选中Item Mesh 在右侧细节面板找到 生成重叠事件 它默认是已经勾选的 现在碰撞预设默认是BlockAllDynamic 它是启用了查询和物理 碰撞响应是阻挡了所有类型 但是如果要想发生重叠事件 这个组件必须设置成能与其它物体重叠 我们希望它能和Pawn也就是角色胶囊体的类型重叠 打开角色蓝图查看 选中胶囊体组件 可以看到它碰撞预设是Pawn 碰撞响应忽略Visibility 其它类型都阻挡 包括摄像机也阻挡 这个Visibility可见性是检测响应的一种 在control rig使用球形追踪就是这个

现在把BP_Item的碰撞预设改为Custom 将Pawn设置为重叠 确保生成重叠事件已开启 打开事件图表 Event ActorBeginOverlap节点 从执行引脚往右拖 搜索print string 我们可以打印一个字符串看看效果 把Hello修改成 Overlapped an Actor 编译 进入PIE 走到它附近与它重叠时 确实会打印字符串 也可以看到摄像机弹簧臂瞬间拉近了 因为我们把摄像机设置成了阻挡
对于BP_Item的任何组件都会产生同样的效果 我们可以再添加一个静态网格体组件试验一下 在组件面板 点击添加 新建一个静态网格体组件 在视口把它拖到原来的网格体旁边 放得稍远一些然后为它指定一个静态网格体 把这个新的网格体组件也指定为Custom并且Pawn重叠 编译进入PIE 这两个网格体都在照着我们很久以前连到Event Tick的World Offset在进行运动 而且与这个新的网格体重叠时 也会打印 现在把新建的那个组件删掉

我们的网格体现在比较小 很难发生重叠 如果我们想检测与网格周围区域的重叠 也就是在靠近时就触发重叠事件 要怎么做?
在BP_Item蓝图 左侧组件面板 点击添加 搜索sphere collision 现在网格体的周围就会出现球体 在细节面板可以修改它的半径 将它设置得大一些 在细节面板往下翻 可以看到它的碰撞预设是OverlapAllDynamic 与每一种类型的碰撞响应都是重叠 编译进入PIE 现在可以在附近就触发
就算网格体离这个球体很远 也可以通过这个球体触发 把这个球体拖到离网格很远 为了演示 我们要在细节面板 - 渲染 把这个球体取消勾选 游戏中隐藏 并且我们只希望用这个球体触发 不希望用网格体本身触发 就需要把Item Mesh的Pawn重叠换成阻挡 但是更好的方式是 单独为每个组件设置事件

在事件图表中 把Event ActorBeginOverlap以及它的后续节点都删了 在左侧组件面板选中那个sphere collision 在事件图表右键搜索 add on component begin overlap 就会得到一个on component begin overlap (sphere)节点 这个事件和ActorBeginOverlap类似 但信息量更大 而且它有(sphere)字样 表示只有当sphere组件发生重叠时 才会触发这个事件 从它的执行引脚往右拖 搜索print string 打印Overlapped with the sphere

现在再与网格体重叠 就不会打印 但是摄像机弹簧臂会发生变化 效果和把Pawn设置成忽略时是一样的 所以仍然觉得还是把网格体的设置成阻挡Pawn更好 并且设置成忽略Camera

对于开发者而言 重叠事件是很好用 这样就可以让球形组件生成重叠事件 而不是Actor本身 这很适合拾取游戏中会被摧毁的道具 一旦拾取 就可以提升Actor的某种属性
这个on component begin overlap (sphere)节点有Overlapped Component引脚 把之前的print string删了 从这个引脚往右拖 搜索get object name 再从return value节点往右拖 搜索print string 把on component begin overlap (sphere)节点的执行引脚连接到print string的执行引脚 编译进入PIE 与球体重叠时 就会打印sphere
从Other Actor引脚get object name 就会打印与当前这个东西重叠的东西 我们将它称之为另一个Actor(Other Actor) 也就是我们的角色名 也就是BP_HelloWorldCharacter_0 这里多了一个0 这是为了追踪信息
从Other Comp引脚get object name 这个Other Comp也就是另一个Actor上进行重叠检测的组件 会打印CollisionCylinder 这是HelloWorldCharacter的胶囊体组件 打开角色蓝图 查看组件面板 确实可以看到 胶囊体组件(CollisionCylinder) 这个CollisionCylinder就是它的内部名称 还记得创建默认子对象时使用的文本TEXT宏吗 我们在指定一个内部名称 这CollisionCylinder就是那个内部名称 这个内部名称时用get object name节点返回的
通过Other Body Index就可以知道是哪个物理体被碰撞或重叠 物理体与一种称为物理资源physics asset的资源相关联 在编辑器中游玩时 每当显示碰撞信息 我们都会看到那些包围角色各个身体部位的胶囊体 它们代表物理体
From Sweep 就可以知道重叠事件是否是扫描生成的 暂时我们还没有深入了解扫描 如果发生了扫描 我们会得到一个名为FHitResult的数据结构 包含重叠点发生的信息 比如空间位置等

委托 delegate

首先要了解设计模式 在软件开发中 设计模式就是解决常见问题的方案 为了避免重复造轮子

观察者模式 有个对象object叫做主题subject 这个subject维护着一份观察者列表 通常是程序中其它对象的指针 每个观察者都有一个函数 我们叫它回调函数 观察者希望这些回调函数能响应程序中的事件Event而被调用 这个设计模式的目标就是让每个观察者的回调函数响应Event然后被调用 subject知道Event什么时候发生 一旦发生 subject就会循环遍历它的观察者列表 并为每个观察者执行响应的回调函数 这种设计模式建立了一对多的关系 subject不需要观察者的任何信息 也不需要预先知道会有多少观察者 程序启动时 观察者会把自己添加到subject的观察者列表中 并指定Event发生时 应该调用哪个回调函数 subject只是可以添加观察者到列表中并且在事件发生时循环遍历列表并调用这些回调函数

UE的委托Delegate是一种特殊的类 可以存储观察者列表 称之为委托列表 我们可以为游戏中某个特定用途的对象创建委托 游戏事件发生时 subject可以向每个观察者广播 这需要循环遍历委托列表 并调用观察者对象上的所有回调函数

比如现在游戏中 你有一个首领敌人 我们可以创建一个名为 召唤小弟 的委托 假设游戏中存在三种不同类型的小弟敌人 比如尖叫者 冲锋者 轰炸者 可能都有各自的回调函数 尖叫者可能有一个尖叫函数 它接收一个名为volume音量的浮点数参数 调用此函数时 尖叫者就会以这个音量尖叫 在UE中 我们会使用委托机制 将这个回调函数绑定到委托上 因此尖叫者需要一个指向首领敌人的引用 它可以通过指向首领敌人的指针 访问召唤小弟的委托 并将它的尖叫回调函数绑定到这个委托上 这样尖叫者的回调函数就添加到召唤小弟委托列表中了 冲锋者可能有它自己的回调函数 接收一个名为speed速度的浮点数参数 调用此函数时 冲锋者会以指定的速度冲向目标 冲锋者要将它的回调函数绑定后到召唤小弟委托列表的冲锋回调上 轰炸者有一个名为投掷炸弹的函数 该函数接收一个名为damage伤害值的浮点数参数 调用时 轰炸者会投掷一枚炸弹 轰炸者对象需要将它的回调函数绑定到召唤小弟委托 从而将它的回调函数添加到委托列表中
游戏中发生某些事件时 首领敌人就可以广播这个委托 甚至可以广播包含某些值的委托 这样委托列表里的每个回调函数都会被调用 并收到那个值 所以如果首领敌人广播召唤小弟委托 比如传入50 那么尖叫者的名为尖叫的回调函数就会被调用 收到50这个值 就会以50的音量尖叫 冲锋者以50的速度 轰炸者以50的伤害值
任何想要绑定到这个委托的回调函数 都必须有合适的签名 它们必须接收一个浮点型输入参数 所以我们不能随便把任何回调函数绑定到召唤小弟委托 我们必须绑定一个带有浮点型输入参数的回调函数

UE里的重叠事件用的是委托 场景组件USceneComponent可以附加东西 球体组件USphereComponent是从场景组件继承来的 所以我们才能把球体组件加到BP_HelloWorldCharacter里 并把它附加到根组件上 但这两个类中间 还有一个类叫原始组件UPrimitiveComponent 球体组件其实是继承自原始组件 原始组件自带很多委托 其中一个委托叫做OnComponnetBeginOverlap 所以我们才能选中sphere 在事件图表右键 添加一个OnComponentBeginOverlap节点 我们把球体添加到BP_Item里时 检查了它的碰撞设置 发现它设置为和Pawn类型重叠 我们的角色有个胶囊体 这个胶囊体的碰撞类型是Pawn 只要胶囊体没有设置成忽略球体的类型也就是没有设置成忽略WorldDynamic 就会发生重叠事件 因为可能发生重叠事件 UE就会检测这两个组件的碰撞 它会检查它们是否重叠 一重叠 就会触发事件 这些都是在UE后台自动完成的 事件触发后 蓝图事件就会执行 并接收委托广播时传入的值 蓝图节点OnComponentBeginOverlap是有很多引脚的 所以其实它委托广播了很多值 Overlapped Component、Other Actor、Other Comp、Other Body Index、From Sweep(C++里是bFromSweep 因为是bool值)、Sweep Result 这个委托属于原始组件类 所以我们的球体组件才会也有一个 我们可以在C++里绑定一个回调函数到这个委托 但是绑定到委托的回调函数必须有正确的签名 也就是要接收特定数量和类型的输入参数 所以如果我们要把回调函数绑定到球体组件(同时也是一个原始组件)的OnComponentOverlap上 回调函数就必须有正确的签名

重叠事件 C++

尝试用C++实现 现在把sphere组件删了 OnComponentOverlap及其后续节点都删了 打开VS Item.h 在官方文档里找到USphereComponent的头文件

// Item.cpp
#include "Components/SphereComponent.h"

所以现在需要在头文件下面前向声明

// Item.h
class USphereComponent;

找到 class HELLOWORLD_API AItem : public AActor 的private部分 在结尾添加

// Item.h
UPROPERTY(VisibleAnywhere)
USphereComponent* Sphere;

按下ctrl+K ctrl+O VS就会跳转到这个.h文件对应的.cpp文件

写好头文件 #include "Components/SphereComponent.h"
AItem::AItem() 构造函数结尾添加

// Item.cpp
Sphere = CreateDefaultSubobject<USphereComponent>(TEXT("Sphere"));
Sphere->SetupAttachment(GetRootComponent());

这样就是在构造函数中创建了球体默认子对象 并且绑定到了根组件上
球体继承自UPrimitiveComponent原始组件 UPrimitiveComponent类型包含很多委托 比如OnComponentBeginOverlap 我们先打开UPrimitiveComponent的头文件 看看这些委托是什么样子

在解决方案资源管理器搜索PrimitiveComponent.h 双击打开 在其中搜索OnComponentBeginOverlap

搜索结果的第一部分是

// PrimitiveComponent.h
/**
 * Delegate for notification of blocking collision against a specific component.  
 * NormalImpulse will be filled in for physics-simulating bodies, but will be zero for swept-component blocking collisions. 
 */
DECLARE_DYNAMIC_MULTICAST_SPARSE_DELEGATE_FiveParams( FComponentHitSignature, UPrimitiveComponent, OnComponentHit, UPrimitiveComponent*, HitComponent, AActor*, OtherActor, UPrimitiveComponent*, OtherComp, FVector, NormalImpulse, const FHitResult&, Hit );
/** Delegate for notification of start of overlap with a specific component */
DECLARE_DYNAMIC_MULTICAST_SPARSE_DELEGATE_SixParams( FComponentBeginOverlapSignature, UPrimitiveComponent, OnComponentBeginOverlap, UPrimitiveComponent*, OverlappedComponent, AActor*, OtherActor, UPrimitiveComponent*, OtherComp, int32, OtherBodyIndex, bool, bFromSweep, const FHitResult &, SweepResult);

这都是宏 DECLARE DYNAMIC MULTICAST 声明了动态多播委托 后面XXXParam就是指定参数个数 这个宏是UE自带的 用来声明委托的类型
DECLARE_DYNAMIC_MULTICAST_SPARSE_DELEGATE_SixParams这个宏
第1个参数 FComponentBeginOverlapSignature 因为这个宏是用来声明委托的类型的 其实就是创建了一个新的C++类型 现在这个新的类型就起名为FComponentBeginOverlapSignature 后面的参数都是用来描述这个新的类型的
第3个参数OnComponentBeginOverlap 这是委托的名字 同名的委托是很多的 但是是不同的类型 我们当前在做的这个委托类型是FComponentBeginOverlapSignature 其实也有别的类型的OnComponentBeginOverlap
所以第2个参数就是UPrimitiveComponent 这个委托将会被声明在UPrimitiveComponent类中 其实就是因为UE在这里写了 所以才会形成所谓的原始组件自带那么一个OnComponentBeginOverlap委托
从第4个参数开始 后面是UPrimitiveComponent*, OverlappedComponent, AActor*, OtherActor, UPrimitiveComponent*, OtherComp, int32, OtherBodyIndex, bool, bFromSweep, const FHitResult &, SweepResult 可以发现类型与名字交替出现 这就是决定能绑定到这种委托的回调函数的参数类型 首先是指向OverlappedComponent的UPrimitiveComponent指针 之后是名为OtherActor的Actor指针 以此类推
所以这些属于原始组件的任何委托都可以绑定 只要我们创建一个函数 它需要接收这些特定的输入参数 所以我们需要做的就是创建一个能够绑定到OnComponentBeginOverlap委托的函数

在Item.h的protected 声明一个新函数 命名为OnSphereOverlap 这就是回调函数 实际上这里可以随意命名

// Item.h
UFUNCTION()
void OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

将其绑定到OnComponentBeginOverlap事件 委托给一个基础组件 特别是我们的球体组件 因为它派生自UPrimitiveComponent 所以刚才在头文件PrimitiveComponent.h里看到的OnComponentBeginOverlap(这是委托的名称)后面的一串名称类型序列 就是回调函数的输入参数 无非是把刚才那一串字母复制过来 把多余的逗号删掉 就可以变成类型声明 这些类型正是之前在蓝图节点里看到的类型 最后一个参数是const FHitResult&类型 这是引用 直接传入 不用复制 但是通过引用传递很危险 因为如果我们在函数内部修改这个输入值 它就会改变传入的实际变量 所以这里使用了const 这样函数内部就不能修改实际值了 这样就既可以避免创建副本获得高效性 也保证了输入参数不会被修改的安全性 这就是const引用的好处

现在我们就得到了这个可以绑定到基本组件的OnComponentBeginOverlap委托的回调函数在头文件里的声明 接下来我们就要把OnSphereOverlap绑定到一个动态多播委托 要将回调函数绑定到动态多播委托 它必须对反射系统可见 因为这种类型的委托可以被蓝图访问 我们可以把事件绑定到这些委托上 这就是为什么蓝图会有OnComponentBeginOverlap事件节点 因为它是一个动态多播委托 绑定到它的任何函数都必须是UFUNCTION 才能被反射系统识别 所以OnSphereOverlap需要一个UFUNCTION宏 这样就能确保我们真正地将这个绑定到委托上

现在我们为球体重叠事件定义一个函数 用螺丝刀在Item.cpp中创建函数定义

// Item.cpp
void AItem::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{

}

这样一来 只要与球体组件发生重叠 就会触发球体重叠事件 如果其它物体与它重叠 就会调用此函数 委托会遍历其委托列表 并调用所有绑定在其上的回调函数 当然我们现在还没有绑定 一旦调用这个函数 它就会接收多个输入参数 当委托调用此函数时 会传入关于重叠事件的信息 当我们在Item.cpp中调用回调函数时 就可以使用这些信息 也就是蓝图里那些Overlapped Component、Other Actor、Other Comp、Other Body Index、From Sweep、Sweep Result引脚代表的信息 这些参数的含义在使用on component begin overlap (sphere)蓝图节点时已经介绍过

现在我们要把这个名为OnSphereOverlap的回调函数绑定到球体组件的委托上 我们需要在BeginPlay里做这个 在构造函数里做还是太早了

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    Sphere->OnComponentBeginOverlap.AddDynamic(this, &AItem::OnSphereOverlap);
}

我们要访问球体组件的委托 也就是叫做OnComponentBeiginOverlap的委托 这个委托是原始组件类型自带的 而球形组件时原始组件的子类 所以它也自带这个委托 鼠标悬停在OnComponentBeiginOverlap就可以看到 类型正是PrimitiveComponent.h里面写着的FComponentBeginOverlapSignature类型 这就是名为OnComponentBeiginOverlap的这个委托的数据类型 这种类型里有很多函数和宏 要绑定函数到这个委托 用AddDynamic 这里用的是.而不是-> 这是因为OnComponentBeiginOverlap不是指针 这个AddDynamic需要2个参数 第1个参数是UserObject类型 这个object包含回调函数 在这里我们就用this 指向我们当前的BP_Item实例 第2个参数是回调函数的地址 仍然是注意要带上AItem::

那么现在就是一旦BeginPlay函数被调用 就会把OnSphereOverlap这个回调函数 绑定到sphere组件的OnComponentBeginOverlap委托上 这样之后 只要发生这个委托(即发生重叠事件) OnSphereOverlap函数就会被调用 当然了现在我们还没有实现OnSphereOverlap这个函数

// Item.cpp
void AItem::OnSphereOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    const FString OtherActorName = OtherActor->GetName();
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(1, 5.f, FColor::Red, OtherActorName);
    }
}

那么现在只要某个物体与sphere重叠 就会将这个Actor的名字打印到屏幕上 委托触发时 这个名字将会作为参数传到我们的回调函数里 然后回调函数的函数体里执行的就是打印

热重载 回到BP_Item蓝图 可以看到左侧组件面板有了Sphere组件 选中它 在视口里查看 我们可以调整它的大小 编译进入PIE 尝试碰撞BP_Item实例 屏幕上会输出红色文字BP_HelloWorldCharacter_0

所以我们现在成功把回调函数绑定到了委托上
回顾一下我们刚刚在做的事情是
首先打开了PrimitiveComponent.h 查看回调函数到底要接收什么样的参数 才能绑定到OnComponentBeginOverlap事件上
然后我们创建了一个名为OnSphereOverlap的回调函数 其实这个函数叫什么名字都可以 但是它的参数结构要和我们在PrimitiveComponent.h里查阅到的一致 其实也和蓝图节点里看到的参数一致 而且因为是动态多播委托 必须要将这个回调函数使用UFUNCTION声明暴露给蓝图使用
最后在BeginPlay里绑定了委托 不在构造函数里是因为构造函数执行得太早了 那时候组件还没有初始化

接下来做OnComponentEndOverlap 和OnComponentBeginOverlap的区别是 只有当某个物体停止与组件重叠时 它才会被触发
所以现在先去PrimitiveComponent.h搜索OnComponentEndOverlap

// PrimitiveComponent.h
/** Delegate for notification of end of overlap with a specific component */
DECLARE_DYNAMIC_MULTICAST_SPARSE_DELEGATE_FourParams( FComponentEndOverlapSignature, UPrimitiveComponent, OnComponentEndOverlap, UPrimitiveComponent*, OverlappedComponent, AActor*, OtherActor, UPrimitiveComponent*, OtherComp, int32, OtherBodyIndex);

发现参数比OnComponentBeginOverlap少了bFromSweep和SweepResult

现在我们要为OnComponentEndOverlap委托创建一个回调函数 之前我们为OnComponentBeginOverlap创建的回调函数命名为OnSphereOverlap 现在更名为OnSphereOverlapBegin 然后新建一个名为OnSphereOverlapEnd的回调函数

// Item.h
UFUNCTION()
void OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
// Item.cpp
void AItem::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    const FString OtherActorName = OtherActor->GetName();
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(1, 5.f, FColor::Red, FString::Printf(TEXT("OverlapBeginOtherActorName: %s"), *OtherActorName));
    }
}

void AItem::OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
    const FString OtherActorName = OtherActor->GetName();
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(1, 5.f, FColor::Blue, FString::Printf(TEXT("OverlapEndOtherActorName: %s"), *OtherActorName));
    }
}

为了区分Begin和End 从打印OtherActorName变成了打印FString::Printf(TEXT("OverlapBeginOtherActorName: %s"), *OtherActorName)) 因为FString::Printf是C风格 而OtherActorName是FString 使用*OtherActorName加一个* 是为了传入const char* 当然对于UE是TCHAR*

// Item.cpp
void AItem::BeginPlay()
{
    Super::BeginPlay();

    Sphere->OnComponentBeginOverlap.AddDynamic(this, &AItem::OnSphereOverlapBegin);
    Sphere->OnComponentEndOverlap.AddDynamic(this, &AItem::OnSphereOverlapEnd);
}

热重载 PIE 靠近时就会打印红色的OverlapBeginOtherActorName: BP_HelloWorldCharacter_0 远离时会打印蓝色的OverlapEndOtherActorName: BP_HelloWorldCharacter_0

比如这个BP_Item实例是一个我们想拾取的物品 比如武器 如果我们与它重叠 我们希望能够拾取它 并将武器附加到角色网格体上 接下来我们就做拾取武器

武器

Weapon类

我们想在Item创建一个子类Weapon 这样就可以继承Item类的属性 又可以专门化自身的行为 我们之前的Item类蓝图里是为它添加了正弦曲线运动的 又在C++里写了重叠Begin和End事件 而且Item类还有一个Item Mesh 所以我们如果创建Weapon类 它就会继承这些属性

除了上述的属性 我们希望武器类有特有的属性 比如可以装备 这样如果我们按下按钮并且靠近武器 武器就将连接到角色的网格体上 也就是手上 还希望这个武器是可以进行攻击的

在UE 内容侧滑菜单 - C++类 - HelloWorld - Public - Items 对于Item类右键 - 创建派生自Item的C++类 选择公共 命名为Weapon 路径为XXX/HelloWorld/Source/HelloWorld/Public/Items/Weapons/

创建成功之后 先把UE关了 再ctrl+F5

在内容文件夹 - Blueprints - Items 右键新建文件夹Weapons 在文件夹内右键新建蓝图类 选择Weapon类 也可以在C++类中Weapon类右键新建蓝图 这个蓝图类命名为BP_Weapon

打开这个BP_Weapon 可以发现它继承了BP_Item类 有Sphere组件
找一个剑的网格体来替换Item Mesh 如果找到的剑的资产是骨骼网格体 在导入到UE时 弹窗中 - 静态网格体 - Common Meshes 强制所有网格为类型 下拉菜单中选择静态网格体 之后再导入
然后把球形组件根据剑的大小放大一些 编译 进入PIE 可以发现这柄剑是在按照Item类C++里写的在进行旋转的 但是没有出现Item蓝图类中增添的的旋转运动 也仍然具备重叠组件的功能

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    AddActorWorldRotation(FRotator(0.f, 45.f*DeltaTime, 0.f));

    RunningTime += DeltaTime;
}

查看Item.cpp的Tick函数 这里只写了一种运动 这就是目前这柄剑的运动 是原地旋转 但是BP_Item蓝图里还有另一种按照正弦函数上下跳动的运动 打开BP_Item蓝图 复制连接到Event Tick之后的节点 可以直接粘贴连接到BP_Weapon的事件图表中 将Event Tick的执行引脚连接上 这就是继承的好处 这个TransformedSin蓝图纯函数是在Item类中定义并暴露到蓝图的 现在派生自Item类的Weapon类也可以使用这个函数
编译进入PIE 现在这柄剑的运动状态就和BP_Item实例一样了

我们想要靠近的时候能够捡起来武器 而不是只打印信息 所以现在要重写重叠函数 为了能够在Weapon类中进行重写 需要将它们设置为虚函数

// Item.h
UFUNCTION()
virtual void OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

UFUNCTION()
virtual void OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
  1. Item.h中的声明 已经写了UFUNCTION() 那么重写的方法中就不能再写UFUNCTION()了 所以Weapon.h中不能再出现UFUNCTION() 因为已经把UFUNCTION()继承过来了 虽然在Weapon.h里看不到
  2. 要把声明写在protected里 这样就对于这个类和它的子类可见 对于这个类的实例不可见
  3. 要在声明末尾写上override 这样就告诉编译器 这是一个重写方法 编译器就会在编译时检查父类 确保存在同名虚函数 如果没有 就会报错 这是为了安全 也能让查看这个方法的人知道 这是一个重写方法 开头加virtual也表示这是一个虚函数 但我们现在重写的这个函数本来就是虚函数 所以在子类重写方法中前面添加virtual是可选的 主要用于文档说明 表示这是一个重写方法
// Weapon.h
UCLASS()
class HELLOWORLD_API AWeapon : public AItem
{
    GENERATED_BODY()
protected:
    virtual void OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult) override;
    virtual void OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex) override;
    
};

现在用螺丝刀为这两个函数在Weapon.cpp中添加定义

// Weapon.cpp
void AWeapon::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{

}

void AWeapon::OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{

}

现在就热重载 进入PIE 再去和剑重叠 就不会打印消息 说明这不是默认行为

void AWeapon::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    Super::OnSphereOverlapBegin(OverlappedComponent, OtherActor, OtherComp, OtherBodyIndex, bFromSweep, SweepResult);

}

void AWeapon::OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
    Super::OnSphereOverlapEnd(OverlappedComponent, OtherActor, OtherComp, OtherBodyIndex);

}

使用Super::OnSphereOverlapBegin就可以调用父类的版本 OnSphereOverlapBegin有6个输入参数 直接使用传入的那些参数 热重载进入PIE 就可以发生打印 这是因为我们调用了父类函数

插槽 sockets

接下来我们就要在里面重写 要把剑捡起来 并且拿到角色的手上 这就需要插槽 插槽可以添加到骨骼网格体的骨骼上

现在打开角色的骨骼网格体资产 可以看到骨骼树 找到手腕的骨骼 对它右键 - 添加插槽 这样就有了一个插槽 可以连接各种物件
如果我们把东西连接到插槽上 而不是直接连接到手骨上 我们可以相对于骨骼移动插槽 而且因为插槽连接在骨骼上 它就会跟着骨骼一起动
我们可以给插槽添加一个预览网格体 查看游戏中连接网格体后的效果 对这个插槽右键 - 添加预览资产 选中我们的剑 现在剑就出现在了角色的手附近 我们可以平移这个插槽 调整一下位置
也可以预览它在动画下的效果 在预览场景设置面板 - 动画 - 预览控制器 选择 使用特定动画 就可以查看动画资产下的效果

手K动画

那么现在我们就需要寻找一些剑攻击的动作资产
我们遭遇了一个资产 双脚相对于地面有位移 但双脚之间没有相对位移 而是合并在了一起 缩放单位都是按照之前的办法设置的 无法解决问题 明显是脚IK的问题 这种时候就需要解除IK之后手K了

首先用MMD Tools - IK切换 关闭足IK.L 足IK.R つま先IK.L つま先IK.R 然后单独创建动画摄影表窗口 在里面把脚IK的关键帧删了
Blender项目自带的下方的那个播放动画的面板 现在把它换成动画摄影表面板 在右侧大纲面板 批量选中大腿小腿脚腕的6个骨骼 在动画摄影表里就可以看到它们的关键帧 点击图钉图标 将这6个序列固定在面板里

在动画摄影表里 把需要修改的帧都删掉 然后在姿态模式下用旋转工具摆好姿势 务必选择线框模式或者实体模式 这样能防止卡死 绝对不要在材质预览模式或者渲染模式下K帧
注意在选中旋转工具后 方向要从默认改成局部 姿态模式右侧稍远的 变换坐标系也要从全局改成局部 这样之后再K 保存时才能定点到我们在编辑器里看到的位置
之后在对应序列对应帧数 右键 - 新建关键帧 但是这样只能保存单一骨骼的关键帧 摆好动作之后 可以在视口里按shift之后挨个点选 批量选中这6个骨骼 然后在对应帧数 键盘上按I 这样就能批量保存
我们分别K了很多段动作 如果想上一段和下一段衔接上 只需要在上一段项目里的最后一帧 姿态模式下 对于_arm在右侧大纲面板键盘按A全选 点击左上角姿态按钮 - 复制姿态 再在下一段动作的项目里 姿态模式下 对于_arm在右侧大纲面板键盘按A全选 在第一帧时 点击姿态按钮 - 粘贴姿态 发现地面是有位移的 但我们不想要位移 保持现在这个全选的模式 选中根骨骼 在右下方属性面板选择绿色骨头图标的骨骼面板 - 变换 可以看到位置的XYZ都不是0 把它们都设置成0 地面位移就回到了原点 然后一定要键盘上按下I 保存当前姿态作为关键帧

物理效果

Kawaii Physics

给头发做一下物理
先下载KawaiiPhysics插件 这样就是针对于骨骼来做的骨骼动画 UE自带的布料模拟是顶点动画 这个KawaiiPhysics只有做头发效果还可以
本例中下载了UE5.6版本 打开UE安装目录 把下载好的解压后复制到???\Epic Games\UE_5.6\Engine\Plugins 这样看似简单 实则UE C++编译很有可能会报错 所以还是放到项目文件夹里 ???\HelloWorld\Plugins 而且只有这样才能打包
打开UE 左上角菜单栏 - 编辑 - 插件 找到KawaiiPhysics 在左侧勾选 之后重启UE

专门新建一个动画蓝图作为后处理蓝图 选择RacingMiku作为骨架 双击打开
在AnimGraph右键搜索并选择input pose 这样主动画蓝图的数据就可以传过来 右键搜索并选择kawaii physics 选择Animation - Skeletal Controls那一栏的Kawaii Physics节点 将其左侧小人节点连接到Input Pose 右侧小人节点连接到Output Pose 它会自动生成两个转换的节点
选中这个Kawaii Physics节点 在细节面板Root Bone里选择头发上的根骨骼 每一部分头发的根骨骼都要对应一个Kawaii Physics节点 全都顺次相连
打开RacingMiku的骨骼网格体 在资产详情面板 动画一栏 - 后期处理动画蓝图 选择我们刚才制作的ABP_RacingMiku-KawaiiPhysics
效果一般 接下来开始调参
先把Input Pose节点断开 换成一个动画用于演示 选中对应骨骼的节点 在细节面板找到Physics Settings - Physics Settings一栏调参 但是头发太长了 没什么效果
在高级一栏 找到Damping Rate by Bone Length Rate 双击右侧的那个类似于图表的东西 进入其内部曲线编辑器 右键随便一个地方 - 添加关键帧到Damping Rate by Bone Length Rate 选中这个关键帧 上方会出现时间和值 比如我们填成1 2 意思就是在长度为1的地方 阻尼的值为2 可以看到左侧预览视口 在头发上会出现数字 那意思就是长度的位置

UE布料模拟

打开想要做布料模拟的骨骼网格体 在右下角资产详情面板 找到想做模拟的材质 选择隔离 这样视口中就可以只看到这个材质了 在视口中对其右键 - 从分段创建布料数据 名字要改成带有这个材质名字后缀的 物理资产就暂时先选择这个骨骼王个体之前做好的物理资产

上方菜单栏 - 窗口 - 布料 把布料面板打开 拖到其它面板旁边固定上 在布料面板选择刚才做的布料 再点上方工具栏 - 激活布料绘制 这块布料就会显示粉红色 现在鼠标默认是布料笔刷 把想要做布料的位置全都刷上 刷上了之后就变成白色
查看布料面板 工具现在默认是笔刷 绘制值是1 也可以修改成0 这样就是某部分不启用布料模拟 这里和刷蒙皮权重差不多
现在把工具选成梯度 可以看到在工具设置一栏 梯度起始值默认0 梯度最终值默认100 那么就可以通过起点到终点做出一个递增的权重 在起点点一些绿色的点 现在其实可以看到这些都是三角形 按住Ctrl再在终点点一些红色的点 一定要变成红色再点 最后按下回车 这样就可以做出一个递增的衰减
刷好之后点击上方工具栏 停用布料绘制 再在视口选中这块布料 右键 - 应用布料数据 选择这块布料对应的布料数据 可以看到这里布料的形态非常夸张 这是因为它默认采用了全身的物理资产 于是现在就需要为裙子专门做一个物理资产

选择骨骼网格体 右键 - 创建 - 物理资产 选择创建 不要选择创建并指定 最小骨骼尺寸改成10 其它都默认 因为现在只做裙子 那么修改这个物理资产只保留骨盆和大腿的部分就可以了 在左侧骨骼树面板批量选中 按del删除 把大腿和盆骨的胶囊旋转到精确的位置 并且调细
回到骨骼网格体的布料面板 把这块布料数据的物理资产选择为新的裙子专用的资产 也可以继续回到那个物理资产去调整
接下来调整这个布料数据的参数 视口上方眼睛图标 -布料 - 风力强度 设置成5 用于演示 在布料面板 - 布料配置 这个ChaosClothSharedSimConfig是调整计算次数的 迭代计数调高 就更增加布料的厚重感 当然也更消耗性能 细分数量默认是1 将其调成2 就能看到布料的运动更具有整体性了
接下来主要调整ChaosClothConfig

但是这个在本例中的模型上效果实在一般 放弃使用

拾取并装备

我们成功地制作了一些动画资产 现在首先用蓝图实现一下在靠近剑时捡起来的功能 打开BP_Weapon 先断开Event Tick和Add Actor World Offset节点的连接 因为我们不想剑到手中的时候还在上下运动 稍后我们会介绍如何保持悬浮效果 并在装备武器后禁用它

我们已经知道球形组件Sphere有OnComponentBeginOverlap事件 在C++里 我们有个回调函数来响应这个事件 蓝图里用的OnComponentBeginOverlap并不是我们自己写的C++回调函数 但无论如何 现在先在蓝图里实现

选中Sphere组件 在事件图表右键 搜索并添加On Component Begin Overlap(sphere)节点 当这个球形组件和另一个角色碰撞时 我们要保证碰撞的时我们的HelloWorldCharacter角色 所以从Other Actor引脚往右拖 搜索 cast to helloworldcharacter 可以看到好几个结果 可以转换成BP_HelloWorldCharacter 也可以转换成HelloWorldCharacter C++类 我们就选择Cast To BP_HelloWorldCharacter 连上执行引脚 把On Component Begin Overlap(sphere)节点的Other Actor引脚连接到Cast To BP_HelloWorldCharacter节点的Object引脚上 如果Other Actor确实是BP_HelloWorldCharacter 那么就会执行Cast To BP_HelloWorldCharacter节点的右上方执行引脚连接的后续的内容 如果转换失败 就会执行Cast Failed执行引脚后续的内容 我们关心的是转换成功的情况
为了装备这柄剑 我们需要访问HelloWorldCharacter的mesh 也就是ItemMesh 把组件面板里的Item Mesh拖入事件图表 从这个Item Mesh节点的引脚往右拖 UE中有很多方法可以实现附加attach 其中一种方法是使用AttachComponentToComponent函数 我们就搜索这个 得到Attach Component To Component节点 可以看到这个函数左侧有很多引脚 是它需要的输入参数 现在Item Mesh连接到了它的Target引脚 鼠标悬停在节点上方Attach Component To Component字样的附近 显示 将此组件附加到另一个场景组件 可以选择附加到命名插槽上 无论组件是否已注册 在组件上调用此方法都是有效的 目标Target是场景组件 我们自然可以推断 这个函数是属于SceneComponent类 并且它可以被蓝图调用 我们现在这个Item Mesh确实是场景组件 所以我们是调用了ItemMesh的AttachComponentToComponent函数 ItemMesh是我们的剑 我们要把它附加到HelloWorldCharacter的角色mesh上 我们可以获取HelloWorldCharacter的mesh 从Cast To BP_HelloWorldCharacter节点的As BP Hello World Character引脚往右拖 搜索get mesh 点击变量 - 角色那一栏的Get Mesh 会得到一个左侧引脚名为Target 右侧引脚名为Mesh的节点 把这个右侧的Mesh引脚连接到Attach Component To Component节点左侧的Parent引脚上 鼠标悬停在这个Parent引脚上 显示 场景组件 对象引用 要添加到的父项 那么现在就是剑mesh附加到了角色mesh上 角色mesh现在是剑mesh的父项了
这个Attach Component To Component节点左侧还有一个Socket Name引脚 这里可以指定SceneComponent上的插槽 这个SceneComponent是角色的骨骼网格体组件 它确实有个插槽 就是我们之前制作的那个 填入它的名字 手首_R插槽 现在左侧还剩几个Rule引脚 在组件绑定到另一个组件上时 我们需要决定怎么绑定 要不要保持偏移量 在本例中 我们不需要保持相对偏移 我们想要直接吸附到目标上 所以Location Rule下拉菜单 有保持相对(Keep Relative) 保持场景(Keep World保持世界坐标) 对齐到目标(Snap To Target吸附到目标) 我们选择对齐到目标 后面的Rotation Rule和Scale Rule都选择对齐到目标 鼠标悬停在Location Rule 显示是EAttachmentRule枚举值 这几个Rule都是Enum枚举类型 最后一个左侧引脚是Weld Similated Bodies 这是一个bool值 表示要不到把模拟物理焊接在一起 默认是勾选的 我们现在没有模拟物理 所以勾选与否都可以 现在我们就保持这个勾选状态
把Cast To BP_HelloWorldCharacter节点右上方执行引脚与Attach Component To Component节点左侧执行引脚连接上 这样角色只要和球形组件重叠 转换就成功了 然后就调用AttachComponentToComponent函数
编译 现在Item Mesh节点报错 说是ItemMesh对蓝图不可见 要改成蓝图只读或者蓝图读写属性 现在打开Item.h 现在ItemMesh是在private里 UPROPERTY(VisibleAnywhere) 也就是可以在细节面板中显示 但事件图表中看不到 如果想在事件图表中看到它 就要改成BlueprintReadOnly和BlueprintReadWrite 而且现在是在private里 就还需要添加meta元说明符 也就是要改成和RunningTime一样的UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true")) 如果不想用meta元说明符 就把它放到protected或者public里 我们现在把它转移到protected里 因为我们想在Item的子类Weapon中访问ItemMesh protected是对于这个类和它的子类可见 这个类的实例不可见 而子类是访问不了private的 为了能在事件图表中访问 还要给它添加一个BlueprintReadOnly 因为现在我们只需要访问 就不设置成读写了

// Item.h protected中
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
UStaticMeshComponent* ItemMesh;

热重载 编译 进入PIE 现在明显是摄像机碰撞出了一些问题 打开BP_Weapon 组件面板选中BP_Weapon(自我) 碰撞预设改成Custom 设置成忽略Camera 重叠Pawn 其余全都阻挡
而且它还在继承我们在Item.cpp中写的旋转 现在回到Item.cpp把那个旋转删掉 也就是删掉AddActorWorldRotation(FRotator(0.f, 45.f*DeltaTime, 0.f));这一行 把这个旋转转移到BP_Item蓝图里 也就是添加一个AddActorWorldRotation节点 制作一个旋转 也就是从Event Tick的Delta Seconds引脚往右拖 搜索multiply 乘45 再右键创建Rotator From Axis and Angle节点 把Multiply的右侧引脚连接到这个节点左侧的Angle引脚上 Axis引脚那里填0 0 1 意思就是旋转轴为Z轴 右侧Return Value引脚连接到Delta Rotation引脚上 再连接到Add Actor World Offset执行引脚的后面 顺便把这个旋转的蓝图也复制到BP_Weapon里 当然目前在BP_Weapon中 这些都是没有连接到Tick函数上的
编译 现在就都正常了

删除所有这些蓝图节点 现在用C++实现 打开Weapon.cpp 我们就要去写Overlap函数的实现

在蓝图实现中 我们首先获取了Other Actor 将其转换为HelloWorldCharacter类型 这也就是传入OnSphereOverlapBegin的那个OtherActor参数 那么我们先要HelloWorldCharacter的头文件 寻找头文件时可以访问Public文件夹内的所有内容 而这个HelloWorldCharacter.h的目录为Public/Characters/HelloWorldCharacter.h 所以就写成

// Weapon.cpp
#include "Characters/HelloWorldCharacter.h"
// Weapon.cpp
void AWeapon::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    Super::OnSphereOverlapBegin(OverlappedComponent, OtherActor, OtherComp, OtherBodyIndex, bFromSweep, SweepResult);

    AHelloWorldCharacter* HelloWorldCharacter = Cast<AHelloWorldCharacter>(OtherActor);
    if (HelloWorldCharacter)
    {
        FAttachmentTransformRules TransformRules(EAttachmentRule::SnapToTarget, true);
        ItemMesh->AttachToComponent(HelloWorldCharacter->GetMesh(), TransformRules, FName("RightHandSocket"));
    }
}

现在我们就要使用UE的cast函数 将OtherActor转换为HelloWorldCharacter类型 这是一个模板函数 需要指定转换的目标类型 括号内就写上要转换的对象 Cast<AHelloWorldCharacter>(OtherActor) 这样这个Cast就会返回一个指向HelloWorldCharacter类型的对象的指针 否则 如果其它类型与球形组件重叠 并且无法转换成HelloWorldCharacter类型 这个Cast函数就会返回一个null 和C++的dynamic_cast还是有些相似的 现在我们就把这个Cast函数的返回值存储在一个HelloWorldCharacter类型指针的局部变量里
接下来需要检测一下这个指针是否为空 如果非空 意思是这个OtherActor确实是HelloWorldCharacter类型 那么我们就可以使用AttachComponentToComponent函数 把剑mesh附加到角色mesh上了 在这里我们可以写ItemMesh 正是因为在Item.h中 我们将其放在了protected里 如果我们当时将它保留在了private里 即使用了meta元说明符 现在我们也无法使用它 ItemMesh是一个StaticMeshComponent类型的指针 所以要用->箭头运算符来调用C++函数 ItemMesh->AttachToComponent
鼠标悬停在AttachToComponent上 查看一下它需要的参数 第1个参数是父项的场景组件 那也就是这个HelloWorldCharacter的mesh HelloWorldCharacter->GetMesh()
第2个参数是FAttachmentTransfdormRules类型的AttachmentRules 一般而言看到F 就说明它大概是一个结构体 那么我们就需要创建一个FAttachmentTransfdormRules类型的结构体 命名为TransformRules 直接写FAttachmentTransformRules TransformRules; 就会报错 鼠标悬浮在上面 显示FAttachmentTransformRules这个类没有默认构造函数 那么我们只能通过传参数来构造 VS弹窗里显示它有4种构造方式 也就是有4个构造函数重载 可以使用InLocationRule InRotationRule InScaleRule bInWeldSimulatedBodies这4个参数来构造 那就是和蓝图的引脚一样 前3个参数的类型是EAttachmentRule枚举 第4个参数是bool型 也可以使用2个参数来构造 第1个参数是EAttachmentRule类型的InRule 第2个参数是bool型的bInWeldSimulatedBodies 或者也可以用现有的FAttachmentTransfdormRules类型作为参数传入来构造 我们现在就选用2个参数的那种构造方式 也就是说我们传入的第1个参数 会同时赋值给InLocationRule InRotationRule InScaleRule 3个Rule使用同一个值 我们需要传入的这个值是枚举类型 就需要EAttachmentRule::来使用这个枚举 这是一个作用域枚举 所以每次使用时都要写全名之后:: 写到这里VS就会弹出一些选项 选择SnapToTarget就可以了 至于那个bool值 就写成true 和我们在蓝图中设置的一样 之后把TransformRules写为ItemMesh->AttachToComponent的第2个参数
第3个参数是socket的名字 默认是none 如果我们只想附加到网格体本身 在这里保持none就好了 忽略传入这个参数即可 但是我们想要指定为手上特定的插槽手首_R插槽 既然已经写入C++中了 还是去骨骼网格体中把插槽名字修改为RightHandSocket更好 那么这里就写入FName("RightHandSocket")
那么现在我们就成功把剑mesh连接到角色mesh的RightHandSocket插槽上了 编译 进入PIE 非常成功

现在是靠近武器就会迅速拿起来 但我们希望能够通过一个按钮来连接武器 那么我们就还需要一个操作映射
打开编辑 - 项目设置 - 输入 现在操作映射里只有一个Jump 我们点击操作映射右侧的+号 再创建一个名为Equip的操作映射 将它绑定到按键E上 那么我们就需要一个回调函数绑定到这个映射 回到VS 把这个回调函数添加到HelloWorldCharacter类里面

// HelloWorldCharacter.h
protected:
    virtual void BeginPlay() override;
    void MoveForward(float Value);
    void MoveRight(float Value);
    void Turn(float Value);
    void LookUp(float Value);
    void EKeyPressed();

我们添加了一行void EKeyPressed(); 意思就是按下E键 这是一个操作映射 不是轴映射 操作映射没有浮点型输入参数 因为这些函数是一次性的 只在按下按键时调用一次
这里面没有Jump函数是因为在跳跃一节介绍过 Jump函数是UE的Character类自带的 不需要我们声明和重写

用螺丝刀在HelloWorldCharacter.cpp中创建EKeyPressed的定义
首先我们需要在SetupPlayerInputComponent里把它绑定到操作映射

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    PlayerInputComponent->BindAxis(FName("MoveForward"), this, &AHelloWorldCharacter::MoveForward);
    PlayerInputComponent->BindAxis(FName("MoveRight"), this, &AHelloWorldCharacter::MoveRight);
    PlayerInputComponent->BindAxis(FName("Turn"), this, &AHelloWorldCharacter::Turn);
    PlayerInputComponent->BindAxis(FName("LookUp"), this, &AHelloWorldCharacter::LookUp);

    PlayerInputComponent->BindAction(FName("Jump"), IE_Pressed, this, &ACharacter::Jump);
    PlayerInputComponent->BindAction(FName("Equip"), IE_Pressed, this, &AHelloWorldCharacter::EKeyPressed);
}

这样按下E键就会调用EkeyPressed函数了 我们想要的是 如果与武器重叠 按下E键 就装备它 现在Weapon类的OnSphereOverlapBegin函数只是简单地把武器装备上 但现在我们想在角色类里添加一个变量 用来存储拾取的物品 这个变量类型应该是AItem 因为我们可能拾取各种物品 不只是武器

打开HelloWorldCharacter.h 添加一个private变量
首先需要前向声明一下Item类

// HelloWorldCharacter.h
class AItem;

现在就可以用AItem类型了

// HelloWorldCharacter.h private中
UPROPERTY(VisibleInstanceOnly)
AItem* OverlappingItem;

最好还是设置UPROPERTY 因为指针变量 只要继承自UObject 都应该设置UPROPERTY Actor类当然也是继承自UObject 设置成VisibleInstanceOnly 这样蓝图编辑器的细节面板不可见 这样就不会扰乱蓝图 但是实例的细节面板中可见

那么现在我们有了这个变量 就需要在重叠时设置这个变量 这是一个private变量 对于Item类及其子类Weapon类都是不可见的 所以就还需要写一个public的set函数 为了保持代码整洁 就接在这个下面再写一个public 而不是到上面的public里面写

// HelloWorldCharacter.h
    UPROPERTY(VisibleInstanceOnly)
    AItem* OverlappingItem;
public:
    FORCEINLINE void SetOverlappingItem(AItem* Item) { OverlappingItem = Item; }

直接在同一行里写函数实现 没必要去cpp里面再实现 最好把这些简单的函数比如get和set设置为inline内联 内联函数的执行效率更高 因为不用jump跳转到函数的位置 只是把函数体复制过去 在前面写inline关键字即可
inline void SetOverlappingItem(AItem* Item) { OverlappingItem = Item; }
但是inline关键字标注的是否真正为内联函数 最终决定权在编译器 编译器可以选择不内联任何函数 但是UE提供了一个宏FORCEINLINE 可以强制编译器内联函数 这样就不用C++的inline关键字了

现在就可以在OnSphereOverlapBegin中设置这个变量了 之前在Item类的OnSphereOverlapBegin的函数实现里 我们就没写什么有用的东西 只是打印调试信息 我们要把刚才在Weapon类的OnSphereOverlapBegin函数里做的事情写到Item类的OnSphereOverlapBegin函数里面 但是不需要绑定到插槽了
先把打印调试信息删了 在Item.cpp里写头文件#include "Characters/HelloWorldCharacter.h"

// Item.cpp
void AItem::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    AHelloWorldCharacter* HelloWorldCharacter = Cast<AHelloWorldCharacter>(OtherActor);
    if (HelloWorldCharacter)
    {
        HelloWorldCharacter->SetOverlappingItem(this);
    }
}

void AItem::OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
    AHelloWorldCharacter* HelloWorldCharacter = Cast<AHelloWorldCharacter>(OtherActor);
    if (HelloWorldCharacter)
    {
        HelloWorldCharacter->SetOverlappingItem(nullptr);
    }
}

SetOverlappingItem需要传入一个Item指针 而我们重叠的Item就是当前的这个物品 这个Item也将是我们要捡起来的物品 想要在HelloWorldCharacter类里面保存的也就是这个Item 所以这里传入this 获取当前的这个类的实例的指针 一旦我们不再与Item重叠 就应该把那个重叠Item的指针设置为空指针 这样我们离开那个Item 并且不在球形组件内的时候 我们就不会带着这个Item走 所以要在OnSphereOverlapEnd里把 设置成空指针 把存储在HelloWorldCharacter变量里的内容置为空

现在我们做的就只是 在就角色靠近时 把这个与角色重叠的物体存储到角色类里面 不再重叠之后就删除掉这个存储
那么这能为我们的角色绑定武器带来什么呢?
我们之前在Weapon.cpp里写的OnSphereOverlapBegin函数是 重叠了就直接绑定上 现在我们不再在OnSphereOverlapBegin函数里做这件事 现在写一个public的函数专门做这件事

// Weapon.h
UCLASS()
class HELLOWORLD_API AWeapon : public AItem
{
    GENERATED_BODY()
public:
    void Equip();

想要做到装备武器这件事情 首先需要一个HelloWorldCharacter 至少是有HelloWorldCharacter的mesh组件 也就是角色mesh 因为我们要使用AttatchToComponent函数 实际上AttatchToComponent函数是只接收一个USceneComponent参数 所以甚至不需要角色mesh 我们还需要直到插槽的名字 因为Equip函数是写在Weapon类里 所以我们直接已经知道是要把哪个武器绑定到角色mesh上了 那么Equip函数就是需要2个参数 1个USceneComponent类型 命名为InParent 就和AttatchToComponent函数的参数一样 1个插槽的名字FName 变量名为InSocketName
用螺丝刀在Weapon.cpp中创建定义

// Weapon.cpp
void AWeapon::Equip(USceneComponent* InParent, FName InSocketName)
{
    FAttachmentTransformRules TransformRules(EAttachmentRule::SnapToTarget, true);
    ItemMesh->AttachToComponent(InParent, TransformRules, InSocketName);
}

只是模仿了我们之前在AWeapon::OnSphereOverlapBegin函数里写的东西 因为现在传入的就是场景组件 所以不需要再转换成HelloWorldCharacter类型并传入mesh了
现在把AWeapon::OnSphereOverlapBegin函数里面除了super::OnSphereOverlapBegin那行都删了

现在我们有了一个能单独处理附加到插槽上的函数Equip 我们希望按下E键就能在HelloWorldCharacter类调用这个函数

打开HelloWorldCharacter.cpp 现在去完成EKeyPressed函数的内部实现
只有在与武器重叠时才会做附加 也就是只有重叠时进行按键才能调用Equip函数 所以我们首先要判断是否发生了重叠 再判断重叠的是否是武器 最简单的方法就是把重叠的Item转换成武器 所以现在需要写Item和Weapon的头文件

// HelloWorldCharacter.cpp
#include "Items/Item.h"
#include "Items/Weapons/Weapon.h"
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::EKeyPressed()
{
    AWeapon* Weapon = Cast<AWeapon>(OverlappingItem);
    if (Weapon)
    {
        Weapon->Equip(GetMesh(), FName("RightHandSocket"));
    }
}

这个OverlappingItem 就是声明在HelloWorldCharacter.h里的变量 它是private的 只能通过HelloWorldCharacter类的SetOverlappingItem函数进行赋值 也就是说 在Item类的OnSphereOverlapBegin函数中 发生重叠事件之后 传给OnSphereOverlapBegin函数的OtherActor参数 也就是与Item发生重叠的角色 这个角色是HelloWorldCharacter类型 角色发生重叠之后 就会把与这个角色发生重叠的那个Item 赋值给OverlappingItem变量 也就是说在发生重叠事件之后 HelloWorldCharacter类把与角色发生碰撞的Item的指针 存储在了HelloWorldCharacter类的内部 于是现在我们就可以在HelloWorldCharacter类的内部使用这个Item了

如果没有发生重叠事件 OverlappingItem就是一个空指针 因为在重叠事件End的时候会清空已经存储了的OverlappingItem 所以如果OverlappingItem不是空的 那么必然是这个Item在当下正在与角色重叠中
再做一步转换 当然如果OverlappingItem是空指针 这个转换是做不了的 而且如果OverlappingItem非空但不是武器 转换也不会成功 所以通过这步转换 就可以判断这个存储在了HelloWorldCharacter类内部的Item是否为武器 如果是武器 我们就能得到一个非空的Weapon指针 所以需要判断一步Weapon是否为空 如果非空 就说明与角色发生重叠的Item确实是武器 那么就可以绑定到插槽上了 Weapon这个变量是一个Weapon类型的指针 我们已经在Weapon类写了Equip函数 现在只需要传入一个场景组件也就是角色网格 和一个插槽名称 我们现在是在HelloWorldCharacter类内部 所以可以直接写GetMesh函数

编译 进入PIE 现在打印到屏幕的调试信息没有了 但我们想知道是否发生了重叠 选中BP_HelloWorldCharacter0 在细节面板里搜索OverlappingItem 现在是无 走到剑附近 OverlappingItem的值就会变成BP_Weapon 发现在靠近Item实例的时候 这里就会显示为BP_Item 此时按下按键E 角色并不会装备这个Item实例 但是在靠近武器时按下E 就会装备这个武器
实际上我们现在可以拾取任何一个继承自Item类的物品 只要把那里的类型转换修改一下就可以 但是这就需要针对于每一个想要被捡起来的Item子类类型去写 当然还有更高效的方法 暂时不介绍

在继续之前 先回顾一下我们之前所做的事情

角色状态枚举

是时候使用我们制作的持剑动画资产了
希望持剑的时候就用持剑的idle 平时还是用平时的idle 所以需要一个变量来知道角色是否持有武器 希望为这个表示角色状态的变量使用一个枚举

// HelloWorldCharacter.h // 未采用
enum CharacterState
{
    Unequipped,
    EquippedOneHandedWeapon
};

然后就可以在private里创建这个表示状态的变量

// HelloWorldCharacter.h // 未采用
private:
    CharacterState State = CharacterState::Unequipped;

这是标准C++的做法 然后后面就可以检测State的不同值来控制不同的游戏逻辑
但是UE中有其它的做法

// HelloWorldCharacter.h
UENUM(BlueprintType)
enum class ECharacterState : uint8
{
    ECS_Unequipped UMETA(DisplayName = "Unequipped"),
    ECS_EquippedOneHandedWeapon UMETA(DisplayName = "Equipped One-Handed Weapon")
};
// HelloWorldCharacter.h
private:
    ECharacterState CharacterState = ECharacterState::ECS_Unequipped;

我们创建了名为Character的enum 但是UE中的枚举都以E开头 这样一眼就能看出是枚举类型 因此我们要更名为UCharacterState 而且UE中常用的是作用域枚举 也就是enum class 这样使用这个枚举的时候就必须用命名空间:: 每一个枚举都对应着一个整数 是int32 因为要跨平台 默认情况下第1个是0 第2个是1 以此类推 鼠标悬停在Unequipped上就可以看到它的值为0 当然也可以指定它们的值 但通常不关心枚举的数值 枚举的数量不多 所以我们可以指定它们为8位整数 就是uint8 和int32类似 但是uint8的u表示无符号 而且它是8位 相比int32的32位要节省内存
为了让每个枚举单拿出来也足够有特征 UE的惯例是使用类型的缩写当前缀 ECharacterState就缩写成ECS 这样看到ECS_就知道它是ECharacterState的枚举
现在这个枚举是C++原生枚举 但我们想在蓝图中直接访问名为CharacterState的这个ECharacterState变量是做不到的 我们不能直接在蓝图中使用它作为类型 需要使用UE的宏UENUM 这样这个类型就能够被反射系统识别 UENUM的BuleprintType说明符 允许我们在蓝图中使用它作为类型
但是现在的ECS_前缀 在使用时还是很麻烦 我们可以为它们设置显示名称 这样在蓝图中使用这个枚举时 就能决定这些枚举常量的显示名称了 使用UMETA宏 设置DisplayName属性 后面再接一个字符串 这样就能设置这个枚举常量的显示名称 这样在蓝图里会显示Unequipped 而不是ECS_Unequipped 蓝图里的名称都是有空格的 而不是程序员风格的

CharacterState初始化的值是ECS_Unequipped 因为默认状态下是没有装备武器的 拿起武器后 就要把状态改成ECS_EquippedOneHandedWeapon

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::EKeyPressed()
{
    AWeapon* Weapon = Cast<AWeapon>(OverlappingItem);
    if (Weapon)
    {
        Weapon->Equip(GetMesh(), FName("RightHandSocket"));
        CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
    }
}

武器装到插槽上之后 角色状态也要随之更改 设置这个状态的意义在于要让动画蓝图知道我们是否装备了武器 然后它就可以改变动作了

现在打开HelloWorldAnimInstance类

// HelloWorldAnimInstance.h
#include "Characters/HelloWorldCharacter.h"
#include "HelloWorldAnimInstance.generated.h"
// HelloWorldAnimInstance.h public中
ECharacterState CharacterState;

这个头文件必须要放在generated.h的上面 generated.h必须是最后include的头文件 但是现在这样头文件就会越写越多 因为我们只是需要这个枚举类型 并不需要整个HelloWorldCharacter类 还记得吗 我们之前写过自己的调试宏头文件 现在我们要做类似的事情 在里面定义枚举
在Public - Characters - 右键 - 添加 - 新建项 - 头文件 命名为CharacterTypes.h 可以看到下面的位置是 把它默认放到了Intermediate文件夹 这个文件夹是自动生成的 编译的时候会使得这个头文件消失不见 应该将它放到???\HelloWorld\Source\HelloWorld\Public\Characters文件夹里
现在就把那个枚举剪切到这里

// CharacterTypes.h
#pragma once

UENUM(BlueprintType)
enum class ECharacterState : uint8
{
    ECS_Unequipped UMETA(DisplayName = "Unequipped"),
    ECS_EquippedOneHandedWeapon UMETA(DisplayName = "Equipped One-Handed Weapon")
};
// HelloWorldCharacter.h
#include "CharacterTypes.h"
// HelloWorldAnimInstance.h
#include "CharacterTypes.h"

现在HelloWorldAnimInstance类已经有了CharacterState变量 反映角色的当前状态
在HelloWorldAnimInstance.cpp的NativeUpdateAnimation函数中 我们每一帧都会设置GroundSpeed和IsFalling 那我们也可以在这里设置角色状态

// HelloWorldAnimInstance.cpp // 未采用
void UHelloWorldAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
    Super::NativeUpdateAnimation(DeltaSeconds);

    if(HelloWorldCharacterMovement)
    {
        GroundSpeed = UKismetMathLibrary::VSizeXY(HelloWorldCharacterMovement->Velocity);
        IsFalling = HelloWorldCharacterMovement->IsFalling();
        CharacterState = HelloWorldCharacter->CharacterState;
    }
}

添加了一句CharacterState = HelloWorldCharacter->CharacterState;
HelloWorldCharacter是一个已经在HelloWorldAnimInstance.h里定义了的值 箭头运算符后面的CharacterState会报错 因为CharacterState是一个private变量 所以要么设置成public 要么创建一个public的get函数 和之前写那个set函数是一样的原因
因为get函数并不修改类中的任何内容 所以可以声明为const

// HelloWorldCharacter.h
public:
    FORCEINLINE void SetOverlappingItem(AItem* Item) { OverlappingItem = Item; }
    FORCEINLINE ECharacterState GetCharacterState() const { return CharacterState; }
// HelloWorldAnimInstance.h
void UHelloWorldAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
    Super::NativeUpdateAnimation(DeltaSeconds);

    if(HelloWorldCharacterMovement)
    {
        GroundSpeed = UKismetMathLibrary::VSizeXY(HelloWorldCharacterMovement->Velocity);
        IsFalling = HelloWorldCharacterMovement->IsFalling();
        CharacterState = HelloWorldCharacter->GetCharacterState();
    }
}

现在这样 在每一帧都会更新角色状态变量 我们还需要把CharacterState暴露给蓝图

// HelloWorldAnimInstance.h public中
UPROPERTY(BlueprintReadOnly, Category = Movement | Character State)
ECharacterState CharacterState;

这个CharacterState也是和Movement有关的 这个|就是创建了一个子类别 Ctrl+F5编译 打开动画蓝图 左侧我的蓝图面板 可以看到变量 - Movement - Character State一栏 确实有Character State这个变量 但是它现在的组织形式并不是我们想要的下面的效果

Movement
  ├──── HelloWorldCharacterMovement
  │──── GroundSpeed
  │──── IsFalling
  └── Character State
        └── CharacterState

它现在是

移动
  ├── HelloWorldCharacterMovement
  │── GroundSpeed
  └── IsFalling
Movement
  ├── Character State
        └── CharacterState

这很奇怪 但是C++里只用Category是实现不了第一种形式的

// HelloWorldAnimInstance.h public中
UPROPERTY(BlueprintReadOnly, Category = "Movement | Basic")
UCharacterMovementComponent* HelloWorldCharacterMovement;

UPROPERTY(BlueprintReadOnly, Category = "Movement | Basic")
float GroundSpeed;

UPROPERTY(BlueprintReadOnly, Category = "Movement | Basic")
bool IsFalling;

UPROPERTY(BlueprintReadOnly, Category = "Movement | Character State")
ECharacterState CharacterState;
Movement
  ├── Basic
  │     ├── HelloWorldCharacterMovement
  │     ├── GroundSpeed
  │     └── IsFalling
  └── Character State
        └── CharacterState

改成这样算了

在视口中打开Ground Locomotion 我们现在处于Idle状态 双击进入Idle
把Character State拖入Idle视口中 可以看到只能选择get 不能选择set 这是因为我们设置了蓝图只读 从右侧引脚往右拖 搜索equal 选择equal(enum) 就可以判断它是否等于某个枚举常量 可以看到下拉菜单里备选的名字是UMETA宏命名的名字 删掉这个equal节点
再把持剑idle动作拖入 在视口右键搜索blend poses echaracterstate 把之前的idle和Output Animation Pose连线断开 将blend poses右侧引脚连到Output Animation Pose上 对blend poses节点上的Default Pose右键 可以看到添加元素引脚 下面是不同的枚举常量 选择Uneuiqpped 添加一个引脚 再添加一个Equipped One-Handed Weapon 把原来的idle动作连接到Uneuiqpped Pose引脚上 把持剑idle连接到Equipped One-Handed Weapon Pose引脚上
把Character State右侧引脚连接到blend poses节点上的Active Enum Value引脚上 这样blend poses节点就会检查角色状态 并返回不同的动作 如果既不是Unequipped状态也不是Equipped One-Handed Weapon状态 就返回默认姿势Default Pose 所以我们还是把原来的idle同时连接到Default Pose上 Ctrl+D复制一个原始idle节点 连上 如果不连就能更清楚地知道角色状态是否无效 如果无效就会呈现A-Pose 目前我们还是先连上
这个blend poses节点还有几个blend time引脚 就是切换动画的平滑过渡所需的时间 现在先编译 进入PIE 把Equipped One-Handed Weapon Blend Time改成0.2衔接比较流畅
用同样的办法在run状态里也添加一个持剑的跑步动画
我们的跑步动画是含有地面位移的 打开这个动画资产 在左侧资产详情面板 - 根运动 - 取消勾选启用根骨骼运动 这样就是原地跑步的动画了

多个动画蓝图

现在动画蓝图越来越复杂了 我们要使用多个动画蓝图来处理不同的内容 现在在Blueprints文件夹内为RacingMiku再创建一个动画蓝图 命名为ABP_RacingMiku_MainStates 双击打开 回到ABP_RacingMiku把Ground Locomotion状态机和Main States状态机以及它们返回到的名为Ground Locomotion和Main States的cached pose节点 一共4个节点 复制到ABP_RacingMiku_MainStates 双击进入Ground Locomotion状态机的idle状态 此时那个Character State节点有些泛白 因为现在Character State不是这个动画蓝图的变量 现在这个动画蓝图不是基于我们的C++类HelloWorldAnimInstance的 之前那个蓝图里的CharacterState IsFalling GroundSpeed变量 最好是在蓝图中找到它们使用的位置 对变量的节点右键 - 创建变量 或者就在左侧 我的蓝图 面板创建这3个变量 变量命名要和ABP_RacingMiku保持一致 由于UE的显示名称 不清楚变量名中是否有空格 就选择变量 之后查看细节面板 编译 有些地方报Warning 暂时不用理会 回到这个动画蓝图的AnimGraph 右键搜索use cached pose main states 连接到Output Pose的Result引脚上 但其实那3个变量根本就没有被赋值 而且怎么在ABP_RacingMiku中使用这个蓝图呢?
回到ABP_RacingMiku 在之前被复制的那4个节点附近右键 - 搜索linked anim graph 选择动画 - 关联动画图表一栏的Linked Anim Graph 选中这个Linked Anim Graph节点 在右侧细节面板 - 设置 - 实例类 选择ABP_RacingMiku_MainStates 那么现在Linked Anim Graph节点 会显示已经更名为了ABP_RacingMiku_MainStates 当然副标题还是Linked Anim Graph 它的小人引脚的返回值就是ABP_RacingMiku_MainStates的AnimGraph的 返回到Output Pose节点的值
所以现在是Ground Locomotion状态机的返回值放在了Main States状态机中被使用 Main States状态机的返回值就是ABP_RacingMiku_MainStates动画蓝图的返回值 现在变成了ABP_RacingMiku动画蓝图的一个节点 将被使用
这样就把另一个动画蓝图与链接到了这个动画蓝图里 但是现在ABP_RacingMiku_MainStates里面那3个变量还是没有被赋值
选中这个Linked Anim Graph节点 在细节面板 - Exposable Properties可公开属性 可以看到那3个变量的名字 右侧下拉菜单默认显示绑定 这个下拉菜单有很多选项 可以公开为引脚 这样就可以使用一些节点对其传入值 比如将Character State公开为引脚 将Character State变量拖入 选择get 连上 也可以取消公开为引脚 在下拉菜单中选择 属性 - CharacterState 这样就是直接绑定 不再需要连上节点 Is Falling和Ground Speed也是一样地去做 现在这些值就会关联到那个动画图表里
现在可以把被复制的那4个节点删了 但是保留返回的那个Main States节点 也就是有Pose引脚的那个 其实这是一个cached pose节点 把Linked Anim Graph右侧小人引脚连接到Pose引脚上 把ABP_RacingMiku_MainStates返回的动作缓存到Main States中 这样后续才能使用use cached pose “Main States”节点 其它3个节点都删除即可

ABP_RacingMiku里的control rig也非常值得单独一个动画蓝图处理 创建一个命名为ABP_RacingMiku_IK
在ABP_RacingMiku中 我们的做法是 blend poses by bool true就做control rig false就做cached pose main states bool判断标准是ground speed是否为0
右键搜索Linked AnimGraph 但可以发现动画 - 关联动画图表一栏 有很多和Link Anim Graph同为跑步小人图标的一些其它选项 选择ABP_RacingMiku_IK - Linked Anim Graph 这样就不需要在细节面板里再进行设置实例类 它已经为我们设置好了
把blend poses by bool及其前面的所有节点都复制到ABP_RacingMiku_IK 顺便把ABP_RacingMiku中被复制的这些节点都删了 然后在ABP_RacingMiku_IK中 把blend poses by bool返回值引脚连接到Output Pose的Result引脚上 但是可以发现 这个control rig是需要use cached pose main states的 但这甚至没办法创建成为一个变量
发现这个control rig也用到了IsFalling GroundSpeed变量 总之先把这两个变量创建一下
把ABP_RacingMiku_IK里那2个use cached pose main states节点删了 我们需要其它的办法把main states的pose传入到ABP_RacingMiku_IK动画蓝图中 在ABP_RacingMiku_IK的AnimGraph 右键 - 搜索input pose 选择动画 - 关联动画节点里的Input Pose 编译ABP_RacingMiku_IK 回到ABP_RacingMiku 找到属于ABP_RacingMiku_IK的那个Linked Anim Graph节点 右键 - 刷新节点 就可以看到有了一个In Pose引脚 回到ABP_RacingMiku_IK 选中Input Pose节点 在细节面板 - 输入 - 名称 可以对它改名 更名为MainStates 编译 回到ABP_RacingMiku 创建一个use cached pose main states节点连接到Linked Anim Graph的Main States引脚上 然后将Linked Anim Graph返回值引脚连接到Output Pose的Result引脚上
回到ABP_RacingMiku_IK 使用Input Pose节点取代之前的那2个use cached pose main states节点的位置 但是Input Pose节点不能复制成2个 所以还是要把Input Pose缓存 右键 - 搜索选择 New Saved cached pose 命名为Main States 然后把Input Pose节点连接到这个Main States节点上 然后用use cached pose main states 取代之前的那2个use cached pose main states 我们是使用Input Pose从ABP_RacingMiku动画蓝图获取Main States的
再绑定一下IsFalling和GroundSpeed变量
双击Linked Anim Graph节点就可以进入对应的动画蓝图

蒙太奇 montages

我们之前都是使用状态机 每个状态对应不同的动画姿势 再根据转换规则 在这些状态之间切换 但有时候我们希望在游戏中发生某些事情时只播放一次动画 就可以使用蒙太奇 蒙太奇可以看作是装载一个或多个动画的容器 可以为动画创建不同的部分 然后通过函数调用来指定播放哪个蒙太奇和跳转到哪个部分

我们要制作攻击动作 那首先就需要为攻击设定一个按键 创建一个操作映射 然后把这个映射关联到某个按键 那么在HelloWorldCharacter类中就需要一个攻击回调函数 这样点击按键时 攻击函数就会被调用 攻击函数里也需要内容 需要做一些事情
我们要播放一个动画蒙太奇 那么就需要访问AnimInstance类 就可以指定并播放一个蒙太奇

先在蓝图中实现
在关卡编辑器 - 编辑 - 项目设置 - 输入 - 操作映射 添加一个Attack 绑定到鼠标左键 按照通常的做法 这时候我们应该去C++绑定了 但是为了快速原型 先在BP_HelloWorldCharacter事件图表右键搜索attack 选择输入 - Action Events一栏的Attack 得到一个InputAction Attack节点

绑定之前 我们得有一个动画蒙太奇 在角色资产Animations文件夹内部新建一个Montages文件夹 右键 - 动画 - 动画蒙太奇 选择RacingMiku骨骼 命名为AM_AttackMontage 双击打开
下方是蒙太奇的面板 在右侧资产浏览器面板 找到attack动画1 拖入蒙太奇 - DefaultGroup.DefaultSlot那一行里 就会出现一个绿条 非常像剪辑软件 上方红色图标是用来预览动画的时间位置 下面的横条可以缩放时间轴的比例 按ctrl+鼠标滚轮也是类似的效果 右键按住 拖动顶端的时间数字轴 也可以用于查看这个序列
再拖入attack动画2 现在动画就是连着的 动画蒙太奇可以分成多个部分 可以给蒙太奇分段 也可以为每个部分命名 可以看到面板左上方有个名为Default的紫色条 在这个紫色条所在的那一行右键 - 新建蒙太奇片段 命名为Attack1 会得到一个紫色条 拖动紫色的竖线就可以选择这个部分开始的位置 我们把它放在第1个动画开始的地方 实际上就是和Defaut重合了 点击选中就会高亮成蓝色 Default变成蓝色时 按del 把它删掉 或者右键 - 删除蒙太奇片段也可以 选中后在右侧细节面板还可以给动作调速率 现在新建一个蒙太奇片段Attack2 放到attack动画2开始的位置 一定要严格地对好轴

播放蒙太奇时 我们可以选择跳转到某个部分 在右下角蒙太奇片段面板 现在有一行 “预览 Attack1 -> Attack2” 意思是从Attack1片段开始播放蒙太奇 Attack1动画结束之后 会自动播放Attack2部分 但是按下红色的清除按钮 就不是这样了 动画播完之后不会进入Attack2 如果想再次设置成Attack1播放完之后自动进入Attack2 就点击Attack1右侧的白色方块 选择 将Attack2设为下个片段 所以一定要记得检查蒙太奇片段面板

现在我们要播放蒙太奇 蒙太奇可以关联到插槽上 插槽只是一个标签或标记 创建动画蒙太奇时 默认会分配到DefaultSlot默认插槽 就是我们摆放动画的那一行 所以我们的动画现在都在默认插槽里 如果我们要播放与特定插槽关联的蒙太奇 就要让我们的动画蓝图知道我们正在使用那个插槽
现在打开ABP_RacingMiku 现在与Output Pose节点相连的节点是Linked Anim Graph(ABP_RacingMiku_IK) 从它右侧的小人引脚往右拖 搜索slot 选择Animation - Montage一栏的Slot ‘DefaultSlot’ 这样在Linked Anim Graph与Output Pose节点之间 就会出现一个Slot ‘DefaultSlot’节点 这样动画蓝图里的所有pose就都会分配到默认插槽

回到BP_HelloWorldCharacter事件图表 播放蒙太奇的函数在AnimInstance类上 我们的动画蓝图其实就是一个AnimInstance 我们总是能通过角色的网格体来访问AnimInstance 所以在左侧组件面板 把网格体拖入事件图表 从Mesh引脚往右拖 搜索并选择get anim instance 现在我们能从AnimInstance访问它的函数 从Get Anim Instance的Return Value引脚往右拖 搜索并选择montage play 然后把InputAction Attack节点的Pressed执行引脚连接到Montage Play节点的执行引脚上 Montage Play节点的Montage to Play引脚可以在下拉菜单选择资产 选择AM_AttackMontage 再下面的In Play Rate引脚可以修改播放速率 保持在1就是原速播放 Montage Play节点右侧Return Value引脚是绿色 说明会返回一个浮点数值 我们可以通过在左侧Return Value Type引脚来设置这个浮点数的含义 可选为蒙太奇长度或者时长 默认是返回蒙太奇长度 左侧In Time to Start Montage At引脚可以指定从一个时间点来播放蒙太奇 默认0就是从头播放 Stop All Montage引脚是bool值 默认勾选 意思是这个函数被调用时 如果我们正在播放其它蒙太奇 它会停止那些并播放这个
现在我们按下鼠标左键时 这个输入动作就会响应 我们就会从网格体中获取AnimInstance 然后调用Montage Play函数 播放我们指定的Attack蒙太奇
现在编译 PIE 按下鼠标左键 默认就会播放蒙太奇里的第1个动作 如果我们不想播放第1段 从Get Anim Instance节点Return Value引脚往右拖 注意不要和Montage Play节点断开 这个引脚Get Anim Instance节点节点可以同时连接到多个节点 搜索并选择Montage Jump to Section 把Section Name引脚的名字None换成Attack2 发现这个引脚是紫色的 还记得吗之前做control rig时 新建的名字引脚就是紫色的 刚才蒙太奇里的蒙太奇片段条也是相同的紫色 因为它们都是Name类型 选择资产AM_AttackMontage 把Montage Play节点右侧的执行引脚连接到Montage Jump to Section节点的执行引脚
编译 进入PIE 按下鼠标左键 现在就会播放第2个动作 当然了这里目前还有个问题是 就算我们没有拿起武器 按下左键也会采取挥剑动作 还有个问题就是一直按左键 就会一直停留在动作的前几帧 暂时我们先不修复

现在要用C++来做 先把BP_HelloWorldCharacter里那些都删了

// HelloWorldCharacter.h protected中
void Attack();

用螺丝刀创建一个函数定义

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
}
// HelloWorldCharacter.cpp
PlayerInputComponent->BindAction(FName("Attack"), IE_Pressed, this, &AHelloWorldCharacter::Attack);

现在就绑定完了 接下来在Acttack函数中实现播放动画蒙太奇
首先需要设置一个变量 动画蒙太奇在C++中的数据类型是UAnimMontage 所以要前向声明

// HelloWorldCharacter.h
class UAnimMontage;
// HelloWorldCharacter.h
private:
    /**
    * Animation Montages
    */
    UPROPERTY(EditDefaultsOnly, Category = Montages)
    UAnimMontage* AttackMontage;

把这个功能开放给蓝图 我们就能从角色蓝图中选取一个动画蒙太奇 希望能在角色默认蓝图里设置这个 所以设置一个EditDefaultsOnly属性

接下来回去补全Attack函数

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && AttackMontage)
    {
        AnimInstance->Montage_Play(AttackMontage);
        int32 Selection = FMath::RandRange(0, 1);
        FName SectionName = FName();
        switch (Selection)
        {
        case 0:
            SectionName = FName("Attack1");
            break;
        case 1:
            SectionName = FName("Attack2");
            break;
        default:
            break;
        }
        AnimInstance->Montage_JumpToSection(SectionName, AttackMontage);
    }
}

需要访问AnimInstance 可以通过角色mesh来获取 所以要调用GetMesh函数 GetMesh()->GetAnimInstance() 现在鼠标悬停在GetAnimInstance上 可以看到它返回一个UAnimInstance指针 查阅官方文档 可以找到它的头文件

// HelloWorldCharacter.cpp
#include "Animation/AnimInstance.h"

将这个返回的UAnimInstance指针存储到一个变量中 所以接下来要检查指针是否为空 调用蒙太奇播放函数Montage_Play 它会接收一个UAnimMontage指针类型的参数 意思是要播放的蒙太奇 后面那些参数和在蓝图里看到的一样 这里就传入我们之前在HelloWorldCharacter类中创建的蒙太奇变量AttackMontage 我们计划在角色蓝图中设置这个 如果没有设置 这个AttackMontage就是一个空指针 所以我们还要检测它是否为空
现在我们那个蒙太奇有两部分 如果想随机跳到蒙太奇的一个部分 可以通过生成随机数来实现 FMath库的RandRange函数接收2个参数 第1个参数是最小值 第2个参数是最大值 它会返回最大值和最小值之间的(包含最大值和最小值)相应类型的数字 现在我们写FMath::RandRange(0, 1) 又只能返回int32 那就是只能随机返回0或1 这之后非常适合switch语句 default对于本例中就是既不是0也不是1的情况 就是不匹配我们已经设置的任何case 通常每一个case结束时都会加一个break 这样就可以跳到下一部分
声明一个FName类型的变量 表明不同的蒙太奇片段 就是紫色的那个类型 确保它一定要被初始化 设置成一个空的FName 之后就是设置蒙太奇的跳转

Ctrl+F5 编译
打开BP_HelloWorldCharacter 左侧组件面板就保持选中BP_HelloWorldCharacter(自我) 在右侧细节面板搜索montage 可以看到Attack Montage 我们设置的时EditDefaultsOnly 所以这里可见也可以编辑 选择AM_AttackMontage 编译 进入PIE 按下鼠标左键 就发现是实现了在这2个攻击动作之间随机

动画通知

现在需要解决的问题是 不持剑按鼠标左键也会触发攻击动作 以及连续按鼠标左键 就只停留在前几帧 还有我们的攻击动画是有地面位移的 但是攻击完之后没有保留这个位移 而是
先把Attack动画蒙太奇改成5段动画的 然后把C++里switch case也改成5个

现在先解决连续按鼠标左键会卡在前几帧 而不是等待播放完动画 我们需要在正在攻击的时候 阻止再次攻击

整理一下Attack函数 把代码块挪到一个独立的函数里 需要采用重构 把播放蒙太奇的部分放到单独一个函数里 也是时候给代码写注释了

// HelloWorldCharacter.h
protected:
    virtual void BeginPlay() override;

    /**
    * Callbacks for input
    */
    void MoveForward(float Value);
    void MoveRight(float Value);
    void Turn(float Value);
    void LookUp(float Value);
    void EKeyPressed();
    void Attack();

    /**
    * Play montage functions
    */
    void PlayAttackMontage();
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
    PlayAttackMontage();
}

void AHelloWorldCharacter::PlayAttackMontage()
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && AttackMontage)
    {
        AnimInstance->Montage_Play(AttackMontage);
        const int32 Selection = FMath::RandRange(0, 4);
        FName SectionName = FName();
        switch (Selection)
        {
        case 0:
            SectionName = FName("Attack1");
            break;
        case 1:
            SectionName = FName("Attack2");
            break;
        case 2:
            SectionName = FName("Attack3");
            break;
        case 3:
            SectionName = FName("Attack4");
            break;
        case 4:
            SectionName = FName("Attack5");
            break;
        default:
            break;
        }
        AnimInstance->Montage_JumpToSection(SectionName, AttackMontage);
    }
}

为了让函数符合const规范 我们已经使用const标记了那些在函数中创建的未被更改的局部变量 比如Selection 但是AnimInstance不能被标记为const 会标红显示和成员函数Montage_Play不兼容

要决定我们此刻能不能攻击 需要跟踪角色的状态 打开CharacterTypes.h 为角色的战斗状态设定一个特定的状态 为此新建一个枚举 用于追踪角色的动作状态 比如攻击或者与Item互动之类的

// CharacterTypes.h
enum class EActionState : uint8
{
    EAS_Unoccupied UMETA(DisplayName = "Unoccupied"),
    EAS_Attacking UMETA(DisplayName = "Attacking")
};

Unoccupied 表示角色未被占用 也就是没有进行攻击或者与某个Item互动 在这种状况下角色才能去攻击

现在也需要在HelloWorldCharacter.h添加一个变量

// HelloWorldCharacter.h private中
ECharacterState CharacterState = ECharacterState::ECS_Unequipped;
EActionState ActionState = EActionState::EAS_Unoccupied;
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
    if (ActionState == EActionState::EAS_Unoccupied)
    {
        PlayAttackMontage();
        ActionState = EActionState::EAS_Attacking;
    }
}

但是现在一旦进入Attacking状态 就无法回到Unoccupied状态上 攻击一次之后再按鼠标左键就再也不会攻击了 而我们希望在攻击动画结束之后能够立即再次攻击

一种办法是使用动画通知 打开AttackMontage动画蒙太奇 希望在动画的某个时刻发生点什么 尤其是在动画结束时 当攻击动画结束时 我们想把ActionState重置为Unoccupied 蒙太奇下方面板中 有一栏通知 展开 可以看到有一个标为1的一横条 在这一条的编辑器那里右键 而不是在栏上右键 选择添加通知 - 新建通知 命名为AttackEnd 就会在1这条 我们右键的地方出现一个通知 可以移动它 放在蒙太奇中第一个动作的接近末尾处 但是要在第二个动作之前 它的位置就是那个菱形标所在的位置 最好放在最后一帧开始时

蒙太奇编辑器右上角 有比如骨骼和跑步小人图标 可以切换到骨架 骨骼网格体 蒙太奇 动画蓝图 通过这些图标快速切换到不同编辑器 右侧的3个点就是从多个资产中进行切换 它们都通过共享的骨骼连接在一起 现在就进入主动画蓝图的事件图表 右键搜索AttackEnd 选择Event AnimNotify_AttackEnd 这个事件会在动画蒙太奇片段到达通知时触发 可以在这里将ActionState重置为Unoccupied 所以ActionState就必须暴露给蓝图 设置成蓝图可读写 但是这是一个private变量 所以还需要一个元说明符

// HelloWorldCharacter.h
UPROPERTY(BlueprintReadWrite, meta = (AllowPrivateAccess = "true"))
EActionState ActionState = EActionState::EAS_Unoccupied;

但是这样写是不行的 因为现在EActionState并不是蓝图类型 还需要将其指定为BlueprintType

// CharacterTypes.h
UENUM(BlueprintType)
enum class EActionState : uint8
{
    EAS_Unoccupied UMETA(DisplayName = "Unoccupied"),
    EAS_Attacking UMETA(DisplayName = "Attacking")
};

Ctrl+F5编译 打开主动画蓝图 将HelloWorldCharacter变量拖入 从它右侧引脚往右拖 搜索action state 这里显示可以get也可以set 这正是蓝图读写 选择set 把Event AnimNotify_AttackEnd节点的执行引脚连接到SET节点的执行引脚上 SET Action State节点的Action State引脚选择Unoccupied 这样Attack动画通知一旦发生 就会进行SET 设置为Unoccupied状态
现在只有第1个动画有动画通知 回到动画蒙太奇 为第2个动作设置动画通知时 不需要再新建 右键之后 选择AttackEnd即可

但我们其实不太想将这个变量暴露给蓝图 所以还是用C++来做 如果我们想在动画通知响应时调用某个C++函数 我们可以创建一个蓝图可调用函数 并用C++来处理这个行为
其实C++和用蓝图做 性能差别不大 现在来演示用C++要怎么做 先把SET Action State节点和HelloWorldCharacter节点删了 创建一个蓝图可调用函数 在AttackEnd通知时调用它

// HelloWorldCharacter.h protected中
// 放在/** Play montage functions */ void PlayAttackMontage(); 后面
UFUNCTION(BlueprintCallable)
void AttackEnd();
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::AttackEnd()
{
    ActionState = EActionState::EAS_Unoccupied;
}

然后把ActionState变量的UPROPERTY(BlueprintReadWrite, meta = (AllowPrivateAccess = "true"))删了

回到主动画蓝图事件图表 SET Action State节点已经报错了 把它删了 从HelloWorldCharacter节点往右拖 搜索attackend 再连上和AnimNotify_AttackEnd的执行引脚
这个HelloWorldCharacter是HelloWorldAnimInstance类中的变量 它是通过HelloWorldCharacter = Cast<AHelloWorldCharacter>(TryGetPawnOwner());来做初始化的 就像C++中的指针一样 再调用函数使用之前最好还是检查一下这个变量已经被设置好了 从HelloWorldCharacter引脚往右拖 搜索is valid 选择Utilities一栏中前面带问号的Is Valid节点 现在这个HelloWorldCharacter引脚就同时连了两个节点 把AnimNotify_AttackEnd的执行引脚连接到Is Valid的Exec执行引脚 再将Is Valid的Is Valid执行引脚连接到Attack End节点上
如果选择前面是f的那个Is Valid 可以看到它是一个蓝图纯函数 返回的是一个bool值 如果对象有效就是真 否则为假
最简单的办法是 对HelloWorldCharacter节点右键 - 转换为有效的get 这样它就会自带Is Valid 这样从AnimNotify_AttackEnd执行引脚连到GET左侧执行引脚 再从GET的Is Valid执行引脚连接到Attack End节点 同时把GET的Hello World Character引脚连接到Attack End的Target引脚上 如果它是一个有效的HelloWorldCharacter 那么Is Valid执行引脚就会被激活

进入PIE 现在再怎么连续按下鼠标左键 也不会打断当前的攻击动作

现在还有一个更大的问题 就算没有拿起剑 按下鼠标左键 还是会触发攻击动画

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
    if (ActionState == EActionState::EAS_Unoccupied && CharacterState != ECharacterState::ECS_Unequipped)
    {
        PlayAttackMontage();
        ActionState = EActionState::EAS_Attacking;
    }
}

只需要多检查一步 是否为Unequipped状态 只有不是Unequipped状态 才能播放攻击动画
但是这一句ActionState == EActionState::EAS_Unoccupied && CharacterState != ECharacterState::ECS_Unequipped实在太长了 将其创建成一个局部bool变量更好 并且要换行写 更易读

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
    const bool bCanAttack = 
        ActionState == EActionState::EAS_Unoccupied &&
        CharacterState != ECharacterState::ECS_Unequipped;
    if (bCanAttack)
    {
        PlayAttackMontage();
        ActionState = EActionState::EAS_Attacking;
    }
}

这里也可以创建成函数 看起来更整洁

// HelloWorldCharacter.h protected中
// 放在/** Play montage functions */ void AttackEnd(); 后面
FORCEINLINE bool CanAttack();
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
    if (CanAttack())
    {
        PlayAttackMontage();
        ActionState = EActionState::EAS_Attacking;
    }
}

bool AHelloWorldCharacter::CanAttack()
{
    return ActionState == EActionState::EAS_Unoccupied &&
        CharacterState != ECharacterState::ECS_Unequipped;
}

还剩下一个问题 希望我们的剑能够旋转展示
回到BP_Weapon 是时候把事件图表中那些旋转的节点连接到Event Tick了 我们在前面已经尝试过 如果这里连上了 武器就算在角色手中 也还是漂浮旋转
现在的修复方案是 对于武器 它需要知道自己是否已经被装备了 如果未被装备 就漂浮旋转 继续使用枚举

但我们希望捡起各种类型的物品 不只是武器 所以要在Item类里面写 Weapon类只是Item类的子类

// Item.h
// 前向声明之后 class HELLOWORLD_API AItem : public AActor之前
enum class EItemState : uint8
{
    EIS_Hovering,
    EIS_Equipped
};

这个枚举只能写在Item.h里 不能写在CharacterTypes.h里 那个头文件里存放的都是角色的状态 Hover的意思是悬浮 我们暂时不将其暴露给蓝图 不要过分地暴露给蓝图

// Item.h protected中
EItemState ItemState = EItemState::EIS_Hovering;

写入protected中 这样Weapon类也可以继承 这个变量现在也写不了UPROPERTY 因为EItemState枚举类没有标记为UENUM
初始化为EIS_Hovering 会使得所有Item在世界中一开始都会处于悬浮状态

先在Item.cpp中把漂浮旋转实现一下 不在蓝图的事件图表里做了 到头来还是要写在Tick函数里 看来之前就不应该删除 把事件图表里的关于AddActorWorldOffset和AddActorWorldRotation的都删了

// Item.cpp
void AItem::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    RunningTime += DeltaTime;

    if (ItemState == EItemState::EIS_Hovering)
    {
        AddActorWorldRotation(FRotator(0.f, 45.f * DeltaTime, 0.f));
        AddActorWorldOffset(FVector(0.f, 0.f, TransformedSin());
    }
}

这样就和在BP_Item事件图表里用节点做的一样了 还添加了只在悬浮状态下才漂浮旋转

但是现在还没有做把Item的ItemState设置成Equipped的时机 Weapon类现在也继承了ItemState 可以在Equipped函数里做

// Weapon.cpp
void AWeapon::Equip(USceneComponent* InParent, FName InSocketName)
{
    FAttachmentTransformRules TransformRules(EAttachmentRule::SnapToTarget, true);
    ItemMesh->AttachToComponent(InParent, TransformRules, InSocketName);
    ItemState = EItemState::EIS_Equipped;
}

热重载 进入PIE 现在这柄剑在旋转展示 到了角色手里之后就变为了静态

继续修复最后一个问题 角色在攻击时 WASD键应该是失效的 不应该边跑动边攻击 在HelloWorldCharacter类中 在MoveForward函数和MoveRight函数的第一行都加上

// HelloWorldCharacter.cpp
if (ActionState == EActionState::EAS_Attacking) return;

声音 meta sounds

在音效下载网站 找到一个剑攻击声音资产wav 重命名为whoosh.wav 在UE中 管理我们的资产的文件夹附近 新建一个Sounds文件夹 把这个wav放进去
那么如何在挥剑时播放这个声音?

先介绍来自于UE4的方法
希望在攻击动画的某一时刻播放这个声音 打开攻击动画资产
在下方编辑器 1那一条上 合适的位置右键 - 添加通知 - 播放音效 这样就有了一个名为PlaySound的通知 选中它 在细节面板里 动画通知 - 音效 下拉菜单里选择刚才的whoosh.wav
当然也可以把音效通知直接放在攻击动画蒙太奇里 如果会发生重叠 就新建一个轨道2
最好是把动画通知打在蒙太奇里 这样假如动画资产出了问题需要进行替换 把通知放在蒙太奇里 就可以尽量保留这些通知的信息 防止需要再次重复地对时间轴

但是现在这样是直接使用wav资产 很难调节一些细节 而且这个资产如果改变 所有使用了它的地方都会改变 这时候需要使用声音线索 在sounds文件夹右键 - 音频 - sound cue 命名为sc_whoosh1 打开它 在内容侧滑菜单里 把whoosh1.wav拖入视口 就转换成了Wave Player whoosh1节点 把它的output引脚连接到Output节点上 点击上方的播放Cue 就能听到它是什么声音 也可以直接对于whoosh1右键 - 创建Cue 就会得到一个已经连接好节点的cue 选中Output节点 在左侧面板就会出现一些参数 可以调整这些参数
现在回到音效通知 就可以选择这个cue作为资产了

更加UE5的方式是meta sounds
在Sounds文件夹空白处右键 - 声音 - meta sounds 命名为SFX_Whoosh 双击打开 这个界面很像蓝图 从Input节点 播放时引脚开始 最后要到Output节点 完成时引脚结束 可以看到还有另一个Output节点 有输出单声道引脚 单声道的意思是同时从左右扬声器播放 不区分左右声道
MetaSound也可以接收输入 在左上角成员面板 点击 输入 右侧的加号 就可以创建一个输入 将其命名为Woosh 选中它 在左下角细节面板 - 常规 - 类型 下拉菜单可以选择数据类型 选择为WaveAsset 其实就是wav
把这个Woosh输入拖入视口 可以看到这也是个Input节点 在视口空白处右键 搜索wave player 选择声波播放器 2.0立体声 其实5.1的是更好的环绕声 但我们现在不做那么复杂的 现有的资产也是仅有双声道 把Input播放时引脚连接到Wave Player节点的运行引脚 把Input Woosh引脚连接到Wave Player节点的声波资产引脚 开始时间引脚默认是0 那么就是从头开始播放 音高频移偏移引脚就是改变音调 循环引脚是循环这段音频 默认不勾选 右侧完成时引脚连接到Output完成时引脚上 把输出左或者输出右连接到Output输出单声道引脚上 连哪个都可以 本次演示中选择了输出左
选中这个Input Woosh节点 查看细节面板 - 默认值 - 值 - 默认 下拉菜单可以选择wav资产 点击播放就有声音了 右侧还有音高和声音波形的条

现在可以做很多复杂的事情了
从Input播放时引脚往右拖 搜索random float 选择随机浮点 随机浮点数需要一个种子 默认是-1 种子是一个传递给随机数生成器的值 用来决定输出 还可以设置最小最大值 默认是0到1之间随机 右侧值引脚返回的就是一个随机值 把这个值引脚连接到Wave Player节点的音高频移偏移引脚上 再按播放 似乎每一次的听感变得有些不一样 如果想看到更明显的效果 就把最小最大值之间的差值放大 比如-5到5 这样不需要寻找多个资产 也能做出每一剑之间声音的差异化
也可以随机调整音量 从Wave Player节点输出左引脚往右拖 搜索* 选择乘以(Audio乘以Float) 从Input播放时引脚往右拖 搜索random float 随机范围设置成了0.8到1.2 然后把值引脚连接到乘以节点左侧另一个引脚上 乘以节点的右侧引脚连接到Output输出单声道引脚上 这样Wave Player节点输出单声道引脚和Ouutput节点输出单声道引脚就会自动断开
回到动画资产里 就可以选择这个MetaSounds作为声音资产

再创建一个MetaSounds 用来随机播放攻击语音 命名为SFX_Exert
在左侧成员面板 创建一个名为Exert的输入 拖入视口 下方细节面板 - 类型 修改为WaveAsset类型 右侧可以勾选 为数组 然后下方默认值 - 默认 这里就显示0数组元素 点击右侧加号就可以添加元素 在下拉菜单选择相应资产 也可以从内容侧滑菜单同时选中多个wav资产 拖到附近 会看到数组元素变成蓝色 就拖到那里 就可以批量创建数组元素 同时也可以看到视口里Input节点Exert引脚的图表变成了多个点的方块 而不是一个圆 这就是数组的符号 可以看到它是蓝色 和刚才把wav文件拖动到数组元素那里时的蓝色是一样的 这个蓝色就是声音类型的颜色

在视口中空白处右键 搜索shuffle 选择随机播放WaveAsset Array 把Input节点的Exert引脚连接到Shuffle节点的输入数组引脚 把Input节点的播放时引脚连接到Shuffle节点的 下个 引脚 右侧的值引脚是蓝色 意思是这个节点的输出仍然是一个声音类型 从值引脚往右拖 搜索wave player 仍然选择2.0立体声 把Shuffle节点的下个时引脚 连接到Wave Player的运行引脚 把Wave Player的完成时引脚连接到Output节点完成时引脚 输出左引脚连接到Output节点单声道引脚
点击上方播放键 就可以听到它在随机播放那些攻击语音
不再添加随机音高节点 这些人声声音较大 希望调整一下音量 从输出左引脚往右拖 搜索* 选择乘以(Audio乘以Float) 下面绿色引脚的float值直接填0.8 意思就是音量变为0.8倍

在动画资产里添加新的通知轨道和通知来添加声音 选中SFX_Exert作为资产 在右侧细节面板中往下翻 - 触发设置 - 通知触发几率 改成0.5

继续做脚步声和跳跃声 都是用meta sounds 脚步声要从一段脚步音频里切出来多个片段 用数组导入 但是这里为了简化 还是采取改变音调和音量的方式来制造差异化

Niagara粒子

也可以为动画添加Niagara粒子 新建一个通知轨道 在合适的位置右键 - 添加通知 - 播放粒子效果 在细节面板 - 动画通知 - Niagara系统 选择一个脚下效果的Niagara粒子资产 点击播放就可以看到脚下的效果 但是它现在的位置只在根骨 可以发现细节面板有一个插槽命名 在这里输入脚骨的名字比如足首_L 就可以绑定到骨骼插槽上
可以先选好位置 然后批量选择资产

演示中在此处保存时一直失败 解决方法是重启电脑 之后删除项目文件夹中的 Saved Intermediate DerivedDataCache文件夹

再给剑添加挥动轨迹Niagara粒子 新建一个动画通知轨道 命名为Trail 右键 - 添加通知状态 - 定时Niagara效果 调整它到一个合适的起止时间 在右侧细节面板 - Niagara系统 - Niagara系统 选择一个剑轨迹Niagara资产 绑定到剑刃根部位置的插槽上 复制一下RightHandSocket 预览资产选择为剑 用本地坐标移动 沿着剑的方向将其移动到剑刃根部
将定时Niagara效果通知的插槽选择为RightHandSocket

动画修复 MMD骨骼修改

先修复一些动作的问题
首先 从jog动画切换回持剑idle动画的瞬间 角色会突然向右剧烈瞬移一下 这是因为持剑idle动画的全ての親骨骼 其XY位置并没有在坐标原点
还有一个问题是 手K的攻击动作的根运动 也就是攻击完之后又回到攻击之前的原点了 没有发生攻击动作里的地面位移
这些都是根运动导致的

在Blender编辑模式里直接把全ての親这个骨骼删了 全ての親2作为根节点 在编辑模式查看 注意现在左上角的模式应该都为全局 在右下方绿色骨头面板也就是骨骼属性面板 - 变换 可以看到全ての親2的头部尾端的XY不都是0 本例中是X为0 Y为-5.88 但是全ての親2现在是根骨骼了 它的XY必须为0 所以需要批量移动全部的骨骼 所有骨骼的Y都应该加上5.88cm 按A全选骨骼 在左侧工具栏选择移动工具 在Y方向进行移动 并在左下角弹窗填入数据 Y为5.88

是时候大幅修改MMD骨骼了 这次就彻底模仿UE标准骨骼来做

现在MMD的骨骼结构为 全ての親 - 全ての親2 - センター - グルーブ - 腰 - 上半身/下半身 观察发现 从上半身/下半身开始才有蒙皮 而导入大量的动画观察 发现一般而言 全ての親没有任何关键帧 基本上全ての親2也是虽有关键帧但没有位移 センター和グルーブ 通常有关键帧 腰没有关键帧 那么实际上 而我们最终的目标是做成和UE一样的 全ての親2 - 腰 - 上半身/下半身 这种结构的骨骼 全ての親2作为root 腰作为plevis
那么 既然动画系统是靠骨骼层级结构层层传递的 就需要让全ての親2作为root拥有第1个发生地面位移的骨骼的位置、四元数旋转、缩放数据 无论这个骨骼是全ての親还是センター或者グルーブ 都要转移到全ての親2 之后把那个骨骼的数据清空 然后把センター和グルーブ上 现在不为空的数据 全都传递给腰 之后把センター和グルーブ骨骼删除

使用约束和烘焙来做吧
本例中全ての親2是有地面位移和旋转数据的 所以不需要把其它骨骼的数据迁移给它了 只需要直接处理腰 先把腰的父骨骼修改成全ての親2
本例中腰上没有关键帧 需要打上第一个关键帧 才能有序列 选中腰 现在腰这个骨骼在姿态模式是不可见的 右下角进入绿色骨头面板也就是骨骼面板 - 视图显示 取消勾选隐藏 把动画摄影表里的所有序列都取消固定 在第1帧键盘按下I 如果腰已经有关键帧序列 就不需要打了 但仍然需要取消勾选隐藏才能看到这个序列 如果看不到腰的序列 后续就添加不了约束 烘焙动作就做不了
选中腰 在姿态模式 右下角蓝色骨头周围有锁链的图标也就是约束面板 点击添加骨骼约束 - 复制变换 目标选择骨架名字也就是XXX_arm 骨骼选择全ての親2 然后再创建两个约束 骨骼分别选择センター和グルーブ
在姿态模式 按A全选 在左上角菜单 - 姿态 - 动画 - 烘焙动作 起始帧和结束帧默认是动画的开始和结束的帧 勾选仅选中的骨骼 勾选可视插帧 勾选清除约束 点击确定
播放一下动作 发现动作是没有变化 但是可以看到动画摄影表上全都打上了关键帧 先把センター和グルーブ上的关键帧删了 然后回到编辑模式 把センター和グルーブ骨骼都删了 现在骨骼结构真的变成了全ての親2 - 腰 和UE的root - previs是一样的了

回到UE 先修复一下UE中的骨骼 在骨骼网格体左侧面板 - 骨架 - 编辑骨架 使用断开连接和移除工具 使其骨骼结构变成 全ての親 - 腰 - 上半身/下半身 不要保留XXX_arm 这就和UE是一样的了 在下方点击接受 弹窗下拉菜单选择合并 勾选合并所有 在右下角先批量保存一下

继续修复其它相关资产
打开control rig资产 在左下角绑定层级面板 对根骨骼右键 - 刷新 - 选择刚才修改过的骨骼网格体
打开之前的动作资产 可以看到有非常多的动画已经扭曲了 打开IK绑定 右侧细节面板 - 预览骨骼网格体 先点击右侧弯箭头重置一下 再重新选择修改过的骨骼网格体 可以看到左侧骨骼结构就已经更新了
打开IK重定向器 在左侧操作栈面板 点击垃圾桶图标把这些都清空 然后点击上方菜单栏的添加默认操作 播放一个动作 就可以看到一切都正常了 在这里重新做重定向即可 同时也可以看到 在角色发生移动的时候 根骨骼真的也在发生移动 证明我们已经成功做成了根运动

现在来解决在Blender里做的手K攻击动画的问题
导入刚才Blender删除全ての親骨骼后的fbx 弹窗中选择仅导入动画 骨架选择我们刚刚修改完的以全ての親作为根的骨架 双击打开这个动画 在左侧资产详情面板 搜索root 在根运动一栏 勾选启用根运动 不勾选根锁定 动画蒙太奇里替换一下这个动作 我们终于可以惊喜地发现 动作结束之后 不会再回到原地 而是真的发生了地面位移 并且摄像机也跟着一起运动
如果是在动画资产里做的动画通知 现在就全都被替换没了 所以一定一定要在动画蒙太奇里做动画通知

还需要修复最后一个动作的方向问题 有一个攻击动作做完之后 身体和脚的方向是直接左转了 然后它blend回了idle 导致脚面朝的方向没有变 最初到最终面朝的方向左转了 其实就是因为根骨骼向左旋转了 才导致这个动画在向左而不是向前 这个动画看起来是向着偏左45度的方向在运动的
这完全是全ての親2的问题 全ての親2也就是根骨骼的变换最初四元数旋转WXYZ 0.8666 0 -0.5 0 最终是四元数旋转WXYZ 1 0 0 0 根据四元数计算 实际上是根骨骼发生了旋转60度
所以现在需要把全ての親2的旋转通过约束烘焙给腰 注意要叠加上腰已有的旋转数据 添加约束时选择复制旋转 混合那一栏默认是替换 我们之前做的约束也全都使用的替换 那是因为之前腰的关键帧都是空的 现在这个混合 在下拉菜单中要选择相加 轴向XYZ都选的话 角色直接躺下了 只选Z轴 看到的动作是正常的 并且其实我们只想修复Z轴旋转也就是Yaw 但是如果只相加Z轴 之后在关键帧序列里 不能只删除掉全ての親2的Z轴旋转数据 旋转是以四元数的形式 不是欧拉数 不能单独删除某个轴 所以使用复制旋转并相加的方式是做不了了 要想其他的办法

Blender物体模式 左上方工具栏点击添加 - 空物体 - 纯轴 在大纲面板选中它 为它添加复制变换约束 目标选XXX_arm 骨骼选盆骨 本例中就是腰 混合选择替换 目标和拥有者都是世界空间 其余都保持默认 左上方菜单栏 物体 - 动画 - 烘焙动作 勾选可视插帧和清除约束 这样现在空物体就已经完全复制了腰的位置旋转缩放 播放动画也可以看到这个纯轴就在腰的位置上
进入姿态模式 把根骨骼全ての親2的四元数旋转4条序列上的关键帧都删了 现在在每一帧 绿色骨头骨骼面板里的旋转数值WXYZ一定不会变了 但它很可能是某个固定的旋转 而不是1 0 0 0 一定要把它置为1 0 0 0 这样就是无旋转 如果这里不清零 后续全部做完会导致 根骨骼从头至尾都是做了这个旋转 就不可能解决动画放完之后角色朝向转变的问题 现在再播放动作肯定是出现了异常 不用管它
仍然在姿态模式 选中腰 添加一个复制变换约束 目标选择刚才添加的纯轴 默认名字应该是空物体 目标和拥有者都是世界空间 混合选择替换 其它都是默认 现在再播放动画 动作恢复了正常
在大纲视图按A全选 左上角工具栏 姿态 - 动画 - 烘焙动作 勾选可视插帧和清除约束 之后把根骨骼全ての親2的四元数旋转4条序列上的关键帧都删了 顺便把脚IK 控制表情和服装的骨骼上的关键帧都删了 再去查看 就发现动画初始和动画结束 根骨骼都没有发生任何旋转 最后再去把空物体删了
这个使用空物体中转的办法 可以无视任何欧拉角或者四元数带来的复杂问题 直接复制
其实去看其它方向正常的动作 会发现根骨骼从头至尾的四元数旋转都是1 0 0 0

上面的方法适用于根骨骼有旋转并想删除这个旋转的情况 如果根骨骼没有旋转只有位移 直接依据根骨骼给盆骨打约束就可以了 不需要中转 但其实如果根骨骼没有旋转只有位移 我们也并不需要打约束然后删除根骨骼的关键帧 毕竟我们是想要保留根运动

重新导入动画的时候 选中原来的动画 右键 - 用新源重新导入 选中我们新做出来的fbx即可

连招

攻击蒙太奇的动作不再随机播放 而是做成连招 用户超过一段时间不再按下攻击键 就blend回到idle

// HelloWorldCharacter.h private中
int32 ComboCount = 0;

设置一个连招计数器

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
    if (CanAttack())
    {
        PlayAttackMontage();
        ActionState = EActionState::EAS_Attacking;

        ComboCount++;
        if (ComboCount >= 5)
        {
            ComboCount = 0;
        }
    }
}

首先设置连招计数器 每次按下鼠标左键 就会触发一次Attack函数 那么就攻击计数一次 如果达到了5次 就重置为0

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::PlayAttackMontage()
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && AttackMontage)
    {
        AnimInstance->Montage_Play(AttackMontage);
        
        FName SectionName = FName();
        switch (ComboCount)
        {
        case 0:
            SectionName = FName("Attack1");
            break;
        case 1:
            SectionName = FName("Attack2");
            break;
        case 2:
            SectionName = FName("Attack3");
            break;
        case 3:
            SectionName = FName("Attack4");
            break;
        case 4:
            SectionName = FName("Attack5");
            break;
        default:
            break;
        }
        AnimInstance->Montage_JumpToSection(SectionName, AttackMontage);
    }
}

不再使用随机动作播放 而是根据ComboCount指定 那么现在就已经实现了根据连招的动作段数指定攻击的动作 但是现在这样 无论角色在原地停止多久 下一次攻击的永远是下一段 不会从头开始 所以需要一个计时器 玩家超过一段时间不按下攻击键 攻击段数也就是ComboCount就会清零

下面来进行实现

// HelloWorldCharacter.h private中
FTimerHandle ComboResetTimer;
// HelloWorldCharacter.h
class UTimerHandle;
// HelloWorldCharacter.cpp
#include "Engine/TimerHandle.h"

设置一个计时器 用于计算当前攻击之后又过了多长时间 比如我们希望玩家如果2秒不按下按键 就重置攻击段数 那么就是这个ComboResetTimer到达了2秒之后 我们就需要做一个重置攻击段数的行为 所以先写一个用于重置的函数

// HelloWorldCharacter.h private中
void ResetCombo() { ComboCount = 0; };

在攻击结束 在Attack函数里设置了一个计时器 动画通知到AttackEnd的时候 就会使用变量ComboResetTimer开始计时

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::AttackEnd()
{
    ActionState = EActionState::EAS_Unoccupied;

    GetWorldTimerManager().SetTimer(
        ComboResetTimer,
        this,
        &AHelloWorldCharacter::ResetCombo,
        2.f,
        false
    );
}

SetTimer的第1个参数是计时器 第4个参数是定时的时间 我们设置的是2秒 第2 3个参数是 如果定时的时间到了 就调用第2个参数指定的对象的 第3个参数指定的函数 第5个参数是指定是否循环 我们选择了不循环 意思是这2秒计时完了就自动销毁

但是如果这2秒中间 用户又按下了攻击键 就必须要停止计时了 销毁这个2秒时间到了就触发什么操作的设置

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
    if (CanAttack())
    {
        GetWorldTimerManager().ClearTimer(ComboResetTimer);

        PlayAttackMontage();
        ActionState = EActionState::EAS_Attacking;

        ComboCount++;
        if (ComboCount >= 5)
        {
            ComboCount = 0;
        }
    }
}

在Attack函数里添加了一行 GetWorldTimerManager().ClearTimer(ComboResetTimer); 首先清除ComboResetTimer计时器 这样就不会触发那个定时到2秒的操作

Ctrl+F5编译 现在已经可以实现连招了 但是想做成能在攻击完之后停滞在最后一帧一段时间 再缓慢blend回idle
先在AM_AttackMontage左侧资产详情面板 把混出触发时间从-1.0改成0
得到的效果还是不能令人满意 想要最后收招动作定格时间长一点 这样等到玩家2秒内按下攻击键 动作看起来就可以比较连续

创建一个AnimNotifyState_MontageSpeed类 等会就在蒙太奇里打通知就可以了

// AnimNotifyState_MontageSpeed.h
#pragma once

#include "CoreMinimal.h"
#include "Animation/AnimNotifies/AnimNotifyState.h"
#include "AnimNotifyState_MontageSpeed.generated.h"

UCLASS()
class HELLOWORLD_API UAnimNotifyState_MontageSpeed : public UAnimNotifyState
{
    GENERATED_BODY()

public:
    float ExactDuration = 0.5f; // 通知持续时间 可调节

    virtual void NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration, const FAnimNotifyEventReference& EventReference) override;
    virtual void NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference& EventReference) override;
    
};

// AnimNotifyState_MontageSpeed.cpp
#include "Characters/AnimNotifyState_MontageSpeed.h"
#include "Animation/AnimInstance.h"
#include "Animation/AnimMontage.h"

void UAnimNotifyState_MontageSpeed::NotifyBegin(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, float TotalDuration, const FAnimNotifyEventReference& EventReference)
{
    Super::NotifyBegin(MeshComp, Animation, TotalDuration, EventReference);

    if (MeshComp && MeshComp->GetAnimInstance())
    {
        const FAnimNotifyEvent* NotifyEvent = EventReference.GetNotify();
        if (!NotifyEvent) return;

        float OriginalLength = NotifyEvent->GetDuration(); // 这段原来的动画时长

        if (ExactDuration <= 0.0f) ExactDuration = 0.01f; // 防止除以0
        float NewPlayRate = OriginalLength / ExactDuration; // 计算播放倍率

        UAnimInstance* AnimInstance = MeshComp->GetAnimInstance();
        if (AnimInstance->Montage_IsPlaying(nullptr))
        {
            AnimInstance->Montage_SetPlayRate(nullptr, NewPlayRate);
        }
    }
}

void UAnimNotifyState_MontageSpeed::NotifyEnd(USkeletalMeshComponent* MeshComp, UAnimSequenceBase* Animation, const FAnimNotifyEventReference& EventReference)
{
    Super::NotifyEnd(MeshComp, Animation, EventReference);

    if (MeshComp && MeshComp->GetAnimInstance())
    {
        UAnimInstance* AnimInstance = MeshComp->GetAnimInstance();

        if (AnimInstance->Montage_IsPlaying(nullptr))
        {
            AnimInstance->Montage_SetPlayRate(nullptr, 1.0f);
        }
    }
}

在蒙太奇中右键添加通知状态 选择Anim Notify State Montage Speed即可

收起剑

现在用几个Mixamo的资产 在Character找到X-Bot 下载这个模型 默认就选择FBX Binary(.fbx) T-Pose 搜索axe能得到一些单手武器攻击动画 下载一个收起剑到背后和一个从背后拿出剑的动画 下载之前要确保演示视窗里是x-bot的模型 可以调节一下Character Arm-Space 本次演示中的角色比较纤细 所以调小一点变成40 点击下载后的弹窗里 Skin选择Without Skin 其它都默认
做个IK重映射 得到新的动作

把从背后拔出剑的动画重命名为Equipping 把收起剑到背后的动画重命名为Unequipping
制作一个蒙太奇 命名为AM_Equip 把这两个动画放进去 蒙太奇片段分别命名为Equipping和Unequipping

// HelloWorldCharacter.h private中
UPROPERTY(EditDefaultsOnly, Category = Montages)
UAnimMontage* EquipMontage;

现在我们打算在重叠范围之外 按E就能卸下武器

在与武器重叠的范围之外 如果角色身上有武器 并且正在闲置状态 没有在进行攻击 并且Equip蒙太奇非空 如果是这样的情况 按E就是卸下武器
可以写成if (CharacterState != ECharacterState::ECS_Unequipped && ActionState == EActionState::EAS_Unoccupied && EquipMontage) 但是这个Bool太长了 可以模仿之前写CanAttack函数的时候一样 写一个CanUnequipping 意思就是可以卸下武器 播放卸下武器的动画了

// HelloWorldCharacter.h protected中 
bool CanUnequipping();
// HelloWorldCharacter.cpp
bool AHelloWorldCharacter::CanUnequipping()
{
    return CharacterState != ECharacterState::ECS_Unequipped &&
        ActionState == EActionState::EAS_Unoccupied &&
        EquipMontage;
}

那么在这个条件下 就要播放Equip蒙太奇

// HelloWorldCharacter.h protected中 
void PlayEquipMontage(const FName& SectionName);
bool CanUnequipping();
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::PlayEquipMontage(const FName& SectionName)
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && EquipMontage)
    {
        AnimInstance->Montage_Play(EquipMontage);
        AnimInstance->Montage_JumpToSection(SectionName, EquipMontage);
    }
}

蒙太奇非空放在播放蒙太奇之前检查了 那么CanUnequipping就不需要检查了 修改成

// HelloWorldCharacter.cpp
bool AHelloWorldCharacter::CanUnequipping()
{
    return CharacterState != ECharacterState::ECS_Unequipped &&
        ActionState == EActionState::EAS_Unoccupied;
}

那么现在EKeyPressed函数就应该是这样

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::EKeyPressed()
{
    AWeapon* OverlappingWeapon = Cast<AWeapon>(OverlappingItem);
    if (OverlappingWeapon)
    {
        OverlappingWeapon->Equip(GetMesh(), FName("RightHandSocket"));
        CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
    }
    else
    {
        if (CanUnequipping())
        {
            PlayEquipMontage(FName("Unequipping"));
            CharacterState = ECharacterState::ECS_Unequipped;
        }
    }
}

同样地 要写播放Equipping的条件 能播放Eqquipping的条件应该是 我们已经有了武器 并且是未装备的状态 且正在闲置状态 没有在进行攻击
所以需要添加一个表示我们已经有武器的变量

// HelloWorldCharacter.h private中
UPROPERTY(VisibleAnywhere, Category = Weapon)
AWeapon* EquippedWeapon;

还需要写一个前向声明

// HelloWorldCharacter.h
class AWeapon;

HelloWorldCharacter.cpp中已经写了Weapon的头文件了 所以不需要再写了

那么之前在捡起武器时 就需要设置这个EquippedWeapon变量

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::EKeyPressed()
{
    AWeapon* OverlappingWeapon = Cast<AWeapon>(OverlappingItem);
    if (OverlappingWeapon)
    {
        OverlappingWeapon->Equip(GetMesh(), FName("RightHandSocket"));
        CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
        EquippedWeapon = OverlappingWeapon; // 添加了一行
    }
// 后半省略

那么现在就可以写播放蒙太奇Eqquipping的条件 已经有武器 并且未装备 并且正在闲置状态 没有在进行攻击

// HelloWorldCharacter.h protected中 
void PlayEquipMontage(const FName& SectionName);
bool CanUnequipping();
bool CanEquipping();
// HelloWorldCharacter.cpp
bool AHelloWorldCharacter::CanEquipping()
{
    return CharacterState == ECharacterState::ECS_Unequipped &&
        ActionState == EActionState::EAS_Unoccupied &&
        EquippedWeapon;
}
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::EKeyPressed()
{
    AWeapon* OverlappingWeapon = Cast<AWeapon>(OverlappingItem);
    if (OverlappingWeapon)
    {
        OverlappingWeapon->Equip(GetMesh(), FName("RightHandSocket"));
        CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
        EquippedWeapon = OverlappingWeapon;
    }
    else
    {
        if (CanUnequipping())
        {
            PlayEquipMontage(FName("Unequipping"));
            CharacterState = ECharacterState::ECS_Unequipped;
        }
        if (CanEquipping())
        {
            PlayEquipMontage(FName("Equipping"));
            CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
        }
    }
}

然而现在 持剑idle时 角色总是在和武器重叠 并不能触发不重叠时的条件去装卸武器
所以需要在武器被装备之后 关掉武器上用于检测重叠的碰撞体 否则这把剑就会一直触发重叠

然而现在 持剑idle时 角色总是在和武器重叠 所以不能触发不重叠的条件之后去装卸武器
所以需要在武器被装备之后 将OverlappingItem置空 这样之后再按E 就没有可能捡拾武器 而是只能装卸武器

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::EKeyPressed()
{
    AWeapon* OverlappingWeapon = Cast<AWeapon>(OverlappingItem);
    if (OverlappingWeapon)
    {
        OverlappingWeapon->Equip(GetMesh(), FName("RightHandSocket"));
        CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
        EquippedWeapon = OverlappingWeapon;
        OverlappingItem = nullptr; // 添加了一行
    }
// 后半省略

热重载 需要到BP_HelloWorldCharacter中 左侧组件面板选中BP_HelloWorldCharacter(自我) 右侧细节面板搜索montage 将Equip Montage指定为AM_EquipMontage

进入PIE 角色拿起武器之后再按E 发现它总是在播放从背后拿出武器的动作
能进入else分支 说明此时OverlappingItem是空指针 因为角色正在拿着武器 肯定是不处于未装备状态 此时也没有再攻击 那么就是满足了CanUnequipping的要求 然后播放Unequipping的蒙太奇 之后CharacterState被置为未装备武器 那么 处于未装备 也不处于攻击状态 EquippedWeapon也是非空 就会满足CanEquipping的要求 然后播放Equipping的蒙太奇 最后CharacterState被置为了装备武器的状态 那么在Equipping的蒙太奇播放完之后 角色就又处于持剑idle了 使得我们永远看不到播放Unequipping的蒙太奇 因此这里需要是else if

// HelloWorldCharacter.cpp void AHelloWorldCharacter::EKeyPressed()中
else
{
    if (CanUnequipping())
    {
        PlayEquipMontage(FName("Unequipping"));
        CharacterState = ECharacterState::ECS_Unequipped;
    }
    else if (CanEquipping()) // 改为else if
    {
        PlayEquipMontage(FName("Equipping"));
        CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
    }
}

现在进入PIE 已经可以正常装卸了 但是收起武器之后武器还在手上 这是一个我们需要修复的问题 我们希望武器能够放到后背上

绑定到背后

打开骨骼 对脊柱上偏上的骨骼右键 - 新建插槽 命名为SpineSocket 用剑预览 右侧预览场景设置面板 - 动画 - 预览控制器 选择使用特定动画 选择装卸的动画 把剑旋转到合适的位置

仍然是需要在蒙太奇里打通知 到达一定的位置 剑就应该从手部插槽换到背部插槽上了 或者从背部插槽换到手部插槽上 在Uneuipping的片段中剑应该彻底被放到背上的位置右键 - 添加通知 - 新建通知 命名为HandToSpine
现在打开ABP_RacingMiku 事件图表 里面已经有AttackEnd的动画通知了 在空白处右键 搜索handtospine 选择AnimNotify_HandToSpine 然后打开VS 我们要做的事情是 把剑从手上转移到背上

当时把武器从地上捡到手上的时候 我们做的事情是
OverlappingWeapon->Equip(GetMesh(), FName("RightHandSocket")); 碰到一个OverlappingWeapon 使用Equip函数 将它装备到手部插槽上 Equip函数是在Weapon类中 调用ItemMesh上AttachToComponent的功能 附着规则是指定为SnapToTarget 这样就实现附着了 之后再把ItemState切换为Equipped

// Weapon.cpp void AWeapon::Equip中
FAttachmentTransformRules TransformRules(EAttachmentRule::SnapToTarget, true);
ItemMesh->AttachToComponent(InParent, TransformRules, InSocketName);

真正核心的就是这两行 选中这两行 右键 - 快速操作和重构 - 提取函数 在弹窗中命名为AttachMeshToSocket 下面可以预览函数签名 void AWeapon::AttachMeshToSocket(USceneComponent * InParent, const FName &InSocketName); 看到它接收的参数是一个场景组件 一个插槽名字 其它选项都保持默认 点击确定 就可以发现从

// Weapon.cpp
void AWeapon::Equip(USceneComponent* InParent, FName InSocketName)
{
    FAttachmentTransformRules TransformRules(EAttachmentRule::SnapToTarget, true);
    ItemMesh->AttachToComponent(InParent, TransformRules, InSocketName);
    ItemState = EItemState::EIS_Equipped;
}

变成了

// Weapon.cpp
void AWeapon::Equip(USceneComponent* InParent, FName InSocketName)
{
    AttachMeshToSocket(InParent, InSocketName);
    ItemState = EItemState::EIS_Equipped;
}

void AWeapon::AttachMeshToSocket(USceneComponent* InParent, const FName& InSocketName)
{
    FAttachmentTransformRules TransformRules(EAttachmentRule::SnapToTarget, true);
    ItemMesh->AttachToComponent(InParent, TransformRules, InSocketName);
}

这就是单独提取出来变成了一个函数 其实一样的办法在我们当时写CanAttack那个bool的时候也可以用

现在我们就可以在其它地方复用这两行了 按下Ctrl+K Ctrl+O 打开Weapon.h 可以看到这个AttachMeshToSocket函数的声明是紧挨着在Equip函数的下面

在播放Unequipping时 蒙太奇会播放到HandToSpine通知 那么我们可以创建一个蓝图可调用的函数 将武器放到背后

// HelloWorldCharacter.h protected中
UFUNCTION(BlueprintCallable)
void HandToSpine();
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::HandToSpine()
{
    if (EquippedWeapon)
    {
        EquippedWeapon->AttachMeshToSocket(GetMesh(), FName("SpineSocket"));
    }
}

首先要拿到武器 检查是否为空 AttachMeshToSocket现在正是Weapon类的方法 所以可以被EquippedWeapon调用

回到ABP_RacingMiku动画蓝图 和AttackEnd一样 使用带有Is Valid的GET 从GET的Is Valid引脚往右拖 搜索HandToSpine 发现搜索不到 那么关闭UE 在VS Ctrl+F5
再从Is Valid引脚往右拖 搜索HandToSpine 然后把GET的HelloWorldCharacter引脚连接到AnimNotifyHandToSpine节点的Target引脚上

编译 进入PIE 非常成功地放到了背上 那么对于Equipping动画蒙太奇片段 也是一样的做法 添加一个名为SpineToHand的动画通知 在C++里写SpineToHand 在事件图表里设置

// HelloWorldCharacter.h protected中
UFUNCTION(BlueprintCallable)
void SpineToHand();
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SpineToHand()
{
    if (EquippedWeapon)
    {
        EquippedWeapon->AttachMeshToSocket(GetMesh(), FName("RightHandSocket"));
    }
}

编译 进入PIE 按E装卸很成功 但是一边走路一边按E 角色就开始在地面滑行了 现在我们希望在按下E时 角色就会自动开始停止走路

// CharacterTypes.h
UENUM(BlueprintType)
enum class EActionState : uint8
{
    EAS_Unoccupied UMETA(DisplayName = "Unoccupied"),
    EAS_Attacking UMETA(DisplayName = "Attacking"),
    EAS_Equipping UMETA(DisplayName = "Equipping")
};
// HelloWorldCharacter.cpp void AHelloWorldCharacter::EKeyPressed中
if (CanUnequipping())
{
    PlayEquipMontage(FName("Unequipping"));
    CharacterState = ECharacterState::ECS_Unequipped;
    ActionState = EActionState::EAS_EquippingWeapon; // 添加了一行
}
else if (CanEquipping())
{
    PlayEquipMontage(FName("Equipping"));
    CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
    ActionState = EActionState::EAS_EquippingWeapon; // 添加了一行
}

那么在AHelloWorldCharacter::MoveForwardAHelloWorldCharacter::MoveRight 它只检查if (ActionState == EActionState::EAS_Attacking) return; 只确保我们没有在攻击 所以这里需要改成

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::MoveForward(float Value)
{
    if (ActionState != EActionState::EAS_Unoccupied) return;
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::MoveRight(float Value)
{
    if (ActionState != EActionState::EAS_Unoccupied) return;

Unoccupied状态表示我们没有做任何会阻碍移动或者其它任何事情的事 很多事情都需要检查这个状态 非常有用 所以应该随时在做完事情之后 将其置为Unoccupied状态 状态在游戏中至关重要 因为它们能启用或禁用特定行为

那么在动画蒙太奇快要播放结束时 应该设置一个动画通知 用于置为Unoccupied状态 在动画蒙太奇里右键添加通知 - 新建通知 - 命名为TrunToOccupied 然后在另一个蒙太奇片段里也粘贴一个

// HelloWorldCharacter.h
UFUNCTION(BlueprintCallable)
void TrunToOccupied();
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::TrunToOccupied()
{
    ActionState = EActionState::EAS_Unoccupied;
}

Ctrl+F5编译 在动画蓝图事件图表里添加TrunToOccupied事件 继续都是一样的做法

播放声音 C++

拔剑和收剑的都直接在蒙太奇里做动画通知就可以 但是 从地面上捡起剑的音效 需要用C++写 捡武器是按E键 所以要写在AHelloWorldCharacter::EKeyPressed的 if 分支里 但是每一种武器都有不同的音效 所以要在Weapon类里面做 最好的播放声音的位置是AWeapon::Equip

MetaSound是从一个C++类派生的 MetaSound有一个父类是USoundWave sound cue也有同样的父类

// Weapon.h
class USoundBase;

// class HELLOWORLD_API AWeapon : public AItem 中
private:
    UPROPERTY(EditAnywhere, Category = "Weapon Properties")
    USoundBase* EquipSound;
// Weapon.cpp
#include "Sound/SoundBase.h"
#include "Kismet/GameplayStatics.h"

void AWeapon::Equip(USceneComponent* InParent, FName InSocketName)
{
    AttachMeshToSocket(InParent, InSocketName);
    ItemState = EItemState::EIS_Equipped;
    if (EquipSound)
    {
        UGameplayStatics::PlaySoundAtLocation(this, EquipSound, GetActorLocation());
    }
}

先检查EquipSound确实已经被设置了
PlaySoundAtLocation在某个位置播放声音 所以需要我们提供一个位置 这个PlaySoundAtLocation函数是静态函数 它属于GameplayStatics这个类 它是静态函数 所以不需要GameplayStatics实例来调用 直接调用静态函数就可以 因为直接调用静态函数而不是通过实例调用 所以静态函数需要知道我们所在的世界 它一般需要传入一个WorldContextObject类型 是我们世界的任何一个对象都可以 它就可以通过这个对象找到我们的世界 通常传入this就可以 第1个参数是WorldContextObject 类型为Object 所以这里传入this即可 第2个参数是USoundBase类型指针的名为Sound的参数 传入我们的EquipSound 第3个参数是FVector类型的名为Location的参数 直接使用GetActionLocation() 再后面的参数是音量 音高 开始时间 衰减设置 也就是这个声音随着我们离它越来越远会如何衰减 都有默认值 所以直接跳过 如果想设置函数的默认值 可以点击函数的名字也就是本例中的PlaySoundAtLocation 右键转到声明 也可以按Ctrl同时点击 看它传入的参数列表 就可以看到默认值

热重载 再打开BP_Weapon 右侧细节面板 搜索weapon 可以看到weapon properties这个类别 将Equip Sound设置为从地面拔剑的音效

对于前面防止武器在被装备时候重叠 仅仅设为nullptr是不够的 因为会发现在其它位置仍然触发从地面拔剑的音效 说明仍然会重叠 有一个更好的方式是一旦装备武器之后 就把武器改成NoCollision

// Item.h 将Sphere从private转移到protected
UPROPERTY(VisibleAnywhere)
USphereComponent* Sphere;

这样之后 Weapon类作为Item的子类 才能调用Sphere

// Weapon.cpp
#include "Components/SphereComponent.h"

void AWeapon::Equip(USceneComponent* InParent, FName InSocketName)
{
    AttachMeshToSocket(InParent, InSocketName);
    ItemState = EItemState::EIS_Equipped;
    if (EquipSound)
    {
        UGameplayStatics::PlaySoundAtLocation(this, EquipSound, GetActorLocation());
    }
    if (Sphere)
    {
        Sphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    }
}

编译 热重载 进入PIE Shift+F1找回鼠标 在右侧大纲面板找到BP_HelloWorldCharacter0 在下方细节面板搜索overlap 找到Overlapping Item OverlappingItem是我们之前在HelloWorldCharacter.h设定的VisibleInstanceOnly变量 所以在这个细节面板可见 蓝图里就不可见了 靠近剑后 OverlappingItem变为了BP_Weapon 一旦装备好剑之后 Overlapping Item就永远都是无

攻击

盒体追踪 Box Trace C++

为了命中目标 剑上应该有某种碰撞体积 box形状的是比较合适的

// Weapon.h private中
UPROPERTY(VisibleAnywhere, Category = "Weapon Properties")
UBoxComponent* WeaponBox;
// Weapon.h
class UBoxComponent;

这个box要在构造函数中就创建 但是Weapon.cpp中现在还没有构造函数 需要创建一个

// Weapon.h public中
public:
    AWeapon();
// Weapon.cpp
#include "Components/BoxComponent.h"

AWeapon::AWeapon()
{
    WeaponBox = CreateDefaultSubobject<UBoxComponent>(TEXT("WeaponBox"));
    WeaponBox->SetupAttachment(GetRootComponent());
}

仍然是创建默认子对象 绑定到根组件 热重载 打开BP_Weapon 在视口中选中Weapon Box可以看到是一个立方体 碰撞组件不要缩放 否则它会带着附着的所有其它场景组件一起缩放 所以在细节面板里 把变换 - 缩放 右侧的锁图标变成锁定 想要调整box的大小要在 细节面板 - 盒体范围那里调节 使其恰好包含整个剑刃部分

仍然是保持选中Weapon box 在事件图表右键 搜索on component begin overlap 就会得到一个On Component Begin Overlap(WeaponBox)节点 因为BoxComponent也是从PrimitiveComponent派生出来的 所以它自带这个BeginOverlap事件

想要在剑击中的位置呈现出一些效果比如血迹 就需要追踪
线性追踪是从起点到终点画一条虚拟的线 如果有物体处于起点和终点之间 画这条线 如果这个物体能被线追踪击中 就会生成命中结果FHitResult FHitResult类型是一个结构体 包含了大量信息 当我们触发命中事件事 这些信息就会被填充 对于线性追踪而言 我们就会得到一个OtherActor 一个撞击点 一个代表空间位置的FVector 这样就可以利用这些信息来生成血液粒子 或者如果是在射击子弹 就生成碰撞粒子或者其它效果 如果能够访问OtherActor 就能对其造成伤害
用形状追踪 和线性追踪类似 起点终点是由形状的 比如起点和终点都是球体 不用直线 而是从起点到终点扫过一个球体 ●■■■■■■■■■● 这样就是形成一个胶囊体 如果在起点和终点之间有物体 并且它的碰撞预设设置为能够被追踪击中 那么我们的球体追踪就会从起点出发 击中这个物体并产生命中结果 命中结果就包含了大量信息 比如一个OtherActor 一个撞击点ImpactPoint 一个代表空间位置的FVector也就是命中位置HitLocation 这个撞击点和命中位置是不一样的 因为球体是一个球形 它与其它物体相撞时 它是与那个物体的表面相切的 这个球体的半径越大 它的中心自然就距离那个其它物体的中心越远 HitLocation的意思就是相撞时 球体的中心 ImpactPoint的意思是它们相切的位置
也可以用其它形状追踪 比如box 如果有一个起点和一个终点 并且中间有物体存在 这个box扫过空间 刚好停在不与那个物体重叠的地方 当然也会得到一个HitResult 里面包含了各种信息 比如 OtherActor ImpactPoint HitLocation
扫描是判断我们看不见的扫描是否撞到东西的良好方式

我们的剑有一个重叠box 当我们挥剑时 可能会与物体重叠 但是box会继续穿过它 我们想知道box开始重叠的那个点在哪里 可以通过追踪来解决这个问题 从剑柄做起点 到剑尖做终点 沿着刀刃扫描一个box 直到它被阻挡block命中 在那个点就可以得到一个HitResult 从而得到ImpactPoint 有了这个撞击点 就可以在撞击点放一些特效比如血迹

70.png

打开BP_Weapon 现在我们想要在剑上做一个起点和一个终点 并且希望能控制它们 比如添加一个轻巧的组件 没有网格或者任何可视元素 只是有一个位置 就需要场景组件
在左侧组件面板点击添加 搜索并添加场景组件 重命名为Start 把它移动到剑的底部 不是剑柄的底部 而是剑刃的根部 再添加一个场景组件命名为End 它不能附着在Start上 应该附着在根组件ItemMesh上 将其移动到剑尖 这样我们就得到了两个不可见的场景组件

先用蓝图做
在事件图表右键 搜索box trace 可以看到有好几种盒体追踪 比如by channel by profile for objects 其中object指的是碰撞对象类型 channel指的是碰撞通道 现在就选择box trace by channel
得到这个节点 可以看到它默认的trace channel追踪通道是可见性通道 多数物体都会被设置为阻挡可见性通道 block visibility 如果对这个可见性通道进行追踪 就能击中任何我们能看到的东西 只要这个可见的东西被设置为阻挡可见性通道
Box Trace By Channel节点有Start和End引脚 把场景组件Start拖入视口 从Start引脚往右拖 搜索get world location 这样它就会返回一个向量类型 连接到Box Trace By Channel节点的Start引脚 End节点同理 连接到End引脚 那么现在就是指定了盒体追踪的起点和终点
half size引脚的意思是 盒体中心到每个轴的距离 有XYZ三个方向 就是用来设置盒子的大小的 现在暂时都指定为2.5 这个是可以可视化的 因为这个节点下面还有一个Draw Debug Type引脚 下拉菜单选择持久 就可以一直绘制debug 点击节点下方向下的三角箭头展开 就可以指定追踪颜色 命中颜色 绘制时间

我们的计划是 一旦武器盒子与其它物体重叠 就执行Box Trace By Channel 那么把On Component Begin Overlap(WeaponBox)节点的执行引脚与Box Trace By Channel节点的执行引脚连接 编译 PIE 拿起武器 挥剑就可以看到盒体追踪的效果 一直在空中残留的红色盒子 命中后就变为绿色

Box Trace By Channel节点有一个输出引脚是Out Hit 在C++中这是一个FHitResult类型 是一个包含很多关于命中的信息的结构体 从Out Hit引脚往右拖 选择Break Hit Result 在这个Break Hit Result节点下方三角箭头下拉菜单 就可以看到这个FHitResult的所有重要变量 Location是盒子碰撞时的中心点 Impact Point是碰撞点 可以在这里画一个调试球体 那么从Impact Point引脚往右拖搜索draw debug sphere 然后把Box Trace By Channel节点右侧执行引脚连接到Draw Debug Sphere左侧执行引脚上 半径Radius设置为25 持续时间Duration设置为5 线条颜色选择蓝色
编译 PIE 找到一个可以攻击的东西攻击 就可以看到碰撞点的位置出现了蓝色的调试球

往地图里拖入一个BP_HelloWorldCharacter 进入PIE 用我们能操纵的这个角色攻击它 发现扫描从没有碰到网格体 而是永远离它有一定距离 这是因为击中的是地图里这个HelloWorldCharacter的胶囊 所以要调整BP_Weapon的碰撞预设 不能调整角色蓝图的碰撞预设 因为它在正常工作 在左侧组件面板选中Weapon Box 在右侧细节面板找到碰撞预设 默认是OverlapAllDynamic 是与所有其它类型重叠 而我们的HelloCharacter自身的对象类型是Pawn 在那个关卡中的HelloWorldCharacter实例上 右侧细节面板 在这里调整就不会影响角色蓝图本身 而只针对于这个实例 选中网格体(CharacterMesh0) 碰撞预设改成Custom 对象类型设置成WorldDynamic 那么这个实例的对象类型就不再是Pawn 在稍微上方的碰撞一栏里 勾选生成重叠事件 这样就能触发我们写在BP_Weapon事件图表中的On Component Begin Overlap 一旦触发 我们就进行盒体追踪
进入PIE 攻击这个角色 有盒体追踪 但还是没有命中 没有出现蓝色调试球
关闭PIE 仍然是在细节面板里选中它的网格体 现在它的碰撞响应中 对于Visiblity是忽略 而我们的盒体追踪就是对可见性通道追踪的 那么既然它设置成忽略Visiblity 就不会被我们的盒体追踪命中 所以对于这个实例的Visiblity碰撞响应要设置成阻挡 这样盒体追踪才能命中
再PIE 攻击 在这个角色实例周围已经可以看到很多碰撞球
BP_Weapon事件图表 从Break Hit Result节点Hit Actor引脚往右拖 搜索get object name 再从Get Obejct Name节点右侧Retuen Value引脚往右拖 搜索print string 把Draw Debug Sphere右侧执行引脚连接到Print String左侧执行引脚 这样就可以把被武器击中的物体的名字打印出来

接下来用C++实现 这些碰撞预设也可以写在C++里 大概就是像下面被注释掉的部分这样写

// Weapon.cpp
AWeapon::AWeapon()
{
    WeaponBox = CreateDefaultSubobject<UBoxComponent>(TEXT("WeaponBox"));
    WeaponBox->SetupAttachment(GetRootComponent());
    // WeaponBox->SetCollisionEnabled(ECollisionEnabled::QueryOnly); // 仅查询 可以通过枚举类修改成 无碰撞 仅查询 查询和物理
    // WeaponBox->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Overlap); // 这样碰撞预设中对于所有通道的响应都是重叠
    // WeaponBox->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Ignore); // 做完所有通道 再修改单一通道 这样碰撞预设中对于Pawn通道的响应是忽略
}

我们之前做的事情是 把角色实例网格体碰撞预设换成Custom 对象类型从Pawn改成WorldDynamic 并且设置阻挡Visibility 勾选生成重叠事件

之前在BP_Weapon事件蓝图中的行为的执行引脚是从event overlap开始的 之前的重叠函数是Sphere重叠函数 现在要为box也做一个 现在对结束重叠不太关心 所以不需要做OverlapEnd了 需要绑定到动态多播委托 所以要声明为UFUNCTION() 而其实OnSphereOverlap的UFUNCTION是在Item类已经写了 所以Weapon类里重写之后就不需要写了

// Weapon.h protected中
UFUNCTION()
void OnBoxOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult);

在Weapon.cpp中创造OnBoxOverlapBegin的定义 现在还需要把这个OnBoxOverlapBegin函数绑定到BoxComponent盒组件的onComponentBeginOverlap BeginPlay是适合做这件事的地方 但是Weapon类里没有BeginPlay 查看其它类的BeginPlay 都是写在protected中 所以我们要在protected中重写它

// Weapon.h protected中
virtual void BeginPlay() override;
// Weapon.cpp
void AWeapon::BeginPlay()
{
    Super::BeginPlay();

    WeaponBox->OnComponentBeginOverlap.AddDynamic(this, &AWeapon::OnBoxOverlapBegin);
}

现在就已经绑定好了 那么在盒组件发生重叠事件时就会收到那个回调

现在来补全OnBoxOverlapBegin的函数实现 蓝图里我们做了Begin和End的场景组件 把这些和事件图表里的东西都删了

// Weapon.h
UPROPERTY(VisibleAnywhere)
USceneComponent* BoxTraceStart;

UPROPERTY(VisibleAnywhere)
USceneComponent* BoxTraceEnd;

现在要去构造函数初始化它们

// Weapon.cpp AWeapon::AWeapon中
BoxTraceStart = CreateDefaultSubobject<USceneComponent>(TEXT("Box Trace Start"));
BoxTraceStart->SetupAttachment(GetRootComponent());

BoxTraceEnd = CreateDefaultSubobject<USceneComponent>(TEXT("Box Trace End"));
BoxTraceEnd->SetupAttachment(GetRootComponent());

编译 PIE 就可以看到Start和End组件了 将它们移动到剑上的合适位置
接下来写一下OnBoxOverlapBegin的定义

// Weapon.cpp
void AWeapon::OnBoxOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    const FVector Start = BoxTraceStart->GetComponentLocation(); // 全局位置 不是本地位置 GetRelativeLocation是本地位置
    const FVector End = BoxTraceEnd->GetComponentLocation();
}

设置好了Begin和End 接下来应该Box Trace By Channel了
官方文档搜索Box Trace By Channel 这是一个蓝图API 不是C++ 但下面有一行Target is Kismet System Library 说明这个函数在Kismet系统库里是有的 Kismet系统库和GamePlayStatics库有些相像 这是一个静态函数库 和GamePlayStatics一样是U开头的类 在官方文档搜索UKismetSystemLibrary 当然这个UKismetSystemLibrary是非常magic 所以其实可以先搜索Kismet BoxTrace 搜索的结果里发现都是UKismetSystemLibrary类的方法 所以直接搜索这个UKismetSystemLibrary类 在这个类的页面内搜索BoxTrace 可以看到好几种 有BoxTraceMulti 旁边解说是Sweeps a box along the given line and returns all hits encountered 用一个box沿着给定的线扫描 返回所有碰撞 还有BoxTraceSingle 旁边解说是Sweeps a box along the given line and returns the first blocking hit encountered 是返回第一个碰撞 我们需要的是这个BoxTraceSingle 还有by profile和for objects的版本 profile配置文件就是碰撞预设里面的 也就是蓝图细节面板里碰撞预设那一栏里面设置的东西 Custom或者其它模式 BoxTraceSingle只对特定的碰撞通道进行扫描 可以使用Visibility这样的参数传进去
先找到头文件

// Weapon.cpp
#include "Kismet/KismetSystemLibrary.h"
// Weapon.cpp
void AWeapon::OnBoxOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    const FVector Start = BoxTraceStart->GetComponentLocation(); // 全局位置 不是本地位置 GetRelativeLocation是本地位置
    const FVector End = BoxTraceEnd->GetComponentLocation();

    TArray<AActor*> ActorsToIgnore;
    ActorsToIgnore.Add(this);

    FHitResult BoxHit;

    UKismetSystemLibrary::BoxTraceSingle(
        this, 
        Start, 
        End,
        FVector(5.f, 5.f, 5.f),
        BoxTraceStart->GetComponentRotation(),
        ETraceTypeQuery::TraceTypeQuery1,
        false,
        ActorsToIgnore,
        EDrawDebugTrace::ForDuration,
        BoxHit,
        true
    );
}

静态函数需要知道我们所在的世界 所以传入this 函数从第一个使用默认值的参数开始 后面所有参数也必须是默认值了 所以在函数定义里 也是要从那个参数开始 后面所有参数都要有默认值
第4个参数是HalfSize 需要传入一个FVector 需要在三个维度上指定half size
第5个参数是Orientation 这是一个方向 我们可以使用与盒子追踪起始相同的方向
第6个参数是ETraceTypeQuery类型的TraceChannel 这是一个枚举 所以直接输入ETraceTypeQuery:: 看VS给我们弹出什么选项 然而这些选项都是TraceTypeQuery一串数字 不能明白什么意思 所以对于ETraceTypeQuery右键 - 速览定义 注释里写Specifies custom collision trace types, overridable per game 意思是custom碰撞追踪类型可以在每个游戏中重写 枚举常量只是TraceTypeQuery1到32 所以如果我们想做custom TraceTypeQuery 自定义追踪类型查询 就可以在这里设置并传入对应我们自定义类型的数字 但我们没做custom 所以这里传入TraceTypeQuery1
第7个参数是名为bTraceComplex的bool值 我们的骨骼网格体是有一个物理资产的 如果这个bTraceComplex设置为false 就按胶囊或者物理资产做追踪 如果设置为true 就按实际上的网格体做追踪 明显更消耗性能 所以这里设置为false
第8个参数是const TArray类型的引用 名为ActorsToIgnore 所以我们需要创建一个TArray 在这里面添加我们希望这个盒体追踪忽略的Actor TArray<AActor*> ActorsToIgnore; 但是什么是TArray呢? 和C++ Array差不多 是一个容器 实际上是一个包含数组的模板类 这个类可以根据需要动态扩展数组的大小 可以像Vector那样动态扩容 这个容器可以存储我们在尖括号里指定的类型的多个值 因为这是一个模板类 现在这个ActorsToIgnore是一个TArray 而不是指向TArray的指针 所以调用函数时要使用.而不是-> Add函数接收一个Actor类型的指针 比如如果想要让盒体追踪忽略武器本身 这里就可以传入this ActorsToIgnore.Add(this); 这样就确保不会击中武器本身
第9个参数是EDrawDebugTrace类型的枚举 表示我们能看到盒体追踪生成的调试盒子 仍然是先输入EDrawDebugTrace:: 看VS给我们的选项 选择EDrawDebugTrace::ForDuration 这样就可以持续显示一段时间
第10个参数是FHitResult类型的引用 名为OutHit 在蓝图节点中这个OutHit是输出引脚 但在这里却变成了函数的输入参数 这是UE中一些函数的表现方式 它们会把FHitResult作为输入 填入这个名为OutHit的参数 这里是用引用传入的 所以就是为了能在函数里修改这个参数 所以也事先创建一个FHitResult类型的变量 FHitResult BoxHit; 传入之后 就可以使用BoxTraceSingle函数修改这个BoxHit 在BoxTraceSingle函数声明中 这个参数名为OutHit 意思是这个参数最终是要传出的 所以一个参数如果是使用引用传递 而且不是常量引用 这就是一个良好的指示 意思是它最后会是输出参数 如果是常量引用 这个函数就无法修改它 那就只是为了能同时接收左值右值罢了
第11个参数是名为bIgnoreSelf的bool值 设置为true 就是在忽略自己 这个的效果有点像把this添加到ActorsToIgnore中 所以看起来有点多余 但这是一个必需的参数
再后面的参数就都有默认值了 追踪颜色红色 命中时绿色 持续时间5s 这些都不用修改了

热重载 PIE 效果和在蓝图里做的一样 可以看到在绿色长方体附近有一个红色的点 这就是从FHitResult获取的impact point撞击点 也就是那个BoxHit传出来的值

现在来右键FHitResult 转到定义 查看一下这个数据结构 为了方便查看 此处把注释和UPROPERTY宏都删了

struct FHitResult
{
    GENERATED_BODY()

    int32 FaceIndex;
    float Time;
    float Distance;
    FVector_NetQuantize Location;
    FVector_NetQuantize ImpactPoint;
    FVector_NetQuantizeNormal Normal;
    FVector_NetQuantizeNormal ImpactNormal;
    FVector_NetQuantize TraceStart;
    FVector_NetQuantize TraceEnd;
// 后面还有一些参数 再之后就是一些方法

FVector_NetQuantizeFVector的子类 只是针对于网络复制进行了优化

现在还有一个问题 在没有意图攻击的时候 只是动作幅度使得剑碰到物体 也会触发碰撞 所以要先明确什么时候启动武器上盒子组件的重叠事件 所以可以在攻击动画中使用动画通知 在一段时间内启用碰撞 然后再关掉盒子组件的碰撞
打开攻击动画蒙太奇 只在挥剑的动作时间段启用碰撞 其余时间都关闭 新建一个通知轨道 命名为EnableCollision 在挥剑开始的位置上新建一个通知 命名为EnableBoxCollision 在挥剑结束的位置新建通知命名为DisableBoxCollision 为每一个攻击动画都打上

打开ABP_RacingMiku事件图表 添加AnimNotify_EnableBoxCollision和AnimNotify_DisableBoxCollision到其中
接下来到C++写蓝图可调用的函数来开关武器碰撞 先把UE关了 因为写这种函数之后 通过热重载基本上都加载不出来 必须Ctrl+F5编译

最开始的时候 我们希望我们的武器是无碰撞的 也就是说在Weapon类构造函数初始化那个WeaponBox时 就应该设置成无碰撞 而不是默认的仅查询

// Weapon.cpp
AWeapon::AWeapon()
{
    WeaponBox = CreateDefaultSubobject<UBoxComponent>(TEXT("WeaponBox"));
    WeaponBox->SetupAttachment(GetRootComponent());
    WeaponBox->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// 后面省略
// HelloWorldCharacter.h public中
UFUNCTION(BlueprintCallable)
void EnableWeaponCollision(ECollisionEnabled::Type CollisionEnabled);

UFUNCTION(BlueprintCallable)
void DisableWeaponCollision();

因为要在蓝图中调用 所以要写在public里 这个EnableWeaponCollision函数 打算将其设置成带有参数的 也就是可以传入ECollisionEnabled枚举类型 输入ECollisionEnabled::到VS之后 选项里前面都是枚举常量的全部选项 最后一行是Type 这样就代表一个类型 那么现在EnableWeaponCollision函数就需要传入这个枚举类型
但是不如直接写一个接受这个枚举类型的SetWeaponCollisionEnabled函数 这样就不需要什么Enable和Disable 把要设置的碰撞类型传入就可以了 先把刚才创建的EnableWeaponCollision和DisableWeaponCollision声明删了

// HelloWorldCharacter.h
UFUNCTION(BlueprintCallable)
void SetWeaponCollisionEnabled(ECollisionEnabled::Type CollisionEnabled);

那么这个函数就应该是用来设置WeaponBox的碰撞的 但是现在WeaponBox是private的 所以要先给它写一个public的Get

// Weapon.h
FORCEINLINE UBoxComponent* GetWeaponBox() const { return WeaponBox; }
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SetWeaponCollisionEnabled(ECollisionEnabled::Type CollisionEnabled)
{
    if (EquippedWeapon)
    {
        EquippedWeapon->GetWeaponBox()->SetCollisionEnabled(CollisionEnabled);
    }
}

现在EquippedWeapon上标红了 因为没有头文件 就不能使用UBoxComponent

// HelloWorldCharacter.cpp
#include "Components/BoxComponent.h"

但是现在这个GetWeaponBox返回的是一个指针 需要检查是否非空 先检查&&左边 如果为false就被截断了 不会再检查&&后面的部分 所以要修改成

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SetWeaponCollisionEnabled(ECollisionEnabled::Type CollisionEnabled)
{
    if (EquippedWeapon && EquippedWeapon->GetWeaponBox())
    {
        EquippedWeapon->GetWeaponBox()->SetCollisionEnabled(CollisionEnabled);
    }
}

Ctrl+F5 打开ABP_RacingMiku 复制一个Is Valid GET节点过来 连接到AnimNotify_EnableBoxCollision执行引脚后面 从HelloWorldCharacter引脚往右拖搜索set weapon collision enabled 它需要一个Collision Enabled输入 这也就是那个枚举类型 因为我们是连在启用盒子碰撞通知后面 所以这里要选择纯查询 AnimNotify_DisableBoxCollision执行引脚后面同理 但是要选择无碰撞

编译 PIE 现在不会再只要转个身剑就触发碰撞了

UE接口 Interface

那么剑要如何击中物体? 我们之前做的事情是 武器上的盒子组件会触发重叠事件 然后我们用盒子追踪来确定击中的确切位置 这个HitResult信息中包含了一个被击中的Actor 我们可以随时检查这个被击中的Actor 并对它做一些操作
最直接的方法应该是 把被我们击中的Actor进行cast转换为特定的类 然后调用一些这个类受击后会发生的一些行为的函数 比如怪物掉血 罐子破碎 但是这样剑就需要检查很多种不同的情况 并且为每种情况准备不同的函数 但其实在剑的类内部 不应该包含这些其它类的信息
这里应该使用回调 也就是说 剑并不在乎它击中了什么 它只知道它发生了击中 然后在每一个受击的类中 只知道针对于自己被击中了 去执行一些行为 就像Button类回调

那么这里就需要使用接口 比如我们可能创建一个类 这个类有自己的函数 但是它只在这个类里提供函数的声明 不提供函数的实现 其实这就是标准C++的接口 或者叫做纯虚函数 接口类里面只有没有被实现的函数 然后强制子类去实现这些函数 比如我们有一个怪物 它是APawn类 但它也可以从接口类IHitInterface继承 这样这个怪物就既是Pawn 也是HitInterface 那么它就继承了接口类中未被实现的函数 怪物类就可以为这个函数提供自己的实现 甚至这件事是强制的 必须要实现 那么其实就可以专门写一个GetHit类 这样怪物类就可以重写GetHit函数 在里面调用自己的函数 比如掉血和死亡的函数 这样剑不需要知道它到底击中了什么 剑只需要检查被剑击中的Actor是否实现了这个IHitInterface接口 然后就调用接口上的GetHit函数 由于被剑击中的类有它自己独特版本的GetHit函数 那么就可以根据受击发生一些行为 可以对任意多个类这样做 无论这个Actor是到底属于哪个类 都可以从IHitInterface派生并重写它自己的GetHit函数版本

所以我们接下来要做的事情是 挥剑 击中目标 检查被剑击中的对象所属的类是否实现了我们即将创建的接口 如果实现了 就调用GetHit函数 剑不需要知道那个被它击中的对象的类型究竟是什么 我们只是调用接口的函数 剩下的事情在那个被击中的类内部 它会自行处理

在内容侧滑菜单 - C++类 - HelloWorld - Public 空白处右键 - 新建C++类 在常见类一栏中往下翻到最后 找到Unreal接口 点击下一步 类型选择公共 将其放到???/HelloWorld/Source/HelloWorld/Public/Interfaces专门的文件夹里 类命名为HitInterface 点击创建类 这样VS中就会出现HitInterface头文件和cpp文件

打开HitInterface.h 可以看到class UHitInterface : public UInterface 那么这个UHitInterface类是继承自UInterface类 但是下面还有一行class HELLOWORLD_API IHitInterface 这里是有两个类 一个是UHitInterface 一个是IHitInterface
这与UE的反射系统有关 UHitInterface允许我们的接口参与反射 不需要做任何修改 IHitInterface是在其它类中要继承时使用的接口 在这里声明那些要被它的子类重写的函数 因为接口类只需要声明函数而不提供实现 所以HitInterface.cpp是空的

先写一个GetHit函数的声明 需要设置成纯虚函数 纯虚函数就是在它声明的类中 在本例中就是HitInterface类 无法实现的函数 需要写上= 0 这样VS就不会提示我们要创建这个函数的定义 写上= 0就意味着它必须在一个子类中实现 如果子类中不实现 这个子类就无法实例化 当然因为接口类只写了函数声明没写实现 接口类本身也不能实例化
因为C++中没有Interface关键字 所以接口类没有什么特殊 这仍然只是一个Class 只不过是我们通过标记为virtual并且写上了= 0 才使得接口类能够只写声明不写实现并且强迫子类实现函数

// HitInterface.h
class HELLOWORLD_API IHitInterface
{
    GENERATED_BODY()

public:
    virtual void GetHit() = 0;
};

敌人 Enemy类

现在基于Character类创建一个敌人类 在内容侧滑菜单 - C++类 - HelloWorld - Public 空白处右键 新建C++类 选择角色 点击下一步 类型选择公共 将其放到???/HelloWorld/Source/HelloWorld/Public/Enemy专门的文件夹里 类命名为Enemy 点击创建类 这样VS中就会出现Enemy.h和Enemy.cpp 先把注释都删了 然后把.h里的两个public内容合并

Enemy类继承自Character类 所以它自带一个Mesh 我们可以访问mesh 并且我们需要在mesh上设置一些属性 让它在我们挥剑时能够被我们的盒子追踪击中
首先要把它设置成WorldDynamic类型 就像之前放到关卡里的BP_Character实例一样

获取Mesh 修改它的碰撞对象类型 需要include头文件

// Enemy.cpp
#include "Components/SkeletalMeshComponent.h"
#include "Components/CapsuleComponent.h"

AEnemy::AEnemy()
{
     PrimaryActorTick.bCanEverTick = true;

    GetMesh()->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic); // 设置为WorldDynamic
    GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block); // 阻挡可见性通道 这样敌人就能被盒子追踪到
    GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore); // 相机通道响应设置为忽略
    GetMesh()->SetGenerateOverlapEvents(true); // 需要勾选生成重叠事件
    GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore); // 胶囊体也要忽略相机

}

VS 快捷键 Shift+Home选中整行 Alt+↑移到上一行

编译 在内容文件夹 - Blueprints 新建文件夹Enemy 进入文件夹 空白处右键 新建蓝图类 在所有类搜索框搜索enemy 选择 命名为BP_Enemy 双击打开它 可以看到它自带网格体 箭头组件

需要做一些敌人的资产了 做前后左右4个方向的受击资产 都命名为HitReact 在动画资产里选择启用根运动 做成动画蒙太奇 分成片段 分别为Back Front Left Right 再做一个物理资产

之后给BP_Enemy分配一个网格体 名为StrawberryMiku 调整其与胶囊体的相对位置 面朝方向应该是箭头指向 再给它指定一个动画资产 idle动画 拖入关卡
角色现在总踩在StrawberryMiku体积比较大的头发上 是control rig的问题 暂时不修复

接口

现在就要做让它对攻击做出反应 敌人需要知道知道自己被击中了哪里 从而做出针对不同方向受击的动作 继续来制作接口 我们易筋经在HitInterface.h里写了一个GetHit 现在需要让它的子类重写 那么就要让Enemy类成为HitInterface类的子类 一个类可以有多个父类

头文件里的头文件都要放在generated.h前面

// Enemy.h
#include "GameFramework/Character.h"
#include "Interfaces/HitInterface.h"
#include "Enemy.generated.h"

写了HitInterface类的头文件之后 才能继承这个类

// Enemy.h
class HELLOWORLD_API AEnemy : public ACharacter, public IHitInterface

现在Enemy类既继承了Character类 也继承了IHitInterface 现在必须重写GetHit了

// Enemy.h public中
virtual void GetHit() override;

现在我们希望在进行盒体追踪并且获得实际上的攻击事件之后 Weapon类中会得到撞击点ImpactPoint 我们就可以从武器类中获取被攻击的物体也就是OtherActor 检查这个被攻击的物体是否实现了这个接口 然后调用GetHit函数

现在希望GetHit能有一个输入参数 按常量引用传入 修改一下

// HitInterface.h public中
virtual void GetHit(const FVector& ImpactPoint) = 0;
// Enemy.h public中
virtual void GetHit(const FVector& ImpactPoint) override;

先画个调试球测试一下

// Enemy.cpp
#include "HelloWorld/DebugMacros.h"
// Enemy.cpp
void AEnemy::GetHit(const FVector& ImpactPoint)
{
    DRAW_SPHERE(ImpactPoint);
}

现在希望武器用盒体追踪击中某物时 某物就能调用这个函数 在Weapon.cpp OnBoxOverlapBegin中 最后传出的其实是BoxHit这个变量的值 它是一个FHitResult类型 使用GetActor()就能返回OtherActor 也就是被击中的物体 当然如果没击中 这里就是空指针 所以要先检查是否非空
那么之后如何检查这个被击中的物体是否实现了接口呢? 查官方文档 Unreal Interfaces 页面内搜索Determine Whether a Class Implements an Interface 它提供的第3个方法是

IReactToTriggerInterface* ReactingObject = Cast<IReactToTriggerInterface>(OriginalObject);

就是查看能不能转换到我们的IHitInterface接口 并且会得到一个指针 因为Enemy类是一个Character的同时 也必然是一个IHitInterface类型的对象 因此可以转换为IHitInterface

// Weapon.cpp
#include "Interfaces/HitInterface.h"
// Weapon.cpp
void AWeapon::OnBoxOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    const FVector Start = BoxTraceStart->GetComponentLocation();
    const FVector End = BoxTraceEnd->GetComponentLocation();

    TArray<AActor*> ActorsToIgnore;
    ActorsToIgnore.Add(this);

    FHitResult BoxHit;

    UKismetSystemLibrary::BoxTraceSingle(
        this, 
        Start, 
        End,
        FVector(5.f, 5.f, 5.f),
        BoxTraceStart->GetComponentRotation(),
        ETraceTypeQuery::TraceTypeQuery1,
        false,
        ActorsToIgnore,
        EDrawDebugTrace::ForDuration,
        BoxHit,
        true
    );

    // 下为新添加部分
    if (BoxHit.GetActor())
    {
        IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
        if (HitInterface)
        {
            HitInterface->GetHit(BoxHit.ImpactPoint);
        }
    }
}

如果命中的目标没有实现这个接口 HitInterface这个变量将是null 所以只需要检查HitInterface是否为空 不为空就调用这个目标的GetHit函数 所以实际上是 让被击中的对象自己表演出来一个被击中的行为 而不是剑直接让被击中的对象发生某种行为 剑只是告诉被击中的对象 你是时候该发生某种行为了 自己表演吧
BoxHit.GetActor()得到的只是一个指针 并且把任何一个被击中的对象的类型最后都Cast为了IHitInterface类型 所以武器从来都不知道它击中的是什么类型的对象 但是毫无疑问它击中的对象是有IHitInterface接口的 那么那个对象自己去执行GetHit函数发生一些行为就好了 所以它并不限定在Enemy类 所有拥有IHitInterface接口的对象 Weapon类都可以调用它的GetHit函数 当然也因为GetHit函数是public的

编译 PIE 击中时确实会在击中位置出现调试球 现在这个调试球对于显示撞击点还是太大了 持续时间也太长

// DebugMacros.h
#define DRAW_SPHERE(Location) if (GetWorld()) DrawDebugSphere(GetWorld(), Location, 25.f, 12, FColor::Red, true)
#define DRAW_SPHERE_COLOR(Location, Color) if (GetWorld()) DrawDebugSphere(GetWorld(), Location, 5.f, 12, Color, true) // 添加了一行

允许传入颜色 顺便设置成只有5秒

// Enemy.cpp
void AEnemy::GetHit(const FVector& ImpactPoint)
{
    DRAW_SPHERE_COLOR(ImpactPoint,FColor::Orange);
}

方向性受击动画

在BP_Enemy旁边 新建一个动画蓝图ABP_StrawberryMiku 那么就要把BP_Enemy改成播放动画蓝图ABP_StrawberryMiku

进入Enemy类添加一个HitReact蒙太奇变量 和HelloWorldCharacter类里写的差不多

// Enemy.h
class UAnimMontage;
// Enemy.h private中
/**
* Animation montages
*/
UPROPERTY(EditDefaultsOnly, Category = Montages)
UAnimMontage* HitReactMontage;
// Enemy.h protected中
void PlayHitReactMontage(const FName& SectionName);
// Enemy.cpp
void AEnemy::PlayHitReactMontage(const FName& SectionName)
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && HitReactMontage)
    {
        AnimInstance->Montage_Play(HitReactMontage);
        AnimInstance->Montage_JumpToSection(SectionName, HitReactMontage);
    }
}

那么接下来就在GetHit函数中播放这个蒙太奇 先播放一个固定动画测试一下
热重载 打开ABP_Enemy 右侧细节面板搜索montage 将Hit React Montage变量指定为我们做好的受击动画蒙太奇
在BP_StrawberryMiku 拖入一个idle动画 再从这个idle动画右侧引脚往右拖 搜索slot 选择Slot ‘DefaultSlot’ 这就是蒙太奇的默认插槽 再将Slot节点右侧引脚连接到Output Pose节点的Result引脚
PIE 敌人已经可以显示受击动画

现在就需要做不同方向的不同动画了
角色已经有箭头组件 那么就已经知道它面朝的方向 可以用点乘来做方向的判断 计算cos值 就知道当前撞击点与敌人网格体中心的连线 与敌人箭头组件方向之间的夹角 这个中心指的是XY平面的中心 因为只做4个方向的受击动画 所以不考虑Z轴
从中心指向撞击点的向量称为ToHit 也就是使用撞击点的坐标减去角色位置坐标 这两个坐标都是世界坐标 箭头组件的向量称为Forward 就可以计算它们之间夹角的cos值
\(cos\theta=\frac{ToHit\cdot Forward}{\vert\vert ToHit\vert\vert\ \vert\vert Forward\vert\vert}\)

71.jpg

从敌人自身看来 规定从敌人身体偏右侧击中形成的夹角为正 左侧为负 那么
-45°到45° 也就是cos值在 \((\frac{\sqrt{2}}{2},1)\) 之间 播放Front
45°到135° cos值在 \((\frac{\sqrt{2}}{2},-\frac{\sqrt{2}}{2})\) 之间 且sin值为正 播放Right
135°到-135° cos值在 \((-1,-\frac{\sqrt{2}}{2})\) 之间 播放Back
-135°到-45° cos值在 \((-\frac{\sqrt{2}}{2},\frac{\sqrt{2}}{2})\) 之间 且sin值为负 播放Left

现在看来只用点乘计算cos值还不够 点乘只能用来指示前后 与Forward之间的夹角只要大于90度 就会是负数 否则为正数 但是无论是从偏左侧击中形成的大于90度 还是从偏右侧击中形成的大于90度 体现在cos值上都是一样的 所以还必须要通过叉乘计算sin值 才能区分左右 而且这个sin值其实也只能指示左右 在敌人身体右侧就是sin值为正 在敌人身体左侧就是sin值为负 而且对于这个sin值的使用 我们只需要知道正负 并不需要知道具体的值 所以不需要通过叉乘结果除以模的乘积来计算sin值 不需要除以模的乘积 只需要计算叉乘结果的正负即可 当然这里最好的做法是直接把ToHit和Forward提前做成单位向量 这样只需要计算点乘叉乘 不需要除以模的乘积 另外叉乘的结果不像点乘一样是标量 叉乘的结果是矢量 所以还需要把这个矢量做一下仅仅识别它的正负
但是正负这种思维还是过于算术了 典型的标量思维 我们现在做的这4个方向识别 只在XY平面上做 而且我们用了敌人网格的右侧作为正向的角度 用Forward叉乘Hit 用右手定则 四指指向的方向应该是在180度之内 从Forward方向 转到Hit方向的过程 注意这个180度之内很重要 那么拇指指向Z轴正方向 就是叉乘为正 拇指指向Z轴负方向 就是叉乘为负 那么我们根本不需要考虑把什么矢量转换为正负 既然我们得到的是一个矢量 要么指向Z轴正方向 要么指向Z轴负方向 我们不妨直接判断这个矢量在Z轴上的正负 当然UE5是左手定则
那么我们需要做的事情就是 通过点乘计算出来cos值 并且计算出叉乘得到的矢量在Z轴上的正负 点乘和叉乘结合起来使用 共同指示前后左右

// Enemy.cpp
void AEnemy::GetHit(const FVector& ImpactPoint)
{
    DRAW_SPHERE_COLOR(ImpactPoint,FColor::Orange);

    const FVector Forward = GetActorForwardVector();
    const FVector ToHit = (ImpactPoint - GetActorLocation()).GetSafeNormal();
    const double CosTheta = FVector::DotProduct(Forward, ToHit);
    if (CosTheta > 0.707106781187)
    {
        PlayHitReactMontage(FName("Front"));
    }
    else if (CosTheta < -0.707106781187)
    {
        PlayHitReactMontage(FName("Back"));
    }
    else
    {
        const double SinTheta = FVector::CrossProduct(Forward, ToHit).Z;
        if (SinTheta > 0)
        {
            PlayHitReactMontage(FName("Right"));
        }
        else
        {
            PlayHitReactMontage(FName("Left"));
        }
    }
}

Forward是单位向量 ToHit也应该转换成单位向量 GetSafeNormal能够安全地对向量做归一化 解决了归一化过程中除以向量模为0的问题
DotProduct是FVector类的静态函数 它接收两个FVector作为输入 通过常量引用传递 DotProduct函数的返回值是一个标量
UE5是左手定则 所以Z为正就是撞击点在敌人右侧 Z为负就是在左侧

PIE 已经可以针对4个方向做出受击动画 但还有一个需要修复的问题就是 如果我们攻击太迅速 它有时候会卡在前几帧 所以我们希望每次攻击都只有1次命中

先把之前播放蒙太奇部分的内容放在单独的函数里 批量高亮 - 右键 - 快速操作和重构 - 提取函数 命名为DirectionalHitReact 选择低于当前函数 可以看到这是一个接收ImpactPoint为参数的函数 这样之后 GetHit函数就只需要写一句调用它

// Enemy.cpp
void AEnemy::GetHit(const FVector& ImpactPoint)
{
    DRAW_SPHERE_COLOR(ImpactPoint,FColor::Orange);

    DirectionalHitReact(ImpactPoint);
}

void AEnemy::DirectionalHitReact(const FVector& ImpactPoint)
{
    const FVector Forward = GetActorForwardVector();
    const FVector ToHit = (ImpactPoint - GetActorLocation()).GetSafeNormal();
    const double CosTheta = FVector::DotProduct(Forward, ToHit);
    if (CosTheta > 0.707106781187)
    {
        PlayHitReactMontage(FName("Front"));
    }
    else if (CosTheta < -0.707106781187)
    {
        PlayHitReactMontage(FName("Back"));
    }
    else
    {
        const double SinTheta = FVector::CrossProduct(Forward, ToHit).Z;
        if (SinTheta > 0)
        {
            PlayHitReactMontage(FName("Right"));
        }
        else
        {
            PlayHitReactMontage(FName("Left"));
        }
    }
}
// Enemy.h
void DirectionalHitReact(const FVector& ImpactPoint);

我们想做的事情是 在挥剑击中角色时 只调用GetHit一次
武器调用GetHit函数的场合只有在AWeapon::OnBoxOverlapBegin函数中 那么调用多次GetHit函数 就是因为盒子与敌人角色发生了多次重叠

// Weapon.cpp
void AWeapon::OnBoxOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    const FVector Start = BoxTraceStart->GetComponentLocation();
    const FVector End = BoxTraceEnd->GetComponentLocation();

    TArray<AActor*> ActorsToIgnore;
    ActorsToIgnore.Add(this);

    FHitResult BoxHit;

    UKismetSystemLibrary::BoxTraceSingle(
        this, 
        Start, 
        End,
        FVector(5.f, 5.f, 5.f),
        BoxTraceStart->GetComponentRotation(),
        ETraceTypeQuery::TraceTypeQuery1,
        false,
        ActorsToIgnore,
        EDrawDebugTrace::ForDuration,
        BoxHit,
        true
    );

    if (BoxHit.GetActor())
    {
        IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
        if (HitInterface)
        {
            HitInterface->GetHit(BoxHit.ImpactPoint);
        }
    }
}

鼠标悬停可以看到 BoxTraceSingle接收一个名为ActorsToIgnore的TArray<AActor*>类型的角色指针数组 现在这个数组里只有this 所以我们要做的事情是 一旦命中一个特定Actor 就把它添加到这个数组里 接下来就可以忽略那个Actor 那么如果OnBoxOverlapBegin再次被调用 就可以忽略已经命中的所有Actor
现在这个ActorsToIgnore变量是在OnBoxOverlapBegin函数里创建的 是局部变量 它的作用域只在OnBoxOverlapBegin函数中 生命期很短 我们需要让它持续存在 那么在Weapon.h中创建

// Weapon.h private中
TArray<AActor*> IgnoreActors;
// Weapon.cpp
void AWeapon::OnBoxOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    const FVector Start = BoxTraceStart->GetComponentLocation();
    const FVector End = BoxTraceEnd->GetComponentLocation();

    IgnoreActors.AddUnique(this); // 修改了

    FHitResult BoxHit;

    UKismetSystemLibrary::BoxTraceSingle(
        this, 
        Start, 
        End,
        FVector(5.f, 5.f, 5.f),
        BoxTraceStart->GetComponentRotation(),
        ETraceTypeQuery::TraceTypeQuery1,
        false,
        IgnoreActors,
        EDrawDebugTrace::ForDuration,
        BoxHit,
        true
    );

    if (BoxHit.GetActor())
    {
        IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
        if (HitInterface)
        {
            HitInterface->GetHit(BoxHit.ImpactPoint);
        }
        IgnoreActors.AddUnique(BoxHit.GetActor()); // 添加了一行
    }
}

AddUnique会检查这个列表中是否已经有我们向添加的Actor 如果已经有了 就不会再添加一次 这样就不会有两个指针指向同一个对象
现在这样之后 我们只要碰到一个角色 就不会再次对那个角色做BoxTrace了 但是这个IgnoreActors是需要重置的 在动画蒙太奇单个片段结束时就清空是最合适的 准确说是在片段中的盒子碰撞结束时 也就是AttackMontage中动画通知DisableBoxCollision之后 触发这个动画通知之后的行为是在ABP_RacingMiku的事件图表里写的 打开事件图表 现在是DisableBoxCollision动画通知发生时 就调用WeaponCollisionEnabled函数 传入的参数是无碰撞 右键这个节点 - 转到代码定义 就可以在C++里找到它

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SetWeaponCollisionEnabled(ECollisionEnabled::Type CollisionEnabled)
{
    if (EquippedWeapon && EquippedWeapon->GetWeaponBox())
    {
        EquippedWeapon->GetWeaponBox()->SetCollisionEnabled(CollisionEnabled);
    }
}

这个函数的语义也无非是 把WeaponBox设置成某种碰撞预设 这个函数只会在动画通知时被调用
那么只需要在这里清空IgnoreActors数组就可以了 调用这个函数的动画通知是在允许碰撞时有一次 停止允许碰撞时还会有一次 其实我们只需要在停止允许碰撞之后清空IgnoreActors数组 但是就算做两次清空也不会有什么影响 直接写在这里就不需要再做一个动画通知了
要把IgnoreActors改成放在Public 这样在HelloWorldCharacter类中才可以访问它

// Weapon.h public中
TArray<AActor*> IgnoreActors;
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SetWeaponCollisionEnabled(ECollisionEnabled::Type CollisionEnabled)
{
    if (EquippedWeapon && EquippedWeapon->GetWeaponBox())
    {
        EquippedWeapon->GetWeaponBox()->SetCollisionEnabled(CollisionEnabled);
        EquippedWeapon->IgnoreActors.Empty(); // 添加了一行
    }
}

关闭UE Ctrl+F5编译 PIE 已经可以攻击一次只触发一次碰撞了

接下来给敌人角色做一个状态机 希望它可以有跳舞动作 平时是idle idle持续10秒之后就开始跳舞
新建一个状态机 命名为IdleState 双击进入状态经济 从Entry执行引脚往右拖 点击添加状态 命名为Idle 再从Idle状态往右拖 点击添加状态 命名为Dance 双击进入Idle到Dance的转换规则 空白处右键搜索Current State Time 从这个节点的Return Value引脚往右拖 搜索> 选择greater 填入10 将greater右侧引脚连接到Result节点的Can Enter Transition引脚 意思讲就是超过10秒就发生状态转换 回到Idle State状态机 从Dance状态往左拖 连接到Idle状态 选中Dance到Idle的转换规则 在细节面板勾选基于状态中序列播放器的自动规则 这样Dacne的动画播完了 就会自动回到Idle状态 之后在动画蓝图中把原来的idle动画删掉 替换成Idle State状态机 连接到动画蒙太奇上

但是 dance动画是有音乐的 我们现在使用的方式是添加动画通知播放音效 但是这个播放音效是只负责播放的 并不负责在动画被打断时停止它 所以现在即使是播放受击动画的蒙太奇 也不会停止播放音乐 现在动画蓝图里蒙太奇动画的逻辑是覆盖在idle动画之上的 底层的Idle动画逻辑还在进行 但是声音组件已经生成并且独立存在了
现在我们就要解决这个问题 让声音能够被打断

需要声音组件UAudioComponent变量

// Enemy.h
class UAudioComponent;
class USoundBase;
// Enemy.h
private:
    /**
    * Play sounds
    */
    UPROPERTY(EditDefaultsOnly, Category = Audio)
    USoundBase*IdleSound; // 存放Meta Sound资产

    UPROPERTY(VisibleAnywhere, Category = Audio)
    UAudioComponent* IdleAudioComponent;

public:
    UAudioComponent* GetIdleAudioComponent() const { return IdleAudioComponent; }

先初始化一下声音组件

// Enemy.cpp
#include "Components/AudioComponent.h"
// Enemy.cpp AEnemy::AEnemy() 末尾添加
IdleAudioComponent = CreateDefaultSubobject<UAudioComponent>(TEXT("IdleAudioComponent"));
IdleAudioComponent->SetupAttachment(GetRootComponent()); // 声音跟随角色移动
IdleAudioComponent->bAutoActivate = false; // 默认不自动播放

热重载 在BP_Enemy左侧组件面板可以看到这个声音组件 选中它 在右侧细节面板 - 音效 指定它的资产

写一个播放声音的函数 稍后就用动画通知触发这个函数来播放音乐 而不是像之前那样用播放音效的动画通知来播放音乐

// Enemy.h public中
UFUNCTION(BlueprintCallable, Category = "Audio")
void PlayIdleMusic();
// Enemy.cpp
void AEnemy::PlayIdleMusic()
{
    if (IdleAudioComponent && IdleSound)
    {
        // 如果已经在播放 就不需要从头开始
        if (!IdleAudioComponent->IsPlaying())
        {
            IdleAudioComponent->Play();
        }
    }
}
// Enemy.cpp
void AEnemy::PlayHitReactMontage(const FName& SectionName)
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && HitReactMontage)
    {
        AnimInstance->Montage_Play(HitReactMontage);
        AnimInstance->Montage_JumpToSection(SectionName, HitReactMontage);

        // 添加了几行
        // 打断正在播放的Idle音乐
        if (IdleAudioComponent && IdleAudioComponent->IsPlaying())
        {
            IdleAudioComponent->FadeOut(0.2f, 0.0f);
        }
    }
}

在dance动画中 把原来播放音效动画通知的位置 替换新建的动画通知PlayIdleMusic 打开ABP_StarwberryMiku事件图表 右键搜索AnimNotify_PlayIdleMusic 可以看到事件图表里已经存在着一个Try Get Pawn Owner节点 之前在HelloWorldCharacter里 我们连接到动画通知之后的是get HelloWorldCharacter变量 但是那是在HelloWorldAnimInstance类里写的 这个Enemy类我们没有给它做AnimInstance 所以直接在动画蓝图事件图表用蓝图里写
这个Try Get Pawn Owner节点返回的值 不能直接用 所以需要一步类型转换 从Return Value引脚往右拖 搜索并选择cast to BP_Enemy
这个Owner我们总是要使用 所以最好还是将其提升为变量 在左侧我的蓝图面板 - 函数 右侧重载 下拉菜单中选择 蓝图初始化动画 这样就会得到一个Event Blueprint Initialize Animation节点 将它的执行引脚连接到Cast To BP_Enemy节点左侧执行引脚上 对Cast To BP_Enemy节点的As BP Enemy引脚右键 - 提升为变量 在左侧我的蓝图面板就会出现一个变量 将其重命名为Owner
接下来将Owner变量拖入事件图表中 选择获取 从Owner节点的引脚往右拖 搜索play idle music并选中 将AnimNotify_PlayIdleMusic执行引脚连接到Play Idle Music节点的执行引脚上

编译 已经可以通过攻击打断音乐
现在还有一个问题 跳舞会播放音乐 但是它必然要离角色远时声音更小 那么就需要制作声音衰减资产
内容侧滑菜单 在Sounds文件夹空白处右键 - 音频 - 音效衰减 命名为SA_Aria Aria为本例中使用的音乐资产名称 双击打开
声音衰减差不多就是有一个环绕声音源的不可见球体 这个球体有一个半径 内部半径这个参数决定了我们可以在多近的近距离内听到完整的声音 默认是400 意思是在400或者更近的地方 就能听到完全的音量 衰减距离默认3600 意思是到3600之外我们就听不到声音 默认衰减函数是线性 这里我们在点击下拉菜单设置为自然声音
接下来给这个音乐资产做一个Meta Sound 然后打开这个Meta Sound 左上角工具栏点击齿轮图标的 源 在左下角细节面板 衰减 - 衰减设置 选中我们做好的衰减设置资产
这样就成功了 最好给每个MetaSound都设置衰减资产 暂时都使用这同一个衰减资产就可以了

打碎网格体

先找到一些想要打碎的网格体 拖入关卡 在左上角将关卡从选择模式切换为破裂模式
创建一个文件夹 命名为Destructibles 我们将在这里存放被打碎后的网格体
在视口中选中要被打碎的网格体 最左侧面板点击生成一栏的新建 会有弹窗让我们选择路径 选择Destructibles文件夹 名字就保持默认 点击创建几何体集合 发现这个网格体看似材质发生了改变 最左侧面板 破裂一栏 有好几种打碎方法 选择统一模式 可以看到出现一个轮廓 上面是破裂的线条 切换到其它破裂模式 发现是不同的破裂方法 圆形模式是从某个中心点开始把网格体打碎 这个中心点是可以调整的 在这里我们选择统一模式即可 在稍右侧的破裂面板 可以看到 统一Voronoi一栏有最小和最大Voronoi点数可以调节 Voronoi是打碎算法的名字 调整这个点数可以获得更多碎片 最小和最大点数决定了碎片数量的随机范围 暂时都保持默认 选择下方的破裂
现在可以看到右侧破裂层级面板 GC_SM_XXX下面已经出现了很多SM_XXX 这是一个几何集合 都是碎片 选中单一的碎片 再点击一次破裂 这个碎片又会分为更小的碎片 这个GC_SM_XXX.GeometryCollectionComponent是一个几何集合组件 现在我们的静态网格体Actor被一个带有几何集合组件的新Actor代替了

这个可破坏的网格体是基于内部的损伤系统设计的 这种损伤是多种因素造成的 比如对这个网格体施加了特定的力量 那么一旦超过某个损伤阈值 这个网格体就会分裂

现在从破裂模式回到选择模式 选中这个网格体 在右侧细节面板勾选模拟物理 现在我们勾选启用重力 将这个网格体放在空中 进入PIE 它就会碎裂 如果很不容易碎裂 在系列面板搜索伤害阈值 把索引[0]的数值调小 就更容易碎裂了 但是现在它表面的材质是五颜六色 原来的材质消失了 选中这个网格体 在右侧细节面板搜索显示骨骼颜色 取消勾选 就会显示成原来的材质了

现在这个东西 我们走近就会打碎 这样是不行的 需要只给武器添加能够使其破碎的场系统
在Blueprints文件夹内 新建一个FieldSystem文件夹 双击进入文件夹 右键 - 新建蓝图类 搜索FieldSyestemActor 命名为BP_FieldSystem 双击进入 发现它只是一个有场系统组件的Actor 场系统组件可以生成场 影响游戏的物理效果
在事件图表 将这个场系统组件拖入 从Field System Component节点右侧引脚往右拖 搜索Add Transient Field 添加瞬态场 勾选Enable Field 只有勾选它才能启用场 所以我们将其勾选 Physics Type是一个枚举 下拉菜单可以选择 在这里我们选择外部张力 将Event BeginPlay节点的执行引脚连接到Add Transient Field节点的执行引脚上

但是想创造一个外部张力 还需要更多数据 它需要知道形变的位置 施加多大力 鼠标悬停在Add Transient Field节点的Field Node引脚上 这明显是一个需要传入的数据
对于外部张力 我们想要从某个位置开始施加一个带有径向衰减的力 意思就是当我们远离力的中心位置时 力会逐渐衰减 那么就需要一个径向衰减组件 左侧组件面板 - 添加 搜索radial falloff 也就是径向衰减 保持默认的名字RadialFallOff 将其拖入事件图表 从这个RadialFallOff节点右侧引脚往右拖 搜索Set Radial Falloff 选择场那一栏的Set Radial Falloff 这里就可以设置一些参数 Field Magnitude是球体衰减场的幅度 这就是我们要施加的力的大小 在这里现在默认是1 也就是最大值 就是这个隐形球体中心的力值 现在可破坏网格体的伤害阈值很高 查看那个我们创建的几何集合网格体就可以看到 伤害阈值是一个3个元素的数组组成的 默认值分别为500000 50000 5000 最大的这个值是50万 当然这些值可以修改 但我们不要动它 我们还是通过调整衰减场的参数来做
我们将Field Magnitude设置成1000000 100万 再下面的参数是Min Range Max Range 鼠标悬停在上面 显示在乘以幅度之前 0和1之间的初始函数将在MinRange和MaxRange之间缩放 最后再乘以场强度 如果希望每次径向衰减都很大 就可以设置为0.8 1 而不是原来的0 1 我们现在就将其设置成0.8 1 再下一个参数是Sphere Radius 这个半径决定了球体必须有多靠近才能受到力 暂时设置成200 再下一个参数Center Position是径向衰减的中心 可以直接用场系统的位置 右键搜索Get Actor Location 把Return Value引脚连接到Center Position引脚上 再下一个参数是Falloff Type衰减类型 鼠标分别悬浮在各个选项上可以看到 线性衰减是与距离成正比 反转衰减是1除以距离 平方衰减是与距离的平方成正比 但我们这里就保持为无 将Set Radial Falloff节点的Return Value引脚连接到Add Transient Field节点的Field Node引脚上 那么现在这个外部张力就有了径向衰减

现在把这个BP_SystemField拖入视口 可被破坏的网格体附近 现在进入PIE 这个网格体就被破坏了 但是现在网格体也就是被破坏了 没有飞起来 但是我们做剑攻击的时候 必然是要把它打飞才行的 所以需要让这些碎片获得更多的速度
再从Field Sysyem Component节点引脚往右拖 搜索Add Transient Field 把前一个Add Transient Field节点的右侧执行引脚连接到这个Add Transient Field节点的左侧执行引脚上 将这个新的Add Transient Field节点的Physics Type修改为线性力 这样如果我们施加了外部张力打碎了物体 它就又会获得一个线性力 这个线性力Transient Field节点也可以接收Field Node 但是它不是要径向衰减 而是要径向向量
在左侧组件面板 - 添加 搜索radial vector径向向量 拖入视口 从这个Radial Vector节点引脚往右拖 搜索set radial vector 仍然是将Get Actor Location传入Center Position 将Field Magnitude设置为15000000 1500万 将Set Radial Vector节点的Return Value引脚连接到Add Transient Field节点的Field Node引脚上

这个FieldSystem也会影响到布料 这个Add Transient Field节点是有一个Meta Data元数据输入引脚的 它的类型是场系统元数据对象引用 我们可以添加一个FieldSystem的元数据组件 左侧组件面板 - 添加 - Meta Data Filter 名字就默认 选中它 在右侧细节面板 场一栏 可以看到 状态类型 Object类型 位置类型 那么这个Meta Data Filter就可以作为过滤器 选择这些类型来过滤这个函数调用 这样它就只会影响我们选择的对象类型和状态类型的组件 这里将状态类型设置为动态 现在我们只对可破坏的对象有兴趣 所以Object类型设置为破坏 将这个FieldSystemMetaDataFilter拖入视口 连接到这两个Add Transient Field节点的Meta Data引脚上

但其实加上了这个线性力之后 反而破碎得不明显了 在视口中选中这个可破坏的网格体 在右侧细节面板中找到群集一栏的启用创建簇 其实就是聚类 启用这个聚类 就会让那些碎片聚集在一起 现在我们取消勾选它 可以看到碎片飞得很远 所以稍微降低一下那个线性力的数值 就设置为5000000 500万

接下来就给剑添加这个场 打开VS

// Weapon.h protected中
UFUNCTION()
void CreateFields(const FVector& FieldLocation);

因为想把场放在蓝图里做 所以设置为BlueprintImplementableEvent 蓝图可实现 意思是函数的具体实现必须要在蓝图里做

现在希望只要调用OnBoxOverlap 一旦追踪到并击中某个Actor 就调用CreateFields函数

// Weapon.cpp AWeapon::OnBoxOverlapBegin中
if (BoxHit.GetActor())
{
    IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
    if (HitInterface)
    {
        HitInterface->GetHit(BoxHit.ImpactPoint);
    }
    IgnoreActors.AddUnique(BoxHit.GetActor());

    CreateFields(BoxHit.ImpactPoint); // 添加了一行
}

这样就是给CreateFields函数传入了碰撞点的位置 那么现在就以碰撞点开始破碎

热重载 打开BP_Weapon 事件图表中右键 搜索Event CreateFields 现在它就有一个FieldLocation引脚 这是C++传递出来的
像之前做BP_FieldSystem一样做 左侧组件面板 - 添加 搜索FieldSystemComponent 添加一个场系统组件 还需要添加RadialFallOff RadialVector FieldSystemMetaDataFilter 将FieldSystemMetaDataFilter的obect类型设置为破坏 把之前BP_FieldSystem里的都复制过来 连接到Event CreateFields的执行引脚 把RadialFallOff RadialVector FieldSystemMetaDataFilter都换成我们刚才新建的 把那2个Get Actor Location节点删了 换成将Event CreateFields的Field Location引脚连接到Center Position引脚上

那么现在 这个可以被打碎的网格体实例 应该勾选生成重叠事件 否则剑没办法对其进行盒子追踪 然后打开这个可以被打碎的网格体资产 碰撞一栏有一个尺寸特定数据 - 索引 这是个数组 里面有碰撞形状 - 索引 这还是个数组 里面有碰撞类型 隐式类型 将隐式类型修改为胶囊体 这样就可以更容易获取击中事件
从最后一个Add TransientField右侧执行引脚往右拖 搜索draw debug sphere 把Event CreateFields节点的Field Location引脚连接到draw debug sphere节点的Center引脚上 挥剑 就可以看到确实有那个调试球
调试球不总是出现 即使调试球没出现的时候 也能把网格体打碎 这说明网格体本身有物理碰撞

// Weapon.cpp
void AWeapon::Equip(USceneComponent* InParent, FName InSocketName)
{
    AttachMeshToSocket(InParent, InSocketName);
    ItemState = EItemState::EIS_Equipped;
    if (EquipSound)
    {
        UGameplayStatics::PlaySoundAtLocation(this, EquipSound, GetActorLocation());
    }
    if (Sphere)
    {
        Sphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    }
    // 下面为新添加
    if (ItemMesh)
    {
        ItemMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    }
}

那么现在我们在武器上已经有这个用于打碎网格体的场了

BreakableActor类

接下来 为了播放打碎的音效和做一些其它在打碎同时发生的事情 需要专门写一个BreakableActor类 那么这个Actor要有一个几何集合组件 我们将创建一个新的Actor类 用它作为可破坏对象的基础类
内容侧滑菜单 - C++类 - HelloWorld - Public 空白处右键 - 新建C++类 选择Actor 下一步 保持公共 命名为BreakableActor 路径为???/HelloWorld/Source/HelloWorld/Public/Breakable 创建类 VS全部重新加载 把注释都删了 把public整理到一起

// BreakableActor.h protected中
UPROPERTY(VisibleAnywhere)
UGeometryCollectionComponent* GeometryCollection;
#include "GeometryCollection/GeometryCollectionComponent.h"

ABreakableActor::ABreakableActor()
{
    PrimaryActorTick.bCanEverTick = false;

    GeometryCollection = CreateDefaultSubobject<UGeometryCollectionComponent>(TEXT("GeometryCollection"));
}

这个BreakableActor不需要Tick 所以直接把bCanEverTick设置为False
查阅官方文档找到UGeometryCollectionComponent的头文件
在官方文档里的继承关系里可以看到 这个GeometryCollectionComponent也是一个USceneComponent场景组件 所以它也可以设置为根组件
接下来需要配置几何集合 需要正确设置碰撞预设 首先我们需要启用生成重叠事件 也就是SetGenerateOverlapEvents(true)
现在编译会报错 显示这个 UGeometryCollectionComponent是无法解析的外部符号 在解决方案资源管理器双击打开HelloWorld.Build.cs

// HelloWorld.Build.cs
public class HelloWorld : ModuleRules
{
    public HelloWorld(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
    
        PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GeometryCollectionEngine" }); // 修改了这行 添加了一个GeometryCollectionEngine

现在就编译成功了 所以如果报错出现no module 无法解析的外部符号 可以在Build.cs里包含它

在Blueprints文件夹内新建文件夹 命名为Breakable 双击进入 新建蓝图类 选择 命名为BP_BreakableActor 双击打开它 可以看到它的根组件就是一个几何集合组件Geometry Collection 选中它 在右侧细节面板 混沌物理一栏 有一个其他集 这里下拉菜单可以选择几何集合 再往下翻 碰撞一栏中 生成重叠事件已经勾选 在碰撞预设中忽略Camera 防止碎片阻挡相机 但是还是回到C++里做

// BreakableActor.cpp ABreakableActor::ABreakableActor 末尾添加
GeometryCollection->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore);

把这个BP_BreakableActor拖入视口 可以正常击碎 多摆几个密集一点就可以一口气击碎 因为场是有半径的

接下来我们就要它在被击中的时候做出一些行为 比如播放音效 掉落物品

// Weapon.cpp AWeapon::OnBoxOverlapBegin中
if (BoxHit.GetActor())
{
    IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
    if (HitInterface)
    {
        HitInterface->GetHit(BoxHit.ImpactPoint);
    }
    IgnoreActors.AddUnique(BoxHit.GetActor());

    CreateFields(BoxHit.ImpactPoint);
}

Weapon类中 一旦在OnBoxOverlapBegin函数中盒子追踪命中 就会调用GetHit函数 我们武器上的场也恰恰是在盒子追踪命中的时候才会施加的 所以如果被击中的实例的类上有HitInterface接口 必然是可以调用GetHit函数的 那么就可以通过让BreakableActor接入HitInterface接口 之后重写GetHit函数 来做一些被击中的时候会发生的行为 所以其实不只是Enemy类 所有能被我们使用盒子追踪击中的类 都可以通过重载GetHit来做一些被击中后发生的行为

// BreakableActor.h 放在.generated.h之前
#include "Interfaces/HitInterface.h"
// BreakableActor.h
class HELLOWORLD_API ABreakableActor : public AActor, public IHitInterface
// BreakableActor.h public中
virtual void GetHit(const FVector& ImpactPoint) override;

蓝图原生事件

接下来就应该去BreakableActor.cpp里实现这个GetHit函数 但是假如想在BP_Breakable蓝图事件图表里写Event GetHit发生时之后的行为 就像把CreateFields声明成BlueprintImplementableEvent 之后在BP_Weapon事件图表里写在Event CreateFields后面 是做不到的 因为GetHit不是蓝图可调用的函数 甚至都没有标注为UFUNCTION
一个理所当然的想法是 那么只在子类重载的版本里也就是BreakableActor类中才标注为BlueprintImplementableEvent蓝图可实现不就可以了 蓝图可实现要求一定要在蓝图里做函数实现 但如果只对BreakableActor类的重载版本要求蓝图可实现 也就不会影响Enemy类中用C++去实现
但是UE反射系统的要求是 UFUNCTON只能在父类或者说在它最早被声明的地方标注 如果在子类中写UFUNCTION 实际上是在试图修改这个函数的签名和调用机制 会无法通过编译

所以我们只能给HitInterface里的GetHit写UFUNCTION
为了实现这个目的 可以把这个函数变成Blueprint Native Event蓝图原生事件 蓝图原生事件使得我们既能用C++ 也能用蓝图 我们能够在C++里实现GetHit 毕竟Enemy类的GetHit就是C++实现的 但如果是成为蓝图可实现 它就必须在蓝图里实现了 用蓝图原生事件 就可以做到 C++提供一个默认实现 蓝图可以重写它 扩展一些功能 或者也可以在蓝图里直接调用父类实现

// HitInterface.h class HELLOWORLD_API IHitInterface public中
UFUNCTION(BlueprintNativeEvent)
void GetHit(const FVector& ImpactPoint);

标注成BlueprintNativeEvent之后 virtual和=0也不用写了 这都是标准C++的东西 但在UE中 这些都会自动处理
UE会为GetHit创建一个C++专用的版本 我们可以在C++里重载的这部分是virtual的 但是GetHit也可以在蓝图中实现
现在就要去Enemy类找到GetHit 发现有标红 因为现在GetHit不是虚函数了 但是我们将GetHit标注成了蓝图原生事件 我们可以重载实现 所以这里要改成GetHit_Implementation 这是在我们创建蓝图原生事件时内部生成的 这就是C++专用的版本 我们在C++中重载的也就只是C++专用的这部分也就是后缀Implementation的这个版本

// Enemy.h public中
virtual void GetHit_Implementation(const FVector& ImpactPoint) override;
// Enemy.cpp
void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)

Weapon类中调用GetHit函数的地方也要改 Execute_GetHit是这个蓝图原生事件自动生成的 在我们从C++调用这个蓝图原生事件时 我们实际上是用这个自动生成的Execute执行函数来执行它们的 确保了蓝图事件能够被触发
它接收两个参数 第1个参数是UObject 第2个参数才是ImpactPoint 也就是首先需要接收一个对象 之后才是GetHit函数原本的参数 它需要知道在哪个对象上执行这个事件 本例中要执行的对象就是被击中的对象BoxHit.GetActor()

// Weapon.cpp AWeapon::OnBoxOverlapBegin中
if (HitInterface)
{
    // 原来是
    // HitInterface->GetHit(BoxHit.ImpactPoint);
    // 修改为
    HitInterface->Execute_GetHit(BoxHit.GetActor(), BoxHit.ImpactPoint);
}

那么对于BreakableActor类也是一样

// BreakableActor.h public中
virtual void GetHit_Implementation(const FVector& ImpactPoint) override;
// BreakableActor.cpp
void ABreakableActor::GetHit_Implementation(const FVector& ImpactPoint)
{

}

Ctrl+F5编译 打开BP_Breakable事件图表 搜索Event Get Hit 这个节点右上角有一个蓝色的齿轮箭头图标 意思是接口 这是我们从HitInterface继承的接口函数 这个事件会在C++中调用GetHit时立即触发 这是其中的一个版本 那个Implementation后缀的是另一个版本 如果想调用那个版本 就对这个节点右键 - 将调用添加到父函数 那么会出现一个Parent: GetHit节点 这个版本才是C++的版本 那么将Event GetHit右侧执行引脚连接到Parent: GetHit节点左侧的执行引脚 将Event GetHit右侧Impact Point引脚连接到Parent: GetHit节点左侧的Impact Point引脚 这样C++版本就会被调用

现在我们要在这里为打碎的同时添加音效了 下载打碎的音效 制作一个Meta Sound资产

我们的C++功能在到达Parent: GetHit节点时就会被调用 在此之前可以播放声音 从Event GetHit执行引脚往右拖 搜索Play Sound at Location 就会出现一个Play Sound at Location节点在Event GetHit节点和Parent: GetHit节点的执行引脚之间 在Sound中选择刚才制作的资产 Location就传入Event GetHit节点的Impact Point
现在就可以播放声音
还是写在C++里比较好

现在还需要优化的一件事情是 希望打碎不久之后 碎片就可以消失 那么就是需要设置生命周期 为Actor设置生命周期 相当于给它设置了一个倒计时 计时结束后 Actor就自动销毁 从Play Sound at Location右侧执行引脚往右拖 搜索Set Life Span 在In Lifespan设置为3秒 那么一旦GetHit事件触发 生命周期就只有3秒
PIE 已经可以自动消失 但是有一些不是我们主动打碎的 也会随着身边的碎掉了被碰碎 所以我们应该设置为只要打碎了就设置生命周期 不管是不是因为GetHit事件触发产生的
在BP_BreakableActor左侧组件面板选中Geometry Collection 在右侧细节面板往下翻 找到事件一栏 有很多事件 找到混沌中断事件时 这个中译非常不精准 其实就是Chaos Break 点击加号 就会得到一个On Chaos Break Event(GeometryCollection)节点 这是在几何集合被打碎时触发的事件 所以那个Set Life Span节点应该连在On Chaos Break Event节点的执行引脚后面
注意要在右侧细节面板里 事件一栏 勾选通知中断Notify Breaks
PIE 这样就都会拥有生命周期 自动消失

宝藏拾取

先找到一些宝藏资产 捡起宝藏时 可以有个计数 比如一个金币计数 捡宝藏时也可以有一些声音效果 再找到一些声音资产

现在我们要基于Item类派生宝藏类
打开BP_Item 事件图表中Event Tick后面还存在着我们让这个Item在空中运动的函数 时至如今BP_Item实例仍然在我们的关卡中无人问津地旋转跳跃着
再打开Item.cpp Tick函数里更新了RunningTime运行时间 并且Item如果是一个空闲状态 它就旋转跳跃 这也就是剑在未被拾取时在空中的旋转
先把BP_Item事件图表里做旋转跳跃的部分断开 剑和那个Item实例仍然在旋转 那么我们不再需要连接在Event Tick后面的这些蓝图节点 删掉它们

Item是有Hovering和Equipped两种状态 都在ItemState枚举类里
Item类里也做了OnSphereOverlap重叠功能 这也就是拾取剑功能正在使用的那个 在武器类 我们并没有重载OnSphereOverlap 只是和Item类里面做的一样 根据正在重叠的状态 在HelloWorldCharacter类中将这个剑对象写入OverlappingItem变量 这样按下按键E 就可以通过OverlappingItem变量(Weapon类型)触发写在Weapon类里的Equip函数 将剑绑定到骨骼 并将剑从空闲状态切换为被装备的状态 将角色切换为持剑状态 将剑绑定到骨骼这件事 是Weapon类做的 不是HelloWorldCharacter类做的 这就再次充分体现C++的思想 Weapon负责主动把自己绑定到角色身上(顺便将自己的状态切换为已经被装备) 而不是在角色类里详尽地写角色如何夺取这个剑 角色类负责的就只是找到这个剑对象 然后调用在这个剑上的能够装备上它的接口 这个接口要由Weapon类来提供 每一个类都只负责管理好自己内部的变量 并为其它类提供操纵自己内部变量的函数接口
所以其实武器类中并没有对OnSphereOverlap重叠函数进行重载 只是利用了重叠函数获得的OverlappingItem变量 进行一些操作 又为剑和角色都做了状态切换
那么其实在Item类中 只要HelloWorldCharacter角色走到Item实例附近 角色胶囊体与Item的球形组件发生了重叠 就会触发球形组件的OnComponentBeginOverlap事件设置好的回调 也就是调用Item类的OnSphereOverlapBegin函数 (在调用OnSphereOverlapBegin函数之前 球形组件需要把它通过重叠事件也就是OnComponentBeginOverlap获得的各种变量比如OverlappedComponent OtherActor等 作为参数传给OnSphereOverlapBegin函数) 在执行Item类的OnSphereOverlapBegin函数时 这个Item对象就被写入了HelloWorldCharacter类的OverlappingItem变量 (在OnSphereOverlapBegin函数里能进行这样的写入 还是要感谢通过球形组件重叠事件传过来的OtherActor 这个OtherActor正是与Item实例发生重叠的角色 才能知道角色的信息) 这不是只有Weapon才有的功能 而是Item类及其子类都可以有的功能 当然其实Item类里写的OnSphereOverlap函数也就这唯一一个功能 也就是 把 与角色发生重叠的Item对象 传给 角色类内部的变量 进而存储起来 而且前提是使用了Super::OnSphereOverlapBegin 否则就没有了
角色永远都不需要知道它碰到的Item到底是个什么东西 角色最开始也不知道自己发生了重叠 是这个Item自己检测到自己被什么东西被重叠了 Item的球形组件自己负责检查自己周围有没有东西 守株待兔 角色是不负责检查的 Item类还写了一个函数 在被重叠的时候就调用(回调) 这个函数的内容是 如果和它(Item)发生重叠的东西是Character类型 它就自己主动把自己的指针传递给和它发生重叠的角色 角色把这个指针存入了自己的变量里 角色这才知道有个东西和自己发生重叠了 但是它也不会因此做出任何行为 直到接下来玩家按下E键 角色类就会对这个E键做出反应 角色尝试把那个与它发生重叠的东西转换为武器 如果转换成功 角色就调用Weapon类提供的Equip接口 把这个武器装备上 角色类不需要知道装备的细节 只需要提供角色类自己的mesh和插槽名字 剩下的事情Weapon类自己会处理 返回给角色类的就是装备好了的样子 如果角色没有与什么东西发生重叠 或者与角色发生重叠的东西不是武器 那么按下E键就不会发生任何事情

但是现在我们不需要做切换状态那么复杂的事情 毕竟只是在宝箱里捡东西 所以重载一下OnSphereOverlap重叠函数即可

在内容侧滑菜单 - C++类 找到Item类 对它右键 - 创建派生自Item的C++类 将其命名为Treasure 路径设置为???/HelloWorld/Source/HelloWorld/Public/Items/Treasures 点击创建 关闭UE 回VS Ctrl+F5

回到Item.h复制OnSphereOverlapBegin函数声明 并且标记为override 不需要复制UFUNCTION 我们不关心OnSphereOverlapEnd

// Treasure.h protected中
virtual void OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult) override;

接下来就做这个OnSphereOverlapBegin函数重载
Item类的OnSphereOverlapBegin所做的事情是 把这个Item传给角色 并存储在角色类的OverlapItem变量里
但是对于捡拾宝藏这个任务 实在没有必要进行存储 只要在重叠的时候自动捡起来就可以了 我们设想的状况是 被打碎的物体的掉落物 只要靠近 我们就能自动拾取
所以在这里不调用super

还是需要像Item类的OnSphereOverlapBegin函数一样 把OtherActor转换成HelloWorldCharacter 因为我们只希望在与玩家角色发生重叠时才做一些事情
现在我们就打算增加角色身上的金币数量 并且播放一些声音 最后捡完了之后 就应该把这个拾取物销毁 也就是在眼前消失

// Treasure.cpp
#include "Characters/HelloWorldCharacter.h"
// Treasure.cpp
void ATreasure::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    AHelloWorldCharacter* HelloWorldCharacter = Cast<AHelloWorldCharacter>(OtherActor);
    if (HelloWorldCharacter)
    {

    }
}

暂时我们先不考虑金币的问题 先播放一个音效 像在AWeapon::Equip函数里那样用PlaySoundLocation函数播放一个音效 那么就也应该像在Weapon.h中一样创建一个Sound变量

// Treasure.h Private中
UPROPERTY(EditAnywhere, Category = "Sounds")
USoundBase* PickupSound;
// Treasure.cpp
#include "Kismet/GameplayStatics.h"
// Treasure.cpp
void ATreasure::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    AHelloWorldCharacter* HelloWorldCharacter = Cast<AHelloWorldCharacter>(OtherActor);
    if (HelloWorldCharacter)
    {
        UGameplayStatics::PlaySoundAtLocation(this,
            PickupSound,
            GetActorLocation());
        
        Destroy(); // 在最后销毁
    }
}

编译 在Blueprints文件夹 创建一个Treasures文件夹 双击进入 创建一个BP_Treasure 选中BP_Treasure(自我) 在细节面板搜索PickupSound 为PickupSound变量选中声音资产

将其拖入视口 编译 PIE 现在只要走近这个BP_Treasure 它就会播放声音并且迅速消失

生成类

计算金币的问题暂时先不考虑 先做打碎网格体 掉落宝藏的功能

打开BP_Breakable事件图表 现在我们要接着在这个Event GetHit后面做一些事情 比如生成类 可以看到这里已经有了一个播放声音的节点 还是把这个播放声音的节点写在C++里 恢复Event GetHit节点和Parent: GetHit节点的直接相连

// Breakable.h private中
UPROPERTY(EditAnywhere, Category = "Sounds")
USoundBase* BreakSound;
// Breakable.cpp
void ABreakableActor::GetHit_Implementation(const FVector& ImpactPoint)
{
    UGameplayStatics::PlaySoundAtLocation(this,
        BreakSound,
        GetActorLocation());
}

在BP_BreakableActor.cpp里为BreakSound选中资产

只需要取得原本BreakableActor的位置 包括旋转 也就是需要取得Transform变换 并且在这个位置用Spawn Actor生成一个Treasure类型的对象 我们希望忽略碰撞也要生成 就在原地生成 不管会不会发生碰撞 为此我们要把Treasure类实例设置成无碰撞
选中BP_Treasure的Item Mesh组件 查看它的碰撞预设 现在它是BlockAllDynamic 我们需要在C++中将其设置为Custom 并且无碰撞 然后对除了相机以外所有类型的对象都阻挡
而且打碎后的碎片会影响我们的角色跑过去捡金币 这样就需要把打碎之后的碎片设置为忽略Pawn

先在官方文档找到SpawnActor的头文件 #include "Engine/World.h"
可以看到它有一个版本是SpawnActor(UClass *, FTransform const &, const FActorSpawnParameters &)

template<class T>  
T * SpawnActor  
(  
    UClass * Class,  
    FTransform const & Transform,  
    const FActorSpawnParameters & SpawnParameters  
)  

接收位置和旋转 还接收一个FActorSpawnParameters类型的常量引用 这个FActorSpawnParameters是传递给SpawnActor函数的可选参数的构造

在BreakableActor.cpp BreakableActor::GetHit_Implementation里面试着写
GetWorld()就能访问世界 输入到GetWorld()->SpawnActor()时 可以看到SpawnActor这个函数有7个重载 第2个重载版本是可以接收我们刚才查看过的传入位置 旋转 FActorSpawnParameters 可以看到位置和旋转参数都有默认值0 写着的是const FVector *Location=(const FVector *)0 这是对于全0的FVector进行的C风格的强制转换 如果不传入这两个参数 当然按照规则就也不能传入后面的FActorSpawnParameters参数 但是FActorSpawnParameters有默认构造函数 所以就是在位置0 0 0 旋转0 0 0 按照默认构造函数生成Actor 但是它的第1个参数是UClass类型的指针 这个SpawnActor函数是一个模板函数 那么就必须在尖括号中指定一个类型T比如ATreasure SpawnActor<ATreasure>() 这样就能生成一个T类型的Actor 本例中就是生成一个ATreasure 但是生成的是一个原始的C++类 不是一个蓝图 但是我们的Treasure类的蓝图BP_Treasure里面有一些额外的信息 比如指定了ItemMesh网格体的资产 原始C++类是没有这个网格体信息的 ItemMesh是在Item.cpp中写的构造函数里面创建的 但是这个组件的静态网格Static Mesh变量没有被初始化 我们在蓝图里选中了资产对其初始化 那么 采用这种方式生成ATreasure 就会生成一个C++对象 却不会设置它的网格 那么其实我们是想根据蓝图生成一个Actor 而不是根据C++类
这就是第2个重载版本中UClass类型的那个参数的作用

SpawnActor(UClass* Class, FVector Location, FRotator Rotation)
UClass是一个额外的类参数 有点像标准C++传给尖括号的类型名 但是UClass不仅能指定C++类 也能指定蓝图类
如果我们只是想要一个普通的C++类 那么能通过传入任何从UClass类派生的类 并且调用这个类继承的StaticClass函数来满足UClass的输入参数 那么对于本例中就是调用ATreasure类的ATreasure::StaticClass()函数 StaticClass函数会返回一个表示这个类的类型的UClass指针 这个指针是一种对于类型的表示方法 而不是指向某个对象的指针 StaticClass函数让我们得到一个UClass指针 它指向原始的C++类 就和在标准C++尖括号里写类型名差不多
但如果我们想指定一个蓝图类 要怎么做? 可以用UClass类型的变量来解决
我们在将要调用SpawnActor这个函数的类(只要是 想要调用 具有UClass作为参数 的函数 又想把这个UClass指定为蓝图类 都可以使用这种方法)里面创建一个UClass指针类型的变量 比如UClass* TreasureClass 然后用UPROPERTY标记它 比如UPROPERTY(EditAnywhere) 这样我们就能在细节面板中为这个UClass指针类型的变量指定一个类 可以选择成C++类 也可以是蓝图类 那么我们就在C++中有一个变量 代表我们在UE编辑器中创建的蓝图 它包含在蓝图上的所有数据 比如我们设置的静态网格体 之后把这个UClass类型的变量传给SpawnActor函数作为参数即可

// BreakableActor.h private中
UPROPERTY(EditAnywhere)
UClass* TreasureClass;

热重载 打开BP_Breakable 细节面板中找到Treasure Class 将其指定为BP_Treasure 通过这样的方式 就可以把BP_Treasure传给C++去使用

// BreakableActor.cpp
void ABreakableActor::GetHit_Implementation(const FVector& ImpactPoint)
{
    UGameplayStatics::PlaySoundAtLocation(this,
        BreakSound,
        GetActorLocation());

    UWorld* World = GetWorld();
    if (World && TreasureClass)
    {
        World->SpawnActor<ATreasure>(TreasureClass,
            GetActorLocation() + FVector(0.f, 0.f, 100.f),
            FRotator::ZeroRotator);
    }
}

用尖括号指定ATreasure 意思是我们要生成一个ATreasure 当然这就需要引入Treasure类的头文件 #include "Items/Treasures/Treasure.h"
位置在这个被打碎的对象Z轴抬升100 无旋转

编译 PIE 打碎网格体 已经可以生成金币

刚才在将Treasure Class指定为BP_Treasure时 备选项是非常多的 因为Treasure Class只是一个UClass类型 我们希望可以限定这里备选项中只出现Treasure及Treasure的子类
这里就需要使用TSubclassOf 这是一个包装器wrapper 包装器是一个结构体 通常是模板类型 专门用来包装指针 因为是模板所以各种包装器的名字基本都是T开头

// BreakableActor.h private中
UPROPERTY(EditAnywhere)
// 原来是
// UClass* TreasureClass;
// 修改为
TSubclassOf<ATreasure> TreasureClass;

改完之后还需要前向声明一下

// BreakableActor.h
class ATreasure;

也可以TSubclassOf<class ATreasure> TreasureClass; 直接在语句里前向声明

这样我们就有了一个UClass类型的变量 但是它被包装在了TSubclassOf指针里 只能从Treasure类及其子类中派生 我们的BP_Treasure蓝图也是从Treasure类中派生的
热重载 打开BP_BreakableActor 细节面板找到Treasure Class 发现现在只可以指定为None BP_Treasure Treasure这3种情况

所以当我们在C++中使用UClass变量时 使用TSubclassOf包装器是首选

接下来我们就做 一旦网格体被打碎 它就应该变成NoCollision 否则影响角色走过去捡金币 而且最好IK也可以在这些碎片上生效 那么我们期待的就是仅查询无碰撞

先让BreakableActor忽略Pawn 我们之前已经做了忽略相机 只需要在后面再添加一行

// BreakableActor.cpp ABreakableActor::ABreakableActor() 末尾添加
GeometryCollection->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Ignore);

但是这样设置之后 虽然打碎之后的碎片并不会阻挡角色走过 但是打碎之前也无碰撞了 角色会直接穿过要被打碎的网格体 虽然脚IK在正常使用

事实上此时在PIE中尝试攻击能被打碎的网格体时 报错蓝图无限循环 这时候需要添加一个bool值

// BreakableActor.h
bool bBroken = false;

默认是没有被打碎 一旦调用了GetHit函数 就设置其为真 但是如果此时已经被打碎了 就提前退出函数 防止反复执行GetHit_Implementation函数

// BreakableActor.cpp ABreakableActor::GetHit_Implementation 开头添加
if (bBroken) return;
bBroken = true;

现在我们的问题是 打碎后的结果 也就是不阻挡角色 而且可以正常脚IK 能让我们满意 我们不满意的是打碎之前没有任何碰撞 被角色直接穿过去了
胶囊体可以解决这个碰撞问题 我们可以用它阻挡角色

// BreakableActor.h protected中
UPROPERTY(VisibleAnywhere, BlueprintReadOnly)
class UCapsuleComponent* Capsule; // 直接前向声明了
// BreakableActor.cpp
#include "Components/CapsuleComponent.h"
// BreakableActor.cpp ABreakableActor::ABreakableActor() 末尾添加
Capsule = CreateDefaultSubobject<UCapsuleComponent>(TEXT("Capsule"));
Capsule->SetupAttachment(GetRootComponent());
Capsule->SetCollisionResponseToAllChannels(ECollisionResponse::ECR_Ignore);
Capsule->SetCollisionResponseToChannel(ECollisionChannel::ECC_Pawn, ECollisionResponse::ECR_Block);

设置为阻挡Pawn 忽略其它通道

打开BP_Breakable 可以看到一个Capsule组件 移动它的位置
这个Capsule组件只是为了阻挡我们的角色 它不会对重叠事件做出反应 也不会对盒子追踪之类的东西做出反应 只是一个简单的阻挡体积

在视口中打开对于碰撞的显示 可以看到网格体被打碎后 胶囊体还在那里 还是阻挡着角色 所以需要在被破坏之后禁用胶囊体的阻挡 进入BP_Breakable的事件图表 拖入Capsule 从它右侧引脚往右拖 搜索set collision response to channel Channel选择为Pawn New Response选择为忽略 然后把Set Life Span节点右侧执行引脚连接到Set Collision Response to Channel执行引脚上

去攻击那个网格体 发现无论如何都无法打碎 从在构造函数里添加GeometryCollection忽略Pawn之后就再也无法打碎 明明已经播放了打碎的音效 也出现了金币 设置在武器上的场系统执行引脚之后的调试球可以正常出现 BoxTrace调试显示ImpactPoint也可以正常出现在网格体上 但是被打碎了的视觉效果并没有出现 重新拖入一个BP_Breakable实例到视口里也没能解决问题 时间原因暂时不修复了

多种宝藏 多种可破坏物

只会生成金币是很单调的 我们需要随机生成多种宝藏 也有可能什么都不生成
在创建不同类型的宝藏之前 需要给每个宝藏设定一个价值 我们将制作一个金币计数器 用于记录玩家收集的金币数量 每一个宝藏都值一定数量的金币 这样我们得到不同类型的宝藏时

那么就需要添加一个宝藏价值的变量

// Treasure.h private中
UPROPERTY(EditAnywhere, Category = "Treasure Properties")
int32 Price;

暴露给蓝图 这样对于每个宝藏实例 都可以设置成不同的价值
现在已经有了宝藏的价值 每次宝藏被捡起 我们就用它来更新HUD元素 显示屏幕上的黄金总量 暂时我们还没实现这个功能

热重载 打开BP_Treasure 右侧细节面板将Price设置为100 这就是当前BP_Treasure中这个网格体的价值
现在这个BP_Treasure的父类是Treasure类 那么我们就可以基于这个蓝图类创建新蓝图 所以强烈不建议在这个BP_Treasure中做一些额外的变换 比如缩放和旋转 否则将无法适用于多种网格体的表现效果 包括当时在Breakable.cpp中写SpawnActor时 也最好不要添加旋转 我们之前做的就是0旋转 位置Z轴稍微抬升了 不要再做更多多余的事情
现在就把BP_Treasure重命名为BP_Base_Treasure 对其右键 - 创建子蓝图类 命名为BP_Gold 表示网格体为金币的宝藏 双击打开它 可以看到右上角它的父类是BP_Base_Treasure 打开它的事件图表 可以看到它是调用了父版本的BeginPlay ActorBeginOverlap Tick
为了视觉表现的效果 现在我们可以在蓝图里对其进行缩放和旋转了了 在事件图表里为其添加旋转 从Event BeginPlay执行引脚往右拖 右键搜索Add Actor World Transform (在这里不能使用Set Actor Transform节点 因为它会覆盖掉之前我们对Item类写的所有变换 而是将要采取在Set Actor Transform节点设置的新变换 那样之后我们在Tick函数里写的旋转跳跃就会消失) 接下来再把Add Actor World Transform右侧执行引脚连接到Parent: BeginPlay左侧执行引脚上 对Add Actor World Transform节点的Delta Transform右键 - 分割结构体引脚 将Delta Transform Rotation设置为-90 0 0 这样就可以设置其旋转 选中ItemMesh 在右侧细节面板设置缩放为10 10 10
现在将BP_Gold拖入视口 现在它放大了10倍但还没有旋转 但是PIE开始之后 它就会如我们所愿地发生一个旋转角 但是却变回了原来的大小 在Add Actor World Transform的Delta Transform Scale引脚设置成10 10 10 细节面板缩放恢复成1 1 1 拖入关卡之后 连放大10倍的效果也没有了 PIE之后也没有发生缩放
那么到底如何单独处理BP_Gold的缩放?
之所以如此执着于在蓝图事件图表里解决这个问题 而不是在拖入视口之后在细节面板里解决 是因为我们接下来要使用C++在SpawnActor来生成这个BP_Gold 我们希望它自带的缩放旋转缩放就是匹配于我们的场景的 为了C++的代码具有统一性 我们不希望把位置旋转缩放问题放到C++里解决 而是放到蓝图里解决
在C++中使用SpawnActor时 我们将要传入BP_Gold作为类 同时我们指定了Rotation和Location 默认指定的旋转是 1 1 1 我们不希望把这个旋转缩放的问题遗留到传给SpawnActor的参数中解决 我们当时只做了一个Location在Z轴上移100 这是因为我们对所有宝藏都想这样处理才这样写的
那么我们在使用SpawnActor生成时 传入的位置旋转缩放就会强行覆盖掉BP_Gold类原本的设置 我们不能通过SpawnActor设置缩放所以默认是1 1 1 覆盖掉了我们原本的设置 因为在BP_Gold设置的默认变换是默认值 SpawnActor传入的是实例值

现在的解决方案是 在Add Actor World Transform节点设置旋转为-90 0 0 缩放保持为1 1 1 在这个节点之后 执行引脚往右拖搜索 Set Actor Scale 3D 设置为10 10 10 最后再将右侧执行引脚连接到Parent: BeginPlay 这样就可以覆盖掉之前做的所有缩放
无论如何 现在我们能生成一个令人满意的视觉效果的金币了

我们现在还有一个更优雅的方式 也就是使用事件图表选项卡旁边的构造脚本 构造脚本不会在游戏中被调用 而是在游戏开始之前被调用 就像我们在细节面板设置的各种参数 也是在触发构造脚本的时候完成改动的 那么我们就把刚才的那两个节点 Add Actor World Transform和Set Actor Scale 3D 转移到构造脚本图表里 放在Construction Script节点和Parent:Construction Script节点之间 将BP_Gold拖入视口 可以看到瞬间它的大小是原来的10倍 因为我们设置缩放使用的是set 但是它总是等一会才会到达我们想要的那个旋转 因为旋转我们用的是add 而针对于生成金币的场合 表现得很完美 这样我们就不需要到事件图表的BeginPlay里再做这件事 更简洁

再创建一个BP_Silver 资产选中银币 同样在时间图表设置成合理的旋转和缩放 将它的价值设置为50 再做一个BP_GoldBar 资产选中金条 价值设置为500

暂时就先做这么几个资产 接下来我们要随机生成这些宝藏的一种 使用TArray类型 它可以动态扩容 我们可以往TArray里添加元素 并且TArray能保存指针
回到BreakbaleActor类 我们是通过TSubclassOf<ATreasure>类型的TreasureClass变量 暴露给蓝图之后 在BP_Breakable里去指定想要生成的BP类 但是现在就需要把TreasureClass指定为TSubclassOf<ATreasure>类型数组中随机的一个 那么应该怎么做呢?

// BreakableActor.h private中
UPROPERTY(EditAnywhere)
TArray<TSubclassOf<ATreasure>> TreasureClasses;
// 删掉原来的
// TSubclassOf<ATreasure> TreasureClass;

热重载 在BP_Breakable细节面板 可以看到Treasure Classes变量是一个数组类型 把数组元素添加成我们做好的BP类 按照价格进行排序 最值钱的放在最后

// BreakableActor.cpp ABreakableActor::GetHit_Implementation中
UWorld* World = GetWorld();
if (World && TreasureClasses.Num() > 0)
{
    int32 Selection = FMath::RandRange(0, TreasureClasses.Num() - 1);

    World->SpawnActor<ATreasure>(TreasureClasses[Selection],
        GetActorLocation() + FVector(0.f, 0.f, 100.f),
        FRotator::ZeroRotator);
}

这样就可以随机生成

现在被碰碎的网格体不会有掉落物 这里是需要依靠On Chaos Break Event委托实现 碰碎了之后就开始执行某个函数

// BreakableActor.cpp ABreakableActor::BeginPlay中
GeometryCollection->OnChaosBreakEvent.AddDynamic(this, & // 试着写到这里

对OnChaosBreakEvent右键 - 速览定义 可以看到FOnChaosBreakEvent OnChaosBreakEvent; 那么它是一个FOnChaosBreakEvent类型 对FOnChaosBreakEvent右键 - 速览定义 DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnChaosBreakEvent, const FChaosBreakEvent&, BreakEvent); 这个函数签名宏的查看方式是 最前面的是这个新的类型的的名字 之后从第2个开始是这个新的类型的组成方式 前面的是变量类型 后面的是变量名 我们就可以在C++创建一个函数 接收FChaosBreakEvent常量引用类型的 名为BreakEvent作为参数的函数 之后就可以使用AddDynamic绑定到委托 注意要把那个函数声明为UFUNCTION这样才能使用AddDynamic

// BreakableActor.h
UFUNCTION()
void OnChaosBreak(const FChaosBreakEvent& BreakEvent);
// BreakableActor.cpp
void ABreakableActor::OnChaosBreak(const FChaosBreakEvent& BreakEvent)
{
    GetHit_Implementation(BreakEvent.Location);
}
// BreakableActor.cpp
void ABreakableActor::BeginPlay()
{
    Super::BeginPlay();

    GeometryCollection->OnChaosBreakEvent.AddDynamic(this, &ABreakableActor::OnChaosBreak);
}

注意一定要确保 BP_Breakable勾选了通知中断Notify Break
热重载 PIE 现在即使是碰碎的也会有掉落物

接下来制作多种可打碎网格体 先在破裂模式里把网格体做成可破坏的 然后以BP_Breakable(现重命名为BP_Base_Breakable)为基类创建新的蓝图类 把网格体替换为新的网格体 再在关卡里 选中原本的网格体 可以批量选中 在大纲面板里右键 - 替换选中的Actor 替换成刚刚制作的BP 这样在关卡里外观不会发生任何变化 但它已经变成了一个BP实例

现在 为可破碎网格体和剑添加粒子效果 否则在地图里太难找了
官方文档中找到UNiagaraComponent的头文件

// Item.cpp
#include "NiagaraComponent.h"

但是标红了 到这种时候就应该去Build.cs里添加module 和之前做GeometryCollectionEngine一样

// HelloWorld.Build.cs
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GeometryCollectionEngine", "Niagara" });

这之后如果头文件还报错 就要在VS先生成一下 之后再添加头文件 如果还不成功 就关掉VS UE 打开项目文件夹 删除Saved Intermediate DerivedDataCache Binaries文件夹 删除.sln 对.uproject右键 - Generate Visual Studio project files

// Item.h protected中
UPROPERTY(EditAnywhere)
class UNiagaraComponent* EmbersEffect;
// Item.cpp AItem::AItem 末尾添加
EmbersEffect = CreateDefaultSubobject<UNiagaraComponent>(TEXT("Embers"));
EmbersEffect->SetupAttachment(GetRootComponent());

先给宝藏设置闪光 打开BP_Base_Treasure 组件面板选中Embers 在右侧细节面板选择Niagara资产即可

接下来设置武器的闪光 但是希望在拿到手里之后 就停止闪光 那么就写在Equip函数里 一旦我们装备武器 就将看不到闪光了

// Weapon.cpp
#include "NiagaraComponent.h"
// Weapon.cpp AWeapon::Equip 末尾添加
if (EmbersEffect)
{
    EmbersEffect->DeactivateImmediate();
}

使用DeactivateImmediate而不是Deactivate 因为这样消失得更快 不仅停止生成新粒子 还会立即销毁所有当前存在的粒子

Actor组件

我们的Character类有一个网格体 是UStaticMeshComponent 还有一个机械臂 是USpringArmComponent 还有摄像机UCameraComponent 总之我们能向任何Actor添加新的组件
但是 有些时候 我们希望可以拥有自己的自定义组件类型

要创建一个自定义组件类 我们可以从UActorComponent派生 UActorComponent是一个可以添加到Actor上的组件 拥有自己的BeginPlay和TickComponent函数 在UActorComponent中是称为TickComponent而不是Tick 我们也可以拥有自己的自定义函数和变量

那么 为什么要创建自己的自定义组件
我们就可以将某些功能封装到它自己的类中 这样就不会存在所谓的GodClass 也就是那种 每个变量和函数都存在于同一个类中的类 这样不是良好的编程实践 因为类只应该处理它们该做的事情

现在我们制作一个属性组件AttributeComponent 属性通常就是游戏中的统计数据
那么 除了BeginPlay和TickComponent 我们现在还应该为它添加什么变量和函数呢?
发生战斗 必然就要有血量Health 而且血量要在屏幕上显示出来 血量确实应该存储在角色属性组件里
还需要有一个金币计数 在屏幕上显示现在的金币计数
角色还需要经验值xp 在游戏里做一些事情就会得到经验点数
如果把这些都放进组件里 这个组件就能加到任何Actor身上 比如我们的角色 或者敌人

内容侧滑菜单 - C++类 - HelloWorld - Public 空白处右键 - 新建C++类 往下翻选择Actor组件 下一步 保持公共 命名为AttributeComponent 路径设置为???/HelloWorld/Source/HelloWorld/Public/Components

在AttributeComponent.h中可以看到#include "Components/ActorComponent.h" 这是引擎的Components文件夹 不是我们创建的那个Components文件夹 不要搞混
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override; 这个TickComponent函数不像Tick函数只有DeltaTime一个参数 还有一个TickType 一个ThisTickFunction 这个FActorComponentTickFunction是结构体 只要组件在运行 我们每一帧都会收到这个参数
构造函数的实现里有PrimaryComponentTick Character类里也是差不多 但是Character类里的是PrimaryActorTick 这个PrimaryComponentTick.bCanEverTick = true; bool值就可以启用或者禁用组件的运行

就从创建Health变量开始 有健康值时也应该有一个最大健康值 这样就能计算出百分比 我们希望做出一个健康条 显示健康值为完整健康的百分比或分数

// AttributeComponent.h private中
// Current health of the actor
UPROPERTY(EditAnywhere, Category = "Actor Attributes")
float Health;

UPROPERTY(EditAnywhere, Category = "Actor Attributes")
float MaxHealth;

先把这个组件放在Enemy类里 因为我们要添加一个新组件

// Enemy.h
class UAttributeComponent;
// Enemy.h private中
UPROPERTY(VisibleAnywhere)
UAttributeComponent* Attributes;
// Enemy.cpp
#include "Components/AttributeComponent.h"
// Enemy.cpp AEnemy::AEnemy 末尾添加
Attributes = CreateDefaultSubobject<UAttributeComponent>(TEXT("Attributes"));

它不需要再做SetupAttachment 它没有网格体 也没有位置或者类似的东西 只是一个组件
Ctrl+F5编译 打开BP_Enemy 现在确实有了一个Attributes组件 也没有绑定到根组件上 选中它 在右侧细节面板 就可以看到目前的属性 目前只有Health和Max Health 暂时都设置为100

HUD Widget组件

接下来希望生命值条能显示在敌人上方
在Blueprints文件夹内新建文件夹 命名为HUD 意思是头顶显示Heads Up Display
进入文件夹 右键 - 用户界面 - 控件蓝图 控件就是Widget 可以显示在屏幕上 会出现一个弹窗 它的基类就是这个用户控件User Widget 是一个驱动控件蓝图的C++类 当然也可以点击 所有类 展开 选择其它类作为父类 但现在我们只要用用户控件的父类即可 命名为WBP_HealthBar WBP就是控件蓝图 创建后双击打开

现在这个视口里的是设计器 可以看到右上方的 设计器 正在高亮 点击这个设计器旁边的 图表 就会出现蓝图的事件图表了 可以看到Pre Construct 类似于构造函数 Construct类似于BeginPlay 还有一个Tick Tick里面还有它的DeltaTime
回到设计器 左侧控制板面板 有一些视觉元素可以拖进视口 展开这些栏 其中每一个元素都是一个Widget 所以控件蓝图里可以包含多个Widget控件 左下角还有一个层级面板 就可以看到控件蓝图里的所有控件 在左上方控制板面板搜索canvas画布面板 将其拖动到层级面板的[WBP_HealthBar]上 现在视口里就会出现一个长方形 在控制板面板 - 通用一栏找到进度条 拖到层级面板里的[画布面板]上 这样它就会具备一个层级结构 attach到了画布面板上 或者也可以直接拖动到视口里的画布面板里面 在层级面板选中这个进度条 右键 对其重命名为HealthBar 视口中画布面板左上角有一个花朵形状的东西 它表示我们的进度条是怎样固定在画布面板上的 这个花朵是一个锚点 屏幕大小变化时 进度条会保持与那个锚点同样的距离 保持选中这个HealthBar 在右侧细节面板 - 锚点一栏 展开下拉菜单 可以改变锚点的位置 我们就选择在画布面板正中心的锚点位置 现在调整进度条位置 可以直接在细节面板中锚点一栏下方的位置X位置Y XY都设置成0.5就是正中心 左上角则是0 0 现在就设置成0 0 下面的对齐设置成0.5 0.5 这样这个HealthBara就完全地处于画布面板的正中心了 尺寸X尺寸Y就是调整这个进度条的大小的
保持选中这个进度条 细节面板往下翻 可以看到进度一栏 拖动调整这个百分比 就可以看到效果 条填充类型可以修改是从哪一侧开始填充 默认是填充成了蓝色 往下翻在外观一栏可以修改这个填充颜色

接下来创建一个C++类 内容侧滑菜单 - C++类 - HelloWorld - Public 空白处右键 - 新建C++类 在所有类搜索并选择WidgetComponent 下一步 保持公共 命名为HealthBarComponent 路径设置为???/HelloWorld/Source/HelloWorld/Public/HUD 点击创建类

在VS可以看到它的父类确实是UWidgetComponent 控件组件可以让我们在任何一个Actor上添加一个显示控件的组件
现在就要为Enemy类添加这样一个控件

// Enemy.h
class UWidgetComponent;
// Enmey.h private中
// AttributesUAttributeComponent* Attributes 下方添加
UPROPERTY(VisibleAnywhere)
UWidgetComponent* HealthBarWidget;
// HelloWorld.Build.cs public class HelloWorld : ModuleRules中
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GeometryCollectionEngine", "Niagara", "UMG" });

之后按一下Ctrl+B 关掉VS 删掉Binaries文件夹 再打开VS Ctrl+F5

// Enmey.cpp
#include "Components/WidgetComponent.h"
// Enemy.cpp AEnemy::AEnemy 末尾添加
HealthBarWidget = CreateDefaultSubobject<UWidgetComponent>(TEXT("HealthBar"));

现在这个HealthBar我们是将其放在了中间的位置 那么它确实有一个位置 所以要绑定到根组件上

热重载 打开BP_Enemy 就可以看到这个Health Bar Widget 使用移动工具将它移动到角色头顶上
在细节面板 往下翻 找到用户界面一栏 控件类下拉菜单 选择WBP_HealthBar
空间默认是场景 其实就是world的中文翻译 也可以修改成屏幕 先保持为场景 PIE可以发现它不是跟随屏幕的 而是就像角色头上悬浮的纸片 随着敌人角色一起定向 所以这里要修改成屏幕

还是想用C++写这个HealthBar 现在打开WBP_HealthBar 可以看到右上角父类是用户控件 我们将创建一个新的C++类作为它的父类 在C++类的HUD文件夹里创建一个新的C++类 在所有类中选择UserWidget 命名为HealthBar 就放在HUD文件夹里

可以看这个HealthBar类的父类是UUserWidget 之前的HealthBarComponent类的父类是UWidgetComponent 它们是不一样的 之前的HealthBarComponent类的作用可能是为项目引入头文件WidgetComponent.h

我们在蓝图WBP_HealthBar中左侧层级面板里的HealthBar 它是作为一个变量存在的 选中它 在右侧细节面板顶端右侧可以看到有一个 是变量 的复选框 那么它是变量 我们就可以修改它的属性
现在如果我们要用一个C++类作为这个蓝图的父类 就可以为进度条添加一个变量 这样就可以把这个C++变量和蓝图的变量HealthBar连接起来

//     HealthBar.h public中
UPROPERTY(meta = (BindWidget))
class UProgressBar* HealthBar;

meta = (BindWidget) 这个元说明符的意思就是 我们C++的HealthBar变量就会与蓝图的HealthBar变量链接 名字必须一致 蓝图里的变量名就也不能有空格
接下来如果我们在C++中设置这个HealthBar比如设置HealthBar的进度 那么它就会在蓝图中改变

在Enemy类我们已经声明了一个UWidgetComponent类型的变量 名为HealthBarWidget 在BP_Enemy中 我们在用户界面一栏将这个HealthBarWidget设置为了WBP_HealthBar 就是说Enemy类里的是一个Widget组件 它要设置成一个Widget 这样敌人类就可以使用我们做的这个进度条

关闭UE Ctrl+F5编译 打开WBP_HealthBar 现在父类还是用户控件 我们要将它的父类变成我们基于用户控件UserWidget创建的新的C++类HealthBar 右上角进入图表 点击上方工具栏里的类设置 左侧细节面板就会出现父类 现在是用户控件 有下拉菜单 选择Health Bar 左上角点击编译 那么现在右上角显示的父类就变成了Health Bar

接下来就需要在HealthBarComponent类里设置HealthBar的百分比

但是现在的问题是 已经不明白HealthBar和HealthBarComponent这两个类的区别和意图
HealthBar类是继承自UUserWidget类 它是UI逻辑 只是2D图形 我们在这个类里写了进度条的代码 还把它BindWidget到了WBP_HealthBar蓝图里
HealthBarComponent类是一个组件 继承自UWidgetComponent类 其实它是个场景组件 也是UActorComponent 它负责把我们做的那个UI挂到角色身上 所以它要指定它要挂的UI 也就是选择为WBP_HealthBar

那么更新进度条的逻辑就应该写在HealthBarComponent里 HealthBar里就只写纯粹的UI

// HealthBarComponent.h public中
void SetHealthPercent(float Percent);

现在先去看看HealthBarComponent类的父类UWidgetComponent 在class HELLOWORLD_API UHealthBarComponent : public UWidgetComponent这一行对UWidgetComponent按Ctrl并单击
可以看到它的父类竟然是UMeshComponent
Ctrl+F搜索GetUserWidget 可以看到UMG_API UUserWidget* GetUserWidgetObject() const; 那么它返回一个UUserWidget 所以这就是这个UWidgetComponent组件使用UUserWidget的方式 使用GetUserWidgetObject() 这样就可以取得这个组件上使用的UUserWidget
我们在BP_Enemy类里 为这个UWidgetComponent组件绑定的UUserWidget是UHealthBar类或者说WBP_HealthBar蓝图 那么之前在UHealthBar类里我们写了一个名为HealthBar的变量 这个变量还是public的 它的图形是进度条 已经在蓝图里设置好了
但是GetUserWidgetObject()函数返回的是UUserWidget类型的指针 不是UHealthBar类型的指针 所有我们要尝试将其转换为UHealthBar类型的指针 这样正好可以检测出来BP_Enemy类里 UWidgetComponent组件的UUserWidget到底有没有指定好 如果指定好了 指定为了UHealthBar类 那么必然是可以转换成功的 我们就可以进行下一步了 也就是更新进度条数据的操作
为了将UUserWidget转换成UHealthBar 就需要UHealthBar类的头文件

// HealthBarComponent.cpp
#include "HUD/HealthBar.h"
// HealthBarComponent.cpp
void UHealthBarComponent::SetHealthPercent(float Percent)
{
    UHealthBar* HealthBarWidget = Cast<UHealthBar>(GetUserWidgetObject());
    if (HealthBarWidget)
    {
        
    }
}

Cast的做法是一直向上检查到所有的父类 直到检查到目标类为止 避免重复地进行转换 为了减少这种检查的开销 最好算完一次之后直接存放到类的成员变量HealthBarWidget里 而不是一直使用局部变量

// HealthBarComponent.h
UPROPERTY()
class UHealthBar* HealthBarWidget;
void UHealthBarComponent::SetHealthPercent(float Percent)
{
    if (HealthBarWidget == nullptr)
    {
        HealthBarWidget = Cast<UHealthBar>(GetUserWidgetObject());
    }
    if (HealthBarWidget)
    {
        
    }
}

这样就是只有HealthBarWidget变量为空的时候才进行转换 否则就直接使用 不需要转换 这样就一共只需要转换一次 给这个HealthBarWidget变量写一个UPROPERTY 就能确保它不会以未初始化的垃圾数据开始 保证它是nullptr
变量名取为HealthBarWidget也是为了表示它本质上是一个UUserWidget类型 虽然它同时也是UUserWidget的子类UHealthBar类型

接下来我们就要取出那个UHealthBar类里的名为HealthBar的变量 也就是那个进度条 UProgressBar类型 对其进行设置
官方文档里搜索UProgressBar 可以看到它有一些方法 页面搜索percent 就可以看到百分比相关的 有一个void SetPercent ( float InPercent ) 它接收一个float类型的参数 注释写着能够设置这个进度条的当前值

// HealthBarComponent.cpp
void UHealthBarComponent::SetHealthPercent(float Percent)
{
    if (HealthBarWidget == nullptr)
    {
        HealthBarWidget = Cast<UHealthBar>(GetUserWidgetObject());
    }
    if (HealthBarWidget)
    {
        HealthBarWidget->HealthBar->SetPercent(Percent); // 添加了一行
    }
}
// HealthBarComponent.cpp
#include "Components/ProgressBar.h"

这样就是对于这个UHealthBar类里的名为HealthBar(ProgressBar类型)的变量 使用这个SetHealthPercent函数传入的参数Percent 进行设置
使用HealthBarWidget->HealthBar变量之前也是需要检查一下非空
那么我们接下来如果想要对于进度条也就是健康条进行设置 只需要调用HealthBarComponent类的这个SetHealthPercent函数就可以了 不需要知道究竟HealthBarComponent类使用的那个UUserWidget类里面到底有什么变量 也就是我们现在为其他类提供了一个设置HealthBar的方法

接下来 就需要在Enemy类中调用这个SetHealthPercent函数 对健康条的数值进行设置
Enemy.h里我们之前声明了一个变量 UWidgetComponent* HealthBarWidget; 现在看来这个变量类型应该修改成UHealthBarComponent

// Enemy.h
// 原来是
// UWidgetComponent* HealthBarWidget;
// 修改成
UPROPERTY(VisibleAnywhere)
UHealthBarComponent* HealthBarWidget;
// Enemy.h
// 原来是
// class UWidgetComponent;
// 修改成
class UHealthBarComponent;
// Enmey.cpp AEnemy::AEnemy
// 原来是
// HealthBarWidget = CreateDefaultSubobject<UWidgetComponent>(TEXT("HealthBar"));
// 修改成
HealthBarWidget = CreateDefaultSubobject<UHealthBarComponent>(TEXT("HealthBar"));
// Enemy.cpp
// 原来是
// #include "Components/WidgetComponent.h"
// 修改成
#include "HUD/HealthBarComponent.h"

现在先测试一下SetHealthPercent这个函数的有效性

// Enmey.cpp
void AEnemy::BeginPlay()
{
    Super::BeginPlay();

    if (HealthBarWidget)
    {
        HealthBarWidget->SetHealthPercent(.1f);
    }
    
}

设置为.1f 这样在游戏开始之后 敌人头上的进度条就会显示为10% 百分比在UE中会被归一化到0到1之间
热重载 PIE 可以看到确实是血条10%

进度条的样式是可以修改的 在WBP_HealthBar中 层级面板选中HealthBar 在细节面板找到样式一栏 展开 可以都换成圆形盒体 还可以调节颜色的透明度 宽度 就可以得到一定程度的自定义样式 当然也可以导入资产

绑定到头顶

现在这个健康条并不会跟着角色的根运动动画发生移动 最好是把这个HealthBarWidget绑定到网格体的骨骼上 当然前提是这个widget使用的是screen模式
最好是在BP_Enemy的事件图表里做 而不是写到C++里 为此就需要把HealthBarWidget改成蓝图只读 才能把它绑定到网格体的骨骼上

// Enemy.h private中
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
UHealthBarComponent* HealthBarWidget;

打开BP_Enemy事件图表 从组件面板中把网格体拖入视口 右键搜索 attach component to component 选择 将组件附加到组件(HealthBarWidget) 把Mesh连接到Parent引脚 SocketName填入敌人网格体的根骨骼名字 本例中是全ての親2 最后将Event BeginPlay执行引脚连接到Attach Component To Component上
现在进入PIE 发现会生成到全ての親2的位置上 那么就还需要调整位置偏移
从Attach Component To Component执行引脚往右拖 搜索transform healthbarwidget 选择添加世界变换(HealthBarWidget) 右键Delta Transform引脚 - 分割结构体引脚 在Delta Transform Location的Z轴填一个数值即可比如275

但是现在我们已经学习了构造脚本 这件事最好还是放到构造脚本里去做 把刚才做的Attach Component To Component节点连接到构造脚本中的Construction Script后面 效果也是一样的 但是之后不能再用Add World Transform节点 因为Add会在每次构造脚本刷新时累加数值 导致血条越飘越高 这里需要使用Set Relative Location 因为我们只感兴趣高度 而且这里是set 不是add 所以不能使用Transform 而必须是Location Z填入275 把HealthBarWidget连接到Target 这些都是一样的 但是现在Attach Component To Component节点的Location Rule要选择为对齐到目标 这样它就会在此时强制对齐到相对于骨骼0 0 0的位置上 这样之后我们再Set Location 就可以使其到达我们目标的位置
如果修改之后没效果 就重新拖入一个BP_Enemy到视口中 我们拖入后 查看BP_Enemy的细节面板 现在不是PIE模式 但我们已经可以看到健康条组件绑定到了网格体上 并且它的位置就是0 0 275
但针对于我们的骨骼 这里明显不是在头顶上 我们之前的add world transform之所以成功了 是因为使用的是UE的坐标系的Z轴上方275 而现在它采用的似乎是骨骼坐标系的Z轴增加275 我们的骨骼坐标系不是标准的 所以就到达了某个位置

(最佳)这里我们不妨直接在全ての親2这个骨骼上添加插槽 命名为HealthBarSocket 放到头顶上合适的位置上 这样我们甚至也不用硬编码275这个数字 这样只需要在Attach Component To Component节点将Socket Name修改为HealthBarSocket 下面的3个Rule都选择为对齐到目标

现在我们整理一下Add / Set / Relative / World的意思 Add是在原有的基础上添加 Set是不管以前是多少 现在要变成这个 World是相对于游戏世界的原点 0 0 0 Relative是相对于它的父组件 比如我们刚才附加到的骨骼
那么Set究竟什么时候会覆盖掉我们在C++写的设置 这取决于执行流程 Actor的初始化顺序是 C++的构造函数 创建组件并设置默认值 然后是蓝图细节面板里修改后的数值 之后是构造脚本里的值 每次我们在编辑器里移动Actor或者修改变量 构造脚本都会重新运行 最后是BeginPlay 所以最后到底是哪个值 要取决于在这个流程中的先后

所以这里我们使用Set Relative
如果用Set World 血条会一直在世界坐标的某个固定点 无论敌人是否移动 它都会在原地
如果用Add Relative 每次敌人在视口中移动一次 构造脚本就执行一次 那么敌人每次移动 都会Add一次 最后血条会飞得很高 我们看不见它
如果用Set Relative 那么无论父骨骼在哪 只需要保持在它上方275

但是假如没有父组件 又或者这个Actor并不是组件 那么Relative是什么意思呢?
首先只有Scene Component场景组件及其子类 才会拥有位置 旋转 缩放 而每一个Actor都有一个Root Component根组件 我们谈到Actor的位置 其实是在说它的根组件的位置 而假如没有父级 Relative的意思就是World 相当于它有一个虚拟的父级也就是世界0 0 0

对敌人造成伤害

接下来就只需要在敌人类设置这个百分比了 敌人的血量是随着角色对其进行攻击而发生改变的

UE引擎内置了伤害Damage的概念 任何Actor都可以受到伤害 AActor类有一个AActor::TakeDamage函数 这是一个虚函数 所以如果想让一个Actor受到伤害 只需要重写这个函数 UGameplayStatics还有一个函数是UGameplayStatics::ApplyDamage 当然UE中还有其它伤害相关的函数

// AActor::TakeDamage 官方文档
virtual float TakeDamage  
(
    float DamageAmount, // 伤害量 浮点数数字
    struct FDamageEvent const & DamageEvent, // FDamageEvnet 包含关于伤害的额外数据的结构
    class AController * EventInstigator, // 名为Event发起者的AController
    AActor * DamageCauser // 伤害源 也就是对其造成伤害的Actor
)

float DamageAmount 伤害量 浮点数数字
struct FDamageEvent const & DamageEvent FDamageEvnet 包含关于伤害的额外数据的结构
class AController * EventInstigator 名为Event发起者的AController 这是一个AController类型 我们认为控制器是伤害的源头 如果我的角色用剑攻击其它角色并且造成伤害 那么控制我的Character或者Pawn的控制器Controller就是伤害的发起者
AActor * DamageCauser 伤害源 也就是对其造成伤害的Actor 射箭的时候 箭就是伤害源 挥剑的时候 剑就是伤害源

// UGameplayStatics::ApplyDamage 官方文档
static float ApplyDamage
(
    AActor* DamagedActor,
    float BaseDamage,
    AController* EventInstigator,
    AActor* DamageCauser,
    TSubclassOf< class UDamageType > DamageTypeClass
)

AActor* DamagedActor 将需要被攻击并受伤的Actor传入
float BaseDamage 造成的伤害值
AController* EventInstigator 明确由那个控制器负责发起伤害
AActor* DamageCauser 传入伤害源 这样在最终调用TakeDamage函数时使用这个Actor的信息
TSubclassOf< class UDamageType > DamageTypeClass 包装了UDamageType UE时有UDamageType类的 可以自定义自己的类来指定不同类型的伤害及其特性 这个输入就允许了自定义伤害功能 既然是TSubclassOf 那么这个函数可以接收UClass类型作为输入参数

那么 每当我们想造成伤害时 可以使用UGameplayStatics::ApplyDamage 传入所有相关的信息 其中最重要的参数就是AActor* DamagedActor 要被攻击的Actor 那么UGameplayStatics::ApplyDamage函数就会调用这个Actor的AActor::TakeDamage函数 使其受击

Weapon类是造成伤害的关键 它就是那个AActor* DamageCauser
查看Weapon.cpp可以看到 AWeapon::OnBoxOverlapBegin函数中 也就是发生了重叠之后这个函数就会被调用 它里面会进行UKismetSystemLibrary::BoxTraceSingle 如果追踪成功就会得到一个FHitResult类型的信息 通过这个FHitResult 调用它的GetActor()方法 就能得到那个被盒体追踪命中的Actor
在这之后我们曾经就对这个Actor做了一些处理 比如调用了这个Actor的GetHit 还在ImpactPoint位置上放了一个场 那么我们就可以在这个对于Actor处理的过程中 添加上对其的伤害

// Weapon.cpp AWeapon::OnBoxOverlapBegin中
if (BoxHit.GetActor())
{
    IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
    if (HitInterface)
    {
        HitInterface->Execute_GetHit(BoxHit.GetActor(), BoxHit.ImpactPoint);
    }
    IgnoreActors.AddUnique(BoxHit.GetActor());

    CreateFields(BoxHit.ImpactPoint);

    UGameplayStatics::ApplyDamage(
        BoxHit.GetActor(),
    ) // 添加一行 没写完
}

第1个参数是DamagedActor 被攻击的Actor 类型为Actor 我们要攻击的就是这个BoxHit.GetActor() 所以第1个参数传入它

第2个参数是BaseDamage 基础伤害 float类型 我们希望每把武器都有不同的伤害值 所以武器应该有伤害值变量

// Weapon.h private中
UPROPERTY(EditAnywhere, Category = "Weapon Properties")
float Damage = 20.f;

第3个参数是AController类型的EventInstigator 我们现在是在武器类中 如何才能知道控制器呢? 如果我们装备了这个武器 就应该在上面设置一些信息 这样我们就能知道是谁在造成伤害 那么就需要在HelloWorldCharacter::EKeypPessed函数中做 在Equip函数被调用之后 就应该在这里设置一些属性

// HelloWorldCharacter.cpp AHelloWorldCharacter::EKeyPressed中
AWeapon* OverlappingWeapon = Cast<AWeapon>(OverlappingItem);
if (OverlappingWeapon)
{
    OverlappingWeapon->Equip(GetMesh(), FName("RightHandSocket"));
    OverlappingWeapon->SetOwner(this); // 添加了一行
    OverlappingWeapon->SetInstigator(this); // 添加了一行
// 后面省略

SetOwner就是设置所有者 角色所有权 在任何Actor上调用GetOwner函数就会返回这个Actor的指定所有者 这样我们的Character就与Weapon连接起来了
也可以SetInstigator 它和SetOwner的区别是 SetInstigator只能接收Pawn类型的参数传入 而SetOwner接收的是Actor类型的参数
有时候Owner和Instigator是一样的 有时候不是

但是现在还不如把这个直接写到Equip函数里面 为Equip函数添加两个参数

// Weapon.h
void Equip(USceneComponent* InParent, FName InSocketName, AActor* NewOwner, APawn* NewInstigator);
// Weapon.cpp
void AWeapon::Equip(USceneComponent* InParent, FName InSocketName, AActor* NewOwner, APawn* NewInstigator)
{
    SetOwner(NewOwner);
    SetInstigator(NewInstigator);
// 后面省略
// HelloWorldCharacter.cpp AHelloWorldCharacter::EKeyPressed中
void AHelloWorldCharacter::EKeyPressed()
{
    AWeapon* OverlappingWeapon = Cast<AWeapon>(OverlappingItem);
    if (OverlappingWeapon)
    {
        OverlappingWeapon->Equip(GetMesh(), FName("RightHandSocket"), this, this); // 修改了这一行
// 后面省略

对于EventInstigator 我们现在知道Instigator实际上是Pawn 但是在伤害的这个情境里 我们称其为EventInstigator 实际上它只是Pawn的Controller GetInstigator()得到的就是这个Pawn 对于这个Pawn还可以GetController() 那么第3个参数就可以写成GetInstigator()->GetController()

第4个参数是DamageCauser 是Actor类型 那么在武器类中 就是这个武器 也就是this

第5个参数是DamageTypeClass 这里要传入一个UClass类型 还必须是UDamageType或者其子类 其实我们可以自定义一个UDamageType类型的子类来使用 但这里就使用官方预设的即可 这里就调用静态类方法StaticClass 这个函数会返回一个代表DamageTypeClass的UClass指针 代表的就是最基础的官方给定的没有任何特殊行为的普通伤害类型

// Weapon.cpp AWeapon::OnBoxOverlapBegin中
if (BoxHit.GetActor())
{
    IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
    if (HitInterface)
    {
        HitInterface->Execute_GetHit(BoxHit.GetActor(), BoxHit.ImpactPoint);
    }
    IgnoreActors.AddUnique(BoxHit.GetActor());

    CreateFields(BoxHit.ImpactPoint);

    // 下为新添加
    UGameplayStatics::ApplyDamage(
        BoxHit.GetActor(),
        Damage,
        GetInstigator()->GetController(),
        this,
        UDamageType::StaticClass()
    );
}

那么现在 每当剑用盒子追踪击中一个Actor时 就会对这个Actor应用伤害 伤害的数量是剑的Damage参数里规定的那么多 发起伤害的控制器是剑的拥有者 发起伤害的是剑 造成伤害的方式是UE自带的默认普通类型 这就是这5个参数的含义

接下来就要在Enemy的TakeDamage函数里处理

每一个受伤害的Actor都可以重写AActor::TakeDamage函数 所以回到Item类 它是直接继承自AActor的 可以通过它跳转到Actor.h 当然也可以直接在解决方案资源管理器里搜索 在Actor.h页面内搜索TakeDamage

// Actor.h public中
/**
 * Apply damage to this actor.
 * @see https://www.unrealengine.com/blog/damage-in-ue4
 * @param DamageAmount        How much damage to apply
 * @param DamageEvent        Data package that fully describes the damage received.
 * @param EventInstigator    The Controller responsible for the damage.
 * @param DamageCauser        The Actor that directly caused the damage (e.g. the projectile that exploded, the rock that landed on you)
 * @return                    The amount of damage actually applied.
 */
ENGINE_API virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser);

它最后Return的是真实应用的伤害量 是float类型 在TakeDamage中 如果我们对应该承受的伤害量做了某种修改 通常我们会返回总的承受伤害量
这个TakeDamage函数是public的 那么我们可以在Enemy类中对它重写

// Enemy.h public中
virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser) override;

这个TakeDamage函数是最终为了在ApplyDamage函数里被调用的 那么在这个TakeDamage函数里我们应该更新健康值和健康条

首先是怎么更新健康值? 我们之前写了一个Attribute组件 里面有一个Health属性 还有一个MaxHealth 现在都是private 就需要写一个public的set函数 对于这种set函数最好还是写在下方新创建的public 我们之前都是写成了FORCEINLINE的Set函数 这次换成其它方式

// AttributeComponent.h
public:
    void ReceiveDamage(float DamageAmount);

首先要检查健康值永远不会低于0 将来要实现死亡机制时 我们需要检查健康值是否真的降到0

// AttributeComponent.cpp // 未采用
void UAttributeComponent::ReceiveDamage(float DamageAmount)
{
    Health -= DamageAmount;
}

最简单的肯定是这样写 但我们要确保Health不要降到负数 所以可以用一个Clamp函数 第1个参数是数值 第2个参数是限定的最小值 第3个参数是限定的最大值 这样这个函数就永远不会返回低于最小值或者高于最大值的结果

// AttributeComponent.cpp
void UAttributeComponent::ReceiveDamage(float DamageAmount)
{
    Health = FMath::Clamp(Health - DamageAmount, 0.0f, MaxHealth);
}

那么现在就来补全UEnemy::TakeDamage函数的实现

// Enemy.cpp
float AEnemy::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    if (Attributes)
    {
        Attributes->ReceiveDamage(DamageAmount);
        if (HealthBarWidget)
        {
            HealthBarWidget->SetHealthPercent(Attributes->GetHealthPercent());
        }
    }
    return DamageAmount;
}

首先检查一下Enemy类里有没有Attribute组件 如果有的话 就调用UAttributeComponent::ReceiveDamage 之后再检查Enemy类有没有HealthBar组件 如果有 就设置它的百分比 SetHealthPercent这个函数是我们之前已经在HealthBarComponent类写好的
那么现在要设置百分比 也就是计算Health / MaxHealth 这两个参数是AttributeComponent类里面的private参数 那么就还是必须写get函数

// AttributeComponent.h
float GetHealthPercent() const { return Health / MaxHealth; }

Ctrl+F5编译 在WBP_HealthBar中 层级面板选中HealthBar 在细节面板设置其默认百分比为1 在BP_Enemy细节面板中对Attribute组件设置Health和MaxHealth 之后再拖入视口 对其进行攻击 可以看到血条在下降

敌人死亡

先找几个死亡动画资产 Mixamo的标准骨骼 根骨骼就是盆骨 没有专门的根骨骼 所以需要修复Mixamo动画 使其有根运动 才能导入到UE中
为Blender下载Mixamo Converter插件 下载这个github项目的整个ZIP 打开Blender 上方菜单栏 - 编辑 - 偏好设置 - 点击右上角的向下三角箭头 也就是插件设置 - 从磁盘安装 选择刚才下载好的zip 但是Blender5.0不支持这个插件 下载一个旧版Blender比如3.6的便携版 选择Windows - Portable版本即可 这个版本不需要安装 解压后双击Blender.exe就可以直接用
安装并启用插件之后 点击视口右侧小三角箭头就可以看到这个名为Mixamo的插件 在Batch选项卡Input选择fbx资产所在的路径 Output选择一个新的文件夹用于存放转换后的资产 之后点击Transfer Rotation使其从蓝色变为灰色 点击Advanced Options进行一些设置 取消勾选Apply Rotation 最后点击下方的Batch Convert 一定要把T-Pose的那个原始骨骼网格体fbx也转换 回到UE把新的fbx导入即可 先导入T-Pose那个fbx 连着骨骼网格体一起导入 做IK绑定的时候可以看到它变得有根骨骼了 将root骨骼绑定到重定向链命名为Root 后面那些有动画的fbx资产就只需要仅导入动画

做好动画资产后 都要勾选启用根骨骼运动 做成动画蒙太奇AM_DeathMontage 在左侧资产详情面板 混入 - 混合时间设置为0 因为我们希望敌人迅速地死亡 不需要渐进地播放动画

接下来写函数逻辑

// AttributeComponent.h public中
bool IsAlive() const { return Health > 0.f; }

接下来就需要根据是否还活着来判断是播放受击蒙太奇还是死亡蒙太奇 我们是在Enemy.cpp中先写了一个播放受击蒙太奇的函数AEnemy::PlayHitReactMontage 然后又写了一个根据受击位置播放受击蒙太奇不同片段的函数AEnemy::DirectionalHitReact 并且在AEnemy::GetHit_Implementation中调用了这个函数
但是在AWeapon::OnBoxOverlapBegin中 是先执行Execute_GetHit函数 之后才执行ApplyDamage函数 如果把检测生命值是否降为0并播放死亡蒙太奇动画的逻辑写在了GetHit函数中 那么如果本次造成伤害之后 角色不会直接死 只会在下次受击的时候 GetHit函数才能检测到角色已经死亡 才播放死亡动画
所以应该先造成损伤 再通过GetHit函数播放动画 需要调整一下顺序

// Weapon.cpp AWeapon::OnBoxOverlapBegin中
if (BoxHit.GetActor())
{
    UGameplayStatics::ApplyDamage(
        BoxHit.GetActor(),
        Damage,
        GetInstigator()->GetController(),
        this,
        UDamageType::StaticClass()
    );

    IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
    if (HitInterface)
    {
        HitInterface->Execute_GetHit(BoxHit.GetActor(), BoxHit.ImpactPoint);
    }
    IgnoreActors.AddUnique(BoxHit.GetActor());

    CreateFields(BoxHit.ImpactPoint);
}

这样造成伤害之后 GetHit函数里再检查健康值是否到达了0 选择播放死亡动画还是受击动画

接下来就到GetHit函数中补全逻辑

// Enemy.cpp
void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)
{
    // DRAW_SPHERE_COLOR(ImpactPoint,FColor::Orange);
    
    if (Attributes && Attributes->IsAlive())
    {
        DirectionalHitReact(ImpactPoint);
    }
    else if (Attributes && !Attributes->IsAlive())
    {
        PlayDeathMontage();
    }
}
// Enemy.h protected中
void PlayDeathMontage();
// Enemy.h private中
UPROPERTY(EditDefaultsOnly, Category = Montages)
UAnimMontage* DeathMontage;
// Enemy.cpp
void AEnemy::PlayDeathMontage()
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();

    if (AnimInstance && DeathMontage)
    {
        AnimInstance->Montage_Play(DeathMontage);

        const int32 Selection = FMath::RandRange(0, 3);
        FName SectionName = FName();
        switch (Selection)
        {
        case 0:
            SectionName = FName("Backward");
            break;
        case 1:
            SectionName = FName("Forward");
            break;
        case 2:
            SectionName = FName("Left");
            break;
        case 3:
            SectionName = FName("Right");
            break;
        }

        AnimInstance->Montage_JumpToSection(SectionName, DeathMontage);
    }
}

打开BP_Enemy 将DeathMontage变量设置为我们做好的蒙太奇资产

进入PIE 发现敌人角色死后 还是会继续播放idle动画 这是因为我们只是在动画蓝图中用播放蒙太奇覆盖了idle动画 蒙太奇播放完之后 它自然就会又开始播放idle动画 我们就需要将死亡动画的最后一帧制作成单独的动画 打开动画资产 播放光标定格到最后一帧 在上方工具栏 点击创建资产 - 创建动画 - 当前姿势 将其命名为Death_Backward_LastFrame_StrawberryMiku类似的形式

需要在敌人角色动画蓝图里做一个新的Dead状态 我们之前已经做了Idle状态和Dance状态 需要添加一个Dead状态形成三角形 将Idle State状态机重命名为Main States 我们只可能从Idle状态或者Dance状态到达Dead状态 不可能从Dead状态变回Idle状态或者Dance状态 所以我们现在只需要写从Idle到Dead和从Dance到Dead的转换规则

需要做一个死亡姿势枚举

// CharacterTypes.h
UENUM(BlueprintType)
enum class EDeathPose : uint8
{
    EDP_Alive UMETA(DisplayName = "Alive"),
    EDP_Backward UMETA(DisplayName = "Backward"),
    EDP_Forward UMETA(DisplayName = "Forward"),
    EDP_Left UMETA(DisplayName = "Left"),
    EDP_Right UMETA(DisplayName = "Right")
};
// Enemy.h
#include "Characters/CharacterTypes.h"
// Enemy.h protected中
UPROPERTY(BlueprintReadOnly)
EDeathPose DeathPose = EDeathPose::EDP_Alive;

不需要在细节面板中可见 而且我们要在动画蓝图事件图表里使用它 所以只需要蓝图只读
那么就只需要在选择播放哪个蒙太奇片段的同时 设置好这个枚举

// Enemy.cpp AEnemy::PlayDeathMontage中
switch (Selection)
{
case 0:
    SectionName = FName("Backward");
    DeathPose = EDeathPose::EDP_Backward;
    break;
case 1:
    SectionName = FName("Forward");
    DeathPose = EDeathPose::EDP_Forward;
    break;
case 2:
    SectionName = FName("Left");
    DeathPose = EDeathPose::EDP_Left;
    break;
case 3:
    SectionName = FName("Right");
    DeathPose = EDeathPose::EDP_Right;
    break;
}

那么接下来就是在动画蓝图事件图表里得到DeathPose当前的枚举值 动画蓝图就需要从它的Owner那里获得数据 我们之前做PlayIdleMusic动画通知的时候做过类似的事情 并且已经把这个Owner提升为变量了

先热重载
(废弃方案)将拖入Owner变量到事件图表中 选择获取 右键 - 转换为有效的Get 从Owner节点的Owner引脚往右拖 搜索get death pose 再从Death Pose引脚往右拖 搜索== 选择Equal(Enum) 这样就可以检查它是否等于某个枚举
将Event Blueprint Update Animation节点的执行引脚连接到GET Owner节点左侧执行引脚上

但是UE5提供了多线程的方法 能够提升性能
为了后续的操作 先把DeathPose后面那些Equal节点删了 对Death Pose引脚右键 - 提升为变量 就保持默认名为Death Pose 将GET Owner节点右侧的执行引脚连接到SET Death Pose节点左侧的执行引脚上
在左侧我的蓝图面板 函数右侧重载 下拉菜单选择 蓝图线程安全更新动画 就会得到一个单独的窗口 里面有一个Blueprint Thread Safe Update Animation节点 它就和Event Blueprint Update Animation节点一样 每一帧都会被调用 只是这个节点可以与其它蓝图线程安全更新动画并行调用 所以我们在这个蓝图线程安全更新动画窗口里面做的事情都必须要保证线程安全 那么如果我们现在把Owner变量拖进来获取 就不是线程安全的 因为我们即将有多个版本的蓝图线程安全更新动画函数在同一个角色的不同动画蓝图上被同时调用 那么在被并行调用的某一个版本之中 可能会改变一些东西 所以所有数据都应该缓存到一个proxy代理数据结构之中 方便管理 接下来我们就可以从代理数据结构中获取Owner的信息 所有的蓝图线程安全更新动画函数完成一帧后 代理数据结构就会更新一次 这样就能避免多线程函数访问数据冲突
在蓝图安全更新动画这个窗口中右键 搜索并选择Property Access 这个节点上有个下拉菜单 默认是绑定 在这个下拉菜单中选择Owner - DeathPose z在Owner里面的众多选项中找不到的话可以使用搜索 将Death Pose变量拖入视口 选择设置 将Owner:DeathPose节点右侧引脚连接到SET Death Pose节点的Death Pose引脚上 这样就是用这个值来设置动画蓝图的Death Pose变量 再将Blueprint Thread Safe Update Animation节点的执行引脚连接到SET Death Pose节点左侧执行引脚上 那么现在就是在以线程安全的方式每一帧调用Owner:DeathPose来设置最新的Death Pose变量值

回Main States状态机 进入Idle到Dead转换规则 将Death Pose拖入视口 从右侧引脚往右拖 搜索!= 选择Not Equal(Enum) 将Not Equal(Enum)节点右侧引脚连接到Result节点的Can Enter Transition引脚上 也就是说 如果角色现在不是Alive 就应该转换到Dead状态 Dance到Dead的转换规则也是一模一样的
进入Dead状态 需要使用Blend Pose根据枚举值来选择不同动画播放 右键搜索blend poses edeathpose 将Death Pose变量拖入视口 连接到Blend Poses节点的Active Enum Value引脚上 对Blend Pose引脚右键 添加多个元素引脚 不应该包括Alive 将对应的单帧动画资产连接上 Default Pose引脚随便找一个单帧动画连上就可以

接下来解决一些细节上的问题
敌人死亡就应该将胶囊体关闭碰撞 而不是留在地面上阻挡角色行走 这个问题应该在AEnemy::PlayDeathMontage函数里顺便解决 那么这个函数就不只是用来播放蒙太奇了 我们将这个函数更名为Die 对这个函数右键 - 查找所有引用 在这些位置都进行更名

// Enemy.h
void Die();
// Enemy.cpp
void AEnemy::Die()
{
// 后面省略
// Enemy.cpp AEnemy::GetHit_Implementation中
else if (Attributes && !Attributes->IsAlive())
{
    Die();
}
// Enemy.cpp AEnemy::Die 末尾添加
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);

接下来 做一个 敌人死后超过一段时间就应该消失

// Enemy.cpp AEnemy::Die 末尾添加
SetLifeSpan(10.0f);

可以发现 死亡后头上的健康条没有跟着消失 而且我们可以做更加优雅的事情 比如 第一次击中之后 敌人头上才会显示健康条 敌人死后健康条就消失 如果距离敌人特别远 这个健康条也应该消失 直到再一次击中敌人才会显示 这样就需要能够自主控制显示或者隐藏健康条的时机

if (HealthBarWidget)
{
    HealthBarWidget->SetVisibility(false);
}

这样的代码就可以隐藏进度条 反之传进true就可以显示进度条 那么接下来我们只需要思考把这种代码放在哪里就可以了

// Enemy.cpp
void AEnemy::BeginPlay()
{
    Super::BeginPlay();

    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(false);
    }
}

开始游戏时 应该默认不显示健康条

// Enemy.cpp AEnemy::GetHit_Implementation 开头添加
if (HealthBarWidget)
{
    HealthBarWidget->SetVisibility(true);
}

这样击中第一次之后 敌人头上就会显示健康条
那么接下来我们就需要做超过一段距离就不显示健康条
可以通过加一个球形组件 将它设置得比较大 和球形组件重叠和结束重叠 设置委托 这似乎有些麻烦
还有另一种办法 一旦敌人被击中 可以把击中它的Actor存在Enemy类的一个变量里 也就是把它当作战斗目标 而且这个战斗目标变量也可以用于之后敌人对我们的角色进行追逐和攻击 一旦Actor离得足够远 就可以把健康条藏起来

// Enemy.h
UPROPERTY()
AActor* CombatTarget;

被击中时 敌人类通过EventInstigator就能得到那个攻击敌人的Actor

// Enemy.cpp AEnemy::TakeDamage 末尾添加
CombatTarget = EventInstigator->GetPawn();
// 放在 return 之前

也不需要做类型转换 因为Pawn本来就是Actor的子类 现在这样一旦敌人受到伤害 就会设置战斗目标 接下来敌人就应该检查到这个战斗目标的距离 这个操作需要在Tick函数里完成

// Enemy.h private中
UPROPERTY(EditAnywhere)
double CombatRadius = 500.f;
// Enemy.cpp
void AEnemy::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (CombatTarget)
    {
        const double DistanceToTarget = (CombatTarget->GetActorLocation() - GetActorLocation()).Size();
        if (DistanceToTarget > CombatRadius)
        {
            CombatTarget = nullptr;
            if (HealthBarWidget)
            {
                HealthBarWidget->SetVisibility(false);
            }
        }
    }
}

(CombatTarget->GetActorLocation() - GetActorLocation()).Size() 得到CombatTarget和当前Enemy实例之间的坐标差距形成一个向量 size()就是用于计算向量长度的 如果距离大于战斗半径 就会对战斗目标失去兴趣 那么就可以隐藏健康条 除非敌人受到伤害或者以其它方式被激怒 否则我们不会再次设置战斗目标 现在我们这个战斗目标变量是只在TakeDamage函数里才会被设置

热重载 在BP_Enemy事件图表中 Event Tick节点后连接一个draw debug sphere 再添加一个Get Actor Location节点 连接到Draw Debug Sphere集结点的Center引脚上作为调试球的中心 半径设置为500 和C++里的战斗半径一样大
PIE 可以看到敌人周围环绕着球体 攻击后出现健康条 离开球体之后健康条消失 再次进入球体也不会显示健康条 除非再次攻击

现在唯一的问题就是死亡之后仍然有健康条

// Enemy.cpp AEnemy::Die中
void AEnemy::Die()
{
    // 前面省略

    // 添加了几句
    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(false);
    }

    GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    SetLifeSpan(10.0f);
}

死亡后还会播放Idle音乐 把停止播放Idle音乐的部分单独提取为函数

// Enemy.cpp
void AEnemy::StopIdleMusic()
{
    // 打断正在播放的Idle音乐
    if (IdleAudioComponent && IdleAudioComponent->IsPlaying())
    {
        IdleAudioComponent->FadeOut(0.2f, 0.0f);
    }
}
// Enemy.cpp AEnemy::PlayHitReactMontage中
// if (AnimInstance && HitReactMontage)内 开头添加
StopIdleMusic();
// Enemy.cpp AEnemy::Die中
// if (AnimInstance && DeathMontage)内 开头添加
StopIdleMusic();

载具

模仿剑装备 做一个载具系统 只是把车绑定到了角色身上 同时换成坐姿动画 现在做一个骑车

// CharacterTypes.h
UENUM(BlueprintType)
enum class ECharacterState : uint8
{
    ECS_Unequipped UMETA(DisplayName = "Unequipped"),
    ECS_EquippedOneHandedWeapon UMETA(DisplayName = "Equipped One-Handed Weapon"),
    ECS_Riding UMETA(DisplayName = "Riding")
};
// Item.h
enum class EItemState : uint8
{
    EIS_Hovering,
    EIS_Equipped
};
// Vehicle.h
class HELLOWORLD_API AVehicle : public AItem
{
    GENERATED_BODY()

public:
    AVehicle();
    virtual void Tick(float DeltaTime) override;

    void RideStart(USceneComponent* InParent, FName InSocketName);
    void RideEnd();

protected:
    virtual void BeginPlay() override;

    virtual void OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult) override;
    virtual void OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex) override;
};
// Vehicle.cpp
#include "Items/Vehicles/Vehicle.h"
#include "Components/SphereComponent.h"

AVehicle::AVehicle()
{
    ItemState = EItemState::EIS_Hovering;
}

void AVehicle::Tick(float DeltaTime)
{
    // 车不需要旋转跳跃 所以不调用父类Item类的Tick函数
}

void AVehicle::RideStart(USceneComponent* InParent, FName InSocketName)
{
    ItemState = EItemState::EIS_Equipped; // 借用一下武器被装备的状态

    // 取消碰撞 让角色和车都不发生碰撞
    if (Sphere)
    {
        Sphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    }
    if (ItemMesh)
    {
        ItemMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    }

    FAttachmentTransformRules TransformRules(EAttachmentRule::SnapToTarget, true);
    ItemMesh->AttachToComponent(InParent, TransformRules, InSocketName);

}

void AVehicle::RideEnd()
{
    ItemState = EItemState::EIS_Hovering;
    // 恢复碰撞
    if (Sphere)
    {
        Sphere->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
    }
    if (ItemMesh)
    {
        ItemMesh->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
    }

    ItemMesh->DetachFromComponent(FDetachmentTransformRules(EDetachmentRule::KeepWorld, true));
}

void AVehicle::OnSphereOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
    Super::OnSphereOverlapBegin(OverlappedComponent, OtherActor, OtherComp, OtherBodyIndex, bFromSweep, SweepResult);
}

void AVehicle::OnSphereOverlapEnd(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex)
{
    Super::OnSphereOverlapEnd(OverlappedComponent, OtherActor, OtherComp, OtherBodyIndex);
}
// HelloWorldCharcter.h
UPROPERTY(VisibleAnywhere, Category = Vehicle)
AVehicle* RidingVehicle;
// HelloWorldCharcter.h
class AVehicle;
// HelloWorldCharcter.cpp
#include "Items/Vehicles/Vehicle.h"

添加一个操作映射命名为Ride 接下来把上车下车绑定到按键R

// HelloWorldCharacter.cpp AHelloWorldCharacter::SetupPlayerInputComponent 末尾添加
PlayerInputComponent->BindAction(FName("Ride"), IE_Pressed, this, &AHelloWorldCharacter::RKeyPressed);
// HelloWorldCharacter.h protected中
void RKeyPressed();

逻辑应该是 如果正在骑车 按下R键就应该下车 如果当前没在骑车 并且与车辆发生了重叠 按下R键就上车 上车之前 如果角色背着剑 应该自动把剑放到后背上

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::RKeyPressed()
{
    if (CharacterState == ECharacterState::ECS_Riding)
    {
        if (RidingVehicle)
        {
            RidingVehicle->RideEnd();
            SetOverlappingItem(RidingVehicle); // 角色和车分离后 角色可以再次与车发生重叠 从而再次迅速骑车
            RidingVehicle = nullptr;
        }
        CharacterState = ECharacterState::ECS_Unequipped;
    }
    else
    {
        if (ActionState == EActionState::EAS_Unoccupied)
        {
            AVehicle* OverlappingVehicle = Cast<AVehicle>(OverlappingItem);
            if (OverlappingVehicle)
            {
                if (CharacterState == ECharacterState::ECS_EquippedOneHandedWeapon)
                {
                    HandToSpine(); // 如果手里有武器 就将武器从手上放到背上
                }
                OverlappingVehicle->RideStart(GetMesh(), FName("BikeSocket"));
                CharacterState = ECharacterState::ECS_Riding;
                RidingVehicle = OverlappingVehicle;
                OverlappingItem = nullptr;
            }
        }
    }
    
}

要确保骑车状态下不能进行攻击

// HelloWorldCharacter.cpp
bool AHelloWorldCharacter::CanAttack()
{
    return ActionState == EActionState::EAS_Unoccupied &&
        CharacterState != ECharacterState::ECS_Unequipped &&
        CharacterState != ECharacterState::ECS_Riding;
}

骑车状态下也不能装卸武器

// HelloWorldCharacter.cpp
bool AHelloWorldCharacter::CanUnequipping()
{
    return CharacterState != ECharacterState::ECS_Unequipped &&
        CharacterState != ECharacterState::ECS_Riding &&
        ActionState == EActionState::EAS_Unoccupied;
}

bool AHelloWorldCharacter::CanEquipping()
{
    return CharacterState == ECharacterState::ECS_Unequipped &&
        CharacterState != ECharacterState::ECS_Riding &&
        ActionState == EActionState::EAS_Unoccupied &&
        EquippedWeapon;
}

接下来在动画蓝图 Ground Locomotion中 为Run和Idle状态内的Blend Pose ECharacterState节点添加元素引脚Riding 并选择骑行的动作

新建一个BP_Vehicle 设置好网格体和Sphere 拖入关卡 进入PIE 按R即可骑行

自定义声音组件

现在还希望播放一个骑车音效 之前我们做过了在敌人类播放Idle音乐 现在是时候写一个专门的类用来播放音乐了 基于UAudioComponent创建一个新的自定义组件类 命名为CharacterAudioComponent 我们要让这个组件不仅能使用声音组件的所有功能 还要扩展一些新功能 最后就用这个组件代替我们原有的UAudioComponent

// CharacterAudioComponent.h public中
UFUNCTION(BlueprintCallable, Category = "Audio")
void PlayMusic(float FadeInTime = 0.2f);

UFUNCTION(BlueprintCallable, Category = "Audio")
void StopMusic(float FadeOutTime = 0.2f);

这里不需要专门写一个存放声音资产的USoundBase类型成员变量 因为父类UAudioComponent自带这个成员变量名为Sound 只需要调用父类的这个Sound就可以了
接下来就写播放声音和停止播放声音的函数

// CharacterAudioComponent.cpp
void UCharacterAudioComponent::PlayMusic(float FadeInTime)
{
    if (!Sound)
    {
        return;
    }
    if (IsPlaying())
    {
        return;
    }
    
    FadeIn(FadeInTime); // FadeIn会自动激活组件并开始播放 如果 FadeInTime 是 0 它就等同于直接 Play()
}

如果没有指定声音资产 就不应该播放 如果正在播放并且播放的就是指定的这一首 也不应该播放

// CharacterAudioComponent.cpp
void UCharacterAudioComponent::StopMusic(float FadeOutTime)
{
    if (IsPlaying())
    {
        FadeOut(FadeOutTime, 0.0f);
    }
}

FadeOut函数的第1个参数是淡出持续时间 第2个参数是淡出的目标音量 指定为0就是最终目标为静音

接下来修改Enemy类的播放Idle音乐的组件

// Enemy.cpp
// 原来是
// #include "Components/UAudioComponent.h"
// 修改为
#include "Components/CharacterAudioComponent.h"
// Enemy.h
// 原来是
// class UAudioComponent;
// 修改为
class UCharacterAudioComponent;
// Enemy.h private中
// 原来是
// UAudioComponent* IdleAudioComponent;
// 修改成
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"), Category = Audio)
UCharacterAudioComponent* IdleAudioComponent;

这里一定要写成对蓝图可见 否则后面在动画蓝图里没办法调用这个组件上的播放音乐函数

// Enemy.h public中
// 原来是
// UAudioComponent* GetIdleAudioComponent() const { return IdleAudioComponent; }
// 修改成
UCharacterAudioComponent* GetIdleAudioComponent() const { return IdleAudioComponent; }
// Enemy.cpp AEnemy::AEnemy中
// 原来是
// IdleAudioComponent = CreateDefaultSubobject<UAudioComponent>(TEXT("IdleAudioComponent"));
// 修改成
IdleAudioComponent = CreateDefaultSubobject<UCharacterAudioComponent>(TEXT("IdleAudioComponent"));
// Enemy.cpp AEnemy::PlayHitReactMontage中
// 原来是
// StopIdleMusic();
// 修改成
IdleAudioComponent->StopMusic();
// Enemy.cpp AEnemy::Die中
// 原来是
// StopIdleMusic();
// 修改成
IdleAudioComponent->StopMusic();

接下来就要去动画蓝图里改动画通知

把之前AnimNotify_PlayIdleMusic后面的PlayIdleMusic函数节点删了 从Owner节点往右拖 搜索get IdleAudioComponent 从这个get IdleAudioComponent的IdleAudioComponent引脚往右拖 搜索Play Music 连接到AnimNotify_PlayIdleMusic的执行引脚后面

接下来为骑车增加音效

// HelloWorldCharacter.cpp
#include "Components/CharacterAudioComponent.h"
// HelloWorldCharacter.h
class UCharacterAudioComponent;
// Enemy.h
private:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"), Category = Audio)
    UCharacterAudioComponent* RideAudioComponent;
public:
    UCharacterAudioComponent* GetRideAudioComponent() const { return RideAudioComponent; }

只需要在切换状态时播放和停止骑车音效就可以了

// HelloWorldCharacter.cpp AHelloWorldCharacter::RKeyPressed中
// if (CharacterState == ECharacterState::ECS_Riding) 分支中
// CharacterState = ECharacterState::ECS_Unequipped; 后面
// 增加一行
RideAudioComponent->StopMusic();
// HelloWorldCharacter.cpp AHelloWorldCharacter::RKeyPressed中
// else 分支中
// CharacterState = ECharacterState::ECS_Riding; 后面
// 增加一行
RideAudioComponent->PlayMusic();

发现这个骑车音效需要循环播放 这个问题其实不需要在C++里解决 打开骑车音效的MetaSound资产 在Wave Player节点里勾选循环即可 还可以设置循环开始和循环时间
但是现在这样 就会是只要在ECS_Riding状态 就全程播放音效 除非到了切换状态那里调用StopMusic函数 我们的骑车做得是非常简易的 只是把车绑定到了角色骨骼上 真正移动还是靠角色本身 我们的角色动画蓝图有一个状态机 两个状态 一个idle 一个run 用blend poses依据状态来切换不同动画 那么骑车状态对应的就是也有两个动画 一个是骑车 一个是在车上坐着不动 现在我们只希望在骑车的时候播放音乐 那么就需要根据GroundSpeed来控制是否播放 虽然连接蓝图很麻烦看起来逻辑也不够清晰 但还是想把控制音乐播放这件事放在蓝图里解决而不是C++

现在打开ABP_RacingMiku_MainStates 左侧我的蓝图面板创建一个BP_HelloWorldCharacter类型的变量Owner 选择为对象引用 那么现在回到主动画蓝图ABP_RacingMiku 它的左侧我的蓝图面板现在可能没有变量 需要点击我的蓝图面板右上方齿轮 勾选显示继承的变量 就可以看到我们之前在HelloWorldAnimInstance类创建的一些变量比如GroundSpeed 打开ABP_RacingMiku事件图表 右键搜索Try Get Pawn Owner 再Cast To BP_HelloWorldCharacter 再对右下角As BP Hello World Character引脚右键 提升为变量 这个流程我们之前做过的 将这个新变量重命名为Owner 把Cast To BP_HelloWorldCharacter节点的执行引脚连接到Event Blueprint Update Animation节点的执行引脚上 打开ABP_RacingMiku的AnimGraph 选中ABP_RacingMiku_MainStates的Linked Anim Graph节点 右侧细节面板中 Exposable Properties一栏 将Owner绑定为Owner 这样就把主动画蓝图的变量传递到了ABP_RacingMiku_MainStates 打开ABP_RacingMiku_MainStates事件图表 拖入Owner 选择获取 右键转换为有效的Get 将这个节点连接到Event Blueprint Update Animation执行引脚后面 从GET节点的Target引脚往右拖 搜索Get Ride Audio Component
从GET节点的Is Valid执行引脚往右拖 搜索Branch 这就和if语句差不多 把CharacterState变量拖入视口 从引脚往右拖搜索== 选择equal enum 下拉菜单选择Riding状态 再拖入GroundSpeed变量 从引脚往右拖搜索> 选择Greater 再从Greater节点右侧引脚往右拖搜索AND 选择boolean and 把AND节点的右侧引脚连接到Branch节点的左侧Condition引脚 这样就是 状态为Riding 并且地面速度大于0 时 要去做一些事情 我们自然是希望这个时候播放音效PlayMusic 否则就停止播放音效StopMusic
从刚才做的Ride Audio Component引脚往右拖 搜索Play Music 就应该将Branch节点的True执行引脚连接到Play Music节点的执行引脚上 在这里我们不需要判断是否Is Playing 因为在C++中CharacterAudioComponent类的PlayMusic函数里 我们已经做过判定了 那么同样从Ride Audio Component引脚往右拖 搜索Stop Music 连接到Branch节点的False执行引脚上
现在来整理一下我们的意图 首先判断现在是否是Riding状态并且地面速度大于0 我们希望唯有这个时候才能播放音效 如果满足这个条件 就PlayMusic 不满足这个条件 就StopMusic
那么其实C++里的PlayMusic和StopMusic就不需要写了 我们就将其换成对于一个新的声音组件 专门用来播放上车音效 下车音效就不用了 MetaSound里也不需要勾选循环 这个组件我们也不打算在蓝图中使用其节点 只需要在蓝图中设置资产就够了

// HelloWorldCharacter.h private中
UPROPERTY(VisibleAnywhere, Category = Audio)
UCharacterAudioComponent* RideRingAudioComponent;
// HelloWorldCharacter.cpp
RideRingAudioComponent = CreateDefaultSubobject<UCharacterAudioComponent>(TEXT("RideRingAudioComponent"));
RideRingAudioComponent->SetupAttachment(GetRootComponent());
RideRingAudioComponent->bAutoActivate = false;
// HelloWorldCharacter.cpp AHelloWorldCharacter::RKeyPressed中
// if (CharacterState == ECharacterState::ECS_Riding) 分支中
// CharacterState = ECharacterState::ECS_Unequipped; 后面
// 增加一行
RideRingAudioComponent->PlayMusic();
// HelloWorldCharacter.cpp AHelloWorldCharacter::RKeyPressed中
// else 分支中
// CharacterState = ECharacterState::ECS_Riding; 后面
// 删掉原来的
 // RideAudioComponent->StopMusic();

PIE 现在可以实现 骑车有音效 停车无音效 上车有音效 下车无音效

敌人AI

AI需要一种叫做导航网格的资产 点击关卡编辑器左上角六边形右下角绿色加号的标志 - 放置Actor面板 在里面搜索nav 或者搜索导航 选择导航网格体边界体积 拖入视口 然后把放置Actor面板关了
这个体积是一个立方体 如果我们使用UE内置的导航功能来移动敌人 那么这种导航只能在这个体积内工作 现在就在XY轴上缩放使其放大 键盘按P可以查看其在地面上覆盖的面积

打开BP_Enemy 左侧组件面板选择BP_Enemy(自我) 右侧细节面板搜索controller 可以看到Pawn一栏有一个AI控制器类 默认设置为了AIController 那么角色默认就会被这个AI控制器控制 再在细节面板搜索auto possess 可以看到Pawn一栏有 自动控制玩家默认是已禁用 现在将其设置为玩家0 现在进入PIE 我们就会进入一个敌人的视角并控制它 所以还是将其设置回已禁用 自动控制AI显示已放置在场景中 现在我们的敌人都是我们手动放置的 不是生成的 假如我们之后还是会使用生成角色 这里就应该设置为 已放置在场景中或已生成 那么接下来我们的所有敌人 无论是放置在世界中还是动态生成的 都将自动被AI控制器所控制

我们可以通过获取AI控制器的节点来访问那个AI控制器类 在事件图表中右键 搜索Get AIController 这是一个返回AI控制器的函数 它左侧有一个输入引脚Controlled Actor 这是一个Actor类型 就是这个AI控制器要控制的Actor 鼠标悬浮在这个节点上 显示 其工作方式是 如果传入的Actor是一个Pawn 这个函数就会检索Pawn的控制器并将其转换为AI控制器 否则这个函数就会将Actor转换为AI控制器并返回 现在Enemy类就是一个Pawn 所以这里获取self就可以了
右键搜索self 选择Get a reference to Self 将Self引脚连接到Get AIController节点的Controlled Actor引脚上 那么现在这个Enemy的控制器就变成了AI控制器 Get AIController返回的是一个AI控制器类型 而AI控制器是控制器类的子类 那么现在我们就可以调用控制器类的方法 从Get AIController节点的Return Value引脚往右拖搜索并选择move to location 那么现在Move to Location节点的Target就是AI控制器 所以现在这个Move to Location就是AI控制器类上的函数
它还需要一个FVector参数Dest 也就是目的地
还有一个Acceptance Redius 接受半径 所以我们可以控制AI移动的接近速度 直到它到达目的地附近
路径查找Use Pathfinding默认已勾选 所以我们正在使用引擎内置的导航路径查找算法 如果这里取消选中 就只会使用简单的接近方式
Project Destination to Navigation将目的地投射到导航 意思是 如果我们的目的地不在导航网格内部 就不能使用 如果这个目的地只是在空中某处 也就是说只有Z轴不在导航网格内部 那么勾选这个复选框就可以把它投影到导航上
Filter Class过滤类是用来配置额外路径查找选项的 暂时我们不用管它
现在我们必须传入一个Dest的FVector 先做一个简易的 对Dest右键 - 分割结构体引脚 就保持这个默认的0 0 0
将Event BeginPlay执行引脚连接到Move to Location节点左侧执行引脚

PIE之前先看好地图上0 0 0点在哪里 并为敌人设置一些障碍物 可以看到敌人确实到达了目标的位置 而且绕过了障碍物
Move to Location节点有一个Return Value输出引脚 鼠标悬浮在上面可以看到这是一个EPathFollowingRequestResult的枚举类型 从Move to Location节点右侧执行引脚往右拖 搜索print string 再将Move to Location节点的Return Value引脚连接到Print String节点的In String引脚上 这样就可以打印到屏幕上 PIE 它打印了一个请求成功 意思就是移动到某个位置的请求成功了

既然可以移动到某个位置 那么又要怎么做追逐一个Actor呢?
也有类似的函数 把Move to Location节点删除 从Get AIController节点的Return Value引脚往右拖 搜索并选择Move to Actor 从Event BeginPlay连接执行引脚到Move to Actor 那么接下来它就要传入一个Goal 需要是一个Actor类型 在左侧我的蓝图面板 上方点击添加 - 变量 将其类型修改为Actor 选择对象类型一栏的Actor 选择为对象引用 重命名为Target 将其拖入视口 选择获取 将其设置为Goal
这个Target其实我们想在BP_Enemy实例的细节面板里设定 那么就要在左侧我的蓝图面板 选中变量Target 在右侧细节面板中勾选 可编辑实例 编译 这样在视口中BP_Enemy实例 细节面板搜索target 在默认一栏有一个Target 在这里就可以选定目标 也可以用右侧取色器在场景中选定一个目标 但由于现在场景中没有我们的HelloWorldCharacter角色 暂时无法选中
这里也可以选择目标点 打开放置Actor面板 搜索target 找到目标点拖入视口中 这个点也可以被选中作为目标
先把目标点删除

那么敌人怎么追逐正在移动的玩家
我们之前挥剑击中敌人 在C++中我们把一个名为CombatTarget的变量存储到了Enemy类中
接下来我们试着做一些并不高效但能解决问题的临时方法 我们要获取所有HelloWorldCharacter类型的Actor 在事件图表中右键搜索并选中get all actor of class 在Actor Class引脚中选中类BP_HelloWorldCharacter 这个Get All Actors Of Class节点最后会返回一个数组 其实我们知道这个数组里必然有唯一的一个元素是我们的HelloWorldCharacter 从Out Actors输出引脚往右拖 搜索并选中get a copy 把Event BeginPlay执行引脚连接到Get All Actors Of Class左侧执行引脚 把Get a copy节点右侧引脚连接到Move to Actor节点左侧Goal引脚 把Get All Actors Of Class节点右侧执行引脚连接到Move to Actor左侧执行引脚
PIE 移动我们的角色 发现敌人也在跟着我们移动 但是一旦我们的角色中途停止 敌人也就会停止到我们停止的位置 当我们再次发生移动 敌人并不会再次跟着我们了 仍然是站在那里 因为它们已经执行了Move to Actor的功能 到达了目的地就不再追赶 如果想让敌人继续追赶我们 就需要再次调用Move to Actor功能 从Move to Actor执行引脚往右拖搜索并选择delay 保持为这个0.2s 将Delay节点右侧Completed执行引脚连接到Get All Actors Of Class节点左侧执行引脚 因为这里出现了循环所以要整理一下蓝图连线的形状 鼠标放在这条线上 线变粗被高亮时 按下键盘上Enter 就会出现一个点 之后移动这个点 就可以改变线条的形状 在左侧组件面板选中角色移动组件 在右侧细节面板角色移动:行走一栏 将最大行走速度从600修改为300
PIE 现在敌人就可以持续追逐我们 并且速度变慢了 几乎追不上我们 我们尝试攻击它

PIE中 键盘按~输入命令show navigation 就可以看到导航网格的区域 如果我们打碎了可破坏物体 发现它原来的位置上还是没有绿色 如果我们打碎的是一个门 那么敌人之后也无法穿过门
那么怎么重新生成导航网格
打开项目设置 - 引擎 - 导航网格体 在运行时一栏 运行时生成 默认是静态 在这里选择为动态 再次进入PIE show navigation 可以看到地面上红绿一直在更新 打碎网格体之后也会更新 但是很明显也更消耗性能 而且可以发现现在导航网格的边缘是比较粗略的 不够精细 在生成一栏 展开导航网格体分辨率 展开高 找到单元大小 将其调小 就可以在视口中看到变化 物体占据的白色地面区域变小了 暂时设置为10 单元高度的意思是 多高的高度才开始分为区块进行阻挡 这样敌人就可以跨越更高的高度 太高了就会使得很小的东西不再发生阻挡 这个参数我们暂时设置为20

混合空间 Blendspace

敌人需要动画 巡逻的时候是走路 如果遇到威胁就开始跑步追击敌人 这个走路和跑步动画都不需要根运动 那么就要勾选强制根锁定 攻击动画也不能有根运动

现在来做不是使用状态机的动作混合 可以检查速度 在空闲 行走 跑步动画之间进行混合 在存放敌人蒙太奇动画的文件夹右键 - 动画 - 混合空间 选择对应骨骼 将其命名为IdleWalkRun_StrawberryMiku
双击打开 将Idle动画拖入下方面板中 可以看到出现了一个点 可以拖动它 也可以按Shift拖动它对齐到合适位置 我们应该把Idle动画放在最左侧 因为混合空间是由速度控制的 速度为0时 就idle 现在这个轴从左到右是0到100 也可以设置成不同的值 不只是0到100 左侧资产详情面板 有一栏Axis Settings 里面有水平坐标垂直坐标 展开水平座标 现在名称是None 命名为GroundSpeed 最小轴值0 最大轴值300 这样如果地面速度到了300或以上 就会完全在右侧这个轴上 那么就应该把run动画放在最右侧 按住Ctrl 拖动绿色叉号图标 就可以预览不同位置的效果 再拖动walk动画到其中 按住Shift 将其放在速度75位置上
打开敌人动画蓝图 Main States进入Idle状态 将刚才做好的混合空间拖入 取代之前的idle动画连接到Output Animation Pose节点上 这个混合空间节点现在就需要我们传入GroundSpeed 如果什么都不传 它就会默认在最左侧
在事件图表中拖入Owner节点并拖入 从Owner引脚往右拖搜索并选择get character movement 那么得到的就是移动组件 对这个Character Movement右键 - 提升为变量 默认名字就是Character Movement 我们就保持这个名字 给Event Blueprint Initialize Animation执行引脚后面连一个Sequence节点 这样之前的取得Owner变量就放在Then 0引脚后面 刚才的SET Character Movement节点就放在Then 1后面 在左侧我的蓝图面板再创建一个变量GroundSpeed 设置为浮点类型 打开敌人动画蓝图的Blueprint Thread Safe Update Animation 右键搜索并选中property access 找到Character Movement一栏里的Velocity 这就是速度 从这个节点右侧引脚往右拖搜索Vector Length XY 这就是在XY平面上的数值 把GroundSpeed拖入视口 选择为SET 这样就可以回到Idle状态中 把GroundSpeed传给混合空间IdleWalkRun_StrawberryMiku节点

现在进入PIE 敌人确实向我们跑来 现在我们还希望它能有前后左右的走路跑步动画 那么垂直轴就应该做成角度 命名为Direction 命名为-180到180 0是正前 -90是左 90是右4 180和-180是右 就把这些对应的资产拖动到对应的位置
那么接下来就只需要把这个旋转传给混合空间 回到敌人动画蓝图的事件图表 新建变量Direction 类型为浮点 打开Blueprint Thread Safe Update Animation 右键搜索并选择property access 选择Owner一栏的Get Actor Rotation 这样就得到了敌人当前的朝向 右键搜索并选择Unrotate Vector 意思是反向旋转向量 复制一个刚才得到的那个Character Movement.Velocity节点 返回值引脚连接到Unrotate Vector的A引脚 把Owner.GetActorRotation节点的返回值引脚连接到Unrotate Vector的B引脚 这个Unrotate Vector节点的作用是 因为我们的速度传入这个节点的是矢量 这个节点就能算出 如果我去掉了旋转 也就是说我的脸是面向正前方的 我的速度是什么 它就是把世界坐标速度转换成了本地坐标速度 从Unrotate Vector节点的Return Value引脚向右拖 右键搜索Break Vector 这样就把这个向量分成了XYZ的分量 对于速度我们只需要XY的分量 空白处右键搜索Atan2 选择Degress那个版本 这个是角度版本 而Radians是弧度版本 这个节点只需要传入两个数字 就可以算出这两个数字的正切角度值 将Break Vector的X输出引脚连接到Atan2的X输入引脚 将Break Vector的Y输出引脚连接到Atan2的Y输入引脚 注意这里不要连错 最后从Atan2的Return Value引脚连接到SET Direction 最后回到Idle状态将Direction变量传给混合空间节点
这里的原理是 本来Enemy现在已经正在有一个旋转了 然后它得到了一个新的速度 这个Enemy我现在要求它应该迅速变成那个新的速度 而我新的速度的方向和我当前已经有的这个旋转的方向是不一样的 那么我要去往那个新的速度方向 需要旋转多少角度 现在我要计算的是这个当前旋转方向与目标旋转方向的差值 这也就是我们刚才计算的Direction的值

现在还需要回到BP_Enemy 左侧组件面板选中角色移动组件 右侧细节面板搜索orient rotation 中文是将旋转朝向运动 取消勾选 在左侧组件面板选中BP_Enemy(自我) 右侧细节面板搜索use controller yaw 中文是使用控制器旋转Yaw 需要勾选 勾选之后敌人就只会朝向它摆放或者生成的那个方向 朝向不会发生旋转 那么它就会发生前后左右移动 非常适合固定区域 敌人只可能站在我们对面的攻击情况 如果取消勾选 当敌人以AI方式移动时 它就会朝向目标 这样就不会触发前后左右移动的混合空间 只会发生朝前移动的动画
我们暂时还是先让它方向朝向它的目标 也就是暂时不播放混合空间中的左右后动画

最好把这些写到C++ Enemy类的构造函数里

// Enemy.cpp
#include "GameFramework/CharacterMovementComponent.h"
// Enemy.cpp AEnemy::AEnemy 末尾添加
GetCharacterMovement()->bOrientRotationToMovement = true;
bUseControllerRotationPitch = false;
bUseControllerRotationYaw = false;
bUseControllerRotationRoll = false;

现在还有一个问题 我们发现敌人一直在跑 没有发生行走 我们之前设置的角色移动最大行走速度是300 混合空间里跑步速度300 走路速度75 我们将角色移动组件的最大行走速度设置为75 PIE 敌人确实在走 根据我们的走路资产 75这个速度太慢了 显得角色好像一边走一边有所倒退 175这个速度还可以 所以混合空间中步行的位置应该设置为175

我们的Idle状态现在也不只是有Idle 而是IdleWalkRun的混合 需要重命名为IdleWalkRun 如果想在游戏中改行走速度 让敌人开始跑 只需要调整速度就可以

随机idle动画 回调绑定

我们对于Dance状态的处理自始至终 现在终于要迎来一个较为稳定的简易解决方案

(废案)现在idle到dance状态的切换 只是idle播了10秒就切换 还要补充一个boolean AND 因为我们之前已经在蓝图线程安全更新图表里获取了GroundSpeed 现在需要再AND一个GroundSpeed==0 这样敌人就不会在追击我们的过程中进入dance 但是这样也不对 因为这样敌人在追击我们的时候还是一直在IdleWalkRun 状态一直在计时 那么只要它停下来满足GroundSpeed==0就会迅速进入Dance状态 这不是我们想要的 我们想要的是在GroundSpeed==0停留10秒后进入Dance状态 这样就需要一个用于计时的浮点数变量 在动画蓝图里新建一个浮点类型变量名为IdleTimer 拖入蓝图线程安全更新动画图表里 选择set 把执行引脚连上 把GroundSpeed拖入视口 get 右键搜索== 选择Equal 连接到GroundSpeed后面 那么现在我们要做的就是 如果GroundSpeed==0就计时 如果不等于0就重置计时 这里我们不用Branch节点 而是使用性能更优的Select Float节点 从Equal的bool返回值引脚往右拖搜索select 可以看到这里有很多select之后是不同类型 比如select int 这里我们就选择select float 这样就是从AB中选择一个float值返回 Pick A引脚连接的是bool值 那么就是bool值为真就返回A 否则就返回B 那么我们应该把IdleTimer+DeltaTime的值相加后连接到A 而B就应该设置为0 那么就是将IdleTimer拖入选择get 从引脚往右拖搜索add add节点的另一个引脚就连接蓝图线程安全动画图表里的那个Blueprint Thread Safe Update Animation节点里的Delta Time 这样就会每一帧都在累加时间 直到被清空 那么现在回到IdleWalkRun到Dance的转换规则 可以把之前写的都删了 只需要拖入IdleTimer 选择get 从引脚往右拖搜索>10 最后把bool返回值连接到Result节点
但是现在还有一个问题 转换到Dance状态之后 IdleTimer没有清零 在MainStates中选中Dance状态 在细节面板中可以看到已进入状态事件 但是它提醒我们 不再推荐 推荐的是使用动画节点函数版本 那么我们就需要在左侧我的蓝图面板新建函数 命名为ResetIdleTimer 在函数里面 拖入IdleTimer选择set 将这个节点的左侧执行引脚连接到ResetIdleTimer节点执行引脚后面 回到MainStates 进入Dance状态
选中Output Animation Pose节点 可以看到细节面板函数一栏 有3个选项
初始更新时 其实在动画里更新Update的意思就类似于Tick 那么初始更新就类似于这个节点的BeginPlay 意思就是这个节点第一次被调用的时候 也就是第一次进入Dance状态之后做返回的时候
变为相关时 意思是这个节点被调用时 也就是每次进入Dance状态之后做返回的时候
更新时 意思是每一帧都调用 类似于tick 但是是针对于这个状态的tick 也就是只有在这个状态之中的时候它才会更新 本例中的意思就是进入Dance状态之后的每一帧它都会tick一次 那么就会执行放在更新时的函数一次 离开这个状态它就不会再tick了
那么我们的ResetIdleTimer函数就应该放在变为相关时

(现行方案)但是受到前面的思路启发 我们似乎发现了更优雅的方式 我们可以利用这个更新时 比如我写一个函数 就在IdleWalkRun状态里执行 计时函数放在更新时里面 这样它每个Delta Time都会计时 而且这个函数也是根据GroundSpeed才计时或者清零 清零函数就放在变为相关时里面 这样就不需要Dance状态做什么工作了 而且IdleWalkRun状态内部本身还可以维护一个保持idle状态多长时间的变量 或许还有什么其它的用处 这样Dance状态就不需要做什么事情 一切都由IdleWalkRun状态自己维护

我们还是保留那个IdleTimer变量 把ResetIdleTimer函数删除 我们将要创建一个新函数 还是把这个函数放在蓝图里写 因为到目前为止关于这个dance动画的音效播放动画通知也是在蓝图里做的 并且我们的敌人动画蓝图没有做成自定义动画实例父类 所以暂时就先用蓝图
双击进入IdleWalkRun状态 选中Output Animation Pose节点 我们先做那个在进入IdleWalkRun状态时就清零IdleTimer的函数 在变为相关时的下拉菜单点击创建绑定 重命名为ResetIdleTimer 必须要在这里创建 如果直接在我的蓝图面板里创建函数 它不会出现在这个下拉菜单里 双击进入 拖入一个IdleTimer节点选择set 数值就设置为0 将其连接到ResetIdleTimer的执行引脚后面 Return Node的执行引脚前面
回到IdleWalkRun状态 选中Output Animation Pose节点 我们现在来做那个更新IdleTimer的函数 在更新时的下拉菜单点击创建绑定 重命名为UpdateIdleTimer 可以直接把蓝图线程安全更新动画图表里相关的部分也就是那个Select Folat逻辑直接复制过来 在UpdateIdleTimer的执行引脚后面连接SET IdleTimer节点的左侧执行引脚 在这个执行引脚后面 我们还需要做 如果这个数值超过10秒也要清零 所以就在SET IdleTimer节点的右侧执行引脚往右拖 搜索Branch 再拖入IdleTimer变量选择get 从IdleTimer的引脚往右拖 搜索> 选择greater将数值设置为10 将greater节点返回的bool值连接到Branch节点的Condition引脚 再拖入一个IdleTimer变量选择set 将其执行引脚连接到Branch节点的True引脚 这样就是如果IdleTimer>10 就重置为0 将后一个SET节点的右侧执行引脚连接到Return Node的执行引脚上 在左侧我的蓝图面板分别选中这两个函数 可以看到它们默认就是线程安全的 而且使用这种函数的办法 我们的动画蓝图事件图表非常整洁 当然我们现在使用的是简易的方式 最好的还是去C++写回调
PIE 现在终于 变成了每静置10秒就会跳一次舞
那么我们同样可以利用这个变为相关时 来设置一个int随机数 最后在Dance节点里blend by int 随机播放动画 那么进入Dance状态 选中Output Animation Pose节点 在变为相关时下拉菜单选择创建绑定 重命名为RandomDanceIndex 在我的蓝图面板新建DanceIndex变量 选为整数型 双击进入RandomDanceIndex函数 将DanceIndex变量拖入选择set 右键 搜索并选择Random Integer in Range from stream 本例中我们做3个动画 所以Min为0 Max为2 这个节点还需要输入一个随机数种子Stream 这是因为计算机其实没有办法做到真的随机 如果使用相同的随机数种子 那么随机出来的序列会是固定的 那就不是真随机 所以需要在左侧我的蓝图面板新建一个DanceStream变量 类型选择为stream也就是随机流送 这个类型的变量它会自动记录上一次随机到哪里 然后每次被调用时它就会自动跳到序列的下一个数字 这样随机后就不会形成固定的序列 左侧我的蓝图面板选中DanceStream变量 右侧细节面板找到默认值一栏 Dance Stream一栏有一个初始种子 现在是0 我们随便写一个数字进去 它就会准备好一串随机序列 本例中我们就填入39 把这个DanceStream变量拖入选择get 将其连接到Random Integer in Range from stream节点的Stream引脚上 把执行引脚都连上 那么接下来回到Dance状态 右键搜索并选择blend poses by int 把3个动画资产都连上 再将DanceIndex拖入 连接到Active Child Index引脚上 这样就可以随机播放动作

巡逻

巡逻目标点

如果想要巡逻 那么只需要设置一个目标点 不久后再移动到另一个点 总之就可以使用设置多个目标点的方式形成一个环路进行巡逻 假设我们在敌人类中使用变量存储巡逻点PatrolTarget 但如果敌人到达巡逻点之后 因为我们有很多个巡逻点 敌人要怎么知道它下一步要走向哪个巡逻点 一个办法是用数组来管理巡逻点TArray<AActor*> PatrolTargets 那么我们就用一个Actor指针数组来设定巡逻目标 每当敌人到达某个巡逻点时 它就能停下来 从数组中选取一个Actor作为新的巡逻目标 然后移动到那个点

打开VS

// Enemy.h
/**
* Navigation
*/

// Current patrol target
UPROPERTY(EditInstanceOnly, Category = "AI Navigation")
AActor* PatrolTarget;

// Array of patrol targets
UPROPERTY(EditInstanceOnly, Category = "AI Navigation")
TArray<AActor*> PatrolTargets;

这个多行注释 在VS中 只需要输入/** 之后换行 下一行就会自动出现*
其实我们之前设置过CombatTarget 但那是开始战斗后才被赋值的 在没有战斗目标的时候 敌人也会巡逻 所以需要单独的变量

现在有了巡逻目标 我们就想让敌人移动到其中一个 在官方文档中找到AAIController类 页面搜索MoveTo 可以看到头文件

virtual FPathFollowingRequestResult MoveTo
(
    const FAIMoveRequest& MoveRequest,
    FNavPathSharedPtr* OutPath
)

第1个参数是FAI移动请求 意思是移动的细节 FAIMoveRequest是一个结构体类型 它的官方文档没有写结构体内部的变量 但是它告诉了我们头文件路径/Engine/Source/Runtime/AIModule/Classes/AITypes.h 只需要找到并打开这个头文件 页面内搜索FAIMoveRequest 可以看到它的方法和成员变量

// AITypes.h
/** move goal: actor */
UPROPERTY()
TWeakObjectPtr<AActor> GoalActor; // 目标Actor

/** move goal: location */
mutable FVector GoalLocation; // 目标位置 是一个FVector

/** pathfinding: navigation filter to use */
TSubclassOf<UNavigationQueryFilter> FilterClass;

/** move goal is an actor */
uint32 bInitialized : 1;

/** move goal is an actor */
uint32 bMoveToActor : 1;

/** pathfinding: if set - regular pathfinding will be used, if not - direct path between two points */
uint32 bUsePathfinding : 1; // 允许路径追踪 打开就是可以遵循NavMesh导航网格的设置绕开障碍物等 如果关闭就是直接直线 不会绕开障碍物

/** pathfinding: allow using incomplete path going toward goal but not reaching it */
uint32 bAllowPartialPath : 1; // 允许不完全的路径追踪 也就是走向目标但不会到达 也就是允许明知道无法抵达目标 却仍然向着目标移动 最后到达一个离目标较近的位置

/** pathfinding: if set - require the end location to be linked to the navigation data*/
uint32 bRequireNavigableEndLocation : 1; // 开启后 就会检查目标点是否在导航网格上

/** pathfinding: if set - the pathfind query cost will be limited based on the heuristic between the start and end location (c.f. CostLimitFactor and MinimumCostLimit). */
uint32 bApplyCostLimitFromHeuristic : 1; // 设置一个成本上限
// CostLimitFactor 设置是直线距离的多少倍 防止找到太远的路 允许适当地绕远路 比直线距离更远 但是绕远之后的路程不应该超过MinimumCostLimit给定的绝对数值
// 这两个参数都是用来限制路程上限的 一个是依靠倍数 一个是依靠绝对的数值
// 最终的上限取决于这两个参数的最大值
// 比如直线距离3米 CostLimitFactor设置为1.5 那么只能走4.5米 这对于近路的绕远是绝对不够的 MinimumCostLimit设置为50米 那么路程上限就是4.5和50的最大值是50米 这个绝对的数值限制对于近路来说是更有效的
// 反而比如直线距离300米 CostLimitFactor设置为1.5 能走450米 MinimumCostLimit设置为50米 路程上限就是450米 对于远路的绕远大致也已经足够了 现在是这个倍数的限制更有效

/** pathfinding: goal location will be projected on navigation data before use */
uint32 bProjectGoalOnNavigation : 1; // 目标位置做投影

/** pathfinding: the request will start from the end of the previous path (if any), and the generated path will be merged with the remaining points of the previous path */
uint32 bStartFromPreviousPath : 1; // 旧路径没有走完就暂停了 如果继续走 就会在已有路径的末端上继续开始 生成的新路径将会和之前路径的剩余点合并

/** pathfollowing: acceptance radius needs to be increased by agent radius (stop on overlap vs exact point) */
uint32 bReachTestIncludesAgentRadius : 1;

/** pathfollowing: acceptance radius needs to be increased by goal actor radius */
uint32 bReachTestIncludesGoalRadius : 1;

/** pathfollowing: keep focal point at move goal */
uint32 bCanStrafe : 1;

/** pathfollowing: required distance to goal to complete move */
float AcceptanceRadius; // 距离目标点多远就可以判定为已经到达 之后停下来
 
/** pathfinding: this multiplier is used to compute a max node cost allowed to the open list (cost limit = CostLimitFactor*InitialHeuristicEstimate) */
float CostLimitFactor;

/** pathfinding: minimum cost limit clamping value (in cost units) used to allow large deviation in short paths */
float MinimumCostLimit;

/** custom user data: structure */
FCustomMoveSharedPtr UserData;

/** custom user data: flags */
int32 UserFlags;

第2个参数是导航路径共享指针 名为OutPath 是可选输出参数 用来填充分配的路径 那么我们可以通过OutPath来查看敌人的移动路径点

// Enemy.cpp
#include "AIController.h"

想要跳转到这个头文件却发现找不到 官方文档中它的头文件路径是/Engine/Source/Runtime/AIModule/Classes/AIController.h 这说明它是AIModule中的 那么就应该在build.cs里写上这个AIModule

 // HelloWorld.Build.cs
 PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GeometryCollectionEngine", "Niagara", "UMG", "KawaiiPhysics", "AIModule" });

先把那行AIController.h头文件注释掉 回到项目文件夹删除 Binaries Intermediate Saved DerivedDataCahce文件夹 对uproject右键生成新的sln
那么现在就可以正常跳转到AIController.h

// Enemy.h private中 /** Navigation */下面
UPROPERTY()
class AAIController* EnemyController;
// Enmey.cpp AEnemy::BeginPlay 末尾添加
EnemyController = Cast<AAIController>(GetController());
if (EnemyController && PatrolTarget)
{
    FAIMoveRequest MoveRequest;
    MoveRequest.SetGoalActor(PatrolTarget);
    MoveRequest.SetAcceptanceRadius(15.f);
    FNavPathSharedPtr NavPath;
    EnemyController->MoveTo(MoveRequest, &NavPath);
    TArray<FNavPathPoint> PathPoints = NavPath->GetPathPoints();
    for (auto Point : PathPoints)
    {
        DRAW_SPHERE_COLOR(Point.Location, FColor::Green);
    }
}
// Enemy.cpp
#include "Navigation/PathFollowingComponent.h"
// 为了使用FNavPathPoint

先将Enemy的Controller转换为AIController类型 之后就调用MoveTo函数 那么这个MoveTo函数就需要传入一个FAIMoveRequest 我们就需要先创建一个FAIMoveRequest 并设定一些参数 还需要传入一个FNavPathSharedPtr类型的指针 名为OutPath 意思是这是一个输出参数 那么在创建的时候我们就不需要进行赋值 只需要等待它最后被传出 MoveTo函数接收的是FNavPathSharedPtr* OutPath 那么它接收的是一个指针 而NavPath现在是一个智能指针对象 它是一个类的实例 而不是单纯的原始指针 这个FNavPathSharedPtr是UE里面的一个智能指针类型 看名字中的Shared就知道它里面除了地址 至少还有一个引用计数器 所以它必然是一个类而不是原始指针 实际上就是用类的对象包装了原始指针 FNavPathSharedPtr*要我们传入的是一个智能指针的地址 所以要传入&NavPath
(这里的逻辑就和我们没有学习过引用之前 要在一个函数内部修改一个外部的变量 就要传地址进去 的设计是一样的 而且OutPath这个参数最后是要传出的 也就是说这个函数内部会对传入的这个东西进行修改 所以一定是要传地址 当然是因为它这个函数定义里限定了是要传地址 平时我们习惯的都是传引用 而这里是要对变量进行修改的 所以不能设计成传const引用 只能是传引用 但是它最后设计成了是传指针
那么为什么要设计成传指针而不是引用? 因为按照现在这样设计 函数里不会对这个变量发生修改的就传const引用 那么在调用这个函数的地方看到的就是p 如果会发生修改就传地址 看到的就是\&p 这样开发者就可以迅速知道 这个 函数对于p发生了修改 所以按照通用的标准 基本上 对于这种传入后修改最后再传出的变量 都是直接传指针 而不是使用非const引用 而且如果是传指针 就可以直接传一个nullptr进去 如果是传引用 就必须事先要再新建一个变量 然后再传进去引用 这样可能就会要新建一个多余的变量)
MoveTo函数最后会传出一个FNavPathSharedPtr类型的NavPath 也就是得到一个路径 FNavPathSharedPtr里面有PathPoints 所以可以用箭头运算符调用GetPathPoints 这个FNavPathPoint类型是从FNavPathLocation派生的 所以我们可以知道它的位置 使用调试球打印出来

热重载 把BP_Enemy中关于AI的东西都删了
选中BP_Enemy实例 细节面板里搜索target 就可以看到Patrol Target变量和Patrol Targets数组 现在数组是0数组元素 我们暂时不动它 先把Patrol Target设置成某个东西 我们打开放置Actor面板 为它放一个目标点 并将其设置为Patrol Target
PIE 可以看到敌人角色在走直线 起点和终点都有绿色的球 将目标点途中再设置一些障碍比如一面完全阻挡的墙 它就会绕过这面墙 形成一些更多的绿色的球 在PIE播放按钮右侧有3个纵向排列的点 点击模拟 就可以看得更清楚 这一系列动作都是MoveTo函数做出来的

到达下一个目标点

接下来我们就做多个巡逻点
在BP_Enemy右侧细节面板 将Patrol Targets添加到3个元素 把视口里的目标点复制成3个 分别放到不同位置 把数组的这3个元素分别选择为这3个目标点 但是现在TargetPoint也就是正在使用的巡逻目标点还是之前的那个 我们并没有写怎么从数组中选择并切换巡逻目标点的机制

之前在Tick函数中 我们使用了CombatTarget并对其做了检查 这个是用来计算敌人到它的战斗目标的距离的 用来控制是否显示血条 如果超过了我们设置的距离 CombatTarget就会被置为空 我们现在可以把 根据指定的半径来判断我们是否在目标范围内 这个功能写成单独的函数 而不是直接写在Tick里

// Enemy.h protected中
bool InTargetRange(AActor* Target, double Radius) const;
// Enemy.cpp
bool AEnemy::InTargetRange(AActor* Target, double Radius) const
{
    const double DistanceToTarget = (Target->GetActorLocation() - GetActorLocation()).Size();
    return DistanceToTarget <= Radius;
}
// Enemy.cpp
void AEnemy::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (CombatTarget)
    {
        if (!InTargetRange(CombatTarget, CombatRadius))
        {
            CombatTarget = nullptr;
            if (HealthBarWidget)
            {
                HealthBarWidget->SetVisibility(false);
            }
        }
    }
}

那么我们接下来也可以利用这个AEnemy::InTargetRange函数来计算敌人到达它的巡逻目标的距离

// Enemy.h private中 /** Navigation */注释段 末尾添加
UPROPERTY(EditAnywhere)
double PatrolRadius = 200.f;

我们之前在BeginPlay函数里设置的接收半径AcceptanceRadius是15.f 这个接收半径是给AI用的 AI在离目标点的距离小于接收半径时 就算是到达了 它就可以停止了 我们写巡逻半径PatrolRadius这个变量 是我们用来判断何时才算是到达目标点并进行切换到下一个目标点的指示 而实际上在到达时 AI控制的敌人的停止位置距离目标点的距离会比我们设置的接收半径15稍微远一些 所以我们要确保只要AI已经到达了 巡逻半径就能足够判定为已经到达 就设置为200 也就是敌人距离目标点200 我们的逻辑就可以认为是到达目标点了 并准备切换到下一个目标点 即使这时AI可能还没到达终点

// Enemy.cpp AEnemy::Tick中 末尾添加 // 未采用
if (PatrolTarget && EnemyController)
{
    if (InTargetRange(PatrolTarget, PatrolRadius))
    {
        const int32 NumPatrolTargets = PatrolTargets.Num();
        if (NumPatrolTargets > 0)
        {
            int32 Selection = FMath::RandRange(0, NumPatrolTargets - 1);
            PatrolTarget = PatrolTargets[Selection];

            // 下面是从AEnemy:BeginPlay复制过来的做法
            FAIMoveRequest MoveRequest;
            MoveRequest.SetGoalActor(PatrolTarget);
            MoveRequest.SetAcceptanceRadius(15.f);
            EnemyController->MoveTo(MoveRequest);
        }
    }
}

如果我们现在距离巡逻目标点已经在巡逻半径范围内了 我们就判定为已经到达巡逻目标点了 接下来就应该开始随机切换到下一个巡逻目标点 然后去向下一个目标点 但因为我们不需要再使用调试球输出NavPath 所以也不需要设置NavPath变量并从MoveTo函数中传出了

我们只要是到达巡逻半径之内之后 就会开始随机生成下一个巡逻目标点 但是我们这件事竟然是在Tick函数里做的 如果从巡逻目标点数组中随机到的这个下一个目标点 正好就是我们已经到达的这个目标点 它就根本不需要移动 直接就是已经到达 因为我们是在Tick函数里写的 下一帧它就又会开始随机生成下一个目标点 假设有一种极限的情况 也就是接下来的每一帧随机生成的都是我们现在这个已经到达的目标点 角色就会卡在这里停止不动 为了避免这种情况 我们要把当前这个已经到达了的目标点 从随机的选项里排除 为此我们专门设置一个名为ValidTargets的数组 意思是有效目标 就在这里把当前已经到达了的这个目标排除 接下来就从这里面开始随机

// Enemy.cpp AEnemy::Tick中 末尾添加
if (PatrolTarget && EnemyController)
{
    if (InTargetRange(PatrolTarget, PatrolRadius))
    {
        TArray<AActor*> ValidTargets; // 从这里面再随机
        for (AActor* Target : PatrolTargets)
        {
            if (Target != PatrolTarget)
            {
                ValidTargets.AddUnique(Target);
            }
        }

        const int32 NumValidPatrolTargets = ValidTargets.Num();
        if (NumValidPatrolTargets > 0)
        {
            int32 Selection = FMath::RandRange(0, NumValidPatrolTargets - 1);
            PatrolTarget = ValidTargets[Selection];

            FAIMoveRequest MoveRequest;
            MoveRequest.SetGoalActor(PatrolTarget);
            MoveRequest.SetAcceptanceRadius(15.f);
            EnemyController->MoveTo(MoveRequest);
        }
    }
}

现在想要调试显示一下位置 每次检查敌人与目标点之间的距离时 都应该显示一次敌人和目标点的位置

// Enemy.cpp
bool AEnemy::InTargetRange(AActor* Target, double Radius) const
{
    const double DistanceToTarget = (Target->GetActorLocation() - GetActorLocation()).Size();
    DRAW_SPHERE_SingleFrame(GetActorLocation());
    DRAW_SPHERE_SingleFrame(Target->GetActorLocation());
    return DistanceToTarget <= Radius;
}

在目标点停留

接下来我们就做 在到达目标点之后停留一段时间再去下一个目标点 那么就需要一个定时器

// Enemy.cpp private中 /** Navigation */注释段 末尾添加
FTimerHandle PatrolTimer;
void PatrolTimerFinished();

现在是时候把我们在BegibPlay和Tick中做的重复的事情整理一下

// Enemy.h
void MoveToTarget(AActor* Target);
// Enemy.cpp
void AEnemy::MoveToTarget(AActor* Target)
{
    if (EnemyController == nullptr || Target == nullptr) return;
    
    FAIMoveRequest MoveRequest;
    MoveRequest.SetGoalActor(Target);
    MoveRequest.SetAcceptanceRadius(15.f);
    EnemyController->MoveTo(MoveRequest);
}
// Enemy.cpp AEnemy::BeginPlay中 修改成
EnemyController = Cast<AAIController>(GetController());
MoveToTarget(PatrolTarget);
// Enemy.cpp
void AEnemy::PatrolTimerFinished()
{
    MoveToTarget(PatrolTarget);
}

计时器时间一到 我们就移动到巡逻目标 这个PatrolTimerFinished函数其实是一个回调函数 我们先测试一下它怎么运作

// Enemy.cpp AEnemy::BeginPlay中
EnemyController = Cast<AAIController>(GetController());
// MoveToTarget(PatrolTarget);
GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, 5.f);

把Tick函数里关于巡逻的部分全都注释掉
热重载 PIE 发现敌人会在起点停留5秒 之后才去往巡逻目标点
但我们想要的其实是先去到目标点 再开始等待 那么在BeginPlay中还是应该直接使用MoveToTarget

// Enemy.cpp
EnemyController = Cast<AAIController>(GetController());
MoveToTarget(PatrolTarget);

是时候整理一下Tick函数了 把选择新的巡逻点写成一个单独的函数ChooseNewPatrolTarget 那么这个函数就应该返回一个选择得到的AActor*指针

// Enemy.h protected中
AActor* ChooseNewPatrolTarget() const;
// Enemy.cpp
AActor* AEnemy::ChooseNewPatrolTarget() const
{
    TArray<AActor*> ValidTargets;
    for (AActor* Target : PatrolTargets)
    {
        if (Target != PatrolTarget)
        {
            ValidTargets.AddUnique(Target);
        }
    }

    const int32 NumValidPatrolTargets = ValidTargets.Num();
    if (NumValidPatrolTargets > 0)
    {
        int32 Selection = FMath::RandRange(0, NumValidPatrolTargets - 1);
        return ValidTargets[Selection];
    }
    return nullptr;
}

我们应该把检测Target是否为空这件事放到各个函数内部 这样在函数外部调用的时候我们就不需要考虑这件事了
顺便把CombatTarget的检查也修改一下

// Enemy.cpp
bool AEnemy::InTargetRange(AActor* Target, double Radius) const
{
    if (Target == nullptr) return false;
    
    const double DistanceToTarget = (Target->GetActorLocation() - GetActorLocation()).Size();
    DRAW_SPHERE_SingleFrame(GetActorLocation());
    DRAW_SPHERE_SingleFrame(Target->GetActorLocation());
    return DistanceToTarget <= Radius;
}
// Enemy.cpp
void AEnemy::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        CombatTarget = nullptr;
        if (HealthBarWidget)
        {
            HealthBarWidget->SetVisibility(false);
        }
    }

    if (InTargetRange(PatrolTarget, PatrolRadius))
    {
        PatrolTarget = ChooseNewPatrolTarget();
        MoveToTarget(PatrolTarget);
    }
}

那么现在我们找到可以放计时器的地方了 也就是我们找到新的巡逻目标之后 先不要移动 而是等待 时间到了就会调用回调函数AEnemy::PatrolTimerFinished 自动移动到目标

// Enemy.cpp AEnemy::Tick中
if (InTargetRange(PatrolTarget, PatrolRadius))
{
    PatrolTarget = ChooseNewPatrolTarget();
    GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, 5.f);
}

热重载 PIE 这样敌人就会先从起点到达目标 在目标停留5秒后再走向下一个目标 为了视觉效果很明显停留的时候应该加一个左顾右盼的动作 但我们暂时先不做 而且敌人类内部现在代码写得非常混乱 但我们稍后再整理
现在还可以重构一下Tick函数 现在Tick函数中我们做的两件事情也就是 检查CombatTarget在不在CombatRadius范围内 检查PatrolTarget在不在PatrolRadius范围内 然后做一些事情 那么我们可以将其提取为函数

// Enemy.h protected中
void CheckCombatTarget();
void CheckPatrolTarget();
// Enemy.cpp
void AEnemy::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    CheckCombatTarget();
    CheckPatrolTarget();
}

void AEnemy::CheckCombatTarget()
{
    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        CombatTarget = nullptr;
        if (HealthBarWidget)
        {
            HealthBarWidget->SetVisibility(false);
        }
    }
}

void AEnemy::CheckPatrolTarget()
{
    if (InTargetRange(PatrolTarget, PatrolRadius))
    {
        PatrolTarget = ChooseNewPatrolTarget();
        GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, 5.f);
    }
}

但现在我们不想只停留5秒 而是随机的时长

// Enemy.h private中 /** Navigation */注释段 末尾添加
UPROPERTY(EditAnywhere, Category = "AI Navigation")
float WaitMin = 5.f;

UPROPERTY(EditAnywhere, Category = "AI Navigation")
float WaitMax = 8.f;
// Enemy.cpp
void AEnemy::CheckPatrolTarget()
{
    if (InTargetRange(PatrolTarget, PatrolRadius))
    {
        PatrolTarget = ChooseNewPatrolTarget();
        const float WaitTime = FMath::FRandRange(WaitMin, WaitMax);
        GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, WaitTime);
    }
}

可以回到BP_Enemy事件图表 把Target变量删除了

再给巡逻等待期间做一个左顾右盼的动画

// Enemy.h public中
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI Navigation")
bool IsWaiting = false;
// Enemy.cpp
void AEnemy::CheckPatrolTarget()
{
    if (InTargetRange(PatrolTarget, PatrolRadius))
    {
        PatrolTarget = ChooseNewPatrolTarget();
        const float WaitTime = FMath::FRandRange(WaitMin, WaitMax);
        IsWaiting = true; // 添加了一行
        GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, WaitTime);
    }
}
// Enemy.cpp
void AEnemy::PatrolTimerFinished()
{
    IsWaiting = false; // 添加了一行
    MoveToTarget(PatrolTarget);
}

这样回到动画蓝图状态机 添加一个状态Wait 左侧我的蓝图面板添加bool型变量IsWait 在蓝图线程安全更新图表 右键搜索property access 下拉菜单选择Owner的IsWaiting 将IsWait变量拖入 选择set 将Owner:IsWaiting连接到SET节点的Is Wait引脚上 最后把SET节点执行引脚连接到Sequence节点的执行引脚上 这样我们就从Enemy类中获取了IsWaiting变量
在IdleWalkRun到Wait转换规则拖入IsWait变量 选择get 连接到Result节点 在Wait到IdleWalkRun转换规则拖入IsWait变量 选择get 后面连接一个not boolean节点 再连接到Result节点 最后进入Wait状态把等待的动画资产放在里面 设置为循环动画

根据后面设置的敌人状态 其实应该是只有在敌人处于巡逻状态时才能切换到Wait状态 那么我们就还要从Owner中获取当前的状态

// Enemy.h private中
UPROPERTY(BlueprintReadOnly, meta = (AllowPrivateAccess = "true"), Category = "AI Navigation")
EEnemyState EnemyState = EEnemyState::EES_Patrolling;

热重载 在蓝图线程安全更新图表 右键搜索property access 下拉菜单选择Owner的EnemyState 后面连接一个Equal Enum节点 下拉菜单选择Patrolling 将Owner:IsWaiting的bool返回值和Enum Equal Patrolling的bool返回值一起连接到一个AND boolean节点 最后返回到SET节点的Is Wait引脚 这样这个IsWait变量就可以用来指示是否进入Wait状态
这样之后 即使我们在它左顾右盼地wait的时候击中它 它就会迅速反应过来 播放受击动作 并且开始追我们

Pawn感应组件

在BP_Enemy中添加一个Pawn感应组件 细节面板中有视觉和听觉阈值 我们可以调节它们 如果阈值范围内有东西发出噪音 Pawn就会被警觉 有一个正在监听阈值 还有一个LOS监听阈值 都是球体 但我们现在更关心视觉 可以调整视线半径 它更像是一个二维的圆片形状 现在周边视觉角度是90度 我们降低它的值 可以发现它变成了一个锥体 这样敌人就只能在这个锥体范围内看到东西
删掉我们刚才创建的Pawn感应组件

现在希望敌人看到我们之后就把我们标记成要追逐的目标 并开始MoveTo追我们 追我们的时候就应该变成跑步 那么就需要设置最大步行速度

我们要使用UPawnSensingComponent 就将这个组件放在之前创建的自定义组件Attributes附近 前面注释为这些是组件 查看一下官方文档

// Enemy.h
class UPawnSensingComponent;
// Enemy.cpp
#include "Perception/PawnSensingComponent.h"
// Enemy.h private中
/**
* Components
*/
UPROPERTY(VisibleAnywhere)
UAttributeComponent* Attributes;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
UHealthBarComponent* HealthBarWidget;

UPROPERTY(VisibleAnywhere)
UPawnSensingComponent* PawnSensing;

回到Enemy.cpp头文件中把关于组件的头文件都整理到一起

// Enemy.cpp AEnemy::AEnemy末尾添加
PawnSensing = CreateDefaultSubobject<UPawnSensingComponent>(TEXT("PawnSensing"));
PawnSensing->SightRadius = 4000.f;
PawnSensing->SetPeripheralVisionAngle(45.f);

敌人看到了Pawn之后会做一些事情 那么这必然就是需要一个委托 之后要用回调来响应委托 我们进入PawnSensingComponent.h头文件中查看 搜索OnSeePawn 可以看到这确实是一个FSeePawnDelegate委托类型 Ctrl并鼠标左键点击进入这个类型的定义 可以看到

// PawnSensingComponent.h
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam( FSeePawnDelegate, APawn*, Pawn );

这就是个函数签名 我们需要一个接收APawn类型的回调函数 每当敌人看到Pawn 就会触发OnSeePawn委托 那么就会调用我们绑定到OnSeePawn委托上的回调函数

// Enemy.h protected中
UFUNCTION()
void PawnSeen(APawn* SeenPawn);

现在我们先测试一下它的功能

// Enemy.cpp
void AEnemy::PawnSeen(APawn* SeenPawn)
{
    UE_LOG(LogTemp, Warning, TEXT("Pawn Seen!"));
}
// Enemy.cpp AEnemy::BeginPlay 末尾添加
if (PawnSensing)
{
    PawnSensing->OnSeePawn.AddDynamic(this, &AEnemy::PawnSeen);
}

热重载 其实UE5.6会告诉我们UPawnSensingComponent已经被弃用了 但是还能通过编译 所以我们暂时就先这么写
PIE 在我们的角色进入敌人范围时 确实会输出Pawn Seen!
那么我们接下来就需要在这个回调函数里实现对于玩家的追逐行为

追逐

敌人状态

敌人也需要状态 巡逻 追逐 攻击
新建一个EnemyTypes.h 放在Enemy文件夹里

// EnemyTypes.h
UENUM(BlueprintType)
enum class EEnemyState : uint8
{
    EES_Patrolling UMETA(DisplayName = "Patrolling"),
    EES_Chasing UMETA(DisplayName = "Chasing"),
    EES_Attacking UMETA(DisplayName = "Attacking")
};
// Enemy.h private中
EEnemyState EnemyState = EEnemyState::EES_Patrolling;
// CharacterTypes.h 末尾添加
UENUM(BlueprintType)
enum class EEnemyState : uint8
{
    EES_Patrolling UMETA(DisplayName = "Patrolling"),
    EES_Chasing UMETA(DisplayName = "Chasing"),
    EES_Attacking UMETA(DisplayName = "Attacking")
};

那么Tick函数中的操作 只有可能在某些状态下才需要发生 敌人正在显示血条 只有可能是要么在攻击玩家 要么在追击玩家 那么就只有在这两种状态的时候才需要检查CombatTarget 而只有在巡逻状态时 才需要检查PatrolTarget
刚才写的枚举类 可以看到从上往下的状态排列 冲突是在逐渐升级的 所以写枚举的时候最好要进行排序 我们已经知道枚举的本质就是数字 那么就可以用>EES_Patrolling来表示追逐或者攻击 那么不是这两种状态的话 就只可能是巡逻状态

// Enemy.cpp
void AEnemy::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (EnemyState > EEnemyState::EES_Patrolling)
    {
        CheckCombatTarget();
    }
    else
    {
        CheckPatrolTarget();
    }
}

我们希望敌人在巡逻状态发现玩家之后 敌人就应该切换到追逐状态 然后开始追逐玩家 但是现在Pawn感应组件不止能发现玩家Pawn 它也能感应到其它Pawn 那么就需要检查那个Pawn是不是我们的HelloWorldCharacter

// Enemy.cpp
void AEnemy::PawnSeen(APawn* SeenPawn)
{
    if (Cast<AHelloWorldCharacter>(SeenPawn))
// 后面省略

我们可以使用这样的转换 但是其实PawnSeen会被调用很多次 每次都这么转换 开销实在太大了
可以使用ActorTag标签 这是在Actor层面上分配给Actor的 必须放在BeginPlay里 构造函数里太早了

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::BeginPlay()
{
    Super::BeginPlay();
    Tags.Add(FName("HelloWorldCharacter"));
}
// Enemy.cpp
void AEnemy::PawnSeen(APawn* SeenPawn)
{
    if (SeenPawn->ActorHasTag(FName("HelloWorldCharacter")))
    {
        UE_LOG(LogTemp, Warning, TEXT("Pawn Seen!"));
    }
}

接下来我们开始补全AEnemy::PawnSeen函数

// Enemy.cpp
void AEnemy::PawnSeen(APawn* SeenPawn)
{
    if (EnemyState == EEnemyState::EES_Chasing) return;

    if (SeenPawn->ActorHasTag(FName("HelloWorldCharacter")))
    {
        EnemyState = EEnemyState::EES_Chasing;
        GetWorldTimerManager().ClearTimer(PatrolTimer);
        GetCharacterMovement()->MaxWalkSpeed = 300.f;
        CombatTarget = SeenPawn;
        MoveToTarget(CombatTarget);
    }
}

首先要将敌人从巡逻状态切换到追逐状态 那么既然不是巡逻状态 就不应该进行计时了 那么它也就不会触发设置在定时器上的那个函数AEnemy::PatrolTimerFinished 就也不会往下一个巡逻点进行移动 那我们就要让敌人追着角色跑 那么就要设置成跑步 也就是设置最大行走速度 这个跑步速度我们暂时就先在C++里硬编码了 是混合空间里跑步动画的速度300 那么既然都开始追逐了 就即将开始战斗 那么把战斗目标就设置成敌人看到的那个目标 然后敌人要追随它移动过去
但是现在还是有一个问题 PawnSeen被调用得是很频繁的 那么如果已经是追逐状态了 就没必要重复这个流程

Ctrl+F5 PIE 现在进入敌人视野 我们已经可以被它追逐 但是如果它追到了我们 也就是到达了AcceptanceRadius之内 它就停止 我们再跑掉 它就停留在原地不会再追我们了 还有就是我们在背后击中敌人的时候 它不会发现我们开始追我们 这都是我们接下来要修复的

但现在我们优先去做 跑得足够远敌人就不再追我们 这个距离设置为是血条消失的那个距离就正好 也就是CombatRadius
AEnemy::CheckCombatTarget里 我们只检查了有没有在CombatRadius范围之外 如果在外面就关闭血条显示 我们正好可以在这里顺便让敌人对我们失去兴趣

// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        CombatTarget = nullptr;
        if (HealthBarWidget)
        {
            HealthBarWidget->SetVisibility(false);
        }
        // 末尾添加
        EnemyState = EEnemyState::EES_Patrolling;
        GetCharacterMovement()->MaxWalkSpeed = 175.f;
        MoveToTarget(PatrolTarget);
    

把敌人设置回巡逻状态 并且要回到步行的速度175 因为我们的混合空间步行动画就在175 并且去向巡逻目标移动 而不是追着战斗目标跑

攻击

接下来修复敌人追到我们停下之后不再继续追的问题 我们现在有一个外圈 超出这个外圈的距离敌人就不会追逐我们 接下来我们要做一个内圈 这样如果我们在这个内圈范围内 敌人就可以对我们进行攻击 一旦我们超出这个内圈 敌人就会又开始追我们 而且我们感觉CombatRadius数值设置得有些小 整个战场非常狭窄 视觉效果不好 可以尝试调大一些

// Enemy.h
UPROPERTY(EditAnywhere)
double CombatRadius = 750.f;

UPROPERTY(EditAnywhere)
double AttackRadius = 250.f;
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        CombatTarget = nullptr;
        if (HealthBarWidget)
        {
            HealthBarWidget->SetVisibility(false);
        }
        EnemyState = EEnemyState::EES_Patrolling;
        GetCharacterMovement()->MaxWalkSpeed = 175.f;
        MoveToTarget(PatrolTarget);
    }
    else if (!InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Chasing)
    {
        EnemyState = EEnemyState::EES_Chasing;
        GetCharacterMovement()->MaxWalkSpeed = 300.f;
        MoveToTarget(CombatTarget);
    }
    else if (InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Attacking)
    {
        EnemyState = EEnemyState::EES_Attacking;
        // TODO: Attack
    }
}

如果在战斗范围之内 但是不在攻击范围之内 那么敌人现在就应该去追逐而不是攻击 我们之前做的逻辑是 如果敌人在战斗范围内看到了我们 就会调用AEnemy::PawnSeen函数开始追我们 如果我们跑到了战斗范围之外(这件事是在Tick函数中检查的 调用了AEnemy::CheckCombatTarget函数) 它就不再追我们并且回到巡逻状态
那么如果我们在战斗范围之内 敌人却没有正在追逐我们 它就应该变更为追逐我们的状态 并且追逐的时候要做一系列事情比如修改最大行走速度 向着我们移动 这其实是我们在AEnemy::PawnSeen里做过的事情
而如果我们在内圈攻击范围之内 并且敌人不在攻击状态 就要切换为攻击状态 然后攻击我们 后面具体的操作比如拿出武器切换动画目前我们就先不实现了

PIE 发现敌人在我们非常接近时停下了追逐进入攻击状态 在我们离得稍远之后就又开始追逐 离得更远之后就对我们失去了兴趣

还需要修复一件事情
我们之前在PawnSeen函数里写的是 如果已经在追逐状态 PawnSeen函数就什么都不做 那么对于已经在攻击状态的 也应该是一样的什么都不做 而且在PawnSeen函数里 只可能会把状态设置为追逐 但如果现在已经是攻击状态 就不应该又修改成追逐 因为如果像现在这样的话就会导致 我们在敌人的攻击距离范围之内 Tick函数内的CheckCombatTarget就会把敌人状态设置为攻击状态 而PawnSeen函数也在频繁地被调用 又迅速会把状态设置为追逐 这样就会攻击状态追逐状态反反复复切换 我们要修复这个问题

// Enemy.cpp
void AEnemy::PawnSeen(APawn* SeenPawn)
{
    if (EnemyState == EEnemyState::EES_Chasing) return;

    if (SeenPawn->ActorHasTag(FName("HelloWorldCharacter")))
    {
        GetWorldTimerManager().ClearTimer(PatrolTimer);
        CombatTarget = SeenPawn;
        
        if (EnemyState != EEnemyState::EES_Attacking)
        {
            EnemyState = EEnemyState::EES_Chasing;
            GetCharacterMovement()->MaxWalkSpeed = 300.f;
            MoveToTarget(CombatTarget);
        }
    }
}

if (EnemyState != EEnemyState::EES_Attacking)语句后面的才是切换到追逐状态后必须要做的一系列操作 所以这里需要迁移顺序

接下来我们要做的事情是 我们在背后攻击敌人之后 敌人并没有变得去追逐我们 所以我们希望它在受击之后也能进入追逐状态 并进行切换到追逐状态之后要做的一系列操作

我们之前的AEnemy::TakeDamage函数 就可以在受击之后将攻击它的Actor设置为CombatTarget 那么我们就可以在这里将敌人设置为追逐状态 然后进行一系列操作比如向目标进行移动

// Enemy.cpp AEnemy::TakeDamage中
CombatTarget = EventInstigator->GetPawn();
// 添加
EnemyState = EEnemyState::EES_Chasing;
GetCharacterMovement()->MaxWalkSpeed = 300.f;
MoveToTarget(CombatTarget);

那么敌人直接进入了追逐状态 我们既然能攻击到敌人 那么肯定是在战斗半径之内 所以在被Tick函数调用的CheckCombatTarget函数里 不会进入if (!InTargetRange(CombatTarget, CombatRadius))分支 而且敌人现在已经是追逐状态了 也不会进入else if (!InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Chasing)分支 那么就算我们现在正在内圈攻击范围之内 就会进入else if (InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Attacking)分支 敌人就会切换到攻击状态 对我们进行攻击
热重载 PIE 我们从背后攻击敌人 现在敌人直接转身过来追逐我们

我们其实在引入AI之后一直都能发现一个问题 也就是敌人死亡之后 它仍然在向着我们的方向进行切换角度 但现在我们暂时不修复这个问题

AEnemy::InTargetRange里的调试球删除

继承 BaseCharacter类

现在基本上全都修复完了 是时候进行敌人攻击我们的具体实现了 但在此之前我们要给敌人装备武器 武器的中心位置是各种各样的 甚至现在我们要给敌人使用的法杖资产 它默认是横着摆放的 而且如果是简单地把BP_Weapon替换网格体 解决不了这个问题 当然可以通过修改骨骼插槽上的插槽增加旋转来解决 但是这样的话就会如果同一个角色想使用两种武器 明明都是单手武器 却要做两个插槽 所以我们就要去Blender修改网格原点
打开Blender 其实这两个武器的中心都是大致放在手持的位置上 但是旋转朝向不一样 要么在Blender里修改 要么在UE的fbx导入窗口里设置旋转偏移位置偏移
修改结束之后 HelloWorldCharacter捡起新武器的手持和装在后背都没什么问题 虽然其实这个武器我们是打算给敌人用的 再给敌人做个武器插槽

发现敌人和角色有些功能是可以共享的 那么就可以使用继承 现在角色类有敌人类需要的功能 比如攻击 敌人类也有角色类需要的功能 比如受击 受伤害 我们肯定不想互相复制粘贴 那么就要利用继承 那么我们就要做角色类AHelloWorldCharacter和敌人类AEnemy共同的基类 命名为ABaseCharacter 然后我们呢就把角色类敌人类都需要的变量和函数转移到BaseCharacter类中 这样就可以按照需要做继承

健康条不会放到BaseCharacter类中 因为角色的健康条我们不打算放在头顶上 而是放在屏幕左上方的固定位置 而AI那些东西是敌人类独有的 键盘WASDER是角色类独有的 而且我们要让角色类也继承HitInterface接口 因为我们之后也要让角色受击

新建一个C++类 放在Characters文件夹里 命名为BaseCharacter

// HelloWorldCharacter.h
#include "BaseCharacter.h"
// 替换掉
// #include "GameFramework/Character.h"

这里直接写BaseCharacter.h就可以 不需要写Characters/ 因为它们都在同一个文件夹Characters中

// Enemy.h
#include "Characters/BaseCharacter.h"
// 替换掉
// #include "GameFramework/Character.h"
// HelloWorldCharacter.h
class HELLOWORLD_API AHelloWorldCharacter : public ABaseCharacter, public IHitInterface
// HelloWorldCharacter.h
#include "Interfaces/HitInterface.h"
// Enemy.h
class HELLOWORLD_API AEnemy : public ABaseCharacter, public IHitInterface

现在整理一下BaseCharacter类 我们不会在BaseCharacter类里添加任何输入

// BaseCharacter.h
// 删掉
// virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;

BaseCharacter.cpp里的ABaseCharacter::SetupPlayerInputComponent函数实现也要删掉 这样的话就是和Character类上的版本是一样的 HelloWorldCharacter类继承的时候就会得到Character类上的版本
有些变量我们希望它能在敌人和角色类中继承并使用 就要移动到BaseCharacter类的protected中 而原本的类中就不应该保留那个变量 而是直接调用父类的 函数同理 有些函数具体实现不一样就要在基类写成虚函数virtual 子类头文件中声明就要写virtual和override 先看HelloWorldCharacter类和Enemy类的public函数和变量 再看protected private 并且共有的private变量要变成protected 这样逐渐整理 一边修改一边Ctrl+B验证有没有错误 有时修改之后VS没能迅速加载导致还报错 就在文本空白处右键 - 重新扫描 - 重新扫描文件
Enemy类继承的接口类 要让BasicCharacter类继承 并且Enemy取消继承 但是AEnemy::GetHit_Implementation函数还是没有任何区别 因为它本来就是virtual override 只是现在父类从IHitInterface变成了继承了IHitInterface类的BasicCharacter类
Enemy类中的AEnemy::SetupPlayerInputComponent完全用不到 所以直接删掉

// BaseCharacter.h
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Interfaces/HitInterface.h"
#include "BaseCharacter.generated.h"

class AWeapon;
class UAnimMontage;
class UAttributeComponent;

UCLASS()
class HELLOWORLD_API ABaseCharacter : public ACharacter, public IHitInterface
{
    GENERATED_BODY()

public:
    ABaseCharacter();

    UFUNCTION(BlueprintCallable)
    void SetWeaponCollisionEnabled(ECollisionEnabled::Type CollisionEnabled);

protected:
    virtual void BeginPlay() override;

    UPROPERTY(VisibleAnywhere, Category = Weapon)
    AWeapon* EquippedWeapon;


    /**
    * Attack
    */
    virtual void Attack();

    UPROPERTY(EditDefaultsOnly, Category = Montages)
    UAnimMontage* AttackMontage;

    virtual void PlayAttackMontage();

    FORCEINLINE virtual bool CanAttack();

    UFUNCTION(BlueprintCallable)
    virtual void AttackEnd();


    /**
    * Hit React
    */
    // override get hit from IHitInterface
    // GetHit_Implementation

    UPROPERTY(EditDefaultsOnly, Category = Montages)
    UAnimMontage* HitReactMontage;

    virtual void PlayHitReactMontage(const FName& SectionName);
    // Specifically played in DirectionalHitReact, determined by the impact point

    virtual void DirectionalHitReact(const FVector& ImpactPoint);

    // override take damage from AActor
    // TakeDamage


    /**
    * Death
    */
    virtual void Die();

    UPROPERTY(EditDefaultsOnly, Category = Montages)
    UAnimMontage* DeathMontage;

    virtual void PlayDeathMontage();


    /**
    * Components
    */
    UPROPERTY(VisibleAnywhere)
    UAttributeComponent* Attributes;

private:


public:    
    virtual void Tick(float DeltaTime) override;
};
// BaseCharacter.cpp
#include "Characters/BaseCharacter.h"
#include "Items/Weapons/Weapon.h"

#include "Components/BoxComponent.h"
#include "Components/AttributeComponent.h"

ABaseCharacter::ABaseCharacter()
{
    PrimaryActorTick.bCanEverTick = true;

    Attributes = CreateDefaultSubobject<UAttributeComponent>(TEXT("Attributes"));

}

void ABaseCharacter::BeginPlay()
{
    Super::BeginPlay();
    
}

void ABaseCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

void ABaseCharacter::SetWeaponCollisionEnabled(ECollisionEnabled::Type CollisionEnabled)
{
    if (EquippedWeapon && EquippedWeapon->GetWeaponBox())
    {
        EquippedWeapon->GetWeaponBox()->SetCollisionEnabled(CollisionEnabled);
        EquippedWeapon->IgnoreActors.Empty();
    }
}

void ABaseCharacter::Attack()
{

}

void ABaseCharacter::PlayAttackMontage()
{

}

bool ABaseCharacter::CanAttack()
{
    return true;
}

void ABaseCharacter::AttackEnd()
{

}

void ABaseCharacter::PlayHitReactMontage(const FName& SectionName)
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && HitReactMontage)
    {
        AnimInstance->Montage_Play(HitReactMontage);
        AnimInstance->Montage_JumpToSection(SectionName, HitReactMontage);
    }
}

void ABaseCharacter::DirectionalHitReact(const FVector& ImpactPoint)
{
    const FVector Forward = GetActorForwardVector();
    const FVector ToHit = (ImpactPoint - GetActorLocation()).GetSafeNormal();
    const double CosTheta = FVector::DotProduct(Forward, ToHit);
    if (CosTheta > 0.707106781187)
    {
        PlayHitReactMontage(FName("Front"));
    }
    else if (CosTheta < -0.707106781187)
    {
        PlayHitReactMontage(FName("Back"));
    }
    else
    {
        const double SinTheta = FVector::CrossProduct(Forward, ToHit).Z;
        if (SinTheta > 0)
        {
            PlayHitReactMontage(FName("Right"));
        }
        else
        {
            PlayHitReactMontage(FName("Left"));
        }
    }
}

void ABaseCharacter::Die()
{

}

void ABaseCharacter::PlayDeathMontage()
{

}
// Enemy.h
#pragma once

#include "CoreMinimal.h"
#include "Characters/BaseCharacter.h"
#include "Characters/CharacterTypes.h"
#include "Enemy.generated.h"

class USoundBase;
class UHealthBarComponent;
class UCharacterAudioComponent;
class UPawnSensingComponent;

UCLASS()
class HELLOWORLD_API AEnemy : public ABaseCharacter
{
    GENERATED_BODY()

public:
    AEnemy();

    virtual void Tick(float DeltaTime) override;

    virtual void GetHit_Implementation(const FVector& ImpactPoint) override;

    virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser) override;

private:
    /**
    * Components
    */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
    UHealthBarComponent* HealthBarWidget;

    UPROPERTY(VisibleAnywhere)
    UPawnSensingComponent* PawnSensing;

    UPROPERTY()
    AActor* CombatTarget;

    UPROPERTY(EditAnywhere)
    double CombatRadius = 750.f;

    UPROPERTY(EditAnywhere)
    double AttackRadius = 250.f;

    /**
    * Play sounds
    */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"), Category = Audio)
    UCharacterAudioComponent* IdleAudioComponent;
public:
    UCharacterAudioComponent* GetIdleAudioComponent() const { return IdleAudioComponent; }

protected:
    virtual void BeginPlay() override;

    void CheckCombatTarget();
    void CheckPatrolTarget();
    bool InTargetRange(AActor* Target, double Radius) const;
    void MoveToTarget(AActor* Target);
    AActor* ChooseNewPatrolTarget() const;

    UFUNCTION()
    void PawnSeen(APawn* SeenPawn);

    virtual void PlayHitReactMontage(const FName& SectionName) override;

    virtual void Die() override;

    virtual void PlayDeathMontage() override;

    UPROPERTY(BlueprintReadOnly)
    EDeathPose DeathPose = EDeathPose::EDP_Alive;

private:
    /**
    * Navigation
    */
    UPROPERTY()
    class AAIController* EnemyController;

    // Current patrol target
    UPROPERTY(EditInstanceOnly, Category = "AI Navigation", BlueprintReadWrite, meta = (AllowPrivateAccess = "true"))
    AActor* PatrolTarget;

    // Array of patrol targets
    UPROPERTY(EditInstanceOnly, Category = "AI Navigation")
    TArray<AActor*> PatrolTargets;

    UPROPERTY(EditAnywhere)
    double PatrolRadius = 200.f;

    FTimerHandle PatrolTimer;
    void PatrolTimerFinished();

    UPROPERTY(EditAnywhere, Category = "AI Navigation")
    float WaitMin = 5.f;

    UPROPERTY(EditAnywhere, Category = "AI Navigation")
    float WaitMax = 8.f;

    EEnemyState EnemyState = EEnemyState::EES_Patrolling;
};
// Enemy.cpp
#include "Enemy/Enemy.h"
#include "HelloWorld/DebugMacros.h"
#include "AIController.h"

#include "Components/SkeletalMeshComponent.h"
#include "Components/CapsuleComponent.h"
#include "Components/AttributeComponent.h"
#include "HUD/HealthBarComponent.h"
#include "Components/CharacterAudioComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Navigation/PathFollowingComponent.h"
#include "Perception/PawnSensingComponent.h"

AEnemy::AEnemy()
{
    PrimaryActorTick.bCanEverTick = true;

    GetMesh()->SetCollisionObjectType(ECollisionChannel::ECC_WorldDynamic); // 设置为WorldDynamic
    GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Visibility, ECollisionResponse::ECR_Block); // 阻挡可见性通道 这样敌人就能被盒子追踪到
    GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore); // 相机通道响应设置为忽略
    GetMesh()->SetGenerateOverlapEvents(true); // 需要勾选生成重叠事件
    GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore); // 胶囊体也要忽略相机

    IdleAudioComponent = CreateDefaultSubobject<UCharacterAudioComponent>(TEXT("IdleAudioComponent"));
    IdleAudioComponent->SetupAttachment(GetRootComponent()); // 声音跟随角色移动
    IdleAudioComponent->bAutoActivate = false; // 默认不自动播放

    HealthBarWidget = CreateDefaultSubobject<UHealthBarComponent>(TEXT("HealthBar"));
    HealthBarWidget->SetupAttachment(GetRootComponent());

    GetCharacterMovement()->bOrientRotationToMovement = true;
    bUseControllerRotationPitch = false;
    bUseControllerRotationYaw = false;
    bUseControllerRotationRoll = false;

    PawnSensing = CreateDefaultSubobject<UPawnSensingComponent>(TEXT("PawnSensing"));
    PawnSensing->SightRadius = 4000.f;
    PawnSensing->SetPeripheralVisionAngle(45.f);
}

void AEnemy::BeginPlay()
{
    Super::BeginPlay();

    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(false);
    }

    EnemyController = Cast<AAIController>(GetController());
    MoveToTarget(PatrolTarget);

    if (PawnSensing)
    {
        PawnSensing->OnSeePawn.AddDynamic(this, &AEnemy::PawnSeen);
    }
}

void AEnemy::PlayHitReactMontage(const FName& SectionName)
{
    if (IdleAudioComponent)
    {
        IdleAudioComponent->StopMusic();
    }
    Super::PlayHitReactMontage(SectionName);
}

void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)
{
    // DRAW_SPHERE_COLOR(ImpactPoint,FColor::Orange);

    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(true);
    }

    if (Attributes && Attributes->IsAlive())
    {
        DirectionalHitReact(ImpactPoint);
    }
    else if (Attributes && !Attributes->IsAlive())
    {
        Die();
    }
}

float AEnemy::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    if (Attributes)
    {
        Attributes->ReceiveDamage(DamageAmount);
        if (HealthBarWidget)
        {
            HealthBarWidget->SetHealthPercent(Attributes->GetHealthPercent());
        }
    }

    CombatTarget = EventInstigator->GetPawn();
    EnemyState = EEnemyState::EES_Chasing;
    GetCharacterMovement()->MaxWalkSpeed = 300.f;
    MoveToTarget(CombatTarget);

    return DamageAmount;
}

void AEnemy::Die()
{
    PlayDeathMontage();

    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(false);
    }

    GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
    SetLifeSpan(10.0f);
}

void AEnemy::PlayDeathMontage()
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();

    if (AnimInstance && DeathMontage)
    {
        IdleAudioComponent->StopMusic();

        AnimInstance->Montage_Play(DeathMontage);

        const int32 Selection = FMath::RandRange(0, 3);
        FName SectionName = FName();
        switch (Selection)
        {
        case 0:
            SectionName = FName("Backward");
            DeathPose = EDeathPose::EDP_Backward;
            break;
        case 1:
            SectionName = FName("Forward");
            DeathPose = EDeathPose::EDP_Forward;
            break;
        case 2:
            SectionName = FName("Left");
            DeathPose = EDeathPose::EDP_Left;
            break;
        case 3:
            SectionName = FName("Right");
            DeathPose = EDeathPose::EDP_Right;
            break;
        }

        AnimInstance->Montage_JumpToSection(SectionName, DeathMontage);
    }
}

void AEnemy::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (EnemyState > EEnemyState::EES_Patrolling)
    {
        CheckCombatTarget();
    }
    else
    {
        CheckPatrolTarget();
    }
}

void AEnemy::CheckCombatTarget()
{
    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        CombatTarget = nullptr;
        if (HealthBarWidget)
        {
            HealthBarWidget->SetVisibility(false);
        }
        EnemyState = EEnemyState::EES_Patrolling;
        GetCharacterMovement()->MaxWalkSpeed = 175.f;
        MoveToTarget(PatrolTarget);
    }
    else if (!InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Chasing)
    {
        EnemyState = EEnemyState::EES_Chasing;
        GetCharacterMovement()->MaxWalkSpeed = 300.f;
        MoveToTarget(CombatTarget);
    }
    else if (InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Attacking)
    {
        EnemyState = EEnemyState::EES_Attacking;
        // TODO: Attack
    }
}

void AEnemy::CheckPatrolTarget()
{
    if (InTargetRange(PatrolTarget, PatrolRadius))
    {
        PatrolTarget = ChooseNewPatrolTarget();
        const float WaitTime = FMath::FRandRange(WaitMin, WaitMax);
        GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, WaitTime);
    }
}

bool AEnemy::InTargetRange(AActor* Target, double Radius) const
{
    if (Target == nullptr) return false;

    const double DistanceToTarget = (Target->GetActorLocation() - GetActorLocation()).Size();
    return DistanceToTarget <= Radius;
}

void AEnemy::MoveToTarget(AActor* Target)
{
    if (EnemyController == nullptr || Target == nullptr) return;

    FAIMoveRequest MoveRequest;
    MoveRequest.SetGoalActor(Target);
    MoveRequest.SetAcceptanceRadius(15.f);
    EnemyController->MoveTo(MoveRequest);
}

AActor* AEnemy::ChooseNewPatrolTarget() const
{
    TArray<AActor*> ValidTargets;
    for (AActor* Target : PatrolTargets)
    {
        if (Target != PatrolTarget)
        {
            ValidTargets.AddUnique(Target);
        }
    }

    const int32 NumValidPatrolTargets = ValidTargets.Num();
    if (NumValidPatrolTargets > 0)
    {
        int32 Selection = FMath::RandRange(0, NumValidPatrolTargets - 1);
        return ValidTargets[Selection];
    }
    return nullptr;
}

void AEnemy::PawnSeen(APawn* SeenPawn)
{
    if (EnemyState == EEnemyState::EES_Chasing) return;

    if (SeenPawn->ActorHasTag(FName("HelloWorldCharacter")))
    {
        GetWorldTimerManager().ClearTimer(PatrolTimer);
        CombatTarget = SeenPawn;
        
        if (EnemyState != EEnemyState::EES_Attacking)
        {
            EnemyState = EEnemyState::EES_Chasing;
            GetCharacterMovement()->MaxWalkSpeed = 300.f;
            MoveToTarget(CombatTarget);
        }
    }
}

void AEnemy::PatrolTimerFinished()
{
    MoveToTarget(PatrolTarget);
}
// HelloWorldCharacter.h
#pragma once

#include "CoreMinimal.h"
#include "BaseCharacter.h"
#include "Interfaces/HitInterface.h"
#include "CharacterTypes.h"
#include "HelloWorldCharacter.generated.h"

class USpringArmComponent;
class UCameraComponent;
class AItem;
class UAnimMontage;
class UTimerHandle;
class AVehicle;
class UCharacterAudioComponent;

UCLASS()
class HELLOWORLD_API AHelloWorldCharacter : public ABaseCharacter
{
    GENERATED_BODY()
public:
    AHelloWorldCharacter();
    virtual void Tick(float DeltaTime) override;
    virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
protected:
    virtual void BeginPlay() override;

    /**
    * Callbacks for input
    */
    void MoveForward(float Value);
    void MoveRight(float Value);
    void Turn(float Value);
    void LookUp(float Value);
    void EKeyPressed();
    void RKeyPressed();
    virtual void Attack() override;

    /**
    * Play montage functions
    */
    virtual void PlayAttackMontage() override;
    virtual void AttackEnd() override;
    FORCEINLINE virtual bool CanAttack() override;

    void PlayEquipMontage(const FName& SectionName);
    bool CanUnequipping();
    bool CanEquipping();

    UFUNCTION(BlueprintCallable)
    void HandToSpine();

    UFUNCTION(BlueprintCallable)
    void SpineToHand();

    UFUNCTION(BlueprintCallable)
    void TrunToOccupied();

private:
    UPROPERTY(VisibleAnywhere)
    USpringArmComponent* SpringArm;

    UPROPERTY(VisibleAnywhere)
    UCameraComponent* ViewCamera;

    ECharacterState CharacterState = ECharacterState::ECS_Unequipped;
    EActionState ActionState = EActionState::EAS_Unoccupied;

    UPROPERTY(VisibleInstanceOnly)
    AItem* OverlappingItem;
public:
    FORCEINLINE void SetOverlappingItem(AItem* Item) { OverlappingItem = Item; }
    FORCEINLINE ECharacterState GetCharacterState() const { return CharacterState; }

private:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"), Category = Audio)
    UCharacterAudioComponent* RideAudioComponent;

    UPROPERTY(VisibleAnywhere, Category = Audio)
    UCharacterAudioComponent* RideRingAudioComponent;
public:
    UCharacterAudioComponent* GetRideAudioComponent() const { return RideAudioComponent; }

private:
    UPROPERTY(VisibleAnywhere, Category = Vehicle)
    AVehicle* RidingVehicle;

    // 连招
    int32 ComboCount = 0;
    FTimerHandle ComboResetTimer;
    void ResetCombo() { ComboCount = 0; };

    UPROPERTY(EditDefaultsOnly, Category = Montages)
    UAnimMontage* EquipMontage;
};
// HelloWorldCharacter.cpp
#include "Characters/HelloWorldCharacter.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Items/Item.h"
#include "Items/Weapons/Weapon.h"
#include "Animation/AnimInstance.h"
#include "Engine/TimerHandle.h"
#include "Components/BoxComponent.h"
#include "Items/Vehicles/Vehicle.h"
#include "Components/CharacterAudioComponent.h"

AHelloWorldCharacter::AHelloWorldCharacter()
{
    PrimaryActorTick.bCanEverTick = true;

    bUseControllerRotationPitch = false;
    bUseControllerRotationYaw = false;
    bUseControllerRotationRoll = false;

    GetCharacterMovement()->bOrientRotationToMovement = true;
    GetCharacterMovement()->RotationRate = FRotator(0.f, 400.f, 0.f);

    SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    SpringArm->SetupAttachment(GetRootComponent());
    SpringArm->TargetArmLength = 300.f;

    ViewCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("ViewCamera"));
    ViewCamera->SetupAttachment(SpringArm);

    AutoPossessPlayer = EAutoReceiveInput::Player0;

    RideAudioComponent = CreateDefaultSubobject<UCharacterAudioComponent>(TEXT("RideAudioComponent"));
    RideAudioComponent->SetupAttachment(GetRootComponent());
    RideAudioComponent->bAutoActivate = false;

    RideRingAudioComponent = CreateDefaultSubobject<UCharacterAudioComponent>(TEXT("RideRingAudioComponent"));
    RideRingAudioComponent->SetupAttachment(GetRootComponent());
    RideRingAudioComponent->bAutoActivate = false;
}

void AHelloWorldCharacter::BeginPlay()
{
    Super::BeginPlay();
    
    Tags.Add(FName("HelloWorldCharacter"));
}

void AHelloWorldCharacter::MoveForward(float Value)
{
    if (ActionState != EActionState::EAS_Unoccupied) return;
    if (Controller && (Value != 0.0f))
    {
        const FRotator ControlRotation = GetControlRotation();
        const FRotator YawRotation(0, ControlRotation.Yaw, 0);

        const FVector DirectionX = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
        AddMovementInput(DirectionX, Value);
    }
}

void AHelloWorldCharacter::MoveRight(float Value)
{
    if (ActionState != EActionState::EAS_Unoccupied) return;
    if (Controller && (Value != 0.0f))
    {
        const FRotator ControlRotation = GetControlRotation();
        const FRotator YawRotation(0, ControlRotation.Yaw, 0);

        const FVector DirectionY = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
        AddMovementInput(DirectionY, Value);
    }
}

void AHelloWorldCharacter::Turn(float Value)
{
    AddControllerYawInput(Value);
}

void AHelloWorldCharacter::LookUp(float Value)
{
    AddControllerPitchInput(Value);
}

void AHelloWorldCharacter::EKeyPressed()
{
    AWeapon* OverlappingWeapon = Cast<AWeapon>(OverlappingItem);
    if (OverlappingWeapon)
    {
        OverlappingWeapon->Equip(GetMesh(), FName("RightHandSocket"), this, this);
        CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
        EquippedWeapon = OverlappingWeapon;
        OverlappingItem = nullptr;
    }
    else
    {
        if (CanUnequipping())
        {
            PlayEquipMontage(FName("Unequipping"));
            CharacterState = ECharacterState::ECS_Unequipped;
            ActionState = EActionState::EAS_EquippingWeapon;
        }
        else if (CanEquipping())
        {
            PlayEquipMontage(FName("Equipping"));
            CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
            ActionState = EActionState::EAS_EquippingWeapon;
        }
    }
}

void AHelloWorldCharacter::RKeyPressed()
{
    if (CharacterState == ECharacterState::ECS_Riding)
    {
        if (RidingVehicle)
        {
            RidingVehicle->RideEnd();
            SetOverlappingItem(RidingVehicle); // 角色和车分离后 角色可以再次与车发生重叠 从而再次迅速骑车
            RidingVehicle = nullptr;
        }
        CharacterState = ECharacterState::ECS_Unequipped;
    }
    else
    {
        if (ActionState == EActionState::EAS_Unoccupied)
        {
            AVehicle* OverlappingVehicle = Cast<AVehicle>(OverlappingItem);
            if (OverlappingVehicle)
            {
                if (CharacterState == ECharacterState::ECS_EquippedOneHandedWeapon)
                {
                    HandToSpine(); // 如果手里有武器 就将武器从手上放到背上
                }
                OverlappingVehicle->RideStart(GetMesh(), FName("BikeSocket"));
                CharacterState = ECharacterState::ECS_Riding;
                RideRingAudioComponent->PlayMusic();
                RidingVehicle = OverlappingVehicle;
                OverlappingItem = nullptr;
            }
        }
    }
    
}

void AHelloWorldCharacter::Attack()
{
    if (CanAttack())
    {
        GetWorldTimerManager().ClearTimer(ComboResetTimer);

        PlayAttackMontage();
        ActionState = EActionState::EAS_Attacking;

        ComboCount++;
        if (ComboCount >= 5)
        {
            ComboCount = 0;
        }
    }
}

bool AHelloWorldCharacter::CanAttack()
{
    return ActionState == EActionState::EAS_Unoccupied &&
        CharacterState != ECharacterState::ECS_Unequipped &&
        CharacterState != ECharacterState::ECS_Riding;
}

void AHelloWorldCharacter::PlayAttackMontage()
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && AttackMontage)
    {
        AnimInstance->Montage_Play(AttackMontage);
        
        FName SectionName = FName();
        switch (ComboCount)
        {
        case 0:
            SectionName = FName("Attack1");
            break;
        case 1:
            SectionName = FName("Attack2");
            break;
        case 2:
            SectionName = FName("Attack3");
            break;
        case 3:
            SectionName = FName("Attack4");
            break;
        case 4:
            SectionName = FName("Attack5");
            break;
        default:
            break;
        }
        AnimInstance->Montage_JumpToSection(SectionName, AttackMontage);
    }
}

void AHelloWorldCharacter::AttackEnd()
{
    ActionState = EActionState::EAS_Unoccupied;

    GetWorldTimerManager().SetTimer(
        ComboResetTimer,
        this,
        &AHelloWorldCharacter::ResetCombo,
        1.f,
        false
    );
}

void AHelloWorldCharacter::PlayEquipMontage(const FName& SectionName)
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && EquipMontage)
    {
        AnimInstance->Montage_Play(EquipMontage);
        AnimInstance->Montage_JumpToSection(SectionName, EquipMontage);
    }
}

bool AHelloWorldCharacter::CanUnequipping()
{
    return CharacterState != ECharacterState::ECS_Unequipped &&
        CharacterState != ECharacterState::ECS_Riding &&
        ActionState == EActionState::EAS_Unoccupied;
}

bool AHelloWorldCharacter::CanEquipping()
{
    return CharacterState == ECharacterState::ECS_Unequipped &&
        CharacterState != ECharacterState::ECS_Riding &&
        ActionState == EActionState::EAS_Unoccupied &&
        EquippedWeapon;
}

void AHelloWorldCharacter::HandToSpine()
{
    if (EquippedWeapon)
    {
        EquippedWeapon->AttachMeshToSocket(GetMesh(), FName("SpineSocket"));
    }
}

void AHelloWorldCharacter::SpineToHand()
{
    if (EquippedWeapon)
    {
        EquippedWeapon->AttachMeshToSocket(GetMesh(), FName("RightHandSocket"));
    }
}

void AHelloWorldCharacter::TrunToOccupied()
{
    ActionState = EActionState::EAS_Unoccupied;
}

void AHelloWorldCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

}

void AHelloWorldCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupPlayerInputComponent(PlayerInputComponent);

    PlayerInputComponent->BindAxis(FName("MoveForward"), this, &AHelloWorldCharacter::MoveForward);
    PlayerInputComponent->BindAxis(FName("MoveRight"), this, &AHelloWorldCharacter::MoveRight);
    PlayerInputComponent->BindAxis(FName("Turn"), this, &AHelloWorldCharacter::Turn);
    PlayerInputComponent->BindAxis(FName("LookUp"), this, &AHelloWorldCharacter::LookUp);

    PlayerInputComponent->BindAction(FName("Jump"), IE_Pressed, this, &ACharacter::Jump);
    PlayerInputComponent->BindAction(FName("Equip"), IE_Pressed, this, &AHelloWorldCharacter::EKeyPressed);
    PlayerInputComponent->BindAction(FName("Attack"), IE_Pressed, this, &AHelloWorldCharacter::Attack);
    PlayerInputComponent->BindAction(FName("Ride"), IE_Pressed, this, &AHelloWorldCharacter::RKeyPressed);
}

Ctrl+F5 发现现在BP_HelloWorldCharacter也有HitReactMontage和DeathMontage了 还有Attributes组件 可以设置Health和MaxHealth 和敌人一样 都设置成1000

其实现在我们的Enemy类和HelloWorldCharacter类的代码组织都不好 但暂时先保持这样 稍后修复

EnemyAnimInstance类

此时此刻动画蓝图的复杂度 足够我们去为它重新写一个父类C++了 最重要的是把之前的关于Dance状态的回调函数绑定到动画蓝图状态机

在C++类 Enemy文件夹 新建C++类 父类选择AnimInstance 命名为EnemyAnimInstance

// Enemy.cpp
public:
    FORCEINLINE EEnemyState GetEnemyState() const { return EnemyState; }
// Enemy.cpp
public:
    EDeathPose GetDeathPose() const { return DeathPose; }
// EnmeyAnimInstance.h
#pragma once

#include "CoreMinimal.h"
#include "Animation/AnimInstance.h"
#include "Characters/CharacterTypes.h"
#include "Animation/AnimExecutionContext.h"
#include "Animation/AnimNodeReference.h"
#include "EnemyAnimInstance.generated.h"

class AEnemy;
class UCharacterMovementComponent;

UCLASS()
class HELLOWORLD_API UEnemyAnimInstance : public UAnimInstance
{
    GENERATED_BODY()
public:
    virtual void NativeInitializeAnimation() override;
    virtual void NativeThreadSafeUpdateAnimation(float DeltaSeconds) override;

    UPROPERTY(BlueprintReadOnly)
    AEnemy* Enemy;

    UPROPERTY(BlueprintReadOnly, Category = "Movement")
    UCharacterMovementComponent* EnemyMovement;

    UPROPERTY(BlueprintReadOnly, Category = "Movement")
    float GroundSpeed;

    // 在混合空间使用
    UPROPERTY(BlueprintReadOnly, Category = "Movement")
    float Direction;

    UPROPERTY(BlueprintReadOnly, Category = "Basic")
    EDeathPose DeathPose;

    UPROPERTY(BlueprintReadOnly, Category = "Basic")
    EEnemyState EnemyState;

    UPROPERTY(BlueprintReadOnly, Category = "Basic")
    bool bIsWait;

    UPROPERTY(BlueprintReadOnly, Category = "Dance")
    float IdleTimer;

    UPROPERTY(BlueprintReadOnly, Category = "Dance")
    int32 DanceIndex;

    UFUNCTION(BlueprintCallable, Category = "Dance", meta = (BlueprintThreadSafe))
    void ResetIdleTimer(const FAnimUpdateContext& Context, const FAnimNodeReference& Node);

    UFUNCTION(BlueprintCallable, Category = "Dance", meta = (BlueprintThreadSafe))
    void UpdateIdleTimer(const FAnimUpdateContext& Context, const FAnimNodeReference& Node);

    UFUNCTION(BlueprintCallable, Category = "Dance", meta = (BlueprintThreadSafe))
    void RandomDanceIndex(const FAnimUpdateContext& Context, const FAnimNodeReference& Node);
};
// EnmeyAnimInstance.cpp
#include "Enemy/EnemyAnimInstance.h"
#include "Enemy/Enemy.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Kismet/KismetMathLibrary.h"

void UEnemyAnimInstance::NativeInitializeAnimation()
{
    Super::NativeInitializeAnimation();
    
    Enemy = Cast<AEnemy>(TryGetPawnOwner());
    if (Enemy)
    {
        EnemyMovement = Enemy->GetCharacterMovement();
    }
}

void UEnemyAnimInstance::NativeThreadSafeUpdateAnimation(float DeltaSeconds)
{
    if (!Enemy || !EnemyMovement) return;

    GroundSpeed = UKismetMathLibrary::VSizeXY(EnemyMovement->Velocity);
        
    Direction = (Enemy->GetActorRotation().UnrotateVector(EnemyMovement->Velocity)).Rotation().Yaw;
    // Enemy->GetActorRotation() 获取Enemy当前在世界空间的旋转 返回的是一个FRotator
    // EnemyMovement->Velocity 获取Enemy在世界空间的目标速度向量
    // UnrotateVector是FRotator类的一个方法
    // UnrotateVector(EnemyMovement->Velocity) 将目标速度向量从世界空间转换为Enemy的局部空间 也就是相对于Enemy的速度向量
    // Rotation() 将局部空间中的速度向量提取为一个旋转值 这个旋转值表示了速度向量的方向
    // Yaw 获取旋转值的Yaw分量 也就是左右偏航角

    EnemyState = Enemy->GetEnemyState();
    DeathPose = Enemy->GetDeathPose();

    bIsWait = Enemy->IsWaiting && (Enemy->GetEnemyState() == EEnemyState::EES_Patrolling) && (GroundSpeed == 0.f);
}

void UEnemyAnimInstance::ResetIdleTimer(const FAnimUpdateContext& Context, const FAnimNodeReference& Node)
{
    IdleTimer = 0.f;
}

void UEnemyAnimInstance::UpdateIdleTimer(const FAnimUpdateContext& Context, const FAnimNodeReference& Node)
{
    if (FMath::IsNearlyZero(GroundSpeed) && IdleTimer < 10.f)
    {
        IdleTimer += GetDeltaSeconds();
    }
    else
    {
        IdleTimer = 0.f;
    }
}

void UEnemyAnimInstance::RandomDanceIndex(const FAnimUpdateContext& Context, const FAnimNodeReference& Node)
{
    DanceIndex = FMath::RandRange(0, 2);
}

Ctrl+F5编译 动画蓝图中点击上方类设置 将当前动画蓝图的父类修改为EnemyAnimInstance 把在C++里已经写过的东西在蓝图里全都删了 替换为我们在C++里做的变量 可以看到在状态内部Output Animation Pose节点绑定处 可以看到我们写的函数 绑定一下 在左侧我的蓝图面板看不到那3个函数是正常的

其实这里用回调也可以 但也可以直接设置一个敌人的Idle状态 然后播放动画蒙太奇 但我们这里就是展示一下回调绑定的做法

武器

对BP_Enemy右键 可以创建子蓝图类 这样我们就可以制作多种武器的敌人 或者多种模型的敌人 但我们现在不关心这个

我们要把敌人做成法师 再给骨骼网格体做一个手部插槽RightHandSocket 预览武器并将插槽调整到合适位置 将BP_Weapon作为基类 创建一个子类 网格体就选择我们的法师武器

我们需要使用生成武器 这样武器就是一个独立的Actor 只不过是暂时绑定到了敌人身上 其实这个绑定的原理就和我们的角色把武器从地上捡起来是一样的 而不是直接把武器绑定到敌人身上成为一个组件从而作为敌人的一部分 成为独立的Actor是一种解耦的做法 这样在敌人消失之后 武器仍然会存在 那么我们可以选择让它掉在地上或者被玩家捡起来 或者销毁武器

// Enemy.h
UPROPERTY(EditAnywhere)
TSubclassOf<class AWeapon> WeaponClass;

这样我们就可以在蓝图中选择到底要使用哪个武器

// Enemy.cpp
#include "Items/Weapons/Weapon.h"
// Enemy.cpp

在这种情况之中 我们不想听到敌人装备武器的音效 所以要把这个EquipSound变量从private转移到protected中 然后在蓝图细节面板中将其设置为空

// Weapon.h
UPROPERTY(EditAnywhere, Category = "Weapon Properties")
USoundBase* EquipSound;

打开BP_Enemy 将Weapon Class设置为法师武器的BP PIE 我们就可以看到敌人一直在拿着武器 但是敌人死后 武器还留在原地 我们可以做掉落武器 也可以直接销毁武器 我们这里选择销毁武器 因为武器现在是存储在EquippedWeapon变量中的 AActor类有一个Destory函数

// Actor.h
/** Called when this actor is explicitly being destroyed during gameplay or in the editor, not called during level streaming or gameplay ending */
ENGINE_API virtual void Destroyed();

那我们就在Enemy类里重载它 那么就会在敌人死亡也就是Destoryed的时候 做我们重写的一系列操作

// Enemy.h protected中
virtual void Destroyed() override;
// Enemy.cpp
void AEnemy::Destroyed()
{
    if (EquippedWeapon)
    {
        EquippedWeapon->Destroy();
    }
}

如果我们后续会做武器掉落 就会在这个函数里做

热重载 PIE 现在敌人死亡之后 武器也会跟着消失

攻击距离

接下来做攻击动画蒙太奇 命名为AM_AttackMontage_StrawberryMiku 我们有必要针对于每个骨骼网格体将蒙太奇添加后缀 都批量重命名一下 把我们的2个攻击动画放进来 分段命名为Attack1 Attack2 左侧资产详情面板 将混入 - 混合时间 设置为0

打开BP_Enemy 右侧细节面板将Attack Montage变量设置为AM_AttackMontage_StrawberryMiku 那么我们就应该在敌人切换到攻击状态时 播放攻击蒙太奇 那么我们就需要重写Attack PlayAttackMontage CanAttack AttackEnd函数 父类什么都没有 所以不需要Super

// Enemy.h protected中
virtual void Attack() override;
virtual void PlayAttackMontage() override;
virtual bool CanAttack() override;
virtual void AttackEnd() override;
// Enemy.cpp
void AEnemy::Attack()
{
    if (CanAttack())
    {
        PlayAttackMontage();
    }
}

void AEnemy::PlayAttackMontage()
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (AnimInstance && AttackMontage)
    {
        AnimInstance->Montage_Play(AttackMontage);

        FName SectionName = FName();
        const int32 Selection = FMath::RandRange(0, 1);
        switch (Selection)
        {
        case 0:
            SectionName = FName("Attack1");
            break;
        case 1:
            SectionName = FName("Attack2");
            break;
        default:
            break;
        }
        AnimInstance->Montage_JumpToSection(SectionName, AttackMontage);
    }
}
// Enemy.cpp AEnemy::CheckCombatTarget中
// else if (InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Attacking)分支 // TODO: Attack处
Attack();

但是现在这样 只会在切换到Attack状态时攻击一次 不会迅速进行下一次攻击 另外如果我们原本在攻击范围的那个内圈内 我们迅速离开了内圈又进入内圈 那么它就会连续攻击我们两次 总之这里需要更精细的处理
但是现在先热重载 PIE 因为我们做的是法师 发现敌人攻击的时候离我们太近了 追逐我们的时候也离我们太近了

// Enemy.h private中
UPROPERTY(EditAnywhere)
double AttackRadius = 600.f;

接收半径也应该扩大 这样它就不会追我们追得太近

// Enemy.cpp AEnemy::MoveToTarget中 // 未采用
MoveRequest.SetAcceptanceRadius(500.f);

战斗半径也应该扩大 否则战场太小了

// Enemy.h private中
UPROPERTY(EditAnywhere)
double CombatRadius = 1200.f;

但是现在接收半径有500 我们的巡逻半径却仍然保持为200 敌人会在距离巡逻目标点500的时候就到了 停下了 那它就会在那里一直站着 不可能到达巡逻目标点200以内 就不能InTargetRange(PatrolTarget, PatrolRadius) 根本无法调用AEnemy::PatrolTimerFinished 去到下一个目标点 我们要对此进行修复 这是因为我们目前对于巡逻目标点和追逐敌人的AcceptRadius是同一套逻辑 所以MoveToTarget函数里的SetAcceptanceRadius数值不应该硬编码

// Enemy.h protected中
void MoveToTarget(AActor* Target, float InAcceptanceRadius);
// Enemy.cpp
void AEnemy::MoveToTarget(AActor* Target, float InAcceptanceRadius)
{
    if (EnemyController == nullptr || Target == nullptr) return;

    FAIMoveRequest MoveRequest;
    MoveRequest.SetGoalActor(Target);
    MoveRequest.SetAcceptanceRadius(InAcceptanceRadius); // 修改了一行
    EnemyController->MoveTo(MoveRequest);
}
// Enemy.h
double PatrolAcceptanceRadius = 15.f;
double ChasingAcceptanceRadius = 300.f;
// Enemy.cpp AEnemy::BeginPlay中
MoveToTarget(PatrolTarget, PatrolAcceptanceRadius);
// Enemy.cpp AEnemy::CheckCombatTarget中
// if (!InTargetRange(CombatTarget, CombatRadius))分支
MoveToTarget(PatrolTarget, PatrolAcceptanceRadius);
// else if (!InTargetRange(CombatTarget, AttackRadius) && EnemyState != EEnemyState::EES_Chasing)分支
MoveToTarget(CombatTarget, ChasingAcceptanceRadius);
// Enemy.cpp AEnemy::PawnSeen中
MoveToTarget(CombatTarget, ChasingAcceptanceRadius);
// Enemy.cpp AEnemy::PatrolTimerFinished中
MoveToTarget(PatrolTarget, PatrolAcceptanceRadius);

这样的话 现在按照我们的参数设置 巡逻的参数保持不变 AI在距离巡逻目标点15(PatrolAcceptanceRadius)时 就判定为已到达 停下 而我们判定巡逻已到达的距离是200(PatrolRadius) 这个距离必须要大于PatrolAcceptanceRadius 这样才能确保我们是在AI已经到达的时候做了一系列关于巡逻到达后的操作 那么追逐玩家也是一样 因为是法师 可以在距离玩家很远的时候就开始攻击 那么AI应该追到距离玩家300(ChasingAcceptanceRadius)的时候就停下来 而逻辑上判定已经追到并且可以攻击的距离600(AttackRadius)应该大于ChasingAcceptanceRadius 这样才能确保在进行攻击时 是已经追到玩家了 而且这两个数值不能相差太小 这个的原理就和PatrolAcceptRadius(15)不能和PatrolRadius(200)相差太小一样 比如ChasingAcceptanceRadius是500而AttackRadius是600 就会发生 AI移动到了距离玩家500的位置 它觉得已经到了 就不再移动了 但是现在实际上距离玩家的距离是远大于500甚至大于600的 这是AI的偏差 (AI认为自己到达巡逻目标点距离15的时候 其实实际上的距离也是远大于15 所以我们要将PatrolRadius设置为200 AI在认为自己到达巡逻目标点时 实际上距离目标点的距离 虽然是大于15 但必然会小于200) 距离玩家大于600 导致无法进入Attack状态 而是一直停留在Chasing状态 站在那里 不追逐我们 所以我们要将ChasingAcceptanceRadius设置为300 这样AI在距离玩家超过300的某处认为自己已经追到了玩家 但是无论如何AI停止的地方不会距离玩家超过600 那么现在它就是在攻击半径之内 可以进行攻击
当然因为是法师 距离多远才进行攻击的要求很宽松 我们不需要精细地调整 反而我们后续应该做一个 玩家离法师很近的时候 法师就不会攻击了 而是尝试后退移动到一定距离 才能继续攻击 这时候正好使用我们之前做好的那个混合空间动画(不转身的那种) 我们暂时先不处理这种复杂情境 先做成只要在AttackRadius内 就会进行攻击

PIE 现在攻击范围很大 但是攻击的时机非常不好 所以需要一个计时器 这样就可以在两次攻击之间暂停一下

重构敌人类

但在进行下一步之前 还是先整理一下代码

首先 我们在屏幕上print string观察一下敌人的状态 可以观察到 有时候在Attacking状态时 它并没有对我们攻击 只是我们在攻击范围内而已 我们需要一个专门的状态Engaged 意思是交战 于是Attacking状态就用来处理在攻击范围内却没有正在攻击的情况 再写一个死亡状态 这样就可以把死亡姿势里的alive去掉

// CharacterTypes.h
UENUM(BlueprintType)
enum class EEnemyState : uint8
{
    EES_Dead UMETA(DisplayName = "Dead"),
    EES_Patrolling UMETA(DisplayName = "Patrolling"),
    EES_Chasing UMETA(DisplayName = "Chasing"),
    EES_Attacking UMETA(DisplayName = "Attacking"),
    EES_Engaged UMETA(DisplayName = "Engaged")
};

那么就回到动画蓝图 IdleWalkRun到Dead和Dance到Dead的转换规则 把判断DeadPose是否为Alive 改成判断EnemyState是否为Dead

删掉Alive枚举 DeathPose也不再需要一个默认值

// CharacterTypes.h
UENUM(BlueprintType)
enum class EDeathPose : uint8
{
    EDP_Backward UMETA(DisplayName = "Backward"),
    EDP_Forward UMETA(DisplayName = "Forward"),
    EDP_Left UMETA(DisplayName = "Left"),
    EDP_Right UMETA(DisplayName = "Right")
};
// Enemy.h
UPROPERTY(BlueprintReadOnly)
EDeathPose DeathPose;

接下来我们专门做战斗 把关卡中的障碍物全部删除
既然要做战斗 那么就要查看AEnemy::CheckCombatTarget的逻辑 可以看到关于健康条这里并不简洁

// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        CombatTarget = nullptr;
        if (HealthBarWidget)
        {
            HealthBarWidget->SetVisibility(false);
        }
// 后面省略
// Enemy.h private中
/** AI Behavior */
void HideHealthBar();
void ShowHealthBar();
// Enemy.cpp
void AEnemy::HideHealthBar()
{
    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(false);
    }
}

void AEnemy::ShowHealthBar()
{
    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(true);
    }
}
// Enemy.cpp AEnemy::CheckCombatTarget中
CombatTarget = nullptr;
HideHealthBar();

这两句是敌人对我们失去兴趣的逻辑 也可以写成一个函数

// Enemy.h
void LoseInterest();
// Enemy.cpp
void AEnemy::LoseInterest()
{
    CombatTarget = nullptr;
    HideHealthBar();
}
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        LoseInterest();
// 后面省略
// Enemy.h private中
UPROPERTY(EditAnywhere, Category = "Combat")
float PatrollingSpeed = 175.f;

UPROPERTY(EditAnywhere, Category = "Combat")
float ChasingSpeed = 300.f;
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        LoseInterest();
        EnemyState = EEnemyState::EES_Patrolling;
        GetCharacterMovement()->MaxWalkSpeed = PatrollingSpeed;
        MoveToTarget(PatrolTarget, PatrolAcceptanceRadius);
// 后面省略
// Enemy.h private中
void StartPatrolling();
// Enemy.cpp
void AEnemy::StartPatrolling()
{
    EnemyState = EEnemyState::EES_Patrolling;
    GetCharacterMovement()->MaxWalkSpeed = PatrollingSpeed;
    MoveToTarget(PatrolTarget, PatrolAcceptanceRadius);
}
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (!InTargetRange(CombatTarget, CombatRadius))
    {
        LoseInterest();
        StartPatrolling();
    }
// 后面省略
// Enemy.h private中
/** AI Behavior */
void HideHealthBar();
void ShowHealthBar();
void LoseInterest();
FORCEINLINE bool IsOutsideCombatRadius();
FORCEINLINE bool IsOutsideAttackRadius();
FORCEINLINE bool IsInsideAttackRadius();
FORCEINLINE bool IsChasing();
FORCEINLINE bool IsAttacking();
void StartPatrolling();
void StartChasing();
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (IsOutsideCombatRadius())
    {
        LoseInterest();
        StartPatrolling();
    }
    else if (IsOutsideAttackRadius() && !IsChasing())
    {
        StartChasing();
    }
    else if (IsInsideAttackRadius() && !IsAttacking())
    {
        EnemyState = EEnemyState::EES_Attacking;
        Attack();
    }
}

bool AEnemy::IsOutsideCombatRadius()
{
    return !InTargetRange(CombatTarget, CombatRadius);
}

bool AEnemy::IsOutsideAttackRadius()
{
    return !InTargetRange(CombatTarget, AttackRadius);
}

bool AEnemy::IsInsideAttackRadius()
{
    return InTargetRange(CombatTarget, AttackRadius);
}

bool AEnemy::IsChasing()
{
    return EnemyState == EEnemyState::EES_Chasing;
}

bool AEnemy::IsAttacking()
{
    return EnemyState == EEnemyState::EES_Attacking;
}

void AEnemy::HideHealthBar()
{
    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(false);
    }
}

void AEnemy::ShowHealthBar()
{
    if (HealthBarWidget)
    {
        HealthBarWidget->SetVisibility(true);
    }
}

void AEnemy::LoseInterest()
{
    CombatTarget = nullptr;
    HideHealthBar();
}

void AEnemy::StartPatrolling()
{
    EnemyState = EEnemyState::EES_Patrolling;
    GetCharacterMovement()->MaxWalkSpeed = PatrollingSpeed;
    MoveToTarget(PatrolTarget, PatrolAcceptanceRadius);
}

void AEnemy::StartChasing()
{
    EnemyState = EEnemyState::EES_Chasing;
    GetCharacterMovement()->MaxWalkSpeed = ChasingSpeed;
    MoveToTarget(CombatTarget, ChasingAcceptanceRadius);
}

现在我们整理完毕 可以开始写战斗的部分了 先设置一个攻击计时器

// Enemy.h
/** Combat */
void StartAttackTimer();

FTimerHandle AttackTimer;

UPROPERTY(EditAnywhere, Category = "Combat")
float AttackMin = 0.5f;

UPROPERTY(EditAnywhere, Category = "Combat")
float AttackMax = 1.0f;
// Enemy.cpp
void AEnemy::StartAttackTimer()
{
    EnemyState = EEnemyState::EES_Attacking;
    const float AttackTime = FMath::RandRange(AttackMin, AttackMax);
    GetWorldTimerManager().SetTimer(AttackTimer, this, &AEnemy::Attack, AttackTime);
}
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (IsOutsideCombatRadius())
    {
        LoseInterest();
        StartPatrolling();
    }
    else if (IsOutsideAttackRadius() && !IsChasing())
    {
        StartChasing();
    }
    else if (IsInsideAttackRadius() && !IsAttacking())
    {
        StartAttackTimer();
    }
}

时间到了就会触发Attack函数

现在还需要整理一些其它的函数

// Enemy.cpp
void AEnemy::PawnSeen(APawn* SeenPawn)
{
    const bool bShouldChaseTarget =
        EnemyState != EEnemyState::EES_Dead &&
        EnemyState != EEnemyState::EES_Chasing &&
        EnemyState < EEnemyState::EES_Attacking &&
        SeenPawn->ActorHasTag(FName("HelloWorldCharacter"));

    if (bShouldChaseTarget)
    {
        CombatTarget = SeenPawn;
        ClearPatrolTimer();
        StartChasing();
    }
}
// Enemy.h
FORCEINLINE bool IsDead();
// Enemy.cpp
bool AEnemy::IsDead()
{
    return EnemyState == EEnemyState::EES_Dead;
}
// Enemy.cpp
void AEnemy::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (IsDead()) return;

    if (EnemyState > EEnemyState::EES_Patrolling)
    {
        CheckCombatTarget();
    }
    else
    {
        CheckPatrolTarget();
    }
}

如果敌人死了 Tick函数就不应该继续运行后面的部分了 没必要检查什么战斗和巡逻
在我们离开战斗半径 敌人失去兴趣的时候 也不应该继续运行攻击计时器了 否则攻击计时器时间一到 它就会发起一次攻击 我们应该及时将它中断 在攻击范围之外 也不应该进行攻击计时 也要中断 而在攻击范围之内 将要开始攻击的时候 如果现在已经有了攻击计时器 就要把现在的攻击计时器结束 重新开始计时 否则就有可能提前攻击 如果现在没有攻击计时器 就要开始设置计时

// Enemy.h private中
void ClearAttackTimer();
// Enemy.cpp
void AEnemy::ClearAttackTimer()
{
    GetWorldTimerManager().ClearTimer(AttackTimer);
}
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
	if (IsOutsideCombatRadius())
	{
		ClearAttackTimer();
		LoseInterest();
		if (!IsEngaged())
			StartPatrolling();
	}
	else if (IsOutsideAttackRadius() && !IsChasing())
	{
		ClearAttackTimer();
		StartChasing();
	}
	else if (IsInsideAttackRadius() && !IsAttacking())
	{
		// ClearAttackTimer();
        // 没必要显式地写出 因为下面启动计时器时会自动重置
		StartAttackTimer();
	}
}

现在虽然每一个条件内部都有ClearAttackTimer() 但我们不能把它放在if外面 否则就会每次Tick每一帧都在清零计时器 而且可以注意到 我们只会在没有处在Attaking状态时(当然同时也要保证处于攻击范围内) 才会设置计时器 如果已经是Attacking状态 就不会清零计时器也不会设置计时器 而实际上如果一直处在攻击半径内 当前的Attacking状态就不会发生改变 会发生改变的只有是否处于Engaged交战状态 等到计时器设置的时间到了 它才会开始攻击 所以我们做了这么半天 实际上目前我们也只是做了一个设置定时从而延时攻击而不是切换状态的时刻就立即进行攻击 还是没能解决 只会在切换到Attacking状态时攻击一次的问题 只要不切换状态又再回到Attacking状态 也就是说我们一直站在战斗半径之内 就不会发生多次攻击 敌人只会攻击我们一次 并且甚至也不会追逐我们 也不会回去巡逻 就一直站在原地 暂时我们先不解决这个问题 先修复一些细节

如果此时此刻正在攻击交战 就不应该马上转到巡逻状态 否则就会在攻击动画没播放完的时候 就开始移动 会导致滑步 如果正在交战 也不应该转为追逐状态 虽然我们直到目前还没有找到在什么时机就将敌人设置成Engaged状态

// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
    if (IsOutsideCombatRadius())
    {
        ClearAttackTimer();
        LoseInterest();
        if (!IsEngaged())
            StartPatrolling();
        // 这里把if的{}删去了 为了看起来更简洁 偶尔在复杂函数内部可以这么做
    }
    else if (IsOutsideAttackRadius() && !IsChasing())
    {
	    ClearAttackTimer();
	    if (!IsEngaged())
		    StartChasing();
    }
// Enemy.h private中
FORCEINLINE bool IsEngaged();
// Enemy.cpp
bool AEnemy::IsEngaged()
{
    return EnemyState == EEnemyState::EES_Engaged;
}

现在其实这个IsInsideAttackRadius() && !IsAttacking()就是发动攻击 进入Attacking状态的条件 我们可以重写BaseCharacter类的CanAttack函数

// Enemy.cpp
bool AEnemy::CanAttack()
{
	return IsInsideAttackRadius() &&
		!IsAttacking() &&
		!IsDead();
}
// Enemy.cpp AEnemy::CheckCombatTarget中
else if (CanAttack())
{
    ClearAttackTimer();
    StartAttackTimer();
}
// Enemy.cpp
void AEnemy::Attack()
{
	PlayAttackMontage();
}

继续整理其它函数

先看AEnemy::GetHit_Implementation函数 我们在BaseCharacter类里为所有BaseCharacter及其子类都添加了Attributes组件 当然就也包括Health值 那么实际上判断IsAlive就对于每一个BaseCharacter成立 所以也不用写成虚函数

// BaseCharacter.h protected中
FORCEINLINE bool IsAlive();
// BaseCharacter.cpp
bool ABaseCharacter::IsAlive()
{
	return Attributes && Attributes->IsAlive();
}
// Enemy.cpp
void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)
{
	ShowHealthBar();

	if (IsAlive())
	{
		DirectionalHitReact(ImpactPoint);
	}
	else
	{
		Die();
	}
}

接下来看AEnemy::TakeDamage 接收伤害值 应该是每一个BaseCharacter都会做的事情 但是修改健康条不是 因为不是所有类都有健康条

// BaseCharacter.h protected中
virtual void HandleDamage(float DamageAmount);
// BaseCharacter.cpp
void ABaseCharacter::HandleDamage(float DamageAmount)
{
	if (Attributes)
	{
		Attributes->ReceiveDamage(DamageAmount);
	}
}
// Enemy.h protected中
virtual void HandleDamage(float DamageAmount) override;
// Enemy.cpp
void AEnemy::HandleDamage(float DamageAmount)
{
	Super::HandleDamage(DamageAmount);

	if (HealthBarWidget)
	{
		HealthBarWidget->SetHealthPercent(Attributes->GetHealthPercent());
	}
}
// Enemy.h
float AEnemy::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
	HandleDamage(DamageAmount);

	CombatTarget = EventInstigator->GetPawn();
	StartChasing();

	return DamageAmount;
}

接下来我们要管理一下蒙太奇播放函数了 我们甚至把蒙太奇分段的名字都写在了代码里 这实在不优雅

// BaseCharacter.h protected中
void PlayMontageSection(UAnimMontage* Montage, const FName& SectionName);
// BaseCharacter.cpp
void ABaseCharacter::PlayMontageSection(UAnimMontage* Montage, const FName& SectionName)
{
	UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
	if (AnimInstance && Montage)
	{
		AnimInstance->Montage_Play(Montage);
		AnimInstance->Montage_JumpToSection(SectionName, Montage);
	}
}
// BaseCharacter.h protected中
UPROPERTY(EditAnywhere, Category = Montages) // 放到蓝图里设置
TArray<FName> AttackMontageSections;
// BaseCharacter.h protected中
virtual void PlayAttackMontage_Random();
// BaseCharacter.cpp
void ABaseCharacter::PlayAttackMontage_Random()
{
	if (AttackMontageSections.Num() <= 0) return;
	const int32 MaxSectionIndex = AttackMontageSections.Num() - 1;
	const int32 Selection = FMath::RandRange(0, MaxSectionIndex);
	PlayMontageSection(AttackMontage, AttackMontageSections[Selection]);
}

那么就把Enemy类里的PlayAttackMontage函数都删掉就可以了
接下来写HelloWorldCharacter类的连招PlayAttackMontage

// BaseCharacter.h protected中
virtual void PlayAttackMontage_Combo();
int32 ComboCount = 0; 
void ResetCombo() { ComboCount = 0; };
FTimerHandle ComboResetTimer;
void StartComboResetTimer();
// BaseCharacter.cpp
void ABaseCharacter::PlayAttackMontage_Combo()
{
	if (AttackMontageSections.Num() <= 0) return;
	if (ComboCount >= AttackMontageSections.Num())
	{
		ResetCombo();
	}
	int32 SectionIndex = ComboCount;
	PlayMontageSection(AttackMontage, AttackMontageSections[SectionIndex]);

	ComboCount++;
}

void ABaseCharacter::StartComboResetTimer()
{
	GetWorldTimerManager().SetTimer(
		ComboResetTimer,
		this,
		&ABaseCharacter::ResetCombo,
		2.f,
		false
	);
}
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::Attack()
{
	if (CanAttack())
	{
		GetWorldTimerManager().ClearTimer(ComboResetTimer);
		PlayAttackMontage_Combo();
		ActionState = EActionState::EAS_Attacking;
	}
}

void AHelloWorldCharacter::AttackEnd()
{
	ActionState = EActionState::EAS_Unoccupied;
	StartComboResetTimer();
}

把原来的PlayAttackMontage删除即可 只需要在蓝图细节面板里设置AttackMontageSections数组

这个随机播放蒙太奇的部分其实可以写成更通用的

// BaseCHaracter.h protected中
virtual int32 PlayMontage_Random(UAnimMontage* Montage, const TArray<FName>& SectionNames);
// BaseCharacter.cpp
int32 ABaseCharacter::PlayMontage_Random(UAnimMontage* Montage, const TArray<FName>& SectionNames)
{
	if (SectionNames.Num() <= 0) return -1;
	const int32 MaxSectionIndex = SectionNames.Num() - 1;
	const int32 Selection = FMath::RandRange(0, MaxSectionIndex);
	PlayMontageSection(Montage, SectionNames[Selection]);
	return Selection;
}
// BaseCharacter.cpp
void ABaseCharacter::PlayAttackMontage_Random()
{
	PlayMontage_Random(AttackMontage, AttackMontageSections);
}

接下来做死亡蒙太奇

// BaseCharacter.h protected中
UPROPERTY(EditAnywhere, Category = Montages)
TArray<FName> DeathMontageSections;
// BaseCharacter.h protected中
virtual int32 PlayDeathMontage();
// BaseCharacter.cpp
int32 ABaseCharacter::PlayDeathMontage()
{
	return PlayMontage_Random(DeathMontage, DeathMontageSections);
}

把播放死亡蒙太奇的函数写成了有返回值int32的
接下来就要回到Enemy类里重写PlayDeathMontage函数

// Enemy.h protected中
virtual int32 PlayDeathMontage() override;

我们是在CharacterTypes.h里写了很多DeathPose枚举 TEnumAsByte是一个包装器 根据传入的整数 使用TEnumAsByte中的接收int32整数的构造函数构造一个变量Pose 将其设置为EDeathPose的值 而我们在枚举类写的 默认第一个枚举常量是0 如果第1个设置为1 后面就可以以此类推为2 3 我们暂时还是保持为默认0 那么Pose就会变成我们传入的那个整数对应的枚举常量的值 我们写的枚举类是uint8 无符号8位 那么就是一个字节
我们要先检查这个Selection有没有超出DeathPose的个数 所以要先给DeathPose设置一个最后标识

// CharacterTypes.h
UENUM(BlueprintType)
enum class EDeathPose : uint8
{
	EDP_Backward UMETA(DisplayName = "Backward"),
	EDP_Forward UMETA(DisplayName = "Forward"),
	EDP_Left UMETA(DisplayName = "Left"),
	EDP_Right UMETA(DisplayName = "Right"),

	EDP_MAX UMETA(DisplayName = "DefaultMAX") // 添加了一行
};
// Enemy.cpp
int32 AEnemy::PlayDeathMontage()
{
	IdleAudioComponent->StopMusic();

	const int32 Selection = Super::PlayDeathMontage();

	TEnumAsByte<EDeathPose> Pose(Selection);
	if (Pose < EDeathPose::EDP_MAX)
	{
		DeathPose = Pose;
	}

	return Selection;
}

这样就设置好了DeathPose 只使用父类的PlayDeathMontage函数得到Selection的数值是不行的 因为我们最后是通过状态机里的Dead状态 里面的blend poses by enum节点传入枚举值DeathPose变量来设置死亡动画的 所以我们必须要使用这个Selection数字来设置DeathPose变量

但是TEnumAsByte不适合和枚举类一起用 枚举类也就是有作用域的枚举 我们这里要改成没有作用域的枚举 也就是不需要加一个EDeathPose::前缀这样的

// CharacterTypes.h
UENUM(BlueprintType)
enum EDeathPose // 修改了一行
// 后面省略

现在使用这个枚举里面的变量时 前面也仍然可以添加EDeathPose::
但是现在这样的话 DeathPose变量就必须放在包装器里

// Enemy.h
UPROPERTY(BlueprintReadOnly)
TEnumAsByte<EDeathPose> DeathPose;
// EnemyAnimInstance.h
UPROPERTY(BlueprintReadOnly, Category = "Basic")
TEnumAsByte<EDeathPose> DeathPose;

接下来就是整理AEnemy::Die函数

// Enemy.cpp
void AEnemy::Die()
{
    EnemyState = EEnemyState::EES_Dead;
    
	PlayDeathMontage();
    
    ClearAttackTimer();
	HideHealthBar();
	DisableCapsule();
	SetLifeSpan(DeathLifeSpan);
}
// BaseCharacter.cpp
#include "Components/CapsuleComponent.h"
// BaseCharacter.h protected中
void DisableCapsule();
// BaseCharacter.cpp
void ABaseCharacter::DisableCapsule()
{
	GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
// Enemy.h protected中
UPROPERTY(EditAnywhere)
float DeathLifeSpan = 10.f;

Ctrl+F5 在BP_Enemy设置DeathMontage数组
PIE 敌人死亡后 确实播放了死亡动画 但是现在死后可能会根据我们所在的方向移动了 我们要修复这个问题

// Enemy.cpp AEnemy::Die 末尾添加
GetCharacterMovement()->bOrientRotationToMovement = false;

接下来设置一下交战状态

// Enemy.cpp
void AEnemy::Attack()
{
	EnemyState = EEnemyState::EES_Engaged; // 添加了一行
	Super::Attack();
	PlayAttackMontage_Random();
}

bool AEnemy::CanAttack()
{
	return IsInsideAttackRadius() &&
		!IsEngaged() && // 添加了一行
		!IsAttacking() &&
		!IsDead();
}

那么结束Engaged状态就应该放到动画通知里来写 结束Engaged状态的一瞬间应该切换到无状态的临时状态 之后就根据距离做CheckCombatTarget切换状态 做出一些行为

// CharacterTypes.h enum class EEnemyState 末尾添加
EES_NoState UMETA(DisplayName = "NoState")
// Enemy.cpp
void AEnemy::AttackEnd()
{
	EnemyState = EEnemyState::EES_NoState;
	CheckCombatTarget();
}

在蒙太奇上新建动画通知AttackEnd 在动画蓝图事件图表里找到AnimNotify_AttackEnd 连上Owner获取有效的get 后面调用AttackEnd函数

PIE 现在就可以连续攻击 只要在攻击范围内 就会Attacking状态与Engaged状态一直在切换 现在攻击转到追逐有点慢 可以打开混合空间 将取样平滑一栏里的权重速度增加到10

在BP_Enemy为Attack Radius和Combat Radius制作显示半径的调试球 颜色分别选为红色和青色 球体中心选为get actor location

整理代码

接下来我们要整理代码

打开Enemy.h 在上面重新写一个public protected private 在重载的方法之前最好写上它是来自于哪个父类的方法 格式是/** <AActor> */ 这之后可以放很多从AActor类继承的函数 在写完之后需要写一个/** </AActor> */表示结束
函数放在前面 变量放在后面 如果想对某个变量重命名 可以对其高亮然后右键 - 重命名 这样整个项目里的这个变量都会被重命名 我们就将WaitMin和WaitMax重命名为PatrolWaitMin和PatrolWaitMax AttackMin和AttackMax重命名为AttackTimeMin和AttackTimeMax

#pragma once

#include "CoreMinimal.h"
#include "Characters/BaseCharacter.h"
#include "Characters/CharacterTypes.h"
#include "Enemy.generated.h"

class USoundBase;
class UHealthBarComponent;
class UCharacterAudioComponent;
class UPawnSensingComponent;

UCLASS()
class HELLOWORLD_API AEnemy : public ABaseCharacter
{
	GENERATED_BODY()

public:
	AEnemy();

	/** <AActor> */
	virtual void Tick(float DeltaTime) override;
	virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser) override;
	virtual void Destroyed() override;
	/** </AActor> */

	/** <IHitInterface> */
	virtual void GetHit_Implementation(const FVector& ImpactPoint) override;
	/** </IHitInterface> */


	FORCEINLINE UCharacterAudioComponent* GetIdleAudioComponent() const { return IdleAudioComponent; }
	FORCEINLINE EEnemyState GetEnemyState() const { return EnemyState; }
	FORCEINLINE EDeathPose GetDeathPose() const { return DeathPose; }
	FORCEINLINE bool GetIsWaiting() const { return IsWaiting; }


protected:
	virtual void HandleDamage(float DamageAmount) override;

	/** <AActor> */
	virtual void BeginPlay() override;
	/** </AActor> */

	/** <ABaseCharacter> */
	virtual void Die() override;
	virtual int32 PlayDeathMontage() override;
	virtual void PlayHitReactMontage(const FName& SectionName) override;
	virtual void Attack() override;
	FORCEINLINE virtual bool CanAttack() override;
	virtual void AttackEnd() override;
	/** </ABaseCharacter> */

	UPROPERTY(BlueprintReadOnly)
	EEnemyState EnemyState = EEnemyState::EES_Patrolling;

	UPROPERTY(BlueprintReadOnly)
	TEnumAsByte<EDeathPose> DeathPose;


private:
	/** AI Behavior*/
	bool InTargetRange(AActor* Target, double Radius) const;
	void MoveToTarget(AActor* Target, float InAcceptanceRadius);
	AActor* ChooseNewPatrolTarget() const;
	void PatrolTimerFinished();
	void ClearPatrolTimer();
	void CheckCombatTarget();
	void CheckPatrolTarget();
	void HideHealthBar();
	void ShowHealthBar();
	void LoseInterest();
	FORCEINLINE bool IsOutsideCombatRadius();
	FORCEINLINE bool IsOutsideAttackRadius();
	FORCEINLINE bool IsInsideAttackRadius();
	FORCEINLINE bool IsChasing();
	FORCEINLINE bool IsAttacking();
	FORCEINLINE bool IsDead();
	FORCEINLINE bool IsEngaged();
	void StartPatrolling();
	void StartChasing();
	void StartAttackTimer();
	void ClearAttackTimer();

	UFUNCTION()
	void PawnSeen(APawn* SeenPawn); // Callback for OnPawnSeen in UPawnSensingComponent
	

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"))
	UHealthBarComponent* HealthBarWidget;

	UPROPERTY(EditAnywhere)
	float DeathLifeSpan = 10.f;

	UPROPERTY(EditAnywhere)
	TSubclassOf<class AWeapon> WeaponClass;

	UPROPERTY()
	class AAIController* EnemyController;

	UPROPERTY(EditInstanceOnly, Category = "AI Navigation", BlueprintReadWrite, meta = (AllowPrivateAccess = "true"))
	AActor* PatrolTarget; // Current patrol target

	UPROPERTY(EditInstanceOnly, Category = "AI Navigation")
	TArray<AActor*> PatrolTargets; // Array of patrol targets

	UPROPERTY(EditAnywhere)
	double PatrolRadius = 200.f;

	FTimerHandle PatrolTimer;

	UPROPERTY(EditAnywhere, Category = "AI Navigation")
	float PatrolWaitMin = 5.f;

	UPROPERTY(EditAnywhere, Category = "AI Navigation")
	float PatrolWaitMax = 13.f;

	UPROPERTY(EditAnywhere, Category = "AI Navigation")
	float PatrollingSpeed = 175.f;

	UPROPERTY(EditAnywhere, Category = "Combat")
	float ChasingSpeed = 300.f;

	double PatrolAcceptanceRadius = 15.f;
	double ChasingAcceptanceRadius = 300.f;

	UPROPERTY(VisibleAnywhere)
	UPawnSensingComponent* PawnSensing;

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI Navigation", meta = (AllowPrivateAccess = "true"))
	bool IsWaiting = false;

	UPROPERTY()
	AActor* CombatTarget;

	UPROPERTY(EditAnywhere)
	double CombatRadius = 1500.f;

	UPROPERTY(EditAnywhere)
	double AttackRadius = 1000.f;

	FTimerHandle AttackTimer;

	UPROPERTY(EditAnywhere, Category = "Combat")
	float AttackTimeMin = 0.5f;

	UPROPERTY(EditAnywhere, Category = "Combat")
	float AttackTimeMax = 1.0f;


	/** Play sounds */
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"), Category = Audio)
	UCharacterAudioComponent* IdleAudioComponent;

};

接下来整理Enemy.cpp
构造函数里的忽略相机其实可以转移到BaseCharacter类的构造函数里 然后我们就可以把Enemy.cpp里的CapsuleComponent.h头文件删除

// BaseCharacter.cpp ABaseCharacter::ABaseCharacter 末尾添加
GetMesh()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore); // 相机通道响应设置为忽略
GetCapsuleComponent()->SetCollisionResponseToChannel(ECollisionChannel::ECC_Camera, ECollisionResponse::ECR_Ignore); // 胶囊体也要忽略相机

其它函数就按照头文件里的顺序进行摆放

// Enemy.cpp
void AEnemy::BeginPlay()
{
	Super::BeginPlay();
    
	if (PawnSensing)  PawnSensing->OnSeePawn.AddDynamic(this, &AEnemy::PawnSeen);
	InitializeEnemy();
}

void AEnemy::InitializeEnemy()
{
	EnemyController = Cast<AAIController>(GetController());
	HideHealthBar();
	MoveToTarget(PatrolTarget, PatrolAcceptanceRadius);
	SpawnDefaultWeapon();
}

void AEnemy::SpawnDefaultWeapon()
{
	UWorld* World = GetWorld();
	if (World && WeaponClass)
	{
		AWeapon* DefaultWeapon = World->SpawnActor<AWeapon>(WeaponClass); // 这里不传入位置和旋转 因为我们稍后会绑定到插槽
		DefaultWeapon->Equip(GetMesh(), FName("RightHandSocket"), this, this);
		EquippedWeapon = DefaultWeapon;
	}
}
// Enemy.h private中 /** AI Behavior*/注释段
void InitializeEnemy();
void SpawnDefaultWeapon();

接下来处理HelloWorldCharacter.cpp
Tick函数除了继承父类没有写什么内容 直接删掉 之后就需要在构造函数里把PrimaryActorTick.bCanEverTick设置为false
其它函数按照语义进行一下排序即可 其实按照我们原本的顺序就可以了

// HelloWorldCharacter.cpp
AHelloWorldCharacter::AHelloWorldCharacter()
{
	PrimaryActorTick.bCanEverTick = false;
// 后面省略

将装备武器的部分提取成单独的函数

// HelloWorldCharacter.cpp
void AHelloWorldCharacter::EKeyPressed()
{
	AWeapon* OverlappingWeapon = Cast<AWeapon>(OverlappingItem);
	if (OverlappingWeapon)
	{
		AttachWeapon(OverlappingWeapon);
	}
// 后面省略
// HelloWorldCharacter.h
void AttachWeapon(AWeapon* OverlappingWeapon);
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::AttachWeapon(AWeapon* Weapon)
{
	Weapon->Equip(GetMesh(), FName("RightHandSocket"), this, this);
	CharacterState = ECharacterState::ECS_EquippedOneHandedWeapon;
	EquippedWeapon = Weapon;
	OverlappingItem = nullptr;
}

继续整理Weapon类

// Weapon.cpp
void AWeapon::Equip(USceneComponent* InParent, FName InSocketName, AActor* NewOwner, APawn* NewInstigator)
{
	ItemState = EItemState::EIS_Equipped;
	SetOwner(NewOwner);
	SetInstigator(NewInstigator);
	AttachMeshToSocket(InParent, InSocketName);

	DisableSphereCollision();
	DisableItemMeshCollision();
	PlayEquipSound();
	DeactivateEmbers();
}

void AWeapon::PlayEquipSound()
{
	if (EquipSound)
	{
		UGameplayStatics::PlaySoundAtLocation(this, EquipSound, GetActorLocation());
	}
}

void AWeapon::DisableSphereCollision()
{
	if (Sphere)
	{
		Sphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	}
}

void AWeapon::DisableItemMeshCollision()
{
	if (ItemMesh)
	{
		ItemMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
	}
}

void AWeapon::DeactivateEmbers()
{
	if (EmbersEffect)
	{
		EmbersEffect->DeactivateImmediate();
	}
}
// Weapon.cpp
void AWeapon::OnBoxOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
	FHitResult BoxHit;
	BoxTrace(BoxHit);

	if (BoxHit.GetActor())
	{
		UGameplayStatics::ApplyDamage(BoxHit.GetActor(), Damage, GetInstigator()->GetController(), this, UDamageType::StaticClass());
		ExecuteGetHit(BoxHit);

		CreateFields(BoxHit.ImpactPoint);
	}
}

void AWeapon::BoxTrace(FHitResult& BoxHit)
{
	const FVector Start = BoxTraceStart->GetComponentLocation();
	const FVector End = BoxTraceEnd->GetComponentLocation();

	IgnoreActors.AddUnique(this);

	UKismetSystemLibrary::BoxTraceSingle(
		this,
		Start,
		End,
		BoxTraceExtent,
		BoxTraceStart->GetComponentRotation(),
		ETraceTypeQuery::TraceTypeQuery1,
		false,
		IgnoreActors,
		bShowBoxDebug ? EDrawDebugTrace::ForDuration : EDrawDebugTrace::None,
		BoxHit,
		true
	);

	if (BoxHit.GetActor())
	{
		IgnoreActors.AddUnique(BoxHit.GetActor());
	}
}

void AWeapon::ExecuteGetHit(FHitResult& BoxHit)
{
	IHitInterface* HitInterface = Cast<IHitInterface>(BoxHit.GetActor());
	if (HitInterface)
	{
		HitInterface->Execute_GetHit(BoxHit.GetActor(), BoxHit.ImpactPoint);
	}
}
// Weapon.h private中
void BoxTrace(FHitResult& BoxHit);
void ExecuteGetHit(FHitResult& BoxHit);

TArray<AActor*> IgnoreActors;

UPROPERTY(EditAnywhere, Category = "Weapon Properties")
FVector BoxTraceExtent = FVector(5.f);

UPROPERTY(EditAnywhere, Category = "Weapon Properties")
bool bShowBoxDebug = false;

这里的AWeapon::BoxTrace函数传的是FHitResult类型的非const引用 这样就可以将修改后的东西传出函数

// Weapon.h public中
FORCEINLINE TArray<AActor*> GetIgnoreActors() const { return IgnoreActors; }
// BaseCharacter.cpp ABaseCharacter::SetWeaponCollisionEnabled中
// 原来是
// EquippedWeapon->IgnoreActors.Empty();
// 修改为
EquippedWeapon->GetIgnoreActors().Empty();

Projectile类

接下来我们就做敌人类命中角色

现在这个Enemy类是角色攻击敌人的近战武器类 我要把敌人做成一个法师 所以要做一个基础武器类 近战武器类和法器类是继承这个基础武器类 和BaseCharacter相对于HelloWorldCharacter和Enemy差不多 但是我们这个法器没有什么功能 暂时直接使用Weapon类就可以 不设置Start和End 就不会有碰撞 也就不能应用伤害
还需要设置一个投掷物Projectile类 是Item类的子类 应该是做动画通知spawn一个投掷物 然后发射出去攻击到角色之后 根据碰撞点触发角色的方向性受击反应
其实真正造成伤害的 进行伤害计算的 是Projectile类 法器其实只是一个装饰品 只需要有一个mesh 并不进行武器的什么功能 但是Projectile类无法写成BaseWeapon类的子类 因为它是一次性的 并不绑定在角色骨骼上 只是飞行后命中 所以我们还是需要一个继承自BaseWeapon类的法器类 由它来Spawn一个Projectile 只是这个法器类的实现会非常简单
攻击是由Enemy类做的 攻击的时候就会播放攻击动画蒙太奇 动画上有动画通知 动画通知里面调用的函数写在法器类里 在动画通知的时候做Spawn Projectile 而Projectile负责飞行 碰撞 ApplyDamage 之后调用它击中的角色的GetHit 使角色做一些受击后的操作 比如方向性受击反应
我们还需要考虑这个Projectile的飞行方式 它应该具有一定的追踪能力 但是也要让玩家可以闪避掉 不能是百分百命中 也不能是完全的简单直线 这里需要使用Homing

我们之前用过的OnComponentBeginOverlap 是来自于PrimitiveComponent.h 但是这次我们不用重叠了 我们使用Hit 这个类里也有OnComponentHit 文件内搜索OnComponentHit

// PrimitiveComponent.h
/**
 * Delegate for notification of blocking collision against a specific component.  
 * NormalImpulse will be filled in for physics-simulating bodies, but will be zero for swept-component blocking collisions. 
 */
DECLARE_DYNAMIC_MULTICAST_SPARSE_DELEGATE_FiveParams( FComponentHitSignature, UPrimitiveComponent, OnComponentHit, UPrimitiveComponent*, HitComponent, AActor*, OtherActor, UPrimitiveComponent*, OtherComp, FVector, NormalImpulse, const FHitResult&, Hit );
/** Delegate for notification of start of overlap with a specific component */
DECLARE_DYNAMIC_MULTICAST_SPARSE_DELEGATE_SixParams( FComponentBeginOverlapSignature, UPrimitiveComponent, OnComponentBeginOverlap, UPrimitiveComponent*, OverlappedComponent, AActor*, OtherActor, UPrimitiveComponent*, OtherComp, int32, OtherBodyIndex, bool, bFromSweep, const FHitResult &, SweepResult);
/** Delegate for notification of end of overlap with a specific component */
DECLARE_DYNAMIC_MULTICAST_SPARSE_DELEGATE_FourParams( FComponentEndOverlapSignature, UPrimitiveComponent, OnComponentEndOverlap, UPrimitiveComponent*, OverlappedComponent, AActor*, OtherActor, UPrimitiveComponent*, OtherComp, int32, OtherBodyIndex);

附近就是OnComponentBeginOverlap和OnComponentEndOverlap 那么回调函数的设计就要以这几个作为参数 可以看到它有一个FHitResult类型的参数 这就是ImpactPoint

// PrimitiveComponent.h
UPROPERTY(BlueprintAssignable, Category="Collision")
FComponentHitSignature OnComponentHit;

到时候就在OnComponentHit后面AddDynamic 设置回调函数 而因为OnComponentHit的类型就是那个FComponentHitSignature函数签名 所以在它调用AddDynamic时 就能将回调函数需要的参数传入

UE自带UProjectileMovementComponent组件 可以每一帧都进行移动 我们要给Projectile类添加它 还需要一个专门用来检测碰撞的Sphere组件 和我们之前用来检测重叠的那个Sphere组件并不一样 而且我们的Projectile要以这个用于检测碰撞的组件作为根组件 这是UE官方推荐的 而不是像之前那样使用mesh作为根组件

// Projectile.h
#pragma once

#include "CoreMinimal.h"
#include "Items/Item.h"
#include "Projectile.generated.h"

class USphereComponent;
class UProjectileMovementComponent;

UCLASS()
class HELLOWORLD_API AProjectile : public AItem
{
	GENERATED_BODY()

public:
	AProjectile();

protected:

	virtual void BeginPlay() override;

	UPROPERTY(VisibleAnywhere)
	USphereComponent* CollisionSphere;

	UPROPERTY(VisibleAnywhere)
	UProjectileMovementComponent* ProjectileMovement;

	UPROPERTY(EditAnywhere)
	float Damage = 100.f;

	UFUNCTION()
	void OnCollisionSphereHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);

public:
	// 外部设置追踪目标(Enemy在Spawn后调用)
	void SetHomingTarget(AActor* Target);
};
// Projectile.cpp
#include "Items/Projectile/Projectile.h"
#include "Kismet/GameplayStatics.h"
#include "Interfaces/HitInterface.h"

#include "Components/SphereComponent.h"
#include "GameFramework/ProjectileMovementComponent.h"

AProjectile::AProjectile()
{
	PrimaryActorTick.bCanEverTick = false;

	CollisionSphere = CreateDefaultSubobject<USphereComponent>(TEXT("CollisionSphere"));
	SetRootComponent(CollisionSphere);

	CollisionSphere->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
	CollisionSphere->SetCollisionObjectType(ECC_WorldDynamic);
	CollisionSphere->SetCollisionResponseToAllChannels(ECR_Ignore);
	CollisionSphere->SetCollisionResponseToChannel(ECC_Pawn, ECR_Block);
    // 需要能够被掩体阻挡
CollisionSphere->SetCollisionResponseToChannel(ECC_WorldStatic, ECR_Block);
CollisionSphere->SetCollisionResponseToChannel(ECC_WorldDynamic, ECR_Block);

	ItemMesh->SetupAttachment(CollisionSphere);

	ProjectileMovement = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovement"));
    ProjectileMovement->SetUpdatedComponent(CollisionSphere);

	ProjectileMovement->InitialSpeed = 800.f;
	ProjectileMovement->MaxSpeed = 800.f;

	// 弱追踪
	ProjectileMovement->bIsHomingProjectile = true;
	ProjectileMovement->HomingAccelerationMagnitude = 5000.f;

	ProjectileMovement->ProjectileGravityScale = 0.f;
}

void AProjectile::BeginPlay()
{
	Super::BeginPlay();

	CollisionSphere->OnComponentHit.AddDynamic(this, &AProjectile::OnCollisionSphereHit);
}

void AProjectile::OnCollisionSphereHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
	if (OtherActor && OtherActor != GetOwner() && OtherActor != GetInstigatorController())
	{
		UGameplayStatics::ApplyDamage(OtherActor, Damage, GetInstigatorController(), this, UDamageType::StaticClass());

		IHitInterface* HitInterface = Cast<IHitInterface>(OtherActor);
		if (HitInterface)
		{
			HitInterface->Execute_GetHit(OtherActor, Hit.ImpactPoint);
		}
	}

	Destroy();
}

void AProjectile::SetHomingTarget(AActor* Target)
{
	if (ProjectileMovement && Target)
	{
		ProjectileMovement->HomingTargetComponent = Target->GetRootComponent();
	}
}

创建一个BP_Projectile 设置好网格体 拖入关卡 PIE之后 可以看到它确实在发生移动 在构造脚本图表里做好它要做的缩放

接下来我们就需要在动画通知里Spawn Projectile 既然是由敌人类调用动画通知生成 那么这个动画通知函数就应该写在敌人类

// Enemy.h private中
UPROPERTY(EditAnywhere)
TSubclassOf<class AProjectile> ProjectileClass;
// Enemy.cpp protected中
UFUNCTION(BlueprintCallable)
void SpawnProjectile();
// Enemy.cpp
#include "Items/Projectile/Projectile.h"
// Enemy.cpp
void AEnemy::SpawnProjectile()
{
	if (!ProjectileClass || !CombatTarget) return;

	const FVector SocketLocation = GetMesh()->GetSocketLocation(FName("ProjectileSocket"));
	const FVector TargetLocation = CombatTarget->GetActorLocation();
	const FRotator Rotation = (TargetLocation - SocketLocation).Rotation();

	UWorld* World = GetWorld();
	if (World)
	{
		AProjectile* Projectile = World->SpawnActor<AProjectile>(ProjectileClass, SocketLocation, Rotation);

		if (Projectile)
		{
			Projectile->SetOwner(this);
			Projectile->SetHomingTarget(CombatTarget);
		}
	}
}

在动画蒙太奇合适位置新建动画通知SpawnProjectile 在动画蓝图中AnimNotify后面调用函数 回到BP_Enemy将ProjectileClass设置为BP_Proectile

PIE 现在没有看到生成了什么东西 在Projectile类的tick函数里画调试球 是根本看不到调试球 说明在刚刚生成后的瞬间 就触发了OnProjectileHit 立即销毁了 这是因为我们确实设置了忽略敌人自身 但是敌人手上现在还有一个作为摆设的武器 生成的Projectile是碰到了它 那么我们就要让OnProjectileHit只对HelloWorldCharacter有反应

// Projectle.cpp
void AProjectile::OnCollisionSphereHit(UPrimitiveComponent* HitComp, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
	if (OtherActor->ActorHasTag("HelloWorldCharacter"))
	{
		UGameplayStatics::ApplyDamage(OtherActor, Damage, GetInstigatorController(), this, UDamageType::StaticClass());

		IHitInterface* HitInterface = Cast<IHitInterface>(OtherActor);
		if (HitInterface)
		{
			HitInterface->Execute_GetHit(OtherActor, Hit.ImpactPoint);
		}
		Destroy();
	}
}

现在能正常生成了 但是却卡在生成点不动了 之前我们生成的东西很小的时候是不会发生这种状况的 现在是因为生成的时候就已经和武器或者敌人身体发生了碰撞 在这个状态下ProjectileMovement就不会移动 所以我们需要在这个Projectile生成的时候设置成无碰撞 就需要给SpawnActor<AProjectile>添加参数

// Enemy.cpp AEnemy::SpawnProjectile中
if (World)
{
    // 添加了几行
    FActorSpawnParameters Params;
    Params.Owner = this;
    Params.Instigator = GetInstigator();
    Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;

    AProjectile* Projectile = World->SpawnActor<AProjectile>(ProjectileClass, SocketLocation, Rotation, Params);
// 后面省略

这样之后只有Projectile生成的那一帧强行没有碰撞 后面就还是被Projectile类接管 CollisionSphere自己考虑自己有没有碰撞

我们之前设置成了 CollisionSphere会阻挡Pawn WorldStatic WorldDynamic 然后在OnHit逻辑里只考虑HelloWorldCharacter类型 但是ProjectileMovement组件所关心的是 它自己所属的Actor有没有处于block状态 我们自己写的那个OnHit逻辑根本就和它没关系 我们写的那个只针对于HelloWorldCharacter的Hit逻辑 不是说CollisionSphere不与其它类型的东西发生碰撞了 只不过是就算是其它类型东西发生了OnCollisionSphereHit事件 也没什么反应而已 实际上它和Pawn WorldStatic WorldDynamic类型都会发生碰撞 那么只要发生碰撞 就会进入block状态 我们之前没有任何感知 是因为我们写的OnHit逻辑根本就不检测CollisionSphere到底有没有进入block状态 我们只检测了和它发生block的到底是不是HelloWorldCharacter类型 如果是的话 就做一些事情 比如应用伤害

与法师周旋 逼退

我们稍后再做角色受击蒙太奇 方向性受击反应 血量条 先实现下一个重要的功能

我们还应该设置一个攻击半径的内圈 也就是角色离敌人实在太近的时候 敌人就不会攻击了 敌人要切换到一个后退状态 写在CharacterTypes.h里 敌人使用带有前后左右动画的混合空间 一直面朝角色进行移动 也就是说 在前后的方向是后退的 在左右的方向上是保持追逐我们的 后退速度可以和巡逻速度一样 敌人发生后退 拉开距离直到攻击范围内 如果后续切换到攻击状态或者追逐状态 角色就应该变成正常的只会朝前移动
我们还发现一件事情 我们攻击敌人 敌人不等转身就直接在原地攻击我们 并不会转身追逐 就在原地一直攻击 直到我们跑出攻击半径 攻击的时候 Projectile的方向很智能 是对着我们的 但是敌人不是面对我的 而是一直处于原来的方向
那么实际上就可以发现 前面说的混合空间就应该还有一个需求 也就是我们绕到敌人后面的时候 它就应该发生转身了 而不只是背对着我们 为了拉开距离而后退(实则对于敌人这时候应该是前进了)并发生左右方向上的追逐移动 也就是说既想保持混合空间的那种会不改变面朝方向的左右移动 也想让它显得不要太呆滞

首先需要新建一个后退状态 实际上这个后退也是属于Chasing的一种 但是我们就把向前追逐保持为命名Chasing 后退就叫Backing

// CharacterTypes.h
enum class EEnemyState : uint8
{
	EES_Dead UMETA(DisplayName = "Dead"),
	EES_Patrolling UMETA(DisplayName = "Patrolling"),
	EES_Chasing UMETA(DisplayName = "Chasing"),
	EES_Backing UMETA(DisplayName = "Backing"),
	EES_Attacking UMETA(DisplayName = "Attacking"),
	EES_Engaged UMETA(DisplayName = "Engaged"),

	EES_NoState UMETA(DisplayName = "NoState")
};
// Enemy.h private中
FORCEINLINE bool IsBacking();
// Enemy.cpp
bool AEnemy::IsBacking()
{
	return EnemyState == EEnemyState::EES_Backing;
}

需要考察一下这样之后 所有关于EnemyState的那些判断 是否需要修改

// Enemy.cpp
void AEnemy::PawnSeen(APawn* SeenPawn)
{
	const bool bShouldChaseTarget =
		EnemyState != EEnemyState::EES_Dead &&
		EnemyState != EEnemyState::EES_Chasing &&
		EnemyState != EEnemyState::EES_Backing && // 添加了一行
		EnemyState < EEnemyState::EES_Attacking &&
		SeenPawn->ActorHasTag(FName("HelloWorldCharacter"));
// 后面省略

接下来做攻击的内圈

// Enemy.h private中
UPROPERTY(EditAnywhere, Category = "Combat")
double InneAttackRadius = 500.f;
// Enemy.h private中
FORCEINLINE bool IsInsideInnerAttackRadius();
// Enemy.cpp
bool AEnemy::IsInsideInnerAttackRadius()
{
	return InTargetRange(CombatTarget, InnerAttackRadius);
}

接下来修改攻击逻辑 我们之前做的攻击 是依赖于CanAttack()这个bool值的判断 但是我们应该先判断 需不需要拉开距离

// Enemy.cpp AEnemy::CheckCombatTarget中
// 原来是
// else if (CanAttack())
// {
//     StartAttackTimer();
// }
// 修改为
else if (IsInsideInnerAttackRadius() && !IsBacking())
{
    ClearAttackTimer();
    StartBacking();
}
else if (CanAttack())
{
    StartAttackTimer();
}

那么接下来补完这个StartBacking函数 需要在这里切换到混合空间模式 开启使用控制器旋转Yaw

// Enemy.h private中
void StartBacking();
// Enemy.cpp
void AEnemy::StartBacking()
{
	EnemyState = EEnemyState::EES_Backing;

	GetCharacterMovement()->bOrientRotationToMovement = false;
	bUseControllerRotationYaw = true;

	GetCharacterMovement()->MaxWalkSpeed = BackingSpeed;
}
// Enemy.h private中
UPROPERTY(EditAnywhere, Category = "Combat")
float BackingSpeed = 175.f;

但是现在只是切换和设置了一些参数 还没有真正地去做后退 我们的目的是 前后方向上后退 左右方向上保持追踪角色

// Enemy.cpp AEnemy::Tick 末尾添加
if (IsBacking())
{
    BackingTick(DeltaTime);
}
// Enemy.h private中
void BackingTick(float DeltaTime);
// Enemy.cpp
void AEnemy::BackingTick(float DeltaTime)
{
	if (!CombatTarget) return;

	const FVector ToTarget = (CombatTarget->GetActorLocation() - GetActorLocation()).GetSafeNormal();

	SetActorRotation(FRotator(0.f, ToTarget.Rotation().Yaw, 0.f)); // 始终面朝玩家

	AddMovementInput(GetActorForwardVector(), -1.f); // 后退

	const FVector RightVector = GetActorRightVector(); // 规定敌人自身右侧的正方向
	
	const float Side = FVector::DotProduct(ToTarget, RightVector); // 使用点乘计算玩家在敌人左侧还是右侧 正为右 负为左
	AddMovementInput(RightVector, FMath::Sign(Side)); // 玩家往右 则Sign(Side)为正 敌人速度就也设置为正 向右
}

角度朝向约束

现在还需要修复一个 敌人不等转身就直接攻击我们

// Enemy.cpp
bool AEnemy::CanAttack()
{
	if (!IsInsideAttackRadius()) return false;
	if (IsEngaged() || IsAttacking() || IsDead()) return false;

	const FVector Forward = GetActorForwardVector();
	const FVector ToTarget = (CombatTarget->GetActorLocation() - GetActorLocation()).GetSafeNormal();
	const double Dot = FVector::DotProduct(Forward, ToTarget);
	return Dot > 0.5; // 大约60度内
}

现在这样 敌人就只会在面朝我们的时候才会发生攻击 这样的话 我们就要使敌人在该攻击我们的时候 如果我们在它身后 它就应该先转身
那么就还需要使用点乘计算ToTarget与Forward之间的点乘值 是时候抽象成一个函数

// Enemy.h private中
double GetFacingDotToTarget(AActor* Target);
bool IsFacingTarget(AActor* Target, double Threshold = 0.5);  // 大约60度内
bool IsBackToTarget(AActor* Target);
// Enemy.cpp
double AEnemy::GetFacingDotToTarget(AActor* Target)
{
	if (!Target) return 0.f;

	const FVector Forward = GetActorForwardVector();
	const FVector ToTarget = (Target->GetActorLocation() - GetActorLocation()).GetSafeNormal();

	return FVector::DotProduct(Forward, ToTarget);
}

bool AEnemy::IsFacingTarget(AActor* Target, double ThresholdCos)
{
	return GetFacingDotToTarget(Target) > ThresholdCos;
}

bool AEnemy::IsBackToTarget(AActor* Target)
{
	return GetFacingDotToTarget(Target) < 0.f;
}
// Enemy.cpp
bool AEnemy::CanAttack()
{
	if (!IsInsideAttackRadius()) return false;
	if (IsEngaged() || IsAttacking() || IsDead()) return false;

	return IsFacingTarget(CombatTarget);
}

那么我们接下来思考 究竟敌人应该在什么情况下转身 究竟是写在Attack函数里 还是TakeDamage 还是GetHit
我们之前把受击后就开始追逐的逻辑写在了TakeDamage函数里 TakeDamage管理的是伤害值 也就是数据层面 比如计算血量和百分比 GetHit管理的是表现层面 比如方向性受击反应和血条UI 但其实我们之前把TakeDamage管理数据的层面放在了HandleDamage函数里 然后TakeDamage调用这个函数 同时TakeDamage函数还管理一些受击之后的逻辑 但其实似乎 这个受击后的各种逻辑 放在GetHit这种表现层也不合适 放在TakeDamage这种数据层也不合适
我们需要将数据层 / 表现层 / 逻辑层 分离 这时候就需要我们自己写一个专门用来处理受击后的逻辑的委托
但是如果我们写成了OnHitReact委托 这个委托却只可能用GetHit下达 所以根本没必要写成委托 直接写成GetHit调用的一个函数就可以了 或者干脆就把逻辑写在GetHit里
如果我们全靠委托来解决 GetHit里面的其它功能其实也没必要写了 GetHit只需要下委托就行了 别的都不用做 比如什么播放动画 播放声音 设置UI 就交给各自的类去自己设置回调自己解决就行了 这样在各自的类就会设置很多意义不明的回调 比如在音频类里 Enemy类受击有回调 HelloWorldCharacter类受击还有另一个回调 各自播放一种声音 这样之后 IHitInterface的各种子类就也不需要重构GetHit函数 只需要把受击后需要做的各个功能里面会使用的类 都写上OnHitReact的回调就可以了 这样的话倒好玩了 音频系统里面只需要写一大堆回调 其它类里面根本不需要额外调用音频系统 只需要下达委托 各种委托 比如通知音频系统 我是Enemy 我在idle 我在攻击 我在受击 你负责为我播放对应的声音 只需要各种委托 音频系统就会自己播声音 但是显然不应该这样写
所以我们还是维持原来的设计 把受击之后的逻辑写在TakeDamage里 HandleDamage负责管理伤害 TakeDamage里的其它部分负责管理逻辑 GetHit函数只负责管理表现

最简单的方式是 写一个专门用来转身的函数 受击TakeDamage的时候调用一次 攻击Attack之前调用一次

// Enemy.h private中
void FaceTarget(AActor* Target);
// Enemy.cpp
void AEnemy::FaceTarget(AActor* Target)
{
	if (!Target) return;

	const FVector ToTarget = (Target->GetActorLocation() - GetActorLocation()).GetSafeNormal();
	SetActorRotation(FRotator(0.f, ToTarget.Rotation().Yaw, 0.f));
}
// Enemy.cpp AEnemy::Attack 开头添加
if (!IsFacingTarget(CombatTarget))
{
	FaceTarget(CombatTarget);
}
// Enemy.cpp
float AEnemy::TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser)
{
	HandleDamage(DamageAmount);

	CombatTarget = EventInstigator->GetPawn();
	FaceTarget(CombatTarget);
	StartBacking();

	return DamageAmount;
}

攻击前要转身 但是转身过程中也不应该攻击 所以可以优化一下CanAttack

// Enemy.cpp
bool AEnemy::CanAttack()
{
	if (!IsInsideAttackRadius()) return false;
    if (IsInsideInnerAttackRadius()) return false;
	if (IsEngaged() || IsAttacking() || IsDead()) return false;

	if (!IsFacingTarget(CombatTarget))
	{
		FaceTarget(CombatTarget);
		return false;
	}
	return true;
}

PIE 发现敌人几乎是一直面朝我们 没有什么左右移动 还可以发现Projectile如果没有打中 就会一直追我们直到打中 所以必须要给它设置生命周期

// Projectile.cpp AProjectile::AProjectile 末尾添加
InitialLifeSpan = 5.f;
// Projectile.h private中
float HomingTime = 0.f;
float MaxHomingTime = 2.f;
// Projectile.cpp
void AProjectile::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	HomingTime += DeltaTime;
	if (HomingTime > MaxHomingTime)
	{
		ProjectileMovement->bIsHomingProjectile = false;
	}
}

至于修复不进行左右移动的问题 因为横向之间有距离差的话 必然就会导致敌人如果想要横向位移 它不可能是面对玩家 它只能是大概面对着玩家面对的那个截面 所以我们不妨不让它持续面对玩家 而是在玩家绕到它身后的时候 才做一个面向玩家

// Enemy.cpp
void AEnemy::BackingTick(float DeltaTime)
{
	if (!CombatTarget) return;

	if (IsBackToTarget(CombatTarget))
	{
		FaceTarget(CombatTarget);
	}
// 后面省略

但是现在我们的FaceTarget是瞬间转身 需要优化一下

// Enemy.cpp
void AEnemy::FaceTarget(AActor* Target)
{
	if (!Target) return;

	const FVector ToTarget = (Target->GetActorLocation() - GetActorLocation()).GetSafeNormal();
	const FRotator NewRotation = FMath::RInterpTo(
		GetActorRotation(),
		ToTarget.Rotation(),
		GetWorld()->GetDeltaSeconds(),
		8.f // 转身速度
	);
	SetActorRotation(FRotator(0.f, NewRotation.Yaw, 0.f));
}

武器 持续攻击检测

现在还有一个问题 我们很难打到它 不是因为它后退速度太快 而是因为它后退得太及时 先加一个延时

// Enemy.h private中
FTimerHandle BackingDelayTimer;

UPROPERTY(EditAnywhere, Category="Combat")
float BackingDelay = 0.4f;

void StartBackingDelayTimer();
void ClearBackingDelayTimer();
// Enemy.cpp
void AEnemy::StartBackingDelayTimer()
{
	if (GetWorldTimerManager().IsTimerActive(BackingDelayTimer)) return;

	GetWorldTimerManager().SetTimer(
		BackingDelayTimer,
		this,
		&AEnemy::StartBacking,
		BackingDelay
	);
}

这个StartBackingDelayTimer函数不应该像StartAttackingTimer函数一样 直接切换状态 必须要等到计时到了 真的开始后退了 再切换状态 至于StartAttackingDelayTimer函数里可以直接切状态 是因为在那个时候已经准备攻击了 只是攻击要有间隔 不是为了攻击前摇才做的攻击计时器 而是为了防止在Attacking状态之中 攻击太频繁 才需要设置攻击计时器
但是如果已经进入后退的前摇之中了 就不应该重新开始计时了 所以需要检查IsTimerActive

// Enemy.cpp
void AEnemy::ClearBackingDelayTimer()
{
	GetWorldTimerManager().ClearTimer(BackingDelayTimer);
}
// Enemy.cpp AEnemy::CheckCombatTarget中
else if (IsInsideInnerAttackRadius() && !IsBacking())
{
	ClearAttackTimer();
	StartBackingDelayTimer();
}
else
{
	ClearBackingDelayTimer();
	if (CanAttack())
	{
		StartAttackTimer();
	}
}
// Enemy.cpp AEnemy::TakeDamage中
// 原来是
// StartBacking();
// 修改为
StartBackingDelayTimer();
// Enemy.cpp AEnemy::StartBacking 开头添加
ClearBackingDelayTimer();

PIE 现在发现一个问题 它只有在切换到Backing状态的时候才会在后退之前延时一点 但是我们想要的效果应该是 只要触发一次后退发生了移动 只要决定移动 就要延时一点再移动 只是切换状态的时候延时是不对的
但其实根本不是这个延时的问题 而是我们在播放攻击动画的时候 敌人已经后退到更远的地方了 导致武器的BoxTrace是检测不到的
但是我们暂时先为了攻击的成功率 修复一下这个问题

// Enemy.h private中
bool bCanBackingMove = false;
// Enemy.cpp
void AEnemy::StartBackingDelayTimer()
{
	if (GetWorldTimerManager().IsTimerActive(BackingDelayTimer)) return;

	bCanBackingMove = false; // 添加了一行

	GetWorldTimerManager().SetTimer(
		BackingDelayTimer,
		this,
		&AEnemy::StartBacking,
		BackingDelay
	);
}
// Enemy.cpp
void AEnemy::StartBacking()
{
	ClearBackingDelayTimer();

	EnemyState = EEnemyState::EES_Backing;

	GetCharacterMovement()->bOrientRotationToMovement = false;
	bUseControllerRotationYaw = true;

	GetCharacterMovement()->MaxWalkSpeed = BackingSpeed;

	bCanBackingMove = true; // 添加了一行
}
// Enemy.cpp AEnemy::BackingTick 开头添加
if (!bCanBackingMove) return;

现在来真正地解决打不到的问题 GetHit函数和TakeDamage函数都不会被调用 不播放受击反应动画 血条也没变化 因为BoxTrace追踪不到 所以这个功能 既不能写在GetHit函数 也不能写在TakeDamage函数
但是也不能让敌人主动站在那等着我们打 比如写一个公共的函数 让Enemy类读取HelloWorldCharacter的状态 那样的话 就算我们在很远的地方挥动武器 就算敌人在巡逻状态 也还是会因为我们而停止的 所以完全不对

我们之前做的是 在OnBoxOverlapBegin的时候 BoxTrace一次 但是OverlapBegin是单帧的 如果这个瞬间BoxTrace没有追踪到 整个攻击动作都相当于白做了 所以要增大攻击窗口 提高BoxTrace的成功率 但是如果一次攻击动作之内 多次BoxTrace 就会触发多次攻击 这就是为什么我们之前会设置武器开关碰撞的动画通知 还要写IgnoreActors
所以我们要做的是 多次检测 单次命中
所以需要把AWeapon::OnBoxOverlapBegin关于BoxTrace的语义删除 转移到Tick函数里去做这件事

// Weapon.h private中
bool bIsAttacking = false;
// Weapon.h public中
void SetIsAttacking(bool bNew);
// Weapon.cpp
void AWeapon::SetIsAttacking(bool bNew)
{
	bIsAttacking = bNew;
}
// BaseCharacter.cpp
void ABaseCharacter::SetWeaponCollisionEnabled(ECollisionEnabled::Type CollisionEnabled)
{
	if (EquippedWeapon && EquippedWeapon->GetWeaponBox())
	{
		EquippedWeapon->GetWeaponBox()->SetCollisionEnabled(CollisionEnabled);
		
		if (CollisionEnabled != ECollisionEnabled::NoCollision) // 在攻击窗口内
		{
			EquippedWeapon->SetIsAttacking(true);
			EquippedWeapon->GetIgnoreActors().Empty();
		}
		else
		{
			EquippedWeapon->SetIsAttacking(false);
		}
	}
}
// Weapon.h public中
virtual void Tick(float DeltaTime) override;
// Weapon.cpp
void AWeapon::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	if (bIsAttacking)
	{
		HandleBoxTrace();
	}
}
// Weapon.h private中
void HandleBoxTrace();
// Weapon.cpp
void AWeapon::BoxTrace(FHitResult& BoxHit)
{
	const FVector Start = BoxTraceStart->GetComponentLocation();
	const FVector End = BoxTraceEnd->GetComponentLocation();

	IgnoreActors.AddUnique(this);

	UKismetSystemLibrary::BoxTraceSingle(
		this,
		Start,
		End,
		BoxTraceExtent,
		BoxTraceStart->GetComponentRotation(),
		ETraceTypeQuery::TraceTypeQuery1,
		false,
		IgnoreActors,
		bShowBoxDebug ? EDrawDebugTrace::ForDuration : EDrawDebugTrace::None,
		BoxHit,
		true
	);
}

void AWeapon::HandleBoxTrace()
{
	FHitResult BoxHit;
	BoxTrace(BoxHit);

	if (BoxHit.GetActor() && !IgnoreActors.Contains(BoxHit.GetActor()))
	{
		UGameplayStatics::ApplyDamage(BoxHit.GetActor(), Damage, GetInstigator()->GetController(), this, UDamageType::StaticClass());
		ExecuteGetHit(BoxHit);
		IgnoreActors.AddUnique(BoxHit.GetActor());

		CreateFields(BoxHit.ImpactPoint);
	}
}

PIE 发现现在这样 我们是能击中正在后退的敌人 但是击中之后 它就不动了 我们再对其攻击 就再也无法击中它 没有方向性受击反应 没有血条变化 这很明显就是之前我们做后退延时的时候 设置的bCanBackingMove = false导致的
按照我们的设计 先进入StartBackingDelayTimer函数将bCanBackingMove设置为false 这样敌人就不会动 直到计时器到了 调用StartBacking函数 又将bCanBackingMove设置为true 敌人就会开始动了
但是TakeDamage函数会调用StartBackingDelayTimer函数 敌人受击之后 将bCanBackingMove设置为了false 请注意敌人在受击的时候它就已经是Backing状态了 在下一帧 在CheckCombatTarget函数中 它就会进入最后的那个else分支 之后开始ClearBackingDelayTimer 所以我们设置的计时根本没等到计时结束 计时器就被清除了 bCanBackingMove一直都不会被设置为true 永远不会进入BackingTick函数 如果我们不离开攻击内圈 它就会一直都是Backing状态 就会持续清除计时器 永远无法再次StartBackingTimer(虽然就算是有机会再次StartBackingTimer 在下一帧就又会被clear掉)
所以我们需要将这个ClearBackingTimer设置成 只有在不需要后退的时候才清除 因为这个Clear的语义并不是为了管理设置重复的BackingTimer(因为我们已经在StartBackingTimer函数开头检查IsTimerActive了) 只是为了攻击在攻击之前 清除未完成的后退准备 使得攻击更敏捷

// Enemy.cpp AEnemy::CheckCombatTarget中
else if (IsInsideInnerAttackRadius() && !IsBacking())
{
	ClearAttackTimer();
	StartBackingDelayTimer();
}
else
{
	if (!IsInsideInnerAttackRadius()) // 添加了一行
	{
		ClearBackingDelayTimer();
	}
	if (CanAttack())
	{
		StartAttackTimer();
	}
}

现在它确实是在 后退途中我们首次击中它 确实能击中 再后面想要击中它 就做不到了 如果在它原地站着的时候首次击中它 后面它发现我们再后退的时候 也无法击中它了 应该还是那个IgnoreActors的问题
仔细查看可以发现 是因为我们在使用动画通知将IgnoreActors清空的时候 使用的是GetIgnoreActors 这个方法是public的没错 但是这个方法是const的 也就是说我们没有做清空 先删掉这个方法 然后把IgnoreActors直接变成public

// Weapon.h public中
TArray<AActor*> IgnoreActors;
// BaseCharacter.cpp ABaseCharacter::SetWeaponCollisionEnabled中
EquippedWeapon->IgnoreActors.Empty();

PIE 还能发现一个问题 杀死敌人之后 进入Dead状态 之后敌人又回到Backing状态 站起来

// Enemy.cpp AEnemy::Die中
ClearAttackTimer();
ClearBackingDelayTimer(); // 添加了一行

现在巡逻追踪攻击非常密集 不会idle很长时间进入Dance状态 现在我们要修复这个问题

现在可以整理一下我们的状态机内容

(废弃)4个状态 IdleWalkRun Dance Dead Wait

  1. IdleWalkRun 状态
    根据角色的移动速度和方向 在待机 走路 跑步之间进行混合
    使用混合空间进行混合 输入X轴为GroundSpeed地面速度 Y轴为Direction移动方向
    • 变量依赖:
      GroundSpeed(float) 当前地面速度
      Direction(float) 移动方向角度
    • 变为相关时:
      进入该状态时调用函数 ResetIdleTimer() 重置IdleTimer
    • 更新时:
      每帧调用 UpdateIdleTimer() 更新IdleTimer
  2. Dance 状态
    播放随机的跳舞动画
    使用BlendListByInt节点 根据DanceIndex选择不同的跳舞动画 目前有3个动画 各动画均不循环 播放一次即停止
    • 变为更新时:
      进入该状态时调用函数RandomDanceIndex() 随机生成DanceIndex值(0-2) 决定播放哪段舞蹈
    • 变量依赖:
      DanceIndex(int) 当前选择的舞蹈索引
  3. Dead 状态
    根据死亡方向播放对应的死亡动画
    使用BlendListByEnum节点 绑定枚举EDeathPose 根据DeathPose枚举值选择死亡动画 目前有 前后左右 4个死亡动画 default默认设置为向前死亡动画
    • 变量依赖:
      DeathPose(EDeathPose枚举) 死亡时的方向
  4. Wait 状态
    循环播放巡逻等待动画

*状态转换规则*

  1. IdleWalkRun 到 Dance:当 IdleTimer > 10.0 时 进入跳舞状态
    IdleWalkRun 到 Wait:当 bIsWait == true 时 进入等待状态
    IdleWalkRun 到 Dead:当 EnemyState == EES_Dead 时 进入死亡状态
  2. Dance 到 IdleWalkRun:当前舞蹈播放结束后自动返回
    Dance 到 Dead:当 EnemyState 为 EES_Dead 时 进入死亡状态
  3. Wait 到 IdleWalkRun:当 bIsWait 为 false 时 返回移动待机状态
  4. Dead 无输出过渡 死亡动画为最终状态

变量说明

  1. GroundSpeed float 角色移动速度
  2. Direction float 角色移动方向角度
  3. IdleTimer float 空闲计时器 用于触发跳舞
  4. bIsWait bool 是否处于等待状态
  5. EnemyState EEnemyState枚举 敌人当前状态 如 EES_Dead
  6. DanceIndex int 当前跳舞动画索引
  7. DeathPose EDeathPose枚举 死亡方向

函数说明

  1. ResetIdleTimer 进入IdleWalkRun时调用 重置计时器
  2. UpdateIdleTimer 每帧更新IdleTimer
  3. RandomDanceIndex 进入Dance时调用 随机生成0-2的DanceIndex

但是由于工作量原因 暂时先跳过行为树

直接把跳舞动画也放在Wait状态里 删掉Dance状态 添加一个Wait次数的计数器 超过一定次数就将Wait标准动画换成跳舞动画并重置计数器

// Enemy.h private中
int32 PatrolDanceCount = 0;
bool bCanDance = false;
// Enemy.h public中
void ResetPatrolDanceCount() { PatrolDanceCount = 0; }
// Enemy.cpp AEnemy::Tick 末尾添加
if (!IsPatrolling())
{
	ResetPatrolDanceCount();
    bCanDance = false;
}
// Enemy.cpp
void AEnemy::CheckPatrolTarget()
{
	if (IsWaiting) return;

	if (InTargetRange(PatrolTarget, PatrolRadius))
	{
		PatrolTarget = ChooseNewPatrolTarget();
		float WaitTime;
		if (PatrolDanceCount < 2)
		{
			bCanDance = false;
			WaitTime = FMath::FRandRange(PatrolWaitMin, PatrolWaitMax);
			PatrolDanceCount++;
		}
		else
		{
			bCanDance = true;
			WaitTime = 30.f;
		}
		GetWorldTimerManager().SetTimer(PatrolTimer, this, &AEnemy::PatrolTimerFinished, WaitTime);
        IsWaiting = true;
	}
}

一定要最后才进入IsWaiting状态 因为这样才可以根据bCanDance的值 在AnimInstance里来决定wait状态到底播放哪个动画 而且也不能在开始等待计时之前切换为IsWaiting状态 否则就直接先播放wait动画 但是没有却没有进入等待时间 就会滑步

// Enemy.h
UPROPERTY(BlueprintReadOnly, Category = "Dance")
bool bCanDance;
// Enemy.cpp UEnemyAnimInstance::NativeThreadSafeUpdateAnimation末尾添加
bCanDance = Enemy->GetCanDance();
// EnemyAnimInstance.cpp
void UEnemyAnimInstance::ResetIdleTimer(const FAnimUpdateContext& Context, const FAnimNodeReference& Node)
{
	IdleTimer = 0.f;
	if (bCanDance)
	{
		Enemy->ResetPatrolDanceCount();
	}
}

在Wait状态里添加一个blend poses by bool节点 bCanDance为true 就播放之前做的那个blend poses by int返回的随机某个跳舞动画 如果bCanDance为false 就播放常规等待时的左顾右盼动画

还需要在动画资产最后放一个DanceEnd动画通知 这样就可以提前结束巡逻等待 开始移动 如果不这样做的话 播放完动画之后就会站在原地不动 直到30s巡逻等待结束 我们设置的这个30s的magic number 是大于最长的跳舞动画的时长的

// Enemy.h private中
UFUNCTION(BlueprintCallable)
void DanceEnd();
// Enemy.cpp
void AEnemy::DanceEnd()
{
	PatrolTimerFinished();
}

进入战斗演出

这时候我们可以做战斗音乐了 也就是角色在敌人CombatTarget里面的时候 可以播放 这个音乐仍然是要使用Enemy播放 只是资产里不使用衰减设置 也是使用我们之前自定义的UCharacterAudioComponent组件

// Enemy.h
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, meta = (AllowPrivateAccess = "true"), Category = Audio)
UCharacterAudioComponent* CombatAudioComponent;

这里不需要USoundBase* CombatSound变量 因为我们自己写的组件是继承自UAudioComponent的 自带USoundBase成员变量 可以直接在组件内部设置

// Enemy.cpp AEnemy::AEnemy中
CombatAudioComponent = CreateDefaultSubobject<UCharacterAudioComponent>(TEXT("CombatAudioComponent"));
CombatAudioComponent->SetupAttachment(GetRootComponent());
CombatAudioComponent->bAutoActivate = false;
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
	if (IsOutsideCombatRadius())
	{
        // 脱战
		if (CombatAudioComponent)
		{
			CombatAudioComponent->StopMusic();
		}

		ClearAttackTimer();
		LoseInterest();
		if (!IsEngaged())
			StartPatrolling();
	}
	else
	{
        // 进入战斗
		if (CombatAudioComponent && !CombatAudioComponent->IsPlaying())
		{
			CombatAudioComponent->PlayMusic();
		}

		if (IsOutsideAttackRadius() && !IsChasing())
		{
			ClearAttackTimer();
			if (!IsEngaged())
				StartChasing();
		}
		else if (IsInsideInnerAttackRadius() && !IsBacking())
// 后面省略
// Enemy.cpp AEnemy::Die 末尾添加
if (CombatAudioComponent)
{
	CombatAudioComponent->StopMusic();
}

进入战斗可以有一个过场动画 应该是进入战斗距离之后 然后这时候进行一个计时器 去允许我们有时间控制摄像机和播放动画 但是如果是在背后受击之后开始进入战斗 是没必要播放这个过场动画
我们打算在敌人一个正前方比较俯视的视角拉近就够了 不需要移动角色 我们做PawnSeen的时候 是做了敌人FaceTarget的 那么就应该在敌人FaceTarget做好之后 敌人面向我们之后 先打断它的StartChasing或者StartBacking StartAttacking 然后去进行一个摄像机的摆放 我们要根据CombatRadius的参数值去设置拍摄距离 触发动画的时候必然是与敌人的距离为CombatRadius 因为我们已经设置了 受击后开始战斗的情况 不会播放这个过场动画 播完动画之后把控制权还给角色
首先要直接拉到一个比较近的距离 然后在播放的几秒内 要持续更加拉近 当然速度和拉的距离也不要太大 但是要展示出动态 而且应该是角色进入比CombatRadius稍微再近一点再开始拉镜头 否则可能会发生镜头播放了 战斗音乐还没来得及播放

// HelloWorldCharacter.h public中
void SetViewToActor(AActor* NewViewTarget, float BlendTime);
void ResetView(float BlendTime);
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::SetViewToActor(AActor* NewViewTarget, float BlendTime)
{
	APlayerController* PC = Cast<APlayerController>(GetController());
	if (PC && NewViewTarget)
	{
		PC->SetViewTargetWithBlend(NewViewTarget, BlendTime);
	}
}

void AHelloWorldCharacter::ResetView(float BlendTime)
{
	APlayerController* PC = Cast<APlayerController>(GetController());
	if (PC)
	{
		PC->SetViewTargetWithBlend(this, BlendTime);
	}
}
// Enemy.h
/** Combat Camera */
UPROPERTY(EditDefaultsOnly, Category = Montages)
UAnimMontage* PowerUpMontage;

void PlayPowerUpMontage();

UPROPERTY()
class ACameraActor* CombatCamera;

FTimerHandle CombatCameraTimer;
FTimerHandle CombatCameraUpdateTimer;

float CurrentDistance;
float TargetDistance;

void StartCombatCamera();
void UpdateCombatCamera();
void EndCombatCamera();
// Enemy.cpp
#include "Camera/CameraActor.h"
#include "Kismet/GameplayStatics.h"
// Enemy.cpp
void AEnemy::PlayPowerUpMontage()
{
	PlayMontageSection(PowerUpMontage, FName("Default"));
}

void AEnemy::StartCombatCamera()
{
	bCanBackingMove = false;
	PlayPowerUpMontage();

	AHelloWorldCharacter* Player = Cast<AHelloWorldCharacter>(CombatTarget);
	if (!Player) return;

	FVector EnemyLocation = GetActorLocation();
	FVector Forward = GetActorForwardVector();

	CurrentDistance = CombatRadius * 0.8f;
	TargetDistance = CombatRadius * 0.3f;

	FVector CameraLocation =
		EnemyLocation +
		Forward * CurrentDistance +
		FVector(0, 0, 150.f);

	FRotator CameraRotation =
		(EnemyLocation - CameraLocation).Rotation();

	CombatCamera = GetWorld()->SpawnActor<ACameraActor>(
		ACameraActor::StaticClass(),
		CameraLocation,
		CameraRotation
	);

	Player->SetViewToActor(CombatCamera, 0.2f);

	GetWorldTimerManager().SetTimer( // 动态推进
		CombatCameraUpdateTimer,
		this,
		&AEnemy::UpdateCombatCamera,
		0.01f,
		true
	);

	GetWorldTimerManager().SetTimer( // 结束
		CombatCameraTimer,
		this,
		&AEnemy::EndCombatCamera,
		CombatIntroTime
	);
}

void AEnemy::UpdateCombatCamera()
{
	if (!CombatCamera) return;

	FVector EnemyLocation = GetActorLocation();
	FVector Forward = GetActorForwardVector();

	CurrentDistance = FMath::FInterpTo(
		CurrentDistance,
		TargetDistance,
		GetWorld()->GetDeltaSeconds(),
		1.5f
	);

	FVector CameraLocation =
		EnemyLocation +
		Forward * CurrentDistance +
		FVector(0, 0, 150.f);

	FRotator CameraRotation =
		(EnemyLocation - CameraLocation).Rotation();

	CombatCamera->SetActorLocation(CameraLocation);
	CombatCamera->SetActorRotation(CameraRotation);
}

void AEnemy::EndCombatCamera()
{
	AHelloWorldCharacter* Player = Cast<AHelloWorldCharacter>(CombatTarget);
	if (!Player) return;

	Player->ResetView(0.3f); // 切回玩家

	GetWorldTimerManager().ClearTimer(CombatCameraUpdateTimer);

	if (CombatCamera)
	{
		CombatCamera->Destroy();
		CombatCamera = nullptr;
	}
}
// Enemy.cpp
void AEnemy::CheckCombatTarget()
{
	if (GetWorld()->GetTimerManager().IsTimerActive(CombatCameraTimer)) return;

	if (IsOutsideCombatRadius())
	{
		if (CombatAudioComponent)
		{
			CombatAudioComponent->StopMusic();
		}

		ClearAttackTimer();
		LoseInterest();
		if (!IsEngaged())
			StartPatrolling();
	}
	else
	{
		if (CombatAudioComponent && !CombatAudioComponent->IsPlaying())
		{
			CombatAudioComponent->PlayMusic();
		}

		if (!bHasPlayedCombatIntro)
		{
			StartCombatCamera();
			bHasPlayedCombatIntro = true;
			return;
		}

		if (IsOutsideAttackRadius() && !IsChasing())
// 后面省略
// Enemy.cpp AEnemy::GetHit_Implementation 末尾添加
bHasPlayedCombatIntro = true;

现在已经可以得到一个基本的进入战斗演出 演出过程中攻击敌人即可打断镜头 回到正常视角

角色受击

先做受击Niagara效果 在ImpactPoint的地方Spawn一个就可以 但是现在我们要做的是一次性的效果 所以还是用NiagaraSystem

// BaseCharacter.cpp
#include "NiagaraSystem.h"
#include "NiagaraFunctionLibrary.h"
// BaseCharacter.h
class UNiagaraSystem;
// BaseCharacter.h
UPROPERTY(EditAnywhere)
UNiagaraSystem* HitEffect;

void SpawnHitEffect(const FVector& ImpactPoint);
// BaseCharacter.cpp
void ABaseCharacter::SpawnHitEffect(const FVector& ImpactPoint)
{
	if (HitEffect)
	{
		UNiagaraFunctionLibrary::SpawnSystemAtLocation(GetWorld(), HitEffect, ImpactPoint);
	}
}
// Enemy.cpp
void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)
{
	ShowHealthBar();
    ClearPatrolTimer(); // 添加了一句

	if (IsAlive())
	{
		DirectionalHitReact(ImpactPoint);
	}
	else
	{
		Die();
	}

	SpawnHitEffect(ImpactPoint); // 添加了一句
	GetWorldTimerManager().ClearTimer(CombatCameraUpdateTimer);
	bHasPlayedCombatIntro = true;
}

在BP_Enemy设置一下HitEffect资产 就可以使用
这里我们还需要修复一下 我们从背后偷袭它时 巡逻计时器没有关掉 它会一边尝试继续巡逻向前走 一边又因为在攻击内圈所以后退 发生抖动 所以需要加一句ClearPatrolTimer

现在我们要写角色受击了 发现敌人类的GetHit 有很多是我们可以复用的 比如方向性受击反应和生成粒子特效 那么就可以在BaseCharacter类里写一个GetHit

// BaseCharacter.h protected中
/** <IHitInterface> */
virtual void GetHit_Implementation(const FVector& ImpactPoint) override;
/** </IHitInterface> */
// BaseCharacter.cpp
void ABaseCharacter::GetHit_Implementation(const FVector& ImpactPoint)
{
	if (IsAlive())
	{
		DirectionalHitReact(ImpactPoint);
	}
	else
	{
		Die();
	}

	SpawnHitEffect(ImpactPoint);
}
// Enemy.cpp
void AEnemy::GetHit_Implementation(const FVector& ImpactPoint)
{
	Super::GetHit_Implementation(ImpactPoint);
    if (IsDead()) // 这里为了让Super::GetHit_Implementation最写在前面 不得不增加判断语句
    {
        ShowHealthBar();
    }
    ClearPatrolTimer();
	
	GetWorldTimerManager().ClearTimer(CombatCameraUpdateTimer);
	bHasPlayedCombatIntro = true;
}
// HelloWorldCharatcer.h
/** <ABaseCharacter> */
virtual void GetHit_Implementation(const FVector& ImpactPoint) override;
/** </ABaseCharacter> */
// HelloWorldCharatcer.cpp
void AHelloWorldCharacter::GetHit_Implementation(const FVector& ImpactPoint)
{
	Super::GetHit_Implementation(ImpactPoint);
}

在BP_HelloWorldCharacter设置受击蒙太奇和粒子特效

还需要做受击的时候禁用玩家操作 不能攻击 也不能移动 那么就需要新建一个状态

// CharacterTypes.h enum class EActionState 末尾添加
EAS_HitReaction UMETA(DisplayName = "HitReaction")
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::GetHit_Implementation(const FVector& ImpactPoint)
{
	Super::GetHit_Implementation(ImpactPoint);

	ActionState = EActionState::EAS_HitReaction;
}

那么接下来我们就要使得在这个状态下不能攻击 AHelloWorldCharacter::Attack函数是通过检查CanAttack()来决定是否攻击的 它要求必须是Unoccupied状态 但我们现在已经是HitReaction状态了 经使得我们无法攻击了
再去看MoveForward MoveRight 都是会检查到非Unoccupied状态就return 那么就是禁用了移动

但是既然已经进入了这个状态 就要设置离开这个HitReaction状态 所以需要做一个动画通知

// HelloWorldCharacter.h
UFUNCTION(BlueprintCallable)
void HitReactEnd();
// HelloWorldCharacter.cpp
void AHelloWorldCharacter::HitReactEnd()
{
	ActionState = EActionState::EAS_Unoccupied;
}

那么就到主角受击动画蒙太奇资产里 添加动画通知 在动画蓝图里调用函数