实习周报6.18

Jun 18, 2025

11 mins read

实习周报6.18

场景飞屑特效表现功能

当角色进入到一个触发范围内会在角色位置上生成特效

使用两个定时器分别是FPlayVfxTimerHandle和FCheckTimerHandle

删除Tick函数设置bCanEverTick = false 因为不使用到Tick所以删除会提升性能

构造函数中初始化初始化一个可见的无碰撞球体,为了布置场景时看到范围。在BeginPlay中直接获取球体半径作为DistanceToPlayer的值。

FCheckTimerHandle定时器用于每秒判断角色是否进入到范围内

FPlayVfxTimerHandle定时器用于不断播放特效,并设置一个bIsVFXPlaying用于判断是否正在播放特效,如果已经在播放特效了并且超出距离就会清空FPlayVfxTimerHandle特效播放定时器;如果没有在播放特效并且在距离范围内则会开启定时器。

为什么一定需要bIsVFXPlaying,因为否则人物在距离内时第一个FCheckTimerHandle会不断地调用CheckDistance函数导致无法播放特效。

APMS_VFXTriggerActor::APMS_VFXTriggerActor()
{
 	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = false;
	DetectionSphere = CreateDefaultSubobject<USphereComponent>(TEXT("DetectionSphere"));
	RootComponent = DetectionSphere;
	DetectionSphere->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
void APMS_VFXTriggerActor::BeginPlay()
{
	Super::BeginPlay();
	DistanceToPlayer = DetectionSphere->GetScaledSphereRadius();
	if(GetWorld()->GetNetMode() == NM_Standalone || GetWorld()->GetNetMode() == NM_Client)
	{
		GetWorld()->GetTimerManager().SetTimer(FCheckTimerHandle, this, &APMS_VFXTriggerActor::CheckDistance, 1.0f, true);	
	}
}
void APMS_VFXTriggerActor::CheckDistance()
{
	APMS_ZombiePlayerCharacter* PlayerCharacter = Cast<APMS_ZombiePlayerCharacter>(GetWorld()->GetFirstPlayerController()->GetPawn());
	if (PlayerCharacter)
	{
		float Distance = PlayerCharacter->GetDistanceTo(this);
		if (Distance <= DistanceToPlayer && !bIsVFXPlaying)
		{
			bIsVFXPlaying = true;
			PlayVFXEffect(PlayerCharacter->GetActorLocation());
			GetWorld()->GetTimerManager().SetTimer(FPlayVfxTimerHandle,
				[this, PlayerCharacter](){PlayVFXEffect(PlayerCharacter->GetActorLocation());},
				VFXDuration,
				true);
		}
		else if (Distance > DistanceToPlayer && bIsVFXPlaying)
		{
			bIsVFXPlaying = false;
			GetWorld()->GetTimerManager().ClearTimer(FPlayVfxTimerHandle);
		}
	}
}
void APMS_VFXTriggerActor::PlayVFXEffect(FVector Location)
{
	if(!VFXEffect) return;
	UNiagaraFunctionLibrary::SpawnSystemAtLocation(GetWorld(), VFXEffect, Location);
}

战备补给箱可被攻击破坏

空投落下后,空投有一定生命值可被子弹破坏,当生命值为0时,补给箱炸飞,空投子物体隐藏。

SupplyBox中补给箱需要有ProjectileMovementComponent,作用是自己被炸飞。

在构造函数中设置球形碰撞体为根组件,原因是让补给箱落地很平稳,不会因为形状不规则而旋转的很奇怪。一开始将ProjectileMovementComponent设为失活,等待爆炸时再启用。

在BeginPlay中绑定自带的投射物落地触发OnSupplyBoxStop事件的委托OnProjectileStop,让组件失活。

	// 空投爆炸补给箱抛射设置
	UPROPERTY(EditAnywhere)
	float MinLaunchSpeed = 800.f;
	UPROPERTY(EditAnywhere)
	float MaxLaunchSpeed = 1200.f;
	UPROPERTY(EditAnywhere)
	float MinUpwardStrength = 800.f;
	UPROPERTY(EditAnywhere)
	float MaxUpwardStrength = 1000.f;
	UPROPERTY(EditAnywhere)
	float RandomCircleRadius = 1.f;
	UPROPERTY(EditAnywhere)
	TObjectPtr<UProjectileMovementComponent> ProjectileMovementComponent;
 
APMS_SupplyBox::APMS_SupplyBox()
{
	PrimaryActorTick.bCanEverTick = true;
	SphereCollision = CreateDefaultSubobject<USphereComponent>(TEXT("SphereCollision"));
	RootComponent = SphereCollision;
	if(SphereCollision)
	{
		MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
		MeshComponent->SetupAttachment(SphereCollision);
	}
	ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovementComponent"));
	if(ProjectileMovementComponent)
	{
		ProjectileMovementComponent->bAutoActivate = false;
	}
}
void APMS_SupplyBox::BeginPlay()
{
	Super::BeginPlay();
	if(HasAuthority())
	{
		ProjectileMovementComponent->OnProjectileStop.AddUniqueDynamic(this, &APMS_SupplyBox::OnSupplyBoxStop);
	}
}
void APMS_SupplyBox::OnSupplyBoxStop(const FHitResult& ImpactResult)
{
	if(ProjectileMovementComponent)
	{
		ProjectileMovementComponent->Deactivate();
	}
}

ExplodeSupplyBox函数是爆炸后补给箱随机飞射的函数

其中LaunchSpeed是飞得远近,UpwardStrength是飞的高低,RandomCircleRadius是飞的发散程度。原理是取圆的随机二维点,变成一个三维向量,X, Y 值是向量归一化后 * LaunchSpeed,向量的Z值是UpwardStrength。再去同步碰撞设置,使箱子不会穿过地面。

void APMS_SupplyBox::ExplodeSupplyBox()
{
	if (ProjectileMovementComponent)
	{
		FVector2D Rand2D = FMath::RandPointInCircle(RandomCircleRadius);
		FVector HorizontalDir = FVector(Rand2D.X, Rand2D.Y, 0.f);
		HorizontalDir.Normalize();
		float UpwardStrength = FMath::FRandRange(MinUpwardStrength, MaxUpwardStrength);
		FVector LaunchVelocity = HorizontalDir * FMath::FRandRange(MinLaunchSpeed, MaxLaunchSpeed);
		LaunchVelocity.Z = UpwardStrength;
		ProjectileMovementComponent->Velocity = LaunchVelocity;
		ProjectileMovementComponent->Activate();
		MulticastConfigureCollision();
	}
}
void APMS_SupplyBox::MulticastConfigureCollision_Implementation()
{
	if(SphereCollision)
	{
		SphereCollision->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
	} 
}

AirDrop中重写ReceiveDamage、CanTakeDamage和ServerTakeDamage函数

其中PreDead是判断生命值是否<=0。如果空投被破坏或者空投结束就不会再进到ServerTakeDamage函数中。

在空投爆炸函数中将SupplyBox与空投分离,因为原来它是作为空投的ChildActor。如果空投正常工作时是可以被破坏的,如果补给箱被取走了空投就结束工作了相当于EAirDropState::EndPlay,这个时候就不能再执行重复执行AirDropEndPlay的逻辑了。并且广播爆炸特效。

void APMS_AirDrop::ReceiveDamage(const FPMS_DamageInfo& DamageInfo)
{
	Super::ReceiveDamage(DamageInfo);
	ServerTakeDamage(DamageInfo);
}
 
bool APMS_AirDrop::CanTakeDamage(const FPMS_DamageInfo& DamageInfo)
{
	return true;
}
 
void APMS_AirDrop::ServerTakeDamage(const FPMS_DamageInfo& DamageInfo)
{
	if(AirDropState == EAirDropState::Destroyed || AirDropState == EAirDropState::EndPlay) return;
	if(PreDead(DamageInfo))
	{
		if(HasAuthority())
		{
			AirDropExplode();
		}
	}
}
 
void APMS_AirDrop::AirDropExplode()
{
	TArray<UChildActorComponent*> ChildActorComponents;
	GetComponents(UChildActorComponent::StaticClass(), ChildActorComponents);
	for(int32 Index = 0; Index <FMath::Min(ChildActorComponents.Num(),DropClassList.Num()); Index++)
	{
		APMS_SupplyBox* SupplyBox = Cast<APMS_SupplyBox>(ChildActorComponents[Index]->GetChildActor());
		if (IsValid(SupplyBox))
		{
			SupplyBox->ExplodeSupplyBox();
			SupplyBox->DetachFromActor(FDetachmentTransformRules::KeepWorldTransform);
			SupplyBox->SetOwner(nullptr);
		}
	}
	if(AirDropState == EAirDropState::Play)
	{
		AirDropState = EAirDropState::Destroyed;
		AirDropEndPlay();
	}
	MulticastExplosionEffect();
}

这个函数将空投的Box设置为NoCollision,只留下空投底座是有碰撞的,并且如果空投是被破坏的,那么就将所有设置为HideComponentTag的子组件SetHiddenInGame。

MARK_PROPERTY_DIRTY_FROM_NAME是手动修改了AirDropState枚举变量标记为Dirty,可以触发同步。

void APMS_AirDrop::AirDropEndPlay()
{
	TArray<UShapeComponent*> ShapeComponents;
	GetComponents(UShapeComponent::StaticClass(), ShapeComponents);
	for(auto ShapeComponent:ShapeComponents)
	{
		if(ShapeComponent->ComponentHasTag(TEXT("EndWork")))
		{
			ShapeComponent->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics);
			ShapeComponent->SetCollisionProfileName(UCollisionProfile::BlockAllDynamic_ProfileName,false);
		}
		else
		{
			ShapeComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision);
		}
	}
	if(AirDropState == EAirDropState::Destroyed)
	{
		TArray<UActorComponent*> PrimitiveComponentList = GetComponentsByTag(UPrimitiveComponent::StaticClass(), HideComponentTag);
		for(UActorComponent* PrimitiveComponent : PrimitiveComponentList)
		{
			Cast<UPrimitiveComponent>(PrimitiveComponent)->SetHiddenInGame(true);
		}
	}
	MARK_PROPERTY_DIRTY_FROM_NAME(ThisClass, AirDropState, this);
}
void APMS_AirDrop::MulticastExplosionEffect_Implementation()
{
	if(GetNetMode() != NM_DedicatedServer)
	{
		if(DestroyEffect !=nullptr)
		{
			UNiagaraFunctionLibrary::SpawnSystemAtLocation(this, DestroyEffect, GetActorLocation(), GetActorRotation(), FVector(1,1,1), true, true, ENCPoolMethod::None, true);
		}
	}
}

