Files
Rainer LeitandClaude Fable 5.1 4f2c55cd2a Step 1: the Salty project, two modules, tests and the gym
Salty.uproject (UE 5.8) from the Third Person template with class redirects, the SaltyCore and Salty modules,
the three build targets, USaltyAssetManager calling InitGlobalData, Config/Tags with the 23 root namespaces,
Scripts/run-tests.sh, build.sh and Authoring/create_gym.py, L_Gym, Git LFS attributes and the placeholder test.
Template variants kept as reference (D-41). The four Fab packs stay out of the repository for now (~10 GB).
The editor serves the engine MCP plugin on 127.0.0.1:8000 through DefaultEditorPerProjectUserSettings.ini.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:10:43 +03:00

344 lines
9.2 KiB
C++

// Copyright Epic Games, Inc. All Rights Reserved.
#include "CombatEnemy.h"
#include "Components/CapsuleComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "CombatAIController.h"
#include "Components/WidgetComponent.h"
#include "Engine/DamageEvents.h"
#include "CombatLifeBar.h"
#include "TimerManager.h"
#include "Components/SkeletalMeshComponent.h"
#include "Animation/AnimInstance.h"
ACombatEnemy::ACombatEnemy()
{
PrimaryActorTick.bCanEverTick = true;
// bind the attack montage ended delegate
OnAttackMontageEnded.BindUObject(this, &ACombatEnemy::AttackMontageEnded);
// set the AI Controller class by default
AIControllerClass = ACombatAIController::StaticClass();
// use an AI Controller regardless of whether we're placed or spawned
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
// ignore the controller's yaw rotation
bUseControllerRotationYaw = false;
// create the life bar
LifeBar = CreateDefaultSubobject<UWidgetComponent>(TEXT("LifeBar"));
LifeBar->SetupAttachment(RootComponent);
// set the collision capsule size
GetCapsuleComponent()->SetCapsuleSize(35.0f, 90.0f);
// set the character movement properties
GetCharacterMovement()->bUseControllerDesiredRotation = true;
// reset HP to maximum
CurrentHP = MaxHP;
}
void ACombatEnemy::DoAIComboAttack()
{
// ignore if we're already playing an attack animation
if (bIsAttacking)
{
return;
}
// raise the attacking flag
bIsAttacking = true;
// choose how many times we're going to attack
TargetComboCount = FMath::RandRange(1, ComboSectionNames.Num() - 1);
// reset the attack counter
CurrentComboAttack = 0;
// play the attack montage
if (UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance())
{
const float MontageLength = AnimInstance->Montage_Play(ComboAttackMontage, 1.0f, EMontagePlayReturnType::MontageLength, 0.0f, true);
// subscribe to montage completed and interrupted events
if (MontageLength > 0.0f)
{
// set the end delegate for the montage
AnimInstance->Montage_SetEndDelegate(OnAttackMontageEnded, ComboAttackMontage);
}
}
}
void ACombatEnemy::DoAIChargedAttack()
{
// ignore if we're already playing an attack animation
if (bIsAttacking)
{
return;
}
// raise the attacking flag
bIsAttacking = true;
// choose how many loops are we going to charge for
TargetChargeLoops = FMath::RandRange(MinChargeLoops, MaxChargeLoops);
// reset the charge loop counter
CurrentChargeLoop = 0;
// play the attack montage
if (UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance())
{
const float MontageLength = AnimInstance->Montage_Play(ChargedAttackMontage, 1.0f, EMontagePlayReturnType::MontageLength, 0.0f, true);
// subscribe to montage completed and interrupted events
if (MontageLength > 0.0f)
{
// set the end delegate for the montage
AnimInstance->Montage_SetEndDelegate(OnAttackMontageEnded, ChargedAttackMontage);
}
}
}
void ACombatEnemy::AttackMontageEnded(UAnimMontage* Montage, bool bInterrupted)
{
// reset the attacking flag
bIsAttacking = false;
// call the attack completed delegate so the StateTree can continue execution
OnAttackCompleted.ExecuteIfBound();
}
const FVector& ACombatEnemy::GetLastDangerLocation() const
{
return LastDangerLocation;
}
float ACombatEnemy::GetLastDangerTime() const
{
return LastDangerTime;
}
void ACombatEnemy::DoAttackTrace(FName DamageSourceBone)
{
// sweep for objects in front of the character to be hit by the attack
TArray<FHitResult> OutHits;
// start at the provided socket location, sweep forward
const FVector TraceStart = GetMesh()->GetSocketLocation(DamageSourceBone);
const FVector TraceEnd = TraceStart + (GetActorForwardVector() * MeleeTraceDistance);
// enemies only affect Pawn collision objects; they don't knock back boxes
FCollisionObjectQueryParams ObjectParams;
ObjectParams.AddObjectTypesToQuery(ECC_Pawn);
// use a sphere shape for the sweep
FCollisionShape CollisionShape;
CollisionShape.SetSphere(MeleeTraceRadius);
// ignore self
FCollisionQueryParams QueryParams;
QueryParams.AddIgnoredActor(this);
if (GetWorld()->SweepMultiByObjectType(OutHits, TraceStart, TraceEnd, FQuat::Identity, ObjectParams, CollisionShape, QueryParams))
{
// iterate over each object hit
for (const FHitResult& CurrentHit : OutHits)
{
/** does the actor have the player tag? */
if (CurrentHit.GetActor()->ActorHasTag(FName("Player")))
{
// check if the actor is damageable
ICombatDamageable* Damageable = Cast<ICombatDamageable>(CurrentHit.GetActor());
if (Damageable)
{
// knock upwards and away from the impact normal
const FVector Impulse = (CurrentHit.ImpactNormal * -MeleeKnockbackImpulse) + (FVector::UpVector * MeleeLaunchImpulse);
// pass the damage event to the actor
Damageable->ApplyDamage(MeleeDamage, this, CurrentHit.ImpactPoint, Impulse);
}
}
}
}
}
void ACombatEnemy::CheckCombo()
{
// increase the combo counter
++CurrentComboAttack;
// do we still have attacks to play in this string?
if (CurrentComboAttack < TargetComboCount)
{
// jump to the next attack section
if (UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance())
{
AnimInstance->Montage_JumpToSection(ComboSectionNames[CurrentComboAttack], ComboAttackMontage);
}
}
}
void ACombatEnemy::CheckChargedAttack()
{
// increase the charge loop counter
++CurrentChargeLoop;
// jump to either the loop or attack section of the montage depending on whether we hit the loop target
if (UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance())
{
AnimInstance->Montage_JumpToSection(CurrentChargeLoop >= TargetChargeLoops ? ChargeAttackSection : ChargeLoopSection, ChargedAttackMontage);
}
}
void ACombatEnemy::ApplyDamage(float Damage, AActor* DamageCauser, const FVector& DamageLocation, const FVector& DamageImpulse)
{
// pass the damage event to the actor
FDamageEvent DamageEvent;
const float ActualDamage = TakeDamage(Damage, DamageEvent, nullptr, DamageCauser);
// only process knockback and effects if we received nonzero damage
if (ActualDamage > 0.0f)
{
// apply the knockback impulse
GetCharacterMovement()->AddImpulse(DamageImpulse, true);
// is the character ragdolling?
if (GetMesh()->IsSimulatingPhysics())
{
// apply an impulse to the ragdoll
GetMesh()->AddImpulseAtLocation(DamageImpulse * GetMesh()->GetMass(), DamageLocation);
}
// stop the attack montages to interrupt the attack
if (UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance())
{
AnimInstance->Montage_Stop(0.1f, ComboAttackMontage);
AnimInstance->Montage_Stop(0.1f, ChargedAttackMontage);
}
// pass control to BP to play effects, etc.
ReceivedDamage(ActualDamage, DamageLocation, DamageImpulse.GetSafeNormal());
}
}
void ACombatEnemy::HandleDeath()
{
// hide the life bar
LifeBar->SetHiddenInGame(true);
// disable the collision capsule to avoid being hit again while dead
GetCapsuleComponent()->SetCollisionEnabled(ECollisionEnabled::NoCollision);
// disable character movement
GetCharacterMovement()->DisableMovement();
// enable full ragdoll physics
GetMesh()->SetSimulatePhysics(true);
// call the died delegate to notify any subscribers
OnEnemyDied.Broadcast();
// set up the death timer
GetWorld()->GetTimerManager().SetTimer(DeathTimer, this, &ACombatEnemy::RemoveFromLevel, DeathRemovalTime);
}
void ACombatEnemy::ApplyHealing(float Healing, AActor* Healer)
{
// stub
}
void ACombatEnemy::NotifyDanger(const FVector& DangerLocation, AActor* DangerSource)
{
// ensure we're being attacked by the player
if (DangerSource && DangerSource->ActorHasTag(FName("Player")))
{
// save the danger location and game time
LastDangerLocation = DangerLocation;
LastDangerTime = GetWorld()->GetTimeSeconds();
}
}
void ACombatEnemy::RemoveFromLevel()
{
// destroy this actor
Destroy();
}
float ACombatEnemy::TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
// only process damage if the character is still alive
if (CurrentHP <= 0.0f)
{
return 0.0f;
}
// reduce the current HP
CurrentHP -= Damage;
// have we run out of HP?
if (CurrentHP <= 0.0f)
{
// die
HandleDeath();
}
else
{
// update the life bar
LifeBarWidget->SetLifePercentage(CurrentHP / MaxHP);
// enable partial ragdoll physics, but keep the pelvis vertical
GetMesh()->SetPhysicsBlendWeight(0.5f);
GetMesh()->SetBodySimulatePhysics(PelvisBoneName, false);
}
// return the received damage amount
return Damage;
}
void ACombatEnemy::Landed(const FHitResult& Hit)
{
Super::Landed(Hit);
// is the character still alive?
if (CurrentHP >= 0.0f)
{
// disable ragdoll physics
GetMesh()->SetPhysicsBlendWeight(0.0f);
}
// call the landed Delegate for StateTree
OnEnemyLanded.ExecuteIfBound();
}
void ACombatEnemy::BeginPlay()
{
// reset HP to maximum
CurrentHP = MaxHP;
// we top the HP before BeginPlay so StateTree picks it up at the right value
Super::BeginPlay();
// get the life bar widget from the widget comp
LifeBarWidget = Cast<UCombatLifeBar>(LifeBar->GetUserWidgetObject());
check(LifeBarWidget);
// fill the life bar
LifeBarWidget->SetLifePercentage(1.0f);
}
void ACombatEnemy::EndPlay(EEndPlayReason::Type EndPlayReason)
{
Super::EndPlay(EndPlayReason);
// clear the death timer
GetWorld()->GetTimerManager().ClearTimer(DeathTimer);
}