diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTDecorator_CheckCombatType.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTDecorator_CheckCombatType.cpp new file mode 100644 index 0000000..5f2d659 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTDecorator_CheckCombatType.cpp @@ -0,0 +1,29 @@ +// Copyright Asterion. All Rights Reserved. + +#include "BT/PS_AI_Behavior_BTDecorator_CheckCombatType.h" +#include "PS_AI_Behavior_AIController.h" +#include "PS_AI_Behavior_Interface.h" + +UPS_AI_Behavior_BTDecorator_CheckCombatType::UPS_AI_Behavior_BTDecorator_CheckCombatType() +{ + NodeName = TEXT("Check Combat Type"); +} + +bool UPS_AI_Behavior_BTDecorator_CheckCombatType::CalculateRawConditionValue( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const +{ + APS_AI_Behavior_AIController* AIC = Cast(OwnerComp.GetAIOwner()); + if (!AIC) return false; + + APawn* Pawn = AIC->GetPawn(); + if (!Pawn || !Pawn->Implements()) return false; + + return IPS_AI_Behavior_Interface::Execute_GetBehaviorCombatType(Pawn) == RequiredType; +} + +FString UPS_AI_Behavior_BTDecorator_CheckCombatType::GetStaticDescription() const +{ + const UEnum* TypeEnum = StaticEnum(); + return FString::Printf(TEXT("Combat Type == %s"), + *TypeEnum->GetDisplayNameTextByValue(static_cast(RequiredType)).ToString()); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_EvaluateReaction.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_EvaluateReaction.cpp index 90f6087..54d8be3 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_EvaluateReaction.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_EvaluateReaction.cpp @@ -2,10 +2,13 @@ #include "BT/PS_AI_Behavior_BTService_EvaluateReaction.h" #include "PS_AI_Behavior_AIController.h" +#include "PS_AI_Behavior_PerceptionComponent.h" #include "PS_AI_Behavior_Interface.h" #include "PS_AI_Behavior_PersonalityComponent.h" +#include "PS_AI_Behavior_PersonalityProfile.h" #include "PS_AI_Behavior_Definitions.h" #include "BehaviorTree/BlackboardComponent.h" +#include "Perception/AISense_Hearing.h" UPS_AI_Behavior_BTService_EvaluateReaction::UPS_AI_Behavior_BTService_EvaluateReaction() { @@ -35,25 +38,124 @@ void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode( const bool bHostile = IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(Pawn); const EPS_AI_Behavior_NPCType NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(Pawn); - // An infiltrated Enemy (hostile=false) has TeamId=1 (civilian disguise). - // When hostile flips to true, switch to TeamId=2 (enemy). + // Get faction from profile + const uint8 Faction = (Personality && Personality->Profile) + ? Personality->Profile->Faction : 0; + + // Infiltrated Enemy (hostile=false) → disguised as Civilian. + // When hostile flips to true → reveal true Enemy TeamId. uint8 ExpectedTeamId; - switch (NPCType) + if (NPCType == EPS_AI_Behavior_NPCType::Enemy && !bHostile) { - case EPS_AI_Behavior_NPCType::Civilian: ExpectedTeamId = 1; break; - case EPS_AI_Behavior_NPCType::Enemy: ExpectedTeamId = bHostile ? 2 : 1; break; - case EPS_AI_Behavior_NPCType::Protector: ExpectedTeamId = 3; break; - default: ExpectedTeamId = FGenericTeamId::NoTeam; break; + ExpectedTeamId = PS_AI_Behavior_Team::DisguisedTeamId; + } + else + { + ExpectedTeamId = PS_AI_Behavior_Team::MakeTeamId(NPCType, Faction); } if (AIC->GetGenericTeamId().GetId() != ExpectedTeamId) { - UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Hostility changed: TeamId %d -> %d (hostile=%d)"), + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Hostility changed: TeamId 0x%02X -> 0x%02X (hostile=%d)"), *AIC->GetName(), AIC->GetGenericTeamId().GetId(), ExpectedTeamId, (int32)bHostile); AIC->SetTeamId(ExpectedTeamId); } } + // ─── Gunshot reaction: flip non-hostile enemies to hostile ────────── + if (Pawn && Pawn->Implements()) + { + const EPS_AI_Behavior_NPCType NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(Pawn); + const bool bHostile = IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(Pawn); + + if (NPCType == EPS_AI_Behavior_NPCType::Enemy && !bHostile) + { + // Check if this enemy perceives a gunshot stimulus → become hostile toward shooter + if (UPS_AI_Behavior_PerceptionComponent* Perception = AIC->GetBehaviorPerception()) + { + AActor* GunShotInstigator = nullptr; + TArray PerceivedActors; + Perception->GetCurrentlyPerceivedActors(nullptr, PerceivedActors); + + for (AActor* RawActor : PerceivedActors) + { + if (!RawActor || GunShotInstigator) continue; + + // Resolve weapon/item to owning Pawn (walk Owner/Instigator chain) + AActor* ResolvedActor = RawActor; + if (!Cast(RawActor)) + { + AActor* Cur = RawActor; + for (int32 D = 0; D < 4; ++D) + { + if (APawn* IP = Cur->GetInstigator()) { ResolvedActor = IP; break; } + AActor* OA = Cur->GetOwner(); + if (!OA || OA == Cur) break; + if (APawn* OP = Cast(OA)) { ResolvedActor = OP; break; } + Cur = OA; + } + } + + // Skip same exact team (same NPCType + same Faction) + // Allied teams still allow gunfire through (e.g. disguised enemy hears Protector fire) + const uint8 MyTeam = AIC->GetGenericTeamId().GetId(); + const uint8 TheirTeam = UPS_AI_Behavior_PerceptionComponent::GetActorTeamId(ResolvedActor); + if (MyTeam == TheirTeam) + { + continue; + } + + FActorPerceptionBlueprintInfo Info; + if (Perception->GetActorsPerception(RawActor, Info)) + { + for (const FAIStimulus& S : Info.LastSensedStimuli) + { + if (S.IsValid() && + S.Type == UAISense::GetSenseID() && + PS_AI_Behavior_Tags_Internal::IsGunfire(S.Tag)) + { + // For VR: check if Pawn has a custom threat actor + if (ResolvedActor->Implements()) + { + AActor* ThreatActor = IPS_AI_Behavior_Interface::Execute_GetBehaviorThreatActor(ResolvedActor); + if (ThreatActor) ResolvedActor = ThreatActor; + } + GunShotInstigator = ResolvedActor; + break; + } + } + } + } + + if (GunShotInstigator) + { + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] Gunshot heard from '%s' — becoming hostile!"), + *AIC->GetName(), *GunShotInstigator->GetName()); + + // 1. Flip hostile → TeamId will update on next block above + IPS_AI_Behavior_Interface::Execute_SetBehaviorHostile(Pawn, true); + + // 2. Immediately update TeamId so perception sees the shooter as hostile NOW + const uint8 EnemyFaction = (Personality && Personality->Profile) + ? Personality->Profile->Faction : 0; + AIC->SetTeamId(PS_AI_Behavior_Team::MakeTeamId(EPS_AI_Behavior_NPCType::Enemy, EnemyFaction)); + + // 3. Write the shooter as ThreatActor in BB so combat targets them directly + BB->SetValueAsObject(PS_AI_Behavior_BB::ThreatActor, GunShotInstigator); + BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, GunShotInstigator->GetActorLocation()); + + // 4. Set a meaningful threat level so EvaluateReaction enters Combat + if (Personality) + { + Personality->PerceivedThreatLevel = FMath::Max(Personality->PerceivedThreatLevel, 0.6f); + BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, Personality->PerceivedThreatLevel); + } + } + } + } + } + // ─── Evaluate and apply the reaction ──────────────────────────────── EPS_AI_Behavior_State NewState = Personality->ApplyReaction(); diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Attack.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Attack.cpp index a5a11d5..8daaa84 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Attack.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Attack.cpp @@ -4,8 +4,11 @@ #include "PS_AI_Behavior_AIController.h" #include "PS_AI_Behavior_Interface.h" #include "PS_AI_Behavior_Definitions.h" +#include "PS_AI_Behavior_PersonalityComponent.h" +#include "PS_AI_Behavior_PersonalityProfile.h" #include "BehaviorTree/BlackboardComponent.h" #include "Navigation/PathFollowingComponent.h" +#include "NavigationSystem.h" UPS_AI_Behavior_BTTask_Attack::UPS_AI_Behavior_BTTask_Attack() { @@ -42,28 +45,68 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask( return EBTNodeResult::Failed; } } + + // ─── Query combat type and range ───────────────────────────────── FAttackMemory* Memory = reinterpret_cast(NodeMemory); Memory->bMovingToTarget = false; Memory->bAttacking = false; + Memory->bInRange = false; + Memory->RepositionTimer = 0.0f; + Memory->CombatType = EPS_AI_Behavior_CombatType::Melee; + Memory->MinRange = 100.0f; + Memory->MaxRange = AttackMoveRadius; - // Tell the Pawn to start attacking via interface + // CombatType from interface (depends on weapon/pawn) if (Pawn->Implements()) { - IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); - Memory->bAttacking = true; + Memory->CombatType = IPS_AI_Behavior_Interface::Execute_GetBehaviorCombatType(Pawn); } - // Move toward target - const EPathFollowingRequestResult::Type Result = AIC->MoveToActor( - Target, AttackMoveRadius, /*bUsePathfinding=*/true, /*bAllowStrafe=*/true); - - if (Result != EPathFollowingRequestResult::AlreadyAtGoal) + // Min/Max attack range from PersonalityProfile + if (UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent()) { - Memory->bMovingToTarget = true; + if (Personality->Profile) + { + Memory->MinRange = Personality->Profile->MinAttackRange; + Memory->MaxRange = Personality->Profile->MaxAttackRange; + } } - UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: started on '%s'"), - *AIC->GetName(), *Target->GetName()); + // Melee: approach to half MinRange (get close). Ranged: approach to midpoint of band. + const float ApproachRange = (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee) + ? Memory->MinRange * 0.5f : (Memory->MinRange + Memory->MaxRange) * 0.5f; + + // Check if already in range + const float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation()); + if (DistToTarget <= Memory->MaxRange) + { + Memory->bInRange = true; + if (Pawn->Implements()) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); + Memory->bAttacking = true; + } + } + + // Initial move toward target if not in range + if (DistToTarget > Memory->MaxRange) + { + const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation( + Target->GetActorLocation(), ApproachRange, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + + if (Result != EPathFollowingRequestResult::AlreadyAtGoal) + { + Memory->bMovingToTarget = true; + } + } + + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: started on '%s' (%s, range=[%.0f-%.0f], dist=%.0f, inRange=%d, attacking=%d, hasInterface=%d)"), + *AIC->GetName(), *Target->GetName(), + Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged ? TEXT("Ranged") : TEXT("Melee"), + Memory->MinRange, Memory->MaxRange, DistToTarget, + Memory->bInRange ? 1 : 0, Memory->bAttacking ? 1 : 0, + Pawn->Implements() ? 1 : 0); // Stay InProgress — the Decorator Observer Aborts will pull us out return EBTNodeResult::InProgress; @@ -105,15 +148,108 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask( FAttackMemory* Memory = reinterpret_cast(NodeMemory); - // Keep moving toward target if out of range - if (Memory->bMovingToTarget && AIC->GetMoveStatus() == EPathFollowingStatus::Idle) + // Tick reposition cooldown + if (Memory->RepositionTimer > 0.0f) { - // Re-issue move if target moved - AIC->MoveToActor(Target, AttackMoveRadius, /*bUsePathfinding=*/true, /*bAllowStrafe=*/true); + Memory->RepositionTimer -= DeltaSeconds; } - // The Pawn handles the actual shooting/melee via the interface - // We just keep the NPC moving toward the target + const float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation()); + const bool bCanReposition = (Memory->RepositionTimer <= 0.0f); + + if (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee) + { + // ─── Melee: continuously chase target (no cooldown — always pursue) ── + if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle && DistToTarget > Memory->MinRange) + { + AIC->MoveToLocation( + Target->GetActorLocation(), Memory->MinRange * 0.5f, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + } + } + else + { + // ─── Ranged: maintain distance between MinRange and MaxRange ─ + const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f; + + if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle) + { + if (bCanReposition && DistToTarget < Memory->MinRange) + { + // Too close — back away to midpoint of band + const FVector AwayDir = (Pawn->GetActorLocation() - Target->GetActorLocation()).GetSafeNormal2D(); + const float RetreatDist = MidRange - DistToTarget + 50.0f; + const FVector RetreatPoint = Pawn->GetActorLocation() + AwayDir * RetreatDist; + + // Project to navmesh + UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent(GetWorld()); + if (NavSys) + { + FNavLocation NavLoc; + if (NavSys->ProjectPointToNavigation(RetreatPoint, NavLoc, FVector(300.0f, 300.0f, 200.0f))) + { + AIC->MoveToLocation( + NavLoc.Location, 50.0f, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/false, /*bCanStrafe=*/false); + // Longer cooldown after retreat to prevent repeated backing + Memory->RepositionTimer = RepositionCooldown * 3.0f; + + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: backing away %.0fcm (dist=%.0f < min=%.0f)"), + *AIC->GetName(), RetreatDist, DistToTarget, Memory->MinRange); + } + } + } + else if (DistToTarget > Memory->MaxRange) + { + // Too far — advance toward target to midpoint of band (no cooldown — chase aggressively) + AIC->MoveToLocation( + Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: advancing to midRange=%.0f (dist=%.0f > max=%.0f)"), + *AIC->GetName(), MidRange, DistToTarget, Memory->MaxRange); + } + // else: between MinRange and MaxRange — hold position, Pawn handles shooting + } + } + + // ─── Toggle attack based on range (with hysteresis to prevent flickering) ── + // Enter range at MaxRange, leave range at MaxRange + buffer + const float EnterRange = Memory->MaxRange; + const float LeaveRange = Memory->MaxRange * 1.1f; // 10% hysteresis + const bool bNowInRange = Memory->bInRange + ? (DistToTarget <= LeaveRange) // already in range → need to go PAST LeaveRange to exit + : (DistToTarget <= EnterRange); // not in range → need to get WITHIN EnterRange to enter + + if (bNowInRange && !Memory->bInRange) + { + // Entered range → start attacking + Memory->bInRange = true; + if (Pawn->Implements() && !Memory->bAttacking) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); + Memory->bAttacking = true; + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range (%.0f <= %.0f) — StartAttack on '%s'"), + *AIC->GetName(), DistToTarget, AttackRange, *Target->GetName()); + } + else if (!Pawn->Implements()) + { + UE_LOG(LogPS_AI_Behavior, Error, TEXT("[%s] Attack: in range but Pawn does NOT implement IPS_AI_Behavior_Interface — StartAttack cannot be called!"), + *AIC->GetName()); + } + } + else if (!bNowInRange && Memory->bInRange) + { + // Left range → stop attacking + Memory->bInRange = false; + if (Pawn->Implements() && Memory->bAttacking) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn); + Memory->bAttacking = false; + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: out of range (%.0f > %.0f) — StopAttack"), + *AIC->GetName(), DistToTarget, AttackRange); + } + } } EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::AbortTask( @@ -147,5 +283,6 @@ void UPS_AI_Behavior_BTTask_Attack::OnTaskFinished( FString UPS_AI_Behavior_BTTask_Attack::GetStaticDescription() const { - return FString::Printf(TEXT("Move to threat (radius %.0fcm) and attack via interface."), AttackMoveRadius); + return FString::Printf(TEXT("Range-aware attack.\nFallback radius: %.0fcm\nReposition cooldown: %.1fs"), + AttackMoveRadius, RepositionCooldown); } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_CoverShootCycle.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_CoverShootCycle.cpp new file mode 100644 index 0000000..e7033a4 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_CoverShootCycle.cpp @@ -0,0 +1,432 @@ +// Copyright Asterion. All Rights Reserved. + +#include "BT/PS_AI_Behavior_BTTask_CoverShootCycle.h" +#include "PS_AI_Behavior_AIController.h" +#include "PS_AI_Behavior_Interface.h" +#include "PS_AI_Behavior_CoverPoint.h" +#include "PS_AI_Behavior_PersonalityComponent.h" +#include "PS_AI_Behavior_PersonalityProfile.h" +#include "PS_AI_Behavior_Definitions.h" +#include "BehaviorTree/BlackboardComponent.h" +#include "Navigation/PathFollowingComponent.h" +#include "EngineUtils.h" + +UPS_AI_Behavior_BTTask_CoverShootCycle::UPS_AI_Behavior_BTTask_CoverShootCycle() +{ + NodeName = TEXT("Cover Shoot Cycle"); + bNotifyTick = true; + bNotifyTaskFinished = true; +} + +EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) +{ + APS_AI_Behavior_AIController* AIC = Cast(OwnerComp.GetAIOwner()); + if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed; + + UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); + if (!BB) return EBTNodeResult::Failed; + + // We need a cover location (written by BTTask_FindCover) + const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation); + if (CoverLoc.IsZero()) + { + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] CoverShootCycle: no CoverLocation in BB."), *AIC->GetName()); + return EBTNodeResult::Failed; + } + + // We need a threat + AActor* Target = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); + if (!Target) + { + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] CoverShootCycle: no ThreatActor in BB."), *AIC->GetName()); + return EBTNodeResult::Failed; + } + + // ─── Initialize memory with personality modulation ─────────────── + FCoverShootMemory* Memory = reinterpret_cast(NodeMemory); + Memory->SubState = EPS_AI_Behavior_CombatSubState::Engaging; + Memory->Timer = 0.0f; + Memory->PhaseDuration = 0.0f; + Memory->CycleCount = 0; + Memory->bMoveRequested = false; + + // Base values + Memory->EffPeekMin = PeekDurationMin; + Memory->EffPeekMax = PeekDurationMax; + Memory->EffCoverMin = CoverDurationMin; + Memory->EffCoverMax = CoverDurationMax; + Memory->EffMaxCycles = MaxCyclesBeforeAdvance; + Memory->bCanAdvance = true; + + // Modulate by personality traits + if (UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent()) + { + const float Aggressivity = Personality->GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity); + const float Caution = Personality->GetTrait(EPS_AI_Behavior_TraitAxis::Caution); + const float Courage = Personality->GetTrait(EPS_AI_Behavior_TraitAxis::Courage); + + // Aggressive → peek longer, cover shorter, advance sooner + const float AggrFactor = 0.7f + Aggressivity * 0.6f; // 0.7 – 1.3 + Memory->EffPeekMin *= AggrFactor; + Memory->EffPeekMax *= AggrFactor; + Memory->EffCoverMin /= AggrFactor; + Memory->EffCoverMax /= AggrFactor; + Memory->EffMaxCycles = FMath::Max(1, FMath::RoundToInt(MaxCyclesBeforeAdvance * (1.5f - Aggressivity * 0.5f))); + + // Cautious → cover longer, peek shorter + const float CautionFactor = 0.5f + Caution * 1.0f; // 0.5 – 1.5 + Memory->EffCoverMin *= CautionFactor; + Memory->EffCoverMax *= CautionFactor; + Memory->EffPeekMin /= CautionFactor; + Memory->EffPeekMax /= CautionFactor; + + // Low courage → never advance + Memory->bCanAdvance = (Courage >= 0.3f); + + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] CoverShootCycle: peek=[%.1f-%.1f]s, cover=[%.1f-%.1f]s, maxCycles=%d, canAdvance=%d"), + *AIC->GetName(), + Memory->EffPeekMin, Memory->EffPeekMax, + Memory->EffCoverMin, Memory->EffCoverMax, + Memory->EffMaxCycles, (int32)Memory->bCanAdvance); + } + + // ─── Move to cover position ────────────────────────────────────── + const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation( + CoverLoc, 80.0f, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + + if (Result == EPathFollowingRequestResult::Failed) + { + return EBTNodeResult::Failed; + } + + if (Result == EPathFollowingRequestResult::AlreadyAtGoal) + { + // Already at cover — start the cycle + Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover; + Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax); + Memory->Timer = Memory->PhaseDuration; + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::AtCover)); + } + else + { + Memory->bMoveRequested = true; + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::Engaging)); + } + + return EBTNodeResult::InProgress; +} + +void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) +{ + APS_AI_Behavior_AIController* AIC = Cast(OwnerComp.GetAIOwner()); + if (!AIC || !AIC->GetPawn()) + { + FinishLatentTask(OwnerComp, EBTNodeResult::Failed); + return; + } + + UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); + if (!BB) { FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; } + + AActor* Target = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); + if (!Target) + { + FinishLatentTask(OwnerComp, EBTNodeResult::Failed); + return; + } + + // Validate target + APawn* Pawn = AIC->GetPawn(); + if (Pawn && Pawn->Implements()) + { + if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target)) + { + AIC->StopMovement(); + BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); + BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f); + FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded); + return; + } + } + + FCoverShootMemory* Memory = reinterpret_cast(NodeMemory); + + switch (Memory->SubState) + { + // ─── ENGAGING: Moving to cover ────────────────────────────────── + case EPS_AI_Behavior_CombatSubState::Engaging: + { + if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle) + { + // Arrived at cover → start duck phase + Memory->bMoveRequested = false; + Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover; + Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax); + Memory->Timer = Memory->PhaseDuration; + + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::AtCover)); + + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: at cover, ducking for %.1fs"), + *AIC->GetName(), Memory->PhaseDuration); + } + break; + } + + // ─── AT COVER: Ducked, waiting ────────────────────────────────── + case EPS_AI_Behavior_CombatSubState::AtCover: + { + Memory->Timer -= DeltaSeconds; + if (Memory->Timer <= 0.0f) + { + // Timer expired → peek and shoot + Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking; + Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax); + Memory->Timer = Memory->PhaseDuration; + + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::Peeking)); + + // Start attacking + if (Pawn->Implements()) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); + } + + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: peeking, shooting for %.1fs"), + *AIC->GetName(), Memory->PhaseDuration); + } + break; + } + + // ─── PEEKING: Shooting at target ──────────────────────────────── + case EPS_AI_Behavior_CombatSubState::Peeking: + { + Memory->Timer -= DeltaSeconds; + if (Memory->Timer <= 0.0f) + { + // Stop attacking + if (Pawn->Implements()) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn); + } + + Memory->CycleCount++; + + // Should we advance to closer cover? + if (Memory->bCanAdvance && Memory->CycleCount >= Memory->EffMaxCycles) + { + // ─── Advance to next cover ────────────────────── + Memory->SubState = EPS_AI_Behavior_CombatSubState::Advancing; + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::Advancing)); + + // Release current cover point + APS_AI_Behavior_CoverPoint* OldPoint = + Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint)); + if (OldPoint) + { + OldPoint->Release(Pawn); + } + + // Find a closer cover + const FVector NpcLoc = Pawn->GetActorLocation(); + const FVector ThreatLoc = Target->GetActorLocation(); + EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any; + if (UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent()) + { + NPCType = Personality->GetNPCType(); + } + + float NewScore = -1.0f; + APS_AI_Behavior_CoverPoint* NewPoint = + FindAdvancingCover(GetWorld(), NpcLoc, ThreatLoc, NPCType, NewScore); + + if (NewPoint) + { + NewPoint->Claim(Pawn); + BB->SetValueAsObject(PS_AI_Behavior_BB::CoverPoint, NewPoint); + BB->SetValueAsVector(PS_AI_Behavior_BB::CoverLocation, NewPoint->GetActorLocation()); + + AIC->MoveToLocation( + NewPoint->GetActorLocation(), 80.0f, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + Memory->bMoveRequested = true; + Memory->CycleCount = 0; + + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: advancing to '%s' (score %.2f)"), + *AIC->GetName(), *NewPoint->GetName(), NewScore); + } + else + { + // No better cover found — stay at current position, reset cycle + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] CoverShootCycle: no advancing cover found, resetting cycle"), + *AIC->GetName()); + Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover; + Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax); + Memory->Timer = Memory->PhaseDuration; + Memory->CycleCount = 0; + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::AtCover)); + } + } + else + { + // ─── Duck back behind cover ───────────────────── + Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover; + Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax); + Memory->Timer = Memory->PhaseDuration; + + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::AtCover)); + + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] CoverShootCycle: ducking back (cycle %d/%d)"), + *AIC->GetName(), Memory->CycleCount, Memory->EffMaxCycles); + } + } + break; + } + + // ─── ADVANCING: Moving to next cover ──────────────────────────── + case EPS_AI_Behavior_CombatSubState::Advancing: + { + if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle) + { + // Arrived at new cover → duck + Memory->bMoveRequested = false; + Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover; + Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax); + Memory->Timer = Memory->PhaseDuration; + + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::AtCover)); + + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: arrived at new cover, ducking"), + *AIC->GetName()); + } + break; + } + } +} + +EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::AbortTask( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) +{ + APS_AI_Behavior_AIController* AIC = Cast(OwnerComp.GetAIOwner()); + if (AIC) + { + AIC->StopMovement(); + + // Stop attacking if we were peeking + APawn* Pawn = AIC->GetPawn(); + if (Pawn && Pawn->Implements()) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn); + } + } + + return EBTNodeResult::Aborted; +} + +void UPS_AI_Behavior_BTTask_CoverShootCycle::OnTaskFinished( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult) +{ + APS_AI_Behavior_AIController* AIC = Cast(OwnerComp.GetAIOwner()); + if (AIC) + { + // Stop attacking + APawn* Pawn = AIC->GetPawn(); + if (Pawn && Pawn->Implements()) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn); + } + + // Release cover point + UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); + if (BB) + { + APS_AI_Behavior_CoverPoint* Point = + Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint)); + if (Point && Pawn) + { + Point->Release(Pawn); + } + } + } + + Super::OnTaskFinished(OwnerComp, NodeMemory, TaskResult); +} + +// ─── Advancing Cover Search ───────────────────────────────────────────────── + +APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_CoverShootCycle::FindAdvancingCover( + const UWorld* World, const FVector& NpcLoc, const FVector& ThreatLoc, + EPS_AI_Behavior_NPCType NPCType, float& OutScore) const +{ + APS_AI_Behavior_CoverPoint* BestPoint = nullptr; + OutScore = -1.0f; + + const float NpcDistToThreat = FVector::Dist(NpcLoc, ThreatLoc); + + for (TActorIterator It(const_cast(World)); It; ++It) + { + APS_AI_Behavior_CoverPoint* Point = *It; + if (!Point || !Point->bEnabled) continue; + + // Type filter + if (Point->PointType != CoverPointType) continue; + + // NPC type accessibility + if (!Point->IsAccessibleTo(NPCType)) continue; + + // Availability + if (!Point->HasRoom()) continue; + + // Distance check from NPC + const float DistFromNpc = FVector::Dist(NpcLoc, Point->GetActorLocation()); + if (DistFromNpc > AdvanceSearchRadius) continue; + + // Must be closer to threat than NPC currently is + const float CoverDistToThreat = FVector::Dist(Point->GetActorLocation(), ThreatLoc); + if (CoverDistToThreat >= NpcDistToThreat) continue; + + // Evaluate quality against threat + float Score = Point->EvaluateAgainstThreat(ThreatLoc); + + // Distance bonus — closer to NPC is better (less travel time) + Score += FMath::GetMappedRangeValueClamped( + FVector2D(0.0f, AdvanceSearchRadius), FVector2D(0.15f, 0.0f), DistFromNpc); + + // Advancement bonus — how much closer to threat this cover gets us + if (NpcDistToThreat > 0.0f) + { + const float AdvanceRatio = (NpcDistToThreat - CoverDistToThreat) / NpcDistToThreat; + Score += AdvancementBias * AdvanceRatio * 0.3f; + } + + if (Score > OutScore) + { + OutScore = Score; + BestPoint = Point; + } + } + + return BestPoint; +} + +FString UPS_AI_Behavior_BTTask_CoverShootCycle::GetStaticDescription() const +{ + return FString::Printf( + TEXT("Cover-shoot cycle.\nPeek: %.1f–%.1fs | Cover: %.1f–%.1fs\nAdvance after %d cycles (radius %.0fcm)"), + PeekDurationMin, PeekDurationMax, + CoverDurationMin, CoverDurationMax, + MaxCyclesBeforeAdvance, AdvanceSearchRadius); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindCover.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindCover.cpp index 87fe617..2f82793 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindCover.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindCover.cpp @@ -215,6 +215,18 @@ APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_FindCover::FindBestManualCove Score += FMath::GetMappedRangeValueClamped( FVector2D(0.0f, SearchRadius), FVector2D(0.15f, 0.0f), Dist); + // Advancement bias — prefer covers closer to threat than NPC is + if (AdvancementBias > 0.0f) + { + const float NpcDistToThreat = FVector::Dist(NpcLoc, ThreatLoc); + const float CoverDistToThreat = FVector::Dist(Point->GetActorLocation(), ThreatLoc); + if (NpcDistToThreat > 0.0f && CoverDistToThreat < NpcDistToThreat) + { + const float AdvanceRatio = (NpcDistToThreat - CoverDistToThreat) / NpcDistToThreat; + Score += AdvancementBias * AdvanceRatio * 0.3f; + } + } + if (Score > OutScore) { OutScore = Score; @@ -258,6 +270,17 @@ float UPS_AI_Behavior_BTTask_FindCover::EvaluateCoverQuality( Score += FMath::GetMappedRangeValueClamped( FVector2D(0.0f, SearchRadius), FVector2D(0.15f, 0.0f), DistFromNpc); + // Advancement bias — prefer candidates closer to threat than NPC is + if (AdvancementBias > 0.0f) + { + const float NpcDistToThreat = FVector::Dist(NpcLoc, ThreatLoc); + if (NpcDistToThreat > 0.0f && DistFromThreat < NpcDistToThreat) + { + const float AdvanceRatio = (NpcDistToThreat - DistFromThreat) / NpcDistToThreat; + Score += AdvancementBias * AdvanceRatio * 0.3f; + } + } + return Score; } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp index 94f92a5..e954fc5 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp @@ -4,9 +4,11 @@ #include "PS_AI_Behavior_Interface.h" #include "PS_AI_Behavior_PerceptionComponent.h" #include "PS_AI_Behavior_PersonalityComponent.h" +#include "PS_AI_Behavior_SplineFollowerComponent.h" #include "PS_AI_Behavior_TeamComponent.h" #include "PS_AI_Behavior_PersonalityProfile.h" #include "BehaviorTree/BehaviorTree.h" +#include "BehaviorTree/BehaviorTreeComponent.h" #include "BehaviorTree/BlackboardComponent.h" #include "BehaviorTree/BlackboardData.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Enum.h" @@ -40,52 +42,37 @@ void APS_AI_Behavior_AIController::OnPossess(APawn* InPawn) *GetName(), *InPawn->GetName()); } - // Auto-assign Team ID from IPS_AI_Behavior interface (preferred) or PersonalityComponent - // Always recalculate — BP child CDOs may reset TeamId to 0 + // Assign Team ID from NPCType + Faction { EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any; if (InPawn->Implements()) { - // Use the interface — the host project controls the storage NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(InPawn); } else if (PersonalityComp) { - // Fallback: get from PersonalityProfile NPCType = PersonalityComp->GetNPCType(); } - // Derive TeamId from NPCType + // Get faction from profile (0 = default) + const uint8 Faction = (PersonalityComp && PersonalityComp->Profile) + ? PersonalityComp->Profile->Faction : 0; + + // Infiltrated enemy: disguised as Civilian until hostile + if (NPCType == EPS_AI_Behavior_NPCType::Enemy && + InPawn->Implements() && + !IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(InPawn)) { - switch (NPCType) - { - case EPS_AI_Behavior_NPCType::Civilian: - TeamId = 1; - break; - case EPS_AI_Behavior_NPCType::Enemy: - // Check if infiltrated (hostile=false → disguised as civilian) - if (InPawn->Implements() && - !IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(InPawn)) - { - TeamId = 1; // Disguised as Civilian - } - else - { - TeamId = 2; - } - break; - case EPS_AI_Behavior_NPCType::Protector: - TeamId = 3; - break; - default: - TeamId = FGenericTeamId::NoTeam; // 255 → Neutral to everyone - break; - } + TeamId = PS_AI_Behavior_Team::DisguisedTeamId; + } + else + { + TeamId = PS_AI_Behavior_Team::MakeTeamId(NPCType, Faction); } - UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Auto-assigned TeamId=%d from NPCType=%s"), - *GetName(), TeamId, *UEnum::GetValueAsString(NPCType)); + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] TeamId=0x%02X (%s, faction %d)"), + *GetName(), TeamId, *UEnum::GetValueAsString(NPCType), Faction); } SetupBlackboard(); @@ -178,6 +165,12 @@ void APS_AI_Behavior_AIController::SetupBlackboard() SplineProgressEntry.EntryName = PS_AI_Behavior_BB::SplineProgress; SplineProgressEntry.KeyType = NewObject(BlackboardAsset); BlackboardAsset->Keys.Add(SplineProgressEntry); + + // CombatSubState (stored as uint8 enum) + FBlackboardEntry CombatSubStateEntry; + CombatSubStateEntry.EntryName = PS_AI_Behavior_BB::CombatSubState; + CombatSubStateEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(CombatSubStateEntry); } UBlackboardComponent* RawBBComp = nullptr; @@ -230,6 +223,56 @@ void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewSta *UEnum::GetValueAsString(NewState)); } Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State, NewVal); + + // ─── Dead: shut down all AI systems ───────────────────────── + if (NewState == EPS_AI_Behavior_State::Dead) + { + HandleDeath(); + } + } +} + +void APS_AI_Behavior_AIController::HandleDeath() +{ + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] HandleDeath — shutting down AI systems."), *GetName()); + + // 1. Stop the Behavior Tree (no more services, tasks, or decorators) + if (UBrainComponent* Brain = GetBrainComponent()) + { + Brain->StopLogic(TEXT("Dead")); + } + + // 2. Stop any active movement + StopMovement(); + + // 3. Stop spline following + if (APawn* MyPawn = GetPawn()) + { + if (UPS_AI_Behavior_SplineFollowerComponent* Spline = + MyPawn->FindComponentByClass()) + { + Spline->StopFollowing(); + } + + // 4. Stop attack if in combat + if (MyPawn->Implements()) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(MyPawn); + } + } + + // 5. Disable perception (stop detecting / being source of stimuli updates) + if (BehaviorPerception) + { + BehaviorPerception->Deactivate(); + } + + // 6. Clear Blackboard threat data + if (Blackboard) + { + Blackboard->ClearValue(PS_AI_Behavior_BB::ThreatActor); + Blackboard->ClearValue(PS_AI_Behavior_BB::ThreatLocation); + Blackboard->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f); } } @@ -301,21 +344,25 @@ ETeamAttitude::Type APS_AI_Behavior_AIController::GetTeamAttitudeTowards(const A return ETeamAttitude::Neutral; } - // Same team → Friendly + // Same TeamId = same NPCType + same Faction → always Friendly if (TeamId == OtherTeam) { return ETeamAttitude::Friendly; } - // ─── Custom cross-team attitudes ──────────────────────────────────── + // ─── NPCType-based attitude ───────────────────────────────────────── + const EPS_AI_Behavior_NPCType MyType = PS_AI_Behavior_Team::GetNPCType(TeamId); + const EPS_AI_Behavior_NPCType TheirType = PS_AI_Behavior_Team::GetNPCType(OtherTeam); - // Civilian (1) ↔ Protector (3) → Friendly - if ((TeamId == 1 && OtherTeam == 3) || (TeamId == 3 && OtherTeam == 1)) + // Civilian ↔ Protector → always Friendly (regardless of faction) + if ((MyType == EPS_AI_Behavior_NPCType::Civilian && TheirType == EPS_AI_Behavior_NPCType::Protector) || + (MyType == EPS_AI_Behavior_NPCType::Protector && TheirType == EPS_AI_Behavior_NPCType::Civilian)) { return ETeamAttitude::Friendly; } - // Everything else → Hostile + // Same NPCType but different faction (e.g. rival enemy gangs) → Hostile + // Different NPCType (e.g. Enemy vs Civilian) → Hostile return ETeamAttitude::Hostile; } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CoverPoint.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CoverPoint.cpp index 8bd7b4a..c84d711 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CoverPoint.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CoverPoint.cpp @@ -4,7 +4,9 @@ #include "Components/ArrowComponent.h" #include "Components/BillboardComponent.h" #include "Engine/World.h" +#include "Engine/Texture2D.h" #include "CollisionQueryParams.h" +#include "UObject/ConstructorHelpers.h" APS_AI_Behavior_CoverPoint::APS_AI_Behavior_CoverPoint() { @@ -27,6 +29,13 @@ APS_AI_Behavior_CoverPoint::APS_AI_Behavior_CoverPoint() SpriteComp->SetRelativeLocation(FVector(0, 0, 80.0f)); SpriteComp->bIsScreenSizeScaled = true; SpriteComp->ScreenSize = 0.0025f; + + // Load editor sprites — Cover: default (no change), HidingSpot: fog icon + static ConstructorHelpers::FObjectFinder HidingSpotSpriteFinder( + TEXT("/Engine/EditorResources/S_AtmosphericHeightFog")); + + CoverSpriteTexture = nullptr; // Keep default billboard sprite for Cover + HidingSpotSpriteTexture = HidingSpotSpriteFinder.Succeeded() ? HidingSpotSpriteFinder.Object : nullptr; #endif } @@ -164,13 +173,17 @@ void APS_AI_Behavior_CoverPoint::UpdateVisualization() if (!ArrowComp) return; FLinearColor Color = FLinearColor::White; + UTexture2D* SpriteTexture = nullptr; + switch (PointType) { case EPS_AI_Behavior_CoverPointType::Cover: Color = FLinearColor(0.2f, 0.5f, 1.0f); // Blue + SpriteTexture = CoverSpriteTexture; break; case EPS_AI_Behavior_CoverPointType::HidingSpot: Color = FLinearColor(1.0f, 0.85f, 0.0f); // Yellow + SpriteTexture = HidingSpotSpriteTexture; break; default: break; @@ -182,6 +195,11 @@ void APS_AI_Behavior_CoverPoint::UpdateVisualization() } ArrowComp->SetArrowColor(Color); + + if (SpriteComp && SpriteTexture) + { + SpriteComp->SetSprite(SpriteTexture); + } #endif } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp index 8bf20ff..116624e 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp @@ -4,6 +4,7 @@ #include "PS_AI_Behavior_Interface.h" #include "PS_AI_Behavior_PersonalityComponent.h" #include "PS_AI_Behavior_PersonalityProfile.h" +#include "PS_AI_Behavior_TeamComponent.h" #include "PS_AI_Behavior_Settings.h" #include "Perception/AISenseConfig_Sight.h" #include "Perception/AISenseConfig_Hearing.h" @@ -53,7 +54,7 @@ void UPS_AI_Behavior_PerceptionComponent::ConfigureSenses() // ─── Hearing ──────────────────────────────────────────────────────── UAISenseConfig_Hearing* HearingConfig = NewObject(this); HearingConfig->HearingRange = Settings->DefaultHearingRange; - HearingConfig->SetMaxAge(Settings->PerceptionMaxAge); + HearingConfig->SetMaxAge(Settings->HearingMaxAge); HearingConfig->DetectionByAffiliation.bDetectEnemies = true; HearingConfig->DetectionByAffiliation.bDetectNeutrals = true; HearingConfig->DetectionByAffiliation.bDetectFriendlies = true; @@ -74,20 +75,113 @@ void UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated(const TArray(Actor)) + { + return ActorAsPawn; + } + + // Not a Pawn — walk up Owner/Instigator chain to find the owning Pawn + AActor* Current = Actor; + for (int32 Depth = 0; Depth < 4; ++Depth) // Safety limit + { + // Try Instigator first (most direct for weapons) + if (APawn* InstigatorPawn = Current->GetInstigator()) + { + return InstigatorPawn; + } + + // Try Owner + AActor* OwnerActor = Current->GetOwner(); + if (!OwnerActor || OwnerActor == Current) break; + + if (APawn* OwnerPawn = Cast(OwnerActor)) + { + return OwnerPawn; + } + + Current = OwnerActor; // Continue up the chain + } + + // Fallback: could not resolve to a Pawn. + // Log once per actor class to avoid spam. + static TSet WarnedClasses; + const FName ClassName = Actor->GetClass()->GetFName(); + if (!WarnedClasses.Contains(ClassName)) + { + WarnedClasses.Add(ClassName); + UE_LOG(LogPS_AI_Behavior, Warning, + TEXT("FindOwningPawn: '%s' (class=%s) could not be resolved to a Pawn. Set Owner or Instigator. Instigator=%s, Owner=%s"), + *Actor->GetName(), *Actor->GetClass()->GetName(), + Actor->GetInstigator() ? *Actor->GetInstigator()->GetName() : TEXT("null"), + Actor->GetOwner() ? *Actor->GetOwner()->GetName() : TEXT("null")); + } + + return nullptr; +} + +/** + * Get the threat target actor for a Pawn. + * Calls GetBehaviorThreatActor() if the Pawn implements the interface, + * otherwise returns the Pawn itself. + * This is what goes into the Blackboard (e.g. PS_AimTargetActor for aiming). + */ +static AActor* GetThreatTarget(APawn* Pawn) +{ + if (!Pawn) return nullptr; + + if (Pawn->Implements()) + { + AActor* Resolved = IPS_AI_Behavior_Interface::Execute_GetBehaviorThreatActor(Pawn); + return Resolved ? Resolved : Pawn; + } + + return Pawn; +} + +/** Extract an actor's TeamId (checking controller, then TeamComponent). */ +uint8 UPS_AI_Behavior_PerceptionComponent::GetActorTeamId(const AActor* Actor) +{ + const APawn* ActorPawn = Cast(Actor); + if (!ActorPawn) return FGenericTeamId::NoTeam; + + if (const AController* C = ActorPawn->GetController()) + { + if (const IGenericTeamAgentInterface* T = Cast(C)) + { + return T->GetGenericTeamId().GetId(); + } + } + + if (const UPS_AI_Behavior_TeamComponent* TC = ActorPawn->FindComponentByClass()) + { + return TC->GetGenericTeamId().GetId(); + } + + return FGenericTeamId::NoTeam; +} + // ─── Actor Classification ─────────────────────────────────────────────────── EPS_AI_Behavior_TargetType UPS_AI_Behavior_PerceptionComponent::ClassifyActor(const AActor* Actor) { if (!Actor) return EPS_AI_Behavior_TargetType::Civilian; // Safe default - // Check if player-controlled const APawn* Pawn = Cast(Actor); - if (Pawn && Pawn->IsPlayerControlled()) - { - return EPS_AI_Behavior_TargetType::Player; - } - // Check via IPS_AI_Behavior interface + // 1) Check via IPS_AI_Behavior interface (NPCs and any Pawn implementing it) if (Actor->Implements()) { const EPS_AI_Behavior_NPCType NPCType = @@ -102,9 +196,21 @@ EPS_AI_Behavior_TargetType UPS_AI_Behavior_PerceptionComponent::ClassifyActor(co } } - // Fallback: check PersonalityComponent + // 2) Check TeamComponent (player characters, non-AI pawns) if (Pawn) { + if (const UPS_AI_Behavior_TeamComponent* TeamComp = Pawn->FindComponentByClass()) + { + switch (TeamComp->Role) + { + case EPS_AI_Behavior_NPCType::Civilian: return EPS_AI_Behavior_TargetType::Civilian; + case EPS_AI_Behavior_NPCType::Enemy: return EPS_AI_Behavior_TargetType::Enemy; + case EPS_AI_Behavior_NPCType::Protector: return EPS_AI_Behavior_TargetType::Protector; + default: break; + } + } + + // 3) Fallback: PersonalityComponent if (const auto* PersonalityComp = Pawn->FindComponentByClass()) { switch (PersonalityComp->GetNPCType()) @@ -115,6 +221,12 @@ EPS_AI_Behavior_TargetType UPS_AI_Behavior_PerceptionComponent::ClassifyActor(co default: break; } } + + // 4) Player-controlled but no role defined → Player type + if (Pawn->IsPlayerControlled()) + { + return EPS_AI_Behavior_TargetType::Player; + } } return EPS_AI_Behavior_TargetType::Civilian; @@ -184,56 +296,117 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( const AAIController* AIC = Cast(Owner); APawn* MyPawn = AIC ? AIC->GetPawn() : Cast(const_cast(Owner)); - for (AActor* Actor : PerceivedActors) + // ─── Score accumulation per resolved actor ─────────────────────── + // Multiple RawActors can resolve to the same Pawn (e.g. Pawn seen + weapon heard). + // Accumulate their sense scores so a single actor gets all stimuli credit. + struct FActorScore { - if (!Actor || Actor == Owner) continue; + AActor* Actor = nullptr; + float Score = 0.0f; + EPS_AI_Behavior_TargetType ActorType = EPS_AI_Behavior_TargetType::Civilian; + bool bIsHostile = false; + }; + TMap ScoreMap; - // Skip self (when owner is AIController, also skip own pawn) - if (AIC && Actor == AIC->GetPawn()) continue; + for (AActor* RawActor : PerceivedActors) + { + // Find the owning Pawn (for team/attitude checks) + APawn* OwningPawn = FindOwningPawn(RawActor); + if (!OwningPawn) continue; // Can't resolve → skip + + // Skip self + if (OwningPawn == Owner) continue; + if (AIC && OwningPawn == AIC->GetPawn()) continue; + + // Get the threat target (PS_AimTargetActor or Pawn itself — for BB targeting) + AActor* ThreatTarget = GetThreatTarget(OwningPawn); + if (!ThreatTarget) ThreatTarget = OwningPawn; + + // Skip non-hostile actors UNLESS they have a gunfire hearing stimulus + bool bActorIsHostile = false; + bool bActorHasGunshot = false; - // Skip non-hostile actors (only Hostile actors are valid threats) if (AIC) { - const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*Actor); - if (Attitude != ETeamAttitude::Hostile) + // Attitude check against the PAWN (has TeamId), not the ThreatTarget + const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*OwningPawn); + bActorIsHostile = (Attitude == ETeamAttitude::Hostile); + + if (!bActorIsHostile) { - continue; + // Same exact team (same NPCType + same Faction) → always skip + // Allied teams (Civilian ↔ Protector) → allow gunfire through + if (AIC->GetGenericTeamId().GetId() == GetActorTeamId(OwningPawn)) + { + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Target '%s': SKIPPED (same team 0x%02X)"), + *Owner->GetName(), *OwningPawn->GetName(), GetActorTeamId(OwningPawn)); + continue; + } + + // Allied or Neutral: check for gunfire tag — gunshot source is a valid threat + FActorPerceptionBlueprintInfo GunInfo; + if (GetActorsPerception(RawActor, GunInfo)) + { + for (const FAIStimulus& S : GunInfo.LastSensedStimuli) + { + if (S.IsValid() && + S.Type == UAISense::GetSenseID() && + PS_AI_Behavior_Tags_Internal::IsGunfire(S.Tag)) + { + bActorHasGunshot = true; + break; + } + } + } + + if (!bActorHasGunshot) + { + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Target '%s': SKIPPED (attitude=%d, not hostile, no gunshot, theirTeam=0x%02X, myTeam=0x%02X)"), + *Owner->GetName(), *OwningPawn->GetName(), static_cast(Attitude), + GetActorTeamId(OwningPawn), AIC->GetGenericTeamId().GetId()); + continue; // Not hostile, no gunshot — skip + } } } // Skip invalid targets (dead, despawning, etc.) via interface if (MyPawn && MyPawn->Implements()) { - if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, Actor)) + if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, ThreatTarget)) { continue; } } - // ─── Classify this actor ──────────────────────────────────────── - const EPS_AI_Behavior_TargetType ActorType = ClassifyActor(Actor); - - // ─── Score calculation ────────────────────────────────────────── - float Score = 0.0f; - - // Priority rank bonus: actors in the priority list score higher - // This is used for COMBAT targeting (who to attack first) - // But ALL hostile actors are valid threats (for fleeing, alerting, etc.) - const int32 PriorityIndex = ActivePriority.IndexOfByKey(ActorType); - if (PriorityIndex != INDEX_NONE) + // ─── Classify & score per owning Pawn, but store ThreatTarget for BB ─── + FActorScore& Entry = ScoreMap.FindOrAdd(OwningPawn); + if (!Entry.Actor) { - Score += (ActivePriority.Num() - PriorityIndex) * 100.0f; - } - else - { - // Not in priority list but still Hostile — valid threat, lower score - Score += 10.0f; + // First time seeing this Pawn — initialize with its ThreatTarget + Entry.Actor = ThreatTarget; + Entry.ActorType = ClassifyActor(OwningPawn); + Entry.bIsHostile = bActorIsHostile; + + // Priority rank bonus (applied once per actor) + const int32 PriorityIndex = ActivePriority.IndexOfByKey(Entry.ActorType); + if (PriorityIndex != INDEX_NONE) + { + Entry.Score += (ActivePriority.Num() - PriorityIndex) * 100.0f; + } + else + { + Entry.Score += 10.0f; + } + + // Distance bonus (applied once per actor) + const float Dist = FVector::Dist(OwnerLoc, ThreatTarget->GetActorLocation()); + Entry.Score += FMath::GetMappedRangeValueClamped( + FVector2D(0.0f, 6000.0f), FVector2D(20.0f, 0.0f), Dist); } - // Damage sense override: actor that hit us gets a massive bonus - // (bypasses priority — self-defense) + // Accumulate sense scores from this RawActor (weapon heard + pawn seen → both count) FActorPerceptionBlueprintInfo Info; - if (GetActorsPerception(Actor, Info)) + if (GetActorsPerception(RawActor, Info)) { for (const FAIStimulus& Stimulus : Info.LastSensedStimuli) { @@ -241,31 +414,78 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( if (Stimulus.Type == UAISense::GetSenseID()) { - Score += 500.0f; // Self-defense: always prioritize attacker + Entry.Score += 500.0f; + } + else if (Stimulus.Type == UAISense::GetSenseID()) + { + if (PS_AI_Behavior_Tags_Internal::IsGunfire(Stimulus.Tag)) + { + Entry.Score += 400.0f; + } + else + { + Entry.Score += 5.0f; + } } else if (Stimulus.Type == UAISense::GetSenseID()) { - Score += 10.0f; - } - else - { - Score += 5.0f; // Hearing + Entry.Score += 10.0f; } } } + } - // Distance: closer targets score higher (0-20 range) - const float Dist = FVector::Dist(OwnerLoc, Actor->GetActorLocation()); - Score += FMath::GetMappedRangeValueClamped( - FVector2D(0.0f, 6000.0f), FVector2D(20.0f, 0.0f), Dist); - - if (Score > BestScore) + // ─── Pick best actor from accumulated scores ───────────────────── + // Target persistence: get current target from BB to avoid flickering + AActor* CurrentTarget = nullptr; + float CurrentTargetScore = -1.0f; + if (AIC) + { + if (UBlackboardComponent* BB = AIC->GetBlackboardComponent()) { - BestScore = Score; - BestThreat = Actor; + CurrentTarget = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); } } + for (const auto& Pair : ScoreMap) + { + const FActorScore& Entry = Pair.Value; + + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s': type=%d, hostile=%d, score=%.0f"), + *Owner->GetName(), *Entry.Actor->GetName(), + static_cast(Entry.ActorType), Entry.bIsHostile ? 1 : 0, Entry.Score); + + if (Entry.Score > BestScore) + { + BestScore = Entry.Score; + BestThreat = Entry.Actor; + } + + // Track current target's score for persistence check + if (Entry.Actor == CurrentTarget) + { + CurrentTargetScore = Entry.Score; + } + } + + // Target persistence: keep current target if its score is within 20% of the best + // This prevents flickering between targets with nearly identical scores + if (CurrentTarget && CurrentTargetScore > 0.0f && BestThreat != CurrentTarget) + { + if (CurrentTargetScore >= BestScore * 0.8f) + { + BestThreat = CurrentTarget; + BestScore = CurrentTargetScore; + } + } + + if (BestThreat) + { + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] → Best target: '%s' (score=%.0f%s)"), + *Owner->GetName(), *BestThreat->GetName(), BestScore, + (BestThreat == CurrentTarget) ? TEXT(" [kept]") : TEXT("")); + } + return BestThreat; } @@ -284,58 +504,109 @@ float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel() const AAIController* AIC = Cast(Owner); APawn* MyPawn = AIC ? AIC->GetPawn() : Cast(const_cast(Owner)); - for (AActor* Actor : PerceivedActors) + for (AActor* RawActor : PerceivedActors) { - if (!Actor) continue; + if (!RawActor) continue; + + // Find the owning Pawn (for team/attitude checks) + APawn* OwningPawn = FindOwningPawn(RawActor); + if (!OwningPawn) continue; + + // Get the threat target for position-based calculations + AActor* ThreatTarget = GetThreatTarget(OwningPawn); + if (!ThreatTarget) ThreatTarget = OwningPawn; + + // Determine hostility and check for gunshot stimuli + bool bIsHostile = false; + bool bHasGunshot = false; - // Only count Hostile actors as threats (skip Friendly and Neutral) if (AIC) { - const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*Actor); - if (Attitude != ETeamAttitude::Hostile) + // Attitude check against the PAWN (has TeamId) + const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*OwningPawn); + bIsHostile = (Attitude == ETeamAttitude::Hostile); + + // Same exact team (same NPCType + same Faction) → always skip + // Allied teams (Civilian ↔ Protector) → allow gunfire through + if (AIC->GetGenericTeamId().GetId() == GetActorTeamId(OwningPawn)) { - continue; // Only Hostile actors generate threat + continue; } } + // For non-hostile actors, check if they have a gunfire hearing stimulus + if (!bIsHostile) + { + FActorPerceptionBlueprintInfo GunInfo; + if (GetActorsPerception(RawActor, GunInfo)) + { + for (const FAIStimulus& S : GunInfo.LastSensedStimuli) + { + if (S.IsValid() && + S.Type == UAISense::GetSenseID() && + PS_AI_Behavior_Tags_Internal::IsGunfire(S.Tag)) + { + bHasGunshot = true; + break; + } + } + } + } + + // Skip actors that are neither hostile nor gunshot sources + if (!bIsHostile && !bHasGunshot) + { + continue; + } + // Skip invalid targets (dead, despawning, etc.) via interface if (MyPawn && MyPawn->Implements()) { - if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, Actor)) + if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, ThreatTarget)) { continue; } } float ActorThreat = 0.0f; - const float Dist = FVector::Dist(OwnerLoc, Actor->GetActorLocation()); + const float Dist = FVector::Dist(OwnerLoc, ThreatTarget->GetActorLocation()); - // Closer = more threatening - ActorThreat += FMath::GetMappedRangeValueClamped( - FVector2D(0.0f, 5000.0f), FVector2D(0.5f, 0.05f), Dist); - - // Sense-based multiplier - FActorPerceptionBlueprintInfo Info; - if (GetActorsPerception(Actor, Info)) + if (bIsHostile) { - for (const FAIStimulus& Stimulus : Info.LastSensedStimuli) - { - if (!Stimulus.IsValid()) continue; + // ─── Normal hostile threat calculation ────────────────────── + ActorThreat += FMath::GetMappedRangeValueClamped( + FVector2D(0.0f, 5000.0f), FVector2D(0.5f, 0.05f), Dist); - if (Stimulus.Type == UAISense::GetSenseID()) + FActorPerceptionBlueprintInfo Info; + if (GetActorsPerception(RawActor, Info)) + { + for (const FAIStimulus& Stimulus : Info.LastSensedStimuli) { - ActorThreat += 0.6f; // Being hit = big threat spike - } - else if (Stimulus.Type == UAISense::GetSenseID()) - { - ActorThreat += 0.2f; - } - else if (Stimulus.Type == UAISense::GetSenseID()) - { - ActorThreat += 0.1f; + if (!Stimulus.IsValid()) continue; + + if (Stimulus.Type == UAISense::GetSenseID()) + { + ActorThreat += 0.6f; + } + else if (Stimulus.Type == UAISense::GetSenseID()) + { + ActorThreat += 0.2f; + } + else if (Stimulus.Type == UAISense::GetSenseID()) + { + ActorThreat += 0.1f; + } } } } + else if (bHasGunshot) + { + // ─── Gunshot from non-hostile actor ──────────────────────── + // Generates significant threat regardless of team affiliation + ActorThreat += FMath::GetMappedRangeValueClamped( + FVector2D(0.0f, 5000.0f), FVector2D(0.4f, 0.05f), Dist); + ActorThreat += 0.3f; // Gunshot hearing boost + } TotalThreat += ActorThreat; } @@ -375,3 +646,31 @@ bool UPS_AI_Behavior_PerceptionComponent::GetThreatLocation(FVector& OutLocation return false; } + +bool UPS_AI_Behavior_PerceptionComponent::GetGunShotStimulusLocation(FVector& OutLocation) +{ + TArray PerceivedActors; + GetCurrentlyPerceivedActors(nullptr, PerceivedActors); + + for (AActor* Actor : PerceivedActors) + { + if (!Actor) continue; + + FActorPerceptionBlueprintInfo Info; + if (GetActorsPerception(Actor, Info)) + { + for (const FAIStimulus& Stimulus : Info.LastSensedStimuli) + { + if (Stimulus.IsValid() && + Stimulus.Type == UAISense::GetSenseID() && + PS_AI_Behavior_Tags_Internal::IsGunfire(Stimulus.Tag)) + { + OutLocation = Stimulus.StimulusLocation; + return true; + } + } + } + } + + return false; +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTDecorator_CheckCombatType.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTDecorator_CheckCombatType.h new file mode 100644 index 0000000..4c42aa5 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTDecorator_CheckCombatType.h @@ -0,0 +1,29 @@ +// Copyright Asterion. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "BehaviorTree/BTDecorator.h" +#include "PS_AI_Behavior_Definitions.h" +#include "PS_AI_Behavior_BTDecorator_CheckCombatType.generated.h" + +/** + * BT Decorator: Checks the Pawn's combat type via IPS_AI_Behavior_Interface. + * Use to route: ranged NPCs → cover-shoot cycle, melee NPCs → rush attack. + */ +UCLASS(meta = (DisplayName = "PS AI: Check Combat Type")) +class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTDecorator_CheckCombatType : public UBTDecorator +{ + GENERATED_BODY() + +public: + UPS_AI_Behavior_BTDecorator_CheckCombatType(); + + /** The combat type this decorator requires to pass. */ + UPROPERTY(EditAnywhere, Category = "Combat Type") + EPS_AI_Behavior_CombatType RequiredType = EPS_AI_Behavior_CombatType::Ranged; + +protected: + virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override; + virtual FString GetStaticDescription() const override; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Attack.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Attack.h index a89ea24..c252e3e 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Attack.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Attack.h @@ -4,11 +4,16 @@ #include "CoreMinimal.h" #include "BehaviorTree/BTTaskNode.h" +#include "PS_AI_Behavior_Definitions.h" #include "PS_AI_Behavior_BTTask_Attack.generated.h" /** * BT Task: Move toward the threat actor and delegate combat to the Pawn. * + * Queries IPS_AI_Behavior_Interface for CombatType and OptimalAttackRange: + * - Melee: rush toward target, stop at optimal range. + * - Ranged: maintain optimal distance — back away if too close, advance if too far. + * * Calls IPS_AI_Behavior_Interface::BehaviorStartAttack() on enter and * BehaviorStopAttack() on abort. The Pawn handles the actual combat * (aim, fire, melee, etc.) via its own systems. @@ -24,10 +29,14 @@ class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_Attack : public UBTTaskNode public: UPS_AI_Behavior_BTTask_Attack(); - /** How close the NPC tries to get to the target (cm). */ + /** Fallback move radius if the Pawn doesn't implement GetBehaviorOptimalAttackRange(). */ UPROPERTY(EditAnywhere, Category = "Attack", meta = (ClampMin = "50.0")) float AttackMoveRadius = 300.0f; + /** Cooldown between reposition attempts (seconds). Prevents constant re-pathing. */ + UPROPERTY(EditAnywhere, Category = "Attack", meta = (ClampMin = "0.5", ClampMax = "5.0")) + float RepositionCooldown = 1.5f; + protected: virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override; virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override; @@ -39,7 +48,12 @@ private: struct FAttackMemory { bool bMovingToTarget = false; - bool bAttacking = false; + bool bAttacking = false; // true when BehaviorStartAttack is active + bool bInRange = false; // true when within MaxRange of target + EPS_AI_Behavior_CombatType CombatType = EPS_AI_Behavior_CombatType::Melee; + float MinRange = 100.0f; // backs away if closer + float MaxRange = 300.0f; // advances if farther + float RepositionTimer = 0.0f; }; virtual uint16 GetInstanceMemorySize() const override { return sizeof(FAttackMemory); } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_CoverShootCycle.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_CoverShootCycle.h new file mode 100644 index 0000000..eb64fd4 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_CoverShootCycle.h @@ -0,0 +1,106 @@ +// Copyright Asterion. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "BehaviorTree/BTTaskNode.h" +#include "PS_AI_Behavior_Definitions.h" +#include "PS_AI_Behavior_BTTask_CoverShootCycle.generated.h" + +class APS_AI_Behavior_CoverPoint; + +/** + * BT Task: Cover-shoot cycle for ranged combat. + * + * State machine: Engaging → AtCover → Peeking → AtCover → ... → Advancing → AtCover ... + * + * The NPC moves to the cover position from Blackboard, then alternates between + * ducking (AtCover) and shooting (Peeking). After MaxCyclesBeforeAdvance peek/duck + * cycles, advances to a closer cover point toward the threat. + * + * Personality traits modulate timing: + * - Aggressivity → shorter cover duration, advances sooner + * - Caution → longer cover duration, shorter peek + * - Courage < 0.3 → never advances (stays in cover) + * + * Writes CombatSubState to BB for animation sync. + * Stays InProgress — Decorator Observer Aborts pulls out on state change. + * + * Prerequisites: BTTask_FindCover must run first to write CoverLocation/CoverPoint to BB. + */ +UCLASS(meta = (DisplayName = "PS AI: Cover Shoot Cycle")) +class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_CoverShootCycle : public UBTTaskNode +{ + GENERATED_BODY() + +public: + UPS_AI_Behavior_BTTask_CoverShootCycle(); + + // ─── Timing ───────────────────────────────────────────────────────── + + /** Minimum time (seconds) spent peeking/shooting. */ + UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5")) + float PeekDurationMin = 2.0f; + + /** Maximum time (seconds) spent peeking/shooting. */ + UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5")) + float PeekDurationMax = 5.0f; + + /** Minimum time (seconds) spent ducked behind cover. */ + UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5")) + float CoverDurationMin = 1.0f; + + /** Maximum time (seconds) spent ducked behind cover. */ + UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5")) + float CoverDurationMax = 3.0f; + + // ─── Advancement ──────────────────────────────────────────────────── + + /** Number of peek/duck cycles before advancing to a closer cover. */ + UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement", meta = (ClampMin = "1", ClampMax = "10")) + int32 MaxCyclesBeforeAdvance = 3; + + /** Search radius for the next cover point when advancing (cm). */ + UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement", meta = (ClampMin = "200.0")) + float AdvanceSearchRadius = 1200.0f; + + /** Advancement bias when searching for the next cover (0=neutral, 1=strongly toward threat). */ + UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float AdvancementBias = 0.7f; + + /** Cover point type to search when advancing. */ + UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement") + EPS_AI_Behavior_CoverPointType CoverPointType = EPS_AI_Behavior_CoverPointType::Cover; + +protected: + virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override; + virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override; + virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override; + virtual void OnTaskFinished(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult) override; + virtual FString GetStaticDescription() const override; + +private: + struct FCoverShootMemory + { + EPS_AI_Behavior_CombatSubState SubState = EPS_AI_Behavior_CombatSubState::Engaging; + float Timer = 0.0f; + float PhaseDuration = 0.0f; + int32 CycleCount = 0; + bool bMoveRequested = false; + + // Effective durations (modulated by personality) + float EffPeekMin = 2.0f; + float EffPeekMax = 5.0f; + float EffCoverMin = 1.0f; + float EffCoverMax = 3.0f; + int32 EffMaxCycles = 3; + bool bCanAdvance = true; + }; + + virtual uint16 GetInstanceMemorySize() const override { return sizeof(FCoverShootMemory); } + + /** Find an advancing cover point closer to the threat. */ + APS_AI_Behavior_CoverPoint* FindAdvancingCover( + const UWorld* World, const FVector& NpcLoc, const FVector& ThreatLoc, + EPS_AI_Behavior_NPCType NPCType, float& OutScore) const; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindCover.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindCover.h index f0e6064..78ed654 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindCover.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindCover.h @@ -62,6 +62,14 @@ public: UPROPERTY(EditAnywhere, Category = "Cover|Manual Points") bool bUseManualPointsOnly = false; + /** + * Bias toward covers that advance toward the threat (0 = none, 1 = strong). + * Used for cover-to-cover progression during combat. + * Covers closer to the threat than the NPC's current position score higher. + */ + UPROPERTY(EditAnywhere, Category = "Cover|Advancement", meta = (ClampMin = "0.0", ClampMax = "1.0")) + float AdvancementBias = 0.0f; + protected: virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override; virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CoverPoint.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CoverPoint.h index 2b65e6c..d7d2056 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CoverPoint.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CoverPoint.h @@ -9,6 +9,7 @@ class UArrowComponent; class UBillboardComponent; +class UTexture2D; /** * A manually placed strategic point in the level. @@ -128,4 +129,13 @@ protected: private: void UpdateVisualization(); + +#if WITH_EDITORONLY_DATA + /** Cached editor sprite textures (loaded once in constructor). */ + UPROPERTY(Transient) + TObjectPtr CoverSpriteTexture; + + UPROPERTY(Transient) + TObjectPtr HidingSpotSpriteTexture; +#endif }; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Definitions.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Definitions.h index ff50927..47f7655 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Definitions.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Definitions.h @@ -70,6 +70,24 @@ enum class EPS_AI_Behavior_CoverPointType : uint8 HidingSpot UMETA(DisplayName = "Hiding Spot", ToolTip = "Civilian hiding place (under desks, in closets, behind cars)"), }; +/** Combat style — determines engagement behavior (cover cycle vs rush). */ +UENUM(BlueprintType) +enum class EPS_AI_Behavior_CombatType : uint8 +{ + Melee UMETA(DisplayName = "Melee", ToolTip = "Close-range: rush the target"), + Ranged UMETA(DisplayName = "Ranged", ToolTip = "Long-range: use cover, maintain distance"), +}; + +/** Sub-state within Combat for the cover-shoot cycle. Written to BB for animation sync. */ +UENUM(BlueprintType) +enum class EPS_AI_Behavior_CombatSubState : uint8 +{ + Engaging UMETA(DisplayName = "Engaging", ToolTip = "Moving to combat position"), + AtCover UMETA(DisplayName = "At Cover", ToolTip = "Ducked behind cover"), + Peeking UMETA(DisplayName = "Peeking", ToolTip = "Leaning out, shooting"), + Advancing UMETA(DisplayName = "Advancing", ToolTip = "Moving to next cover"), +}; + /** Personality trait axes — each scored 0.0 (low) to 1.0 (high). */ UENUM(BlueprintType) enum class EPS_AI_Behavior_TraitAxis : uint8 @@ -81,6 +99,77 @@ enum class EPS_AI_Behavior_TraitAxis : uint8 Discipline UMETA(DisplayName = "Discipline", ToolTip = "0 = undisciplined, 1 = disciplined"), }; +// ─── TeamId Encoding ──────────────────────────────────────────────────────── +// +// TeamId encodes NPCType + Faction in a single uint8: +// High nibble = NPCType (0=Civilian, 1=Enemy, 2=Protector) +// Low nibble = Faction (0-15) +// +// Examples: +// Civilian → 0x00 (TeamId 0) +// Enemy faction 0 → 0x10 (TeamId 16) +// Enemy faction 1 → 0x11 (TeamId 17) +// Protector → 0x20 (TeamId 32) +// + +namespace PS_AI_Behavior_Team +{ + /** Build a TeamId from NPCType + Faction. */ + inline uint8 MakeTeamId(EPS_AI_Behavior_NPCType Type, uint8 Faction = 0) + { + uint8 Base; + switch (Type) + { + case EPS_AI_Behavior_NPCType::Civilian: Base = 0x00; break; + case EPS_AI_Behavior_NPCType::Enemy: Base = 0x10; break; + case EPS_AI_Behavior_NPCType::Protector: Base = 0x20; break; + default: return 255; // NoTeam + } + return Base | (Faction & 0x0F); + } + + /** Extract the NPCType from a TeamId. */ + inline EPS_AI_Behavior_NPCType GetNPCType(uint8 InTeamId) + { + switch (InTeamId & 0xF0) + { + case 0x00: return EPS_AI_Behavior_NPCType::Civilian; + case 0x10: return EPS_AI_Behavior_NPCType::Enemy; + case 0x20: return EPS_AI_Behavior_NPCType::Protector; + default: return EPS_AI_Behavior_NPCType::Any; + } + } + + /** Extract the Faction from a TeamId. */ + inline uint8 GetFaction(uint8 InTeamId) + { + return InTeamId & 0x0F; + } + + /** TeamId used for disguised enemies (same as Civilian faction 0). */ + inline constexpr uint8 DisguisedTeamId = 0x00; +} + +// ─── Stimulus Tags ────────────────────────────────────────────────────────── + +namespace PS_AI_Behavior_Tags +{ + /** Tag for enemy gunfire noise events. */ + inline const FName EnemyFire = TEXT("EnemyFire"); + + /** Tag for player/protector gunfire noise events. */ + inline const FName PlayerFire = TEXT("PlayerFire"); +} + +namespace PS_AI_Behavior_Tags_Internal +{ + /** Returns true if the tag is any gunfire tag (EnemyFire or PlayerFire). */ + inline bool IsGunfire(const FName& Tag) + { + return Tag == PS_AI_Behavior_Tags::EnemyFire || Tag == PS_AI_Behavior_Tags::PlayerFire; + } +} + // ─── Blackboard Key Names ─────────────────────────────────────────────────── namespace PS_AI_Behavior_BB @@ -95,4 +184,5 @@ namespace PS_AI_Behavior_BB inline const FName HomeLocation = TEXT("HomeLocation"); inline const FName CurrentSpline = TEXT("CurrentSpline"); inline const FName SplineProgress = TEXT("SplineProgress"); + inline const FName CombatSubState = TEXT("CombatSubState"); } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Interface.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Interface.h index 69041c4..dc6838a 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Interface.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Interface.h @@ -141,4 +141,30 @@ public: */ UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior") bool CanBehaviorAttack(AActor* Target) const; + + // ─── Combat Style ─────────────────────────────────────────────────── + + /** + * Get this NPC's combat type (Melee or Ranged). + * Depends on the Pawn's current weapon — implement on your Character. + * Melee NPCs rush the target; Ranged NPCs use cover and maintain distance. + * Default: Melee. + */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior") + EPS_AI_Behavior_CombatType GetBehaviorCombatType() const; + + // ─── Actor Resolution ────────────────────────────────────────────── + + /** + * Get the actor that represents the threat source for this Pawn. + * Called by the perception system to resolve weapons, VR tracked actors, etc. + * to the actual actor that NPCs should target and flee from. + * + * Default: returns Self (the Pawn itself). + * Override in VR to return the tracked body actor instead of the static Pawn root. + * + * @return The actor to use as threat source / target. + */ + UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior") + AActor* GetBehaviorThreatActor() const; }; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PerceptionComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PerceptionComponent.h index 6b6cf30..f3c0360 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PerceptionComponent.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PerceptionComponent.h @@ -53,6 +53,17 @@ public: UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Perception") bool GetThreatLocation(FVector& OutLocation); + /** + * Get the location of a perceived gunshot stimulus (any team affiliation). + * Used to set ThreatLocation when a gunshot is heard but the shooter isn't hostile. + * @param OutLocation Filled with the gunshot location if found. + * @return True if a gunshot stimulus was found. + */ + bool GetGunShotStimulusLocation(FVector& OutLocation); + + /** Extract an actor's TeamId (checking controller, then TeamComponent). Returns NoTeam if unresolvable. */ + static uint8 GetActorTeamId(const AActor* Actor); + protected: virtual void BeginPlay() override; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityProfile.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityProfile.h index 35badf1..83ba50f 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityProfile.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityProfile.h @@ -32,6 +32,21 @@ public: UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality") EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Civilian; + /** + * Faction index within the same NPCType. + * Used to create rival groups of the same type (e.g. two enemy gangs). + * + * Same NPCType + same Faction → Friendly (allies) + * Same NPCType + different Faction → Hostile (rivals) + * Civilian ↔ Protector → always Friendly (regardless of faction) + * Everything else → Hostile + * + * Example: "Gang A" Enemy profile → Faction 0, "Gang B" Enemy profile → Faction 1. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality", + meta = (ClampMin = "0", ClampMax = "15")) + uint8 Faction = 0; + // ─── Trait Scores ─────────────────────────────────────────────────── /** Personality trait scores. Each axis ranges from 0.0 to 1.0. */ @@ -80,6 +95,21 @@ public: UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat") TArray TargetPriority; + /** + * Minimum attack range (cm). NPC backs away if target is closer than this. + * Melee: ~100cm. Ranged: ~600cm. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat", meta = (ClampMin = "0.0")) + float MinAttackRange = 100.0f; + + /** + * Maximum attack range (cm). NPC advances if target is farther than this. + * Between Min and Max, the NPC holds position and attacks. + * Melee: ~300cm. Ranged: ~1500cm. + */ + UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat", meta = (ClampMin = "50.0")) + float MaxAttackRange = 300.0f; + // ─── Movement Speed per State ────────────────────────────────────── /** diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdMode.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdMode.cpp index 087b348..9a77656 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdMode.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdMode.cpp @@ -94,54 +94,56 @@ bool FPS_AI_Behavior_SplineEdMode::HandleClick( return false; // No ground hit } - FVector ClickLocation = Hit.ImpactPoint; + // Use the ray trace impact point directly — no re-snap needed, this IS the ground + FVector ClickLocation = Hit.ImpactPoint + FVector(0, 0, 5.0f); // Slight offset above ground - // Ctrl+Click on existing spline → select for extension - if (Click.IsControlDown()) + // ─── Without Ctrl: pass click through to editor (select/move actors) ─ + if (!Click.IsControlDown()) { - // Check if we hit a SplinePath - AActor* HitActor = Hit.GetActor(); - APS_AI_Behavior_SplinePath* HitSpline = Cast(HitActor); - - if (!HitSpline) - { - // Check nearby splines - for (TActorIterator It(World); It; ++It) - { - float Dist = 0.0f; - FVector ClosestPt; - if ((*It)->GetClosestPointOnSpline(ClickLocation, Dist, ClosestPt) < 200.0f) - { - HitSpline = *It; - break; - } - } - } - - if (HitSpline) - { - SelectSplineForExtension(HitSpline); - return true; - } - - return false; + return false; // Let the editor handle selection/dragging } - // Snap to ground - if (bSnapToGround) - { - SnapToGround(ClickLocation); - } - - // ─── Route to active tool ─────────────────────────────────────────── + // ─── Ctrl+Click: place or extend ───────────────────────────────────── switch (ActiveTool) { case EPS_AI_Behavior_EdModeTool::Spline: - AddPointToSpline(ClickLocation); + { + // Ctrl+Click near an existing spline → select for extension + if (!ActiveSpline) + { + AActor* HitActor = Hit.GetActor(); + APS_AI_Behavior_SplinePath* HitSpline = Cast(HitActor); + + if (!HitSpline) + { + // Check nearby splines + for (TActorIterator It(World); It; ++It) + { + float Dist = 0.0f; + FVector ClosestPt; + if ((*It)->GetClosestPointOnSpline(ClickLocation, Dist, ClosestPt) < 200.0f) + { + HitSpline = *It; + break; + } + } + } + + if (HitSpline) + { + SelectSplineForExtension(HitSpline); + return true; + } + } + + // Ctrl+Click elsewhere → add spline point + AddPointToSpline(ClickLocation); + } break; case EPS_AI_Behavior_EdModeTool::CoverPoint: { + // Ctrl+Click → place cover point // Cover point faces toward the camera (typical workflow) const FVector CamLoc = InViewportClient->GetViewLocation(); const FVector DirToCamera = (CamLoc - ClickLocation).GetSafeNormal2D();