上一回我们讲到我们基础的都已经搭好了,现在继续完成我们的开火蓝图
/Game/Weapons/GA_Weapon_Fire.GA_Weapon_Fire
就是这个蓝图

手枪轻微抖动,调试技能确实被触发了,射线能够发射
这个蓝图可真抽象呢???
射击失败的事件监听的蓝图逻辑 GA_Weapon_Fire
然后就是Cue好多。。。
经过测试,大概是这样
GameplayCue.Weapon.Pistol.Fire 是开火的弹道,击中特效,声音
GameplayCue.Weapon.Pistol.Impact 是击中受击蒙太奇,跳字
/EqZeroCore/Weapons/Pistol/GCN_Weapon_Pistol_Fire.GCN_Weapon_Pistol_Fire
先是这个Cue,里面是声音和摄像机震动。还有一个

这样就有声音了,另外Weapon的Fire接口没崩,所以弹道是有的,但是明显方向不对。
问题:
无限子弹,HP UI没有,扣血全靠打印,准星没有瞄准抽象。
准星UI
/EqZeroCore/UserInterface/W_EqZHUDLayout.W_EqZHUDLayout
这里把中心的 HUD.Slot.Reticle 的 TAG挂接点加上
完成 /EqZeroCore/UserInterface/HUD/W_WeaponReticleHost.W_WeaponReticleHost
这个一个SizeBox 套 Overlay 全空
完成 OnWeaponChanged 事件,是蓝图,有点长了,后面写到代码里面。
【主要逻辑是从物品片段上读取准星UI配置。然后加到屏幕上】
物品的瞄准片段 /EqZeroCore/Weapons/Pistol/ID_Pistol.ID_Pistol 这个
配置一下UI
但是在这之前还有写几个类
UCircumferenceMarkerWidget
UCLASS()
class UEqZeroCircumferenceMarkerWidget : public UWidget
{
GENERATED_BODY()
public:
UEqZeroCircumferenceMarkerWidget(const FObjectInitializer& ObjectInitializer);
/*
* UWidget interface
*/
public:
virtual void SynchronizeProperties() override;
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
/*
* UVisual interface
*/
public:
virtual void ReleaseSlateResources(bool bReleaseChildren) override;
// ==========================================================================
public:
// 绘制标记的位置 / 方向列表
// (0, 0), (90, 90), (-90, -90), (180, 180)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
TArray<FEqZeroCircumferenceMarkerEntry> MarkerList;
// 我们画的是准星,这那个圆的半径
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance, meta=(ClampMin=0.0))
float Radius = 48.0f;
// 我们画的是准星,上下左右四个正方形。这是其中的一个正方形的图片
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FSlateBrush MarkerImage;
// 标线片角部图像是否放置在扩散半径之外
// 改为 0-1 的浮点对齐(例如,在半径内部 / 之上 / 外部)?
UPROPERTY(EditAnywhere, Category=Corner)
uint8 bReticleCornerOutsideSpreadRadius : 1;
public:
UFUNCTION(BlueprintCallable, Category = "Appearance")
void SetRadius(float InRadius);
private:
TSharedPtr<SEqZeroCircumferenceMarkerWidget> MyMarkerWidget;
};
这是画一个slate
重写 RebuildWidget 在里面返回一个 Slate对象
SynchronizeProperties 是编辑器属性修改后的回调
这是那个slate,怎么绘制给看stale的OnPlant
TSharedRef<SWidget> UEqZeroCircumferenceMarkerWidget::RebuildWidget()
{
MyMarkerWidget = SNew(SEqZeroCircumferenceMarkerWidget)
.MarkerBrush(&MarkerImage)
.Radius(this->Radius)
.MarkerList(this->MarkerList);
return MyMarkerWidget.ToSharedRef();
}
FSlateRenderTransform SEqZeroCircumferenceMarkerWidget::GetMarkerRenderTransform(const FEqZeroCircumferenceMarkerEntry& Marker, const float BaseRadius, const float HUDScale) const
{
// 步骤 1:确定实际半径
float XRadius = BaseRadius;
float YRadius = BaseRadius;
if (bReticleCornerOutsideSpreadRadius)
{
// 图片中心在圆周外侧半个图片宽度处
// 即图片的内边缘刚好贴住半径圆
XRadius += MarkerBrush->ImageSize.X * 0.5f;
YRadius += MarkerBrush->ImageSize.X * 0.5f;
}
// 步骤 2:角度转弧度
const float LocalRotationRadians = FMath::DegreesToRadians(Marker.ImageRotationAngle);
const float PositionAngleRadians = FMath::DegreesToRadians(Marker.PositionAngle);
// 步骤 3:绕图片自身中心旋转
// Slate 的旋转基准点默认是左上角,要绕中心旋转需要用"先移-旋-移回"技巧:
// T(-W/2, -H/2) → 把图片中心平移到原点
// R(θ) → 绕原点旋转
// T(+W/2, +H/2) → 恢复位置
FSlateRenderTransform RotateAboutOrigin(
Concatenate(
FVector2D(
-MarkerBrush->ImageSize.X * 0.5f, -MarkerBrush->ImageSize.Y * 0.5f),
FQuat2D(LocalRotationRadians),
FVector2D(MarkerBrush->ImageSize.X * 0.5f, MarkerBrush->ImageSize.Y * 0.5f)
)
);
// 步骤 4:平移到圆周上的目标位置
// 极坐标 → 笛卡尔坐标(Slate Y轴朝下,所以 Y 取负才是"上方")
// x = R · sin(θ) → 0°时 sin=0,无水平偏移
// y = -R · cos(θ) → 0°时 cos=1,-R 即向上
return TransformCast<FSlateRenderTransform>(
Concatenate(
RotateAboutOrigin,
FVector2D(
XRadius * FMath::Sin(PositionAngleRadians) * HUDScale, // X 偏移
-YRadius * FMath::Cos(PositionAngleRadians) * HUDScale // Y 偏移(负号!)
)
)
);
}
int32 SEqZeroCircumferenceMarkerWidget::OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
// 1. 是否启用(父控件禁用则自身也禁用)
const bool bIsEnabled = ShouldBeEnabled(bParentEnabled);
const ESlateDrawEffect DrawEffects = bIsEnabled ? ESlateDrawEffect::None : ESlateDrawEffect::DisabledEffect; // 禁用时灰显
// 2. 计算控件本地空间的中心点
// GetLocalPositionAtCoordinates(0.5, 0.5) = 控件矩形的正中心
const FVector2D LocalCenter = AllottedGeometry.GetLocalPositionAtCoordinates(FVector2D(0.5f, 0.5f));
// 3. 前置检查:MarkerList 非空 且 笔刷非空
const bool bDrawMarkers = (MarkerList.Num() > 0) && (MarkerBrush != nullptr);
if (bDrawMarkers == true)
{
// 4. 确定颜色
// 优先使用用户手动设置的 ColorAndOpacity
// 否则用父控件颜色调色 * 笔刷自身颜色(颜色叠加)
const FLinearColor MarkerColor = bColorAndOpacitySet ?
ColorAndOpacity.Get().GetColor(InWidgetStyle) :
(InWidgetStyle.GetColorAndOpacityTint() * MarkerBrush->GetTint(InWidgetStyle));
// 5. Alpha 为 0 时跳过绘制(性能优化)
if (MarkerColor.A > KINDA_SMALL_NUMBER)
{
const float BaseRadius = Radius.Get();
// 6. 获取 HUD 缩放系数(适配不同分辨率)
const float ApplicationScale = GetDefault<UUserInterfaceSettings>()->ApplicationScale;
// 7. 遍历每一个标记,逐个绘制
for (const FEqZeroCircumferenceMarkerEntry& Marker : MarkerList)
{
// 8. 计算该标记的变换矩阵
// 包含:图片自旋 + 平移到圆周上的位置
const FSlateRenderTransform MarkerTransform = GetMarkerRenderTransform(Marker, BaseRadius, ApplicationScale);
// 9. 构建绘制几何体
// - 大小 = MarkerBrush->ImageSize(图片像素尺寸)
// - 布局偏移 = LocalCenter - ImageSize*0.5 → 先把图片左上角对齐到控件中心
// - 渲染变换 = MarkerTransform → 再做旋转+平移到圆周
// - Pivot = (0, 0) → 变换基准点是图片左上角
const FPaintGeometry Geometry(AllottedGeometry.ToPaintGeometry(MarkerBrush->ImageSize,
FSlateLayoutTransform(LocalCenter - (MarkerBrush->ImageSize * 0.5f)),
MarkerTransform, FVector2D(0.0f, 0.0f)));
// 10. 提交一个 Box 绘制命令(即绘制一张贴图)
FSlateDrawElement::MakeBox(OutDrawElements, LayerId, Geometry, MarkerBrush, DrawEffects, MarkerColor);
}
}
}
return LayerId; // 没有新增层级,直接返回原 LayerId
}
UEqZeroHitMarkerConfirmationWidget
/*
* 这个画的是击中效果那个红色的叉
*
*/
UCLASS()
class UEqZeroHitMarkerConfirmationWidget : public UWidget
{
GENERATED_BODY()
public:
UEqZeroHitMarkerConfirmationWidget(const FObjectInitializer& ObjectInitializer);
/*
* UWidget interface
*/
protected:
virtual TSharedRef<SWidget> RebuildWidget() override;
/*
* UVisual interface
*/
public:
virtual void ReleaseSlateResources(bool bReleaseChildren) override;
// ==========================================================================
public:
// 显示击中通知的持续时间(以秒为单位)(它们会在这段时间内逐渐变为透明)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance, meta=(ClampMin=0.0, ForceUnits=s))
float HitNotifyDuration = 0.4f;
// 用于绘制单个命中标记的标记图像
// 建议看蓝图,一个红色的菱形
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
FSlateBrush PerHitMarkerImage;
// 这是一个 TAG=>图片。例如爆头识别到这个TAG,就显示这个图片盖上去
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category=Appearance)
TMap<FGameplayTag, FSlateBrush> PerHitMarkerZoneOverrideImages;
// 如果存在任何命中结果时要绘制的标记图像。
// 常规的命中红叉
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Appearance)
FSlateBrush AnyHitsMarkerImage;
private:
TSharedPtr<SEqZeroHitMarkerConfirmationWidget> MyMarkerWidget;
};
int32 SEqZeroHitMarkerConfirmationWidget::OnPaint(const FPaintArgs& Args, const FGeometry& AllottedGeometry, const FSlateRect& MyCullingRect, FSlateWindowElementList& OutDrawElements, int32 LayerId, const FWidgetStyle& InWidgetStyle, bool bParentEnabled) const
{
// 是否启用(父控件禁用则自身也禁用)
const bool bIsEnabled = ShouldBeEnabled(bParentEnabled);
const ESlateDrawEffect DrawEffects = bIsEnabled ? ESlateDrawEffect::None : ESlateDrawEffect::DisabledEffect;
// 计算控件本地空间的中心点
// GetLocalPositionAtCoordinates(0.5, 0.5) = 控件矩形的正中心
const FVector2D LocalCenter = AllottedGeometry.GetLocalPositionAtCoordinates(FVector2D(0.5f, 0.5f));
// 根据当前的击中通知不透明度决定是否绘制标记
const bool bDrawMarkers = (HitNotifyOpacity > KINDA_SMALL_NUMBER);
if (bDrawMarkers)
{
TArray<FEqZeroScreenSpaceHitLocation> LastWeaponDamageScreenLocations;
if (APlayerController* PC = MyContext.IsInitialized() ? MyContext.GetPlayerController() : nullptr)
{
if (UEqZeroWeaponStateComponent* WeaponStateComponent = PC->FindComponentByClass<UEqZeroWeaponStateComponent>())
{
WeaponStateComponent->GetLastWeaponDamageScreenLocations(LastWeaponDamageScreenLocations);
}
}
if ((LastWeaponDamageScreenLocations.Num() > 0) && (PerHitMarkerImage != nullptr))
{
const FVector2D HalfAbsoluteSize = AllottedGeometry.GetAbsoluteSize() * 0.5f;
for (const FEqZeroScreenSpaceHitLocation& Hit : LastWeaponDamageScreenLocations)
{
// 1. 查是否有这个 HitZone 的专属图片(如爆头),没有则用默认图
const FSlateBrush* LocationMarkerImage = PerHitMarkerZoneOverrideImages.Find(Hit.HitZone);
if (LocationMarkerImage == nullptr)
{
LocationMarkerImage = PerHitMarkerImage;
}
// 2. 颜色 × HitNotifyOpacity(实现淡出效果)
FLinearColor MarkerColor = bColorAndOpacitySet ?
ColorAndOpacity.Get().GetColor(InWidgetStyle) :
(InWidgetStyle.GetColorAndOpacityTint() * LocationMarkerImage->GetTint(InWidgetStyle));
MarkerColor.A *= HitNotifyOpacity;
// 3. 关键坐标转换:窗口屏幕坐标 → 控件本地坐标
// Hit.Location 是视口坐标,加上 MyCullingRect.TopLeft 转为窗口坐标
// 再用 AbsoluteToLocal 转为控件内部坐标
const FVector2D WindowSSLocation = Hit.Location + MyCullingRect.GetTopLeft(); // 在非全屏模式下考虑窗口装饰
const FSlateRenderTransform DrawPos(AllottedGeometry.AbsoluteToLocal(WindowSSLocation));
// 图片中心对齐到命中点
const FPaintGeometry Geometry(AllottedGeometry.ToPaintGeometry(LocationMarkerImage->ImageSize, FSlateLayoutTransform(-(LocationMarkerImage->ImageSize * 0.5f)), DrawPos));
FSlateDrawElement::MakeBox(OutDrawElements, LayerId, Geometry, LocationMarkerImage, DrawEffects, MarkerColor);
}
}
if (AnyHitsMarkerImage != nullptr)
{
FLinearColor MarkerColor = bColorAndOpacitySet ?
ColorAndOpacity.Get().GetColor(InWidgetStyle) :
(InWidgetStyle.GetColorAndOpacityTint() * AnyHitsMarkerImage->GetTint(InWidgetStyle));
MarkerColor.A *= HitNotifyOpacity;
// 否则在十字准线的中心显示命中通知
const FPaintGeometry Geometry(
AllottedGeometry.ToPaintGeometry(
AnyHitsMarkerImage->ImageSize,
FSlateLayoutTransform(LocalCenter - (AnyHitsMarkerImage->ImageSize * 0.5f))));
FSlateDrawElement::MakeBox(OutDrawElements, LayerId, Geometry, AnyHitsMarkerImage, DrawEffects, MarkerColor);
}
}
return LayerId;
}
准星UI显示
先把UI显示出来?
找不到刚刚的OnWeaponChanged了。。。
全局搜一下
/EqZeroCore/UserInterface/HUD/W_WeaponReticleHost.W_WeaponReticleHost
加到HUD上
走的 /EqZeroCore/Experiences/LAS_EqZGame_StandardHUD.LAS_EqZGame_StandardHUD
现在能看到准星了。
组件总结
整理一下我们动态挂接的组件
- GameFeature:
- Controller
- InventoryManagerComponent
- Character
- EquipmentManagerComponent
- Controller
- Experience
- Controller
- B_PickRandomCharacter
- EqGameState
- DefaultSpawningRules => Eq Player Spawning Manager Component 转发逻辑选择场景重生点
- Stand Component (通过配置批量加了一批标准的组件)
- QuickBar Component
- Controller
简单来说,对于武器来说涉及
物品组件,装备组件,快捷栏组件
现在UI出来了 ,但是没有其他效果。
BUG重生呢后边T-Pose了
重生后武器被clear了。链接不到动画层。
从原版的加物品,加装备流程调试,确定流程就是跑这里
void UEqZeroQuickBarComponent::SetActiveSlotIndex_Implementation(int32 NewIndex)
{
if (Slots.IsValidIndex(NewIndex) && (ActiveSlotIndex != NewIndex))
{
// 卸下当前 -> 切换索引 -> 穿上新装备
UnEquipItemInSlot();
ActiveSlotIndex = NewIndex;
EquipItemInSlot();
OnRep_ActiveSlotIndex();
}
}
但是我们的 ActiveSlotIndex 是 0,和重写加的 0 冲突,导致加不上
死亡的时候没有清理
这个蓝图在 K2_OnDeathFinished
调试发现,这个都没跑
void AEqZeroCharacter::OnDeathFinished(AActor*)
{
GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ThisClass::DestroyDueToDeath);
}
一路查到死亡技能没有EndAbility
他是从
UAbilityTask_WaitDelay* Task = UAbilityTask_WaitDelay::WaitDelay(this, DeathDuration);
Task->OnFinish.AddDynamic(this, &ThisClass::OnDeathWaitFinished);
Task->ReadyForActivation();
死亡后8s后结束技能,触发了结束死亡的流程
void UEqZeroGameplayAbility_AutoRespawn::OnPlayerReset()
{
// ...
bShouldFinishRestart = false;
if (auto ASC = UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(ControllerToReset->PlayerState))
{
TArray<FGameplayAbilitySpecHandle> OutAbilityHandles;
ASC->FindAllAbilitiesWithTags(
OutAbilityHandles,
FGameplayTagContainer(EqZeroGameplayTags::Ability_Type_StatusChange_Death), true);
for (const FGameplayAbilitySpecHandle& Handle : OutAbilityHandles)
{
if (FGameplayAbilitySpec* Spec = ASC->FindAbilitySpecFromHandle(Handle))
{
if (UEqZeroGameplayAbility* AbilityInstance = Cast<UEqZeroGameplayAbility>(Spec->GetPrimaryInstance()))
{
AbilityInstance->ForceEndAbility(Handle, AbilityInstance->GetCurrentActorInfo(), AbilityInstance->GetCurrentActivationInfo(), true, false);
}
}
}
}
ForceEndAbility 自己实现一个代替蓝图的K2
BUG: 瞄准不是屏幕正前

???感觉角色转反了
但是RootYawOffset 好像是正常的
瞄准偏移,漏了一个负号
AimYaw = RootYawOffset * -1.f;
血条UI
/Game/UI/Hud/W_Healthbar
原来蓝图的代码,写到C++了
UEqZeroHealthBarWidget
动画抄了过来,扣血的。
从生命值组件的事件监听。
void UEqZeroHealthBarWidget::OnHealthChange(UEqZeroHealthComponent* HealthComponent, float OldValue, float NewValue, AActor* Instigator)
{
const float OldNormalized = OldValue / 100.0f;
const float NewNormalized = NewValue / 100.0f;
NormalizedHealth = NewNormalized;
// 1. 更新材质参数:Health_Current = 旧值, Health_Updated = 新值
for (UMaterialInstanceDynamic* MID : TArray<UMaterialInstanceDynamic*>{ BarBorderMID, BarFillMID, BarGlowMID })
{
if (MID)
{
MID->SetScalarParameterValue(TEXT("Health_Current"), OldNormalized);
MID->SetScalarParameterValue(TEXT("Health_Updated"), NewNormalized);
}
}
// 2. 重置动画状态
ResetAnimatedState();
// 3. 数字已在目标值且是治疗/不变时跳过动画播放
const bool bAlreadyAtTarget = HealthNumber && FMath::IsNearlyEqual(HealthNumber->GetTargetValue(), NewValue);
const bool bIsHealingOrSame = NewValue >= OldValue;
if (bAlreadyAtTarget && bIsHealingOrSame)
{
return;
}
// 4. 设置 DamageOrHealing 参数并播放对应动画
const float DamageOrHealingValue = bIsHealingOrSame ? 1.0f : 0.0f;
for (UMaterialInstanceDynamic* MID : TArray<UMaterialInstanceDynamic*>{ BarBorderMID, BarFillMID, BarGlowMID })
{
if (MID)
{
MID->SetScalarParameterValue(TEXT("DamageOrHealing"), DamageOrHealingValue);
}
}
UWidgetAnimation* AnimToPlay = bIsHealingOrSame ? OnHealed : OnDamaged;
PlayAnimation(AnimToPlay, 0.0f, 1, EUMGSequencePlayMode::Forward, 1.0f);
// 5. 数字先跳到旧值,再插值到新值
if (HealthNumber)
{
HealthNumber->SetCurrentValue(OldValue);
HealthNumber->InterpolateToValue(NewValue, 1.0f, 4.0f, 0.0f);
}
// 6. 否为小数点比较2 = 0,绑定 OnDamaged 动画结束时触发 EventOnEliminated
if (FMath::IsNearlyZero(NewValue))
{
FWidgetAnimationDynamicEvent EliminatedDelegate;
EliminatedDelegate.BindDynamic(this, &ThisClass::EventOnEliminated);
BindToAnimationFinished(OnDamaged, EliminatedDelegate);
}
}
装备栏
common text block
common numeric text block
/EqZeroCore/UserInterface/HUD/W_WeaponAmmoAndName.W_WeaponAmmoAndName
/EqZeroCore/UserInterface/HUD/W_QuickBarSlot.W_QuickBarSlot
/EqZeroCore/UserInterface/HUD/W_QuickBar.W_QuickBar
搓UI

读取一下 UEqZeroQuickBarComponent 的数据,基于事件更新一下子弹信息,UI逻辑。没啥好贴代码的。
Fire的消耗
关于 UEqZeroAbilityCost_ItemTagStack 的配置
/EqZeroCore/Weapons/Pistol/GA_Weapon_Fire_Pistol.GA_Weapon_Fire_Pistol

这个cost配置
void UEqZeroAbilityCost_ItemTagStack::ApplyCost(const UEqZeroGameplayAbility* Ability, const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo)
{
if (ActorInfo->IsNetAuthority())
{
if (const UEqZeroGameplayAbility_FromEquipment* EquipmentAbility = Cast<const UEqZeroGameplayAbility_FromEquipment>(Ability))
{
if (UEqZeroInventoryItemInstance* ItemInstance = EquipmentAbility->GetAssociatedItem())
{
const int32 AbilityLevel = Ability->GetAbilityLevel(Handle, ActorInfo);
const float NumStacksReal = Quantity.GetValueAtLevel(AbilityLevel);
const int32 NumStacks = FMath::TruncToInt(NumStacksReal);
ItemInstance->RemoveStatTagStack(Tag, NumStacks);
}
}
}
}
他的消耗核心逻辑是,拿到技能 SourceObject 这是一个赋予技能的时候我们可以自己关联的值,我们关联了物品实例
获取了物品上有一个 tag=>数字 的容器,给我记录子弹