Add ResetBehavior() and de-escalation on hostile flip

- AIController::ResetBehavior() — immediate reset to initial-tick state: stops movement, clears all BB threat/cover/patrol keys, recomputes TeamId, forces state to Idle (triggers speed update via HandleStateChanged). For civilian calm-down, enemy surrender, mission reset.
- PersonalityComponent::ResetRuntimeState() — clears PerceivedThreatLevel and combat/cover cycle internals. Traits preserved.
- PersonalityComponent::EvaluateReaction() — disguised Enemy (hostile=false) never returns Combat regardless of threat level. Prevents stuck-in-combat bug when hostility flips mid-fight.
- BTService_EvaluateReaction — when hostile flips true→false, soft de-escalation: clears threat so next ApplyReaction transitions to Idle naturally (no abrupt movement stop).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 20:20:22 +02:00
parent 48d1543329
commit f338c0586e
5 changed files with 192 additions and 7 deletions

View File

@@ -62,6 +62,24 @@ void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode(
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);
// Enemy becoming non-hostile (disguise) — soft de-escalation:
// clear threat so the BT re-evaluates to Idle naturally on the next
// ApplyReaction below. Do NOT stop movement or force speed abruptly —
// the natural state transition will handle that via HandleStateChanged.
if (NPCType == EPS_AI_Behavior_NPCType::Enemy && !bHostile)
{
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
BB->ClearValue(PS_AI_Behavior_BB::ThreatLocation);
BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f);
if (Personality)
{
Personality->PerceivedThreatLevel = 0.0f;
}
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Hostile→false: threat cleared, BT will transition naturally."),
*AIC->GetName());
}
}
}

View File

@@ -324,6 +324,111 @@ void APS_AI_Behavior_AIController::StopBehavior()
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] StopBehavior — BT stopped, entering Scripted state."), *GetName());
}
void APS_AI_Behavior_AIController::ResetBehavior()
{
APawn* MyPawn = GetPawn();
if (!MyPawn)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] ResetBehavior: no Pawn."), *GetName());
return;
}
// ─── 1. Stop any active movement ────────────────────────────────────
StopMovement();
if (auto* Spline = MyPawn->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>())
{
Spline->PauseFollowing();
}
// ─── 2. Reset PersonalityComponent runtime state ────────────────────
if (PersonalityComp)
{
PersonalityComp->ResetRuntimeState();
}
// ─── 3. Recompute TeamId from current NPCType + Faction + Hostile ──
{
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any;
if (MyPawn->Implements<UPS_AI_Behavior_Interface>())
{
NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(MyPawn);
}
else if (PersonalityComp)
{
NPCType = PersonalityComp->GetNPCType();
}
const uint8 Faction = (PersonalityComp && PersonalityComp->Profile)
? PersonalityComp->Profile->Faction : 0;
uint8 NewTeamId;
if (NPCType == EPS_AI_Behavior_NPCType::Enemy &&
MyPawn->Implements<UPS_AI_Behavior_Interface>() &&
!IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(MyPawn))
{
NewTeamId = PS_AI_Behavior_Team::DisguisedTeamId;
}
else
{
NewTeamId = PS_AI_Behavior_Team::MakeTeamId(NPCType, Faction);
}
SetTeamId(NewTeamId);
}
// ─── 4. Reset Blackboard keys to initial-tick defaults ──────────────
if (Blackboard)
{
// Threat
Blackboard->ClearValue(PS_AI_Behavior_BB::ThreatActor);
Blackboard->ClearValue(PS_AI_Behavior_BB::ThreatLocation);
Blackboard->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f);
Blackboard->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition);
Blackboard->ClearValue(PS_AI_Behavior_BB::ThreatPawnName);
// Cover
Blackboard->ClearValue(PS_AI_Behavior_BB::CoverLocation);
Blackboard->ClearValue(PS_AI_Behavior_BB::CoverPoint);
Blackboard->SetValueAsBool(PS_AI_Behavior_BB::PreferCover, false);
Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::Engaging));
// Patrol / Spline
Blackboard->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex, 0);
Blackboard->ClearValue(PS_AI_Behavior_BB::CurrentSpline);
Blackboard->SetValueAsFloat(PS_AI_Behavior_BB::SplineProgress, 0.0f);
// Re-capture HomeLocation to current actor location (initial-tick semantics)
Blackboard->SetValueAsVector(PS_AI_Behavior_BB::HomeLocation,
MyPawn->GetActorLocation());
// Conversation pause
Blackboard->SetValueAsBool(PS_AI_Behavior_BB::ConversationPaused, false);
}
// ─── 5. Clear local gaze / conversation caches ──────────────────────
bConversationPaused = false;
bUserHasSpokenInConversation = false;
ProximityGazeTarget = nullptr;
ProximityGazeDuration = 0.0f;
ProximityGazeCooldownTimer = 0.0f;
ProximityGazeCooldownActor = nullptr;
ClearGazeTarget();
// ─── 6. Force state back to Idle ────────────────────────────────────
// ForceState on PersonalityComp updates CurrentState + triggers HandleStateChanged
// which calls SetBehaviorMovementSpeed on the Pawn (so walk speed is reset).
// SetBehaviorState syncs the Blackboard for the BT.
if (PersonalityComp)
{
PersonalityComp->ForceState(EPS_AI_Behavior_State::Idle);
}
SetBehaviorState(EPS_AI_Behavior_State::Idle);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] ResetBehavior — back to initial-tick state (Idle)."), *GetName());
}
void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewState)
{
if (Blackboard)

View File

@@ -77,6 +77,21 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() c
return EPS_AI_Behavior_State::Scripted;
}
// Disguised Enemy (NPCType=Enemy but IsBehaviorHostile=false) — cannot enter
// active combat states. Behave like a civilian (Alerted max, or Fleeing if terrified).
bool bDisguisedEnemy = false;
if (APawn* OwnerPawn = Cast<APawn>(GetOwner()))
{
if (OwnerPawn->Implements<UPS_AI_Behavior_Interface>())
{
const EPS_AI_Behavior_NPCType NPCT =
IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(OwnerPawn);
const bool bHostile =
IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(OwnerPawn);
bDisguisedEnemy = (NPCT == EPS_AI_Behavior_NPCType::Enemy && !bHostile);
}
}
const float Courage = GetTrait(EPS_AI_Behavior_TraitAxis::Courage);
const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
@@ -119,18 +134,22 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() c
return EPS_AI_Behavior_State::Fleeing;
}
if (CurrentState == EPS_AI_Behavior_State::Combat)
// Disguised enemies never enter active combat — skip the Combat branch entirely.
if (!bDisguisedEnemy)
{
// Stay in combat until threat drops well below the threshold
if (PerceivedThreatLevel >= (EffectiveAttackThresh - Hysteresis))
if (CurrentState == EPS_AI_Behavior_State::Combat)
{
// Stay in combat until threat drops well below the threshold
if (PerceivedThreatLevel >= (EffectiveAttackThresh - Hysteresis))
{
return EPS_AI_Behavior_State::Combat;
}
}
else if (PerceivedThreatLevel >= EffectiveAttackThresh && Aggressivity > 0.0f)
{
return EPS_AI_Behavior_State::Combat;
}
}
else if (PerceivedThreatLevel >= EffectiveAttackThresh && Aggressivity > 0.0f)
{
return EPS_AI_Behavior_State::Combat;
}
if (PerceivedThreatLevel >= AlertThresh)
{
@@ -280,6 +299,22 @@ float UPS_AI_Behavior_PersonalityComponent::CalculatePhaseDuration(EPS_AI_Behavi
return Duration;
}
void UPS_AI_Behavior_PersonalityComponent::ResetRuntimeState()
{
if (!GetOwner() || !GetOwner()->HasAuthority())
{
return;
}
PerceivedThreatLevel = 0.0f;
CombatCoverTimer = 0.0f;
bCombatCoverCycleActive = false;
bPreferCover = false;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] PersonalityComponent runtime state reset."),
*GetOwner()->GetName());
}
void UPS_AI_Behavior_PersonalityComponent::ForceState(EPS_AI_Behavior_State NewState)
{
// Only server can change replicated state

View File

@@ -115,6 +115,25 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void StopBehavior();
/**
* Reset the NPC to its initial post-possession state (as if OnPossess had just run).
* Works for any NPC type (civilian, enemy, protector).
*
* Resets:
* - Blackboard: all threat, cover, patrol, spline keys back to defaults
* - Blackboard: HomeLocation re-captured to current actor location
* - PersonalityComponent: PerceivedThreatLevel = 0, combat/cover cycle cleared
* - TeamId: recomputed from current NPCType + Faction + Hostile flag
* - BehaviorState: forced to Idle (BT transitions to Patrol if on a spline)
* - Movement: any pending MoveTo cancelled, spline follower paused/reset
*
* Does NOT reset: runtime traits (ModifyTrait changes are kept), BT (stays running).
*
* Use cases: civilian calmed down by dialogue, enemy surrendering, mission reset.
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior")
void ResetBehavior();
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;

View File

@@ -113,6 +113,14 @@ public:
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
void ForceState(EPS_AI_Behavior_State NewState);
/**
* Reset runtime state — perceived threat level, combat/cover cycle.
* Does NOT reset traits (ModifyTrait changes are preserved).
* Called by AIController::ResetBehavior().
*/
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
void ResetRuntimeState();
/** Get the NPC type from the interface or profile. */
UFUNCTION(BlueprintCallable, Category = "ASTERION|PS_AI_Behavior|Personality")
EPS_AI_Behavior_NPCType GetNPCType() const;