在使用距离匹配时需要先在插件中开启AnimationLocomotionLibrary和AnimationWarping这两个插件
游戏中动画驱动有两种方式,分别为位移驱动和动画驱动。位移驱动中玩家控制角色胶囊体的运动,动画自己播放,这种方式会导致角色动画的步幅与移动速度不匹配;动画驱动是由动画的根运动来驱动角色位移,这种方式可以很好的解决角色运动中的滑步现象。
假如我们有一个起步动画,我们将角色的位移提取为距离曲线,同时每一帧获取曲线上的值,这个值就是角色的位移距离(距离起点的距离),将该值应用到角色的位移中就可以实现步幅匹配。

提取距离曲线主要使用DistanceCurveModifier类,这个类主要包含的内容如下:
1 | enum class EDistanceCurve_Axis : uint8 |
我们来逐个解析各个参数的含义:
实现代码如下:
1 | void UDistanceCurveModifier::OnApply_Implementation(UAnimSequence* Animation) |
对源码进行分析:
1 | // 判断是否直接使用动画结束时间作为最小速度 |
1 | // 计算采样间隔,由暴露给蓝图的值决定 |
通过提取的距离曲线,我们可以实现起步与停步的步幅匹配

起步动画主要依靠AdvanceTimeByDistanceMatching,通过序列求值器选择每一帧的动画进行播放
1 |
|
函数解释:根据动画前进的距离来设置动画的前进而非使用时间,这需要一个用于描述动画根骨骼位置的距离曲线,这个曲线我们可以从UDistanceCurveModifier中的来获取,这个在之前已经解释。
主要的三个参数为:
SequenceEvaluator:当前动画的序列求值器
DistanceTraveled:本帧移动的距离
DistanceCurveName:距离曲线
1 | FSequenceEvaluatorReference UAnimDistanceMatchingLibrary::AdvanceTimeByDistanceMatching(const FAnimUpdateContext& UpdateContext, const FSequenceEvaluatorReference& SequenceEvaluator, |

当角色停止运动时(玩家不再有运动方向的输入),此时角色会逐渐减速,这时需要计算角色的运动距离,然后将这个距离与动画进行匹配
GetPredictedStopDistance:这个函数已经提供了用于预测角色停止运动后的前进距离,在C++中为PredictGroundMovementStopLocation
DistanceMatchToTarget:这个函数用于将预测的距离与动画进行匹配
定义
1 | /** |
可以看到注释内容为:通过角色移动组件中的属性来预测当角色停止运动时角色移动的位置
实现
1 | FVector UAnimCharacterMovementLibrary::PredictGroundMovementStopLocation(const FVector& Velocity, |
定义:
1 | /** |
注释为:通过距离来设置序列求值器的播放时间,通过每帧选择动画中与角色剩余距离匹配的点来实现
实现:
1 | FSequenceEvaluatorReference UAnimDistanceMatchingLibrary::DistanceMatchToTarget(const FSequenceEvaluatorReference& SequenceEvaluator, |
1 | flowchart TD |
主要用于角色的循环移动动画,当角色完成起步后回进入循环移动动画,但角色的移动速度与动画的速度不匹配时依然会出现滑步的情况,所以我们通常会拿到角色的移动速度与动画的移动速度做比值来调整动画的播放速度从而使动画的移动与角色的速度相匹配。
除了动态调整循环动画的播放速度外,还需要调整角色的脚步步幅,一般来讲,当人的奔跑速度增加时,出了步频会增加外,步幅同样也会增加,所以为了防止角色的高速或低速情况下出现脚步鬼畜的情况,还需要对角色进行footik来调整步幅。
综上步幅匹配主要包括两部分:速度匹配和脚步ik

主要通过SetPlayrateToMatchSpeed来实现
1 | /** |
注释为:根据角色在游戏中的移动速度来设置动画的播放速度
1 | FSequencePlayerReference UAnimDistanceMatchingLibrary::SetPlayrateToMatchSpeed(const FSequencePlayerReference& SequencePlayer, float SpeedToMatch, FVector2D PlayRateClamp) |
主要是通过跨步扭曲(StrideWarping)来实现。

在细节面板中展示了需要调整的选项,主要为添加需要进行匹配的脚步骨骼,StrideWarping节点本身需要传入开始混入的alpha值(skeleton control的强度)以及角色地面的移动速度(MatchSpeed)。
该节点定义在UAnimGraphNode_StrideWarping中,其中最主要的内容为声明的一个FAnimNode_StrideWarping类型的成员变量Node,在这个Node中定义了大多在细节面板中使用到的属性,同时还包括一些节点使用的初始化和更新方法。
其中初始化方法为:Initialize_AnyThread
1 | void FAnimNode_StrideWarping::Initialize_AnyThread(const FAnimationInitializeContext& Context) |
其中还有另一个初始化方法:InitializeBoneReferences,这个方法用于初始化我们在节点中写入的骨骼引用
其中更新的方法为:EvaluateSkeletalControl_AnyThread,这是更新的主要方法,也是进行跨步扭曲的核心方法,接下来将对这个方法进行解析。
1 | void FAnimNode_StrideWarping::EvaluateSkeletalControl_AnyThread(FComponentSpacePoseContext& Output, TArray<FBoneTransform>& OutBoneTransforms) |
更新流程:
1 | flowchart TD |
服务器倒带(Server Rewind)主要用于解决多人在线游戏中,由于网络延迟导致的客户端与服务器表现不一致问题。该技术特别适用于第一人称射击(FPS)和动作类游戏,能够在不牺牲响应性的前提下保证游戏的公平性。
在服务器上保存玩家一段时间内的历史状态信息(位置、旋转等),当收到客户端的命中请求时,根据客户端上报的时间戳将目标玩家的状态回滚到对应时刻,重新进行碰撞检测以验证命中有效性。
所以我们需要如下结构体:
1 | struct FHistoryCache |
在这个结构体中我们保存了游戏的运行时间,UE给我们提供了游戏自开始一共运行了多少时间,我们可以很容易的获取并将其保存到缓存中,但需要注意的是:这个时间会因为客户端服务端的差异而存在不同,所以我们需要对时间进行校准。
我们可以向服务器请求一次当前服务器的运行时间并将客户端时间一并上报,当服务器收到客户端的请求后便将服务器运行时间于客户端上报的时间一并下发给客户端,这样便可以计算出服务器的运行时间。
具体过程:
我们将实现放在玩家的控制器中,继承APlayerController类。
1 |
|
1 | ASGPlayerController::ASGPlayerController() |
为了便于实现,我们只在玩家身上挂载一个UBoxComponent, 将这个BoxComponent用作玩家回滚后的击中判定:
1 | class ASGCharacter : public ACharacter |
我们使用一个组件来承担倒带的主要逻辑,主要实现功能为:
1 | /** 历史缓存的结构体,存储每帧玩家的位置以及时间信息 */ |
1 | USGLagComponent::USGLagComponent() |
以下mermaid展示了整个服务器倒带的工作流程:
1 | graph LR |
如果要模拟高延迟的情况,可以选择在DefaultEngine.ini中添加:
1 | [PacketSimulationSettings] |
可修改PktLag这个值来改变测试的延迟
依然可优化的方案:
https://zhuanlan.zhihu.com/p/690661788
https://zhuanlan.zhihu.com/p/1929071131677143054
这是对上一篇消息系统的重置和补充,之前的消息系统在使用上并不灵活,使用起来过于死板,于是参考了Lyra的消息系统并对原有的项目进行改进。
在新的消息系统中,将使用充分使用C++模板函数和UScriptStruct的特性,使得消息统统能够支持更灵活的定义和数据传递。
项目使用UE版本为5.2。
消息系统的一个重要特性就是可以降低代码的耦合度,使代码更加简洁。
在UE中,如果当一个对象需要向另一个对象想要对另一个对象的某些变化做出响应,一般而言可以使用使用虚幻的委托来实现这个功能,但美中不足的是我们依然需要先在两个对象间建立联系随后绑定一方的委托,这依然带来了代码耦合度的提升。所以为了进一步优化我们的代码,我们便可以使用消息系统,当一个对象需要对一些变化做出响应时,就只需要监听一条特定的消息,等待消息的发起者将消息广播给所有监听者即可,这样一来我们便不用关系消息的发起者是谁,只需要处理消息发送过来的数据即可。
为了实现上述描述的消息系统,我们需要实现以下功能:
为了方便管理我们的消息,所以我们可以先创建一个消息子系统用于管理所有的消息,之后所有的功能都将在这个类里实现。
注册消息的目的是能够监听消息的变化并处理一些数据,所以我们需要一个注册的消息通道和一个接收到的数据类型,在UE中,我们使用FGameplayTag来作为消息通道,使用UStruct来作为绑定的数据类型。
因为我们可以知道这个监听者的回调函数应该长这样:
1 | void Callback(FGameplayTag Channel, const FSomeStruct& SomeStruct) |
为了能匹配通道类型和数据类型,我们将这个回调函数作为观察者注册进消息系统,当进行广播时由消息系统来调用这个回调函数。
这样我们的注册函数的样子大致如下:
1 | void Register(FGameplayTag Channel, class SomeClass* Owner, void(SomeClass::*Callback)(FGameplayTag, const FSomeStruct&)); |
为了能够对类型进行匹配,我们将其改为模板函数,并在这个函数中对传进来的参数进行一些处理,在这一步对消息数据的类型进行解析同时将回调函数进行注册:
1 | // 这个的Owner不一定会是UObject类型,只是UE中大多数的类都是使用UObject的,所以这里使用UObject作为默认类型 |
先看代码:
1 | template<typename FMessageStructType, typename Owner = UObject> |
这一步我们先创建了一个Owner的弱引用并在lambda表达式中捕获,并且在函数体中执行Owner的回调函数,在这一步需要确保回调函数的参数和数据类型能够匹配。
这一步我们需要对回调函数进一步封装,使之能够获取正确的数据类型:
1 | template<typename FMessageStructType, typename Owner = UObject> |
在这一步我们定义了一个Thunk函数,这个函数的主要作用是将发送过来的数据转化为与回调函数类型匹配的数据
在这一步完成后我们便完成了对注册内容的初步解析,随后我们需要将解析的内容保存在消息系统中,所以我们使用一个结构体来对消息进行保存:
1 | USTRUCT() |
有了存储的结构以后还需要让消息系统持有这个结构体,已知一个通道可以存储多个监听,又有多个消息通道,所以选择使用TMap的key来存储所有的消息通道,用一个数组来存储所有该消息通道下的监听者。为了便于使用,需要将数组进行一层包装:
1 | /** |
1 | UCLASS() |
随后我们将解析的内容保存进这个结构体并由消息系统持有:
1 | void RegisterListener_Internal(FGameplayTag Channel, TFunction<void(FGameplayTag, const void*)>&& Callback, const UScriptStruct* StructType) |
直到这一步我们便完成了消息的注册功能,接下来将消息进行广播。
这一步就比较简单,广播消息时只需要设置消息的通道和发送的数据即可,在消息系统内部会对数据进行处理,对外提供如下方法:
1 | template<typename FMessageStructType> |
从消息的注册到广播可以发现对回调的处理是由内及外和由外及内的,在注册时,我们对回调函数进行封装处理使之变成广播时易于调用的类型,在广播时又逐层解析调用真正的回调函数。这一切都发生在Thunk函数中,这个函数便是消息系统能够支持任意类型数据结构的基石之一。
当一个对象不在需要监听消息时便需要对消息进行解绑。
当下的问题时,我们只在消息注册时保存了这份消息,而注册者却什么都不知道,所以在注册成功后我们需要返回一个句柄,当注册者不再需要监听时便通过这个句柄来解除绑定,所以我们需要一个句柄结构体同时对现有的一些结构体进行一些小小的修改:
1 | USTRUCT(BlueprintType) |
在Handle和Data这两个结构体中都包含一个HandleId,这个id由消息系统分配并由Handle和Data各自持有一份,在解绑时通过对比HandleId来确认是否为同一个注册的消息。
我们对外提供解绑方法:
1 | void Unregister(FGameplayMessageHandle& Handle) |
在Handle中我们同样提供了解绑的方法,该方法对消息系统提供的解绑方法进行了一次封装,这样在解绑时我们可以直接调用这个Handle中的Unregister方法。
1 | void Unregister() |
至此我们便完成了消息系统的三个基本功能,接下来我们可以在一个类中进行测试:
1 | USTRUCT(BlueprintType) |
我们在蓝图中分别调用这几个函数进行测试,可以看到消息正常发送和接受。
这里我们实现了在C++中使用消息系统,但UE并不只有C++,消息系统需要同样支持蓝图调用才算完整,后面我们会对消息系统进行蓝图支持。
代码中对获取方法提供了封装,使之更易于调用。
此外我们还可以使用宏对注册和广播进行封装,使之更加简洁易用,不过这里并未提供。
提供的代码与上文中的代码略有不同,主要在于参数传递时有一个UScriptStruct*类型的参数,这个是用于提供蓝图支持的,暂时可以忽略。
1 | // Fill out your copyright notice in the Description page of Project Settings. |
1 | // Fill out your copyright notice in the Description page of Project Settings. |
消息系统允许对象之间进行通信而不需要知道对象的具体类型,只需要接受消息的对象完成注册即可接收从各个地方发送来的消息从而执行相关的逻辑。
与UE中的委托类似,但是使用委托在绑定时仍需要知道具体的对象,而消息则不需要,所以使用消息能更进一步完成解耦。
可以采取与委托类似的方式,不同的是可以将绑定的信息存储在一个容器中,当消息被发送时就从容器中查找相应的委托并执行逻辑。
首先在这个容器中存储了一个委托,其次需要一个唯一的标识符,所以相应的应该还有一个ID。
消息的最基本结构体定义完成后,还需要知道消息的具体类型,采用FGameplayTag作为消息类型的标识符。
定义委托:
1 | DECLARE_DYNAMIC_DELEGATE(FMessageDelegate); |
所以消息结构体的基本构成为:
1 | struct FGameplayMessageListenerHandle |
有了消息的基本结构体后,就需要一个容器来存放这些消息。之前定义了FGameplayTag作为消息类型,一个消息同时有一组接收者,因而采取map的形式维护消息类型与接收者之间的映射关系。
1 | struct FGameplayMessageHandleWarpper |
使用一个静态成员变量作为标识ID,没当调用注册函数时就为其注册的Handle分配一个HandleID。
同时消息本身应该具有合法性判断、解绑、委托执行的方法:
1 | struct FGameplayMessageListenerHandle |
消息系统在整个游戏中明显应该只存在一份,所以采用UE的子系统来实现,同时让消息结构体中持有一份该系统的弱引用:
1 | class UMessageSubsystem:public UGameInstanceSubsystem |
我们提供一个模板方法来完成对消息的注册,为了完成消息的注册,我们需要一些注册的基本信息:注册的消息类型、注册者以及注册的委托函数,所以需要三个基本参数:FGameplayTag、UserObject、Function。
最终提供一个静态方法完成注册:
1 | template<typename UserClass> |
在消息执行时只需要传入一个消息类型即可完成消息的发送,同样将其定义为静态方法:
1 | void BroadcastMessage(UObject* WorldContextObject, FGameplayTag Channel) |
直接对对象持有的消息本身进行解绑,之前我们对消息结构体中定义了Unregister方法,现在只需要实现该方法即可:
1 | void FGameplayMessageListenerHandle::Unregister() |
蓝图与C++实现最大的区别在于无法直接使用FGameplayMessageHandle这个结构体,在蓝图与C++的交互中,结构体会以值传递的方式传递,即使定义了以引用的方式传递,在蓝图中的结构体进入C++时仍然会拷贝出一个副本,此时无法保证操作的一致性;而类类型在蓝图与C++的交互中是以引用的方式传递,此时可以保证操作的一致性,所以需要对消息结构在做一层类封装。
1 | class UGameplayMessageAction: public UObject |
同样提供静态方法进行注册,不同的是这次不传入函数名而是直接传入一个事件引脚,当发送消息时直接通过蓝图事件来执行,此时便不再需要传入UserObject:
1 | UFUNCTION(BlueprintCallable, Category = "Message") |
蓝图消息的执行与C++消息的执行方式相同,直接调用BroadcastMessage即可。
在注册消息时我们使用一个MessageAction接收返回值,并使用它的完成消息解绑:
1 | void UGameplayMessageAction::Unregister() |
1 | DECLARE_DYNAMIC_DELEGATE(FMessageDelegate); |
1 |
|
使用默认的主角类进行测试,在C++中提供以下函数并绑定一些按键进行:
1 | FGameplayMessageListenerHandle TestListenerHandler; |
在蓝图中则直接可以搜索K2_RegisterGameplayMessageListerner和BroadcastMessage进行绑定和广播,直接调用Action的Unregister()函数即可取消监听。
后续会增加消息的参数传递功能,使其能够携带任意数量的参数进行传递。
前文介绍了GAS与增强输入的基本使用,本文将介绍如何将输入与GA进行绑定来完成解耦和扩展。
技能与输入的绑定方法,查看BindAction的函数声明,可以看到在后面有一次额外的参数包,在绑定时可以将Tag做为参数在绑定时传入:
1 | BindAction(IA, TriggerEvent, UserObject, BindFunc, Tag); |
在触发影响的IA时就可以获取到对应的Tag:
1 | void BindFunc(FGameplayTag Tag) |
在初始化时可以通过将Tag和GA进行绑定,在触发输入时通过Tag来查找对应的GA。
初始化时需要将GA和Tag进行绑定,所以考虑将对应关系放入一张数据表中,在玩家进行初始化时读入这张表完成绑定。
其基本的数据结构应包含一个GA和一个TagContainer,一个技能可能会有几种触发方式,所以提供Container来存储多个Tag。
1 | USTRUCT(BlueprintType) |
同时提供基本的表结构:
1 | UCLASS() |
同样使用数据表来存储输入关系,基本应该包含一个Tag和一个IA,其中Tag用于查找对应的GA。
所以输入的基本数据结构为:
1 | USTRUCT(BlueprintType) |
输入的表结构,其中提供查找方法和一个映射上下文:
1 | UCLASS() |
准备好两张表后就可以准备完成初始化与绑定了!
在GameplayAbility的实例GameplayAbilitySpec中包含一个DynamicAbilityTag的TagContainer,使用其来查找对应的GA。
绑定函数:
1 | void InitializeAbility() |
在绑定中,将输入分为两种,一种为GA输入;另一种为本地输入,包括玩家的基本移动等,所以在绑定时要对其进行区分,所以输入表中需要对结构进行改进,增加一个用于区分是否为技能的bool值;此外技能同样有长按与单次触发的类型,所以再增加一个bool值进行区分。
改进后的结构应为:
1 | USTRUCT(BlueprintType) |
为了确保绑定可以通用,考虑将其作为增强输入组件的模板成员函数。对于本地输入的初始化只要从输入表中拿到对应的IA完成绑定即可;对于技能输入则需要增加一个扩展参数包。
所以玩家的本地输入绑定函数应为:
1 | template<class UserClass, typename FuncType> |
绑定技能的输入为:
1 | template<class UserClass, typename Press, typename Release> |
对于玩家的本地输入,可以直接调用BindNativeAction进行绑定,有多少绑定多少,对于GA的绑定则只需要一次。
当IA触发以后则需要根据对应的Tag去寻找对应的GA,提供两个函数用来处理技能输入的按下与抬起:
首先在ASC中提供两个函数用来处理技能输入的按下与抬起:
1 | void ProcessAbilityPressInput(FGameplayTag InputTag) |
随后可以在玩家的输入组件中调用这两个函数:
1 | void OnAbilityPressd(FGameplayTag InputTag) |
最终在增强输入中提供初始化函数SetPlayerInput:
1 | void SetPlayerInput() |
最终完成了IA与GA的绑定
参考lyra工程使用namespace作为Tag管理,同样我们也提供一个名为GameplayTags的namespace:
1 | namespace GameplayTags |
以上是直接将资产挂载在玩家身上,并不方便管理,所以可以考虑使用全局配置项来进行管理。
用UDeveloperSettings作为父类创建名为ProjectAssetSettings的子类,注意需要在uproject文件中新加一条”DeveloperSettings”的配置。
1 | UCLASS(config = Game, defaultconfig, meta = (DisplayName = "Game Asset Config")) |
通过以上步骤,我们将输入与技能进行了绑定,并且通过namespace管理Tag和ini文件管理了输入与技能的资产。
主要包括冒泡排序、堆排序、归并排序、快速排序四个。
冒泡排序是一种很简单的原地排序算法,在每一轮遍历都将最大的元素(或者最小)放在数组的末尾
过程为:
1 | void BubbleSort(vector<int>& nums) |
堆排序是一种原地排序算法,依赖于堆实现
过程为:
1 | void Heapfiy(vector<int>& nums,int n,int i) |
归并依赖于递归操作
过程为:
1 | void Merge(vector<int>& nums,int left,int mid,int right) |
快排是最常用的排序算法之一
过程为:
1 | int partition(vector<int>& nums,int left,int right) |
上一章我们制作了一个简单的线程池,本次将使用智能指针来管理创建的对象:各种任务和创建的线程
首先对线程池中进行修改:
具体改动:
1 | //void AddTask(BaseTask* Task); |
在其它地方因为我们使用了auto自动类型推导,所以并不需要有太大变化。
对BaseTask类做出修改,增加promise变量以及Set和Get函数:
1 | #include <future> |
1 | int main() |
至此就完成了一个功能相对完善的线程池并尽可能的减少了内存泄漏的问题。
缺失模块。
1、请确保node版本大于6.2
2、在博客根目录(注意不是yilia-plus根目录)执行以下命令:
npm i hexo-generator-json-content --save
3、在根目录_config.yml里添加配置:
jsonContent:
meta: false
pages: false
posts:
title: true
date: true
path: true
text: false
raw: false
content: false
slug: false
updated: false
comments: false
link: false
permalink: false
excerpt: false
categories: false
tags: true