换弹、攀爬按钮状态切换

以攀爬为例,通过自定义的PMS_GameDelegate_WorldSubsystem向UI层(TS脚本)进行广播,再由UI接收到消息后执行切换图片

在PMS_GameDelegate_WorldSubsystem中定义无参数委托类型

DECLARE_DYNAMIC_MULTICAST_DELEGATE(FNotifyWithNoParam);

	UPROPERTY(BlueprintAssignable)
	FNotifyWithNoParam OnJumpStart;
 
	UPROPERTY(BlueprintAssignable)
	FNotifyWithNoParam OnJumpEnd;
 
	FNotifyWithNoParam& GetOnJumpStart() { return OnJumpStart; }
	FNotifyWithNoParam& GetOnJumpEnd() { return OnJumpEnd; }

然后广播事件,其中BroadcastEvent是自定义封装的函数

static void BroadcastEvent(UWorld* World, DelegateType Delegate, Args&&... args)
{
    if (World != nullptr)
    {
       if (UPMS_GameDelegate_WorldSubsystem* GameDelegate = World->GetSubsystem<UPMS_GameDelegate_WorldSubsystem>())
       {
          if ((GameDelegate->*Delegate)().IsBound())
          {
             (GameDelegate->*Delegate)().Broadcast(std::forward<Args>(args)...);
          }
       }
    }
}
UPMS_GameDelegate_WorldSubsystem::BroadcastEvent(GetWorld(), &UPMS_GameDelegate_WorldSubsystem::GetOnJumpStart);
UPMS_GameDelegate_WorldSubsystem::BroadcastEvent(GetWorld(), &UPMS_GameDelegate_WorldSubsystem::GetOnJumpEnd);

然后在TypeScript\HUD\GameInputSystem.ts中

将函数绑定到OnJumpStart中

    @D.UEGameDelegateBind("OnJumpStart")
    OnJumpStart() {
        console.log("OnJumpStart called!");
        this.UEWidget.GameButton_Jump.NormalBorder.SetBrush(
            UE.WidgetBlueprintLibrary.MakeBrushFromTexture(this.UEWidget.GameButton_Jump.EnableSprite)
        );
    }
 
    @D.UEGameDelegateBind("OnJumpEnd")
    OnJumpEnd() {
        this.UEWidget.GameButton_Jump.NormalBorder.SetBrush(
            UE.WidgetBlueprintLibrary.MakeBrushFromTexture(this.UEWidget.GameButton_Jump.NormalSprite)
        );
    }

Sharing is caring